Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe60ae71e6 | ||
|
|
ec6c1da94c | ||
|
|
6d37cd8eb9 | ||
|
|
18927bbc86 | ||
|
|
a4b546058a | ||
|
|
0010b023f6 | ||
|
|
e18ccab15f | ||
|
|
46d98a3544 | ||
|
|
2eb4e47585 | ||
|
|
ffccd31bbc | ||
|
|
4c79a1f09d | ||
|
|
a07c26c55b | ||
|
|
af0ab508f6 | ||
|
|
bd5aa81988 | ||
|
|
7877892f9b | ||
|
|
f8f36bcdb8 | ||
|
|
d1c014d9b3 | ||
|
|
1db8eee8ea | ||
|
|
74e17386ee | ||
|
|
a9ef795c90 | ||
|
|
153abf4352 | ||
|
|
da5c7c1cfa | ||
|
|
58d4cc0164 | ||
|
|
a3105200e3 | ||
|
|
c75cba214e |
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
WEB_PORT=5173
|
WEB_PORT=5173
|
||||||
API_PORT=3000
|
API_PORT=4006
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/zeavis_edu
|
DATABASE_URL=postgres://asephs:***@100.121.180.82:6432/zeavis_edu
|
||||||
|
|
||||||
# ── Telemetry / ClickHouse ──────────────────────────────────────────
|
# ── Telemetry / ClickHouse ──────────────────────────────────────────
|
||||||
# These credentials are used by the telemetry Docker Compose stack.
|
# These credentials are used by the telemetry Docker Compose stack.
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
name: Build Android APK
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- 'apps/web/**'
|
|
||||||
- 'apps/tauri/**'
|
|
||||||
- 'packages/shared/**'
|
|
||||||
- '.github/workflows/android.yml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'apps/web/**'
|
|
||||||
- 'apps/tauri/**'
|
|
||||||
- 'packages/shared/**'
|
|
||||||
- '.github/workflows/android.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL || 'https://zeavisedu.asepharyana.my.id' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-apk:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.version.outputs.version }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
fetch-tags: true
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Cache Bun dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
apps/*/node_modules
|
|
||||||
packages/*/node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', '**/package.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Setup Java 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: '21'
|
|
||||||
|
|
||||||
- name: Setup Android SDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
with:
|
|
||||||
packages: 'platforms;android-36 build-tools;36.0.0'
|
|
||||||
- name: Pin NDK version
|
|
||||||
run: echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/$(ls ${ANDROID_SDK_ROOT}/ndk | sort -V | head -1)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Setup Rust with Android targets
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: >-
|
|
||||||
aarch64-linux-android,
|
|
||||||
armv7-linux-androideabi,
|
|
||||||
i686-linux-android,
|
|
||||||
x86_64-linux-android
|
|
||||||
|
|
||||||
- name: Cache Cargo
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
apps/tauri/target
|
|
||||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('apps/tauri/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-android-
|
|
||||||
|
|
||||||
- name: Cache Gradle
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: ${{ runner.os }}-gradle-android-${{ hashFiles('apps/tauri/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-gradle-android-
|
|
||||||
|
|
||||||
- name: Install Tauri CLI
|
|
||||||
run: cd apps/tauri && bun install
|
|
||||||
|
|
||||||
- name: Compute version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
# Get latest git tag, default to v0.1.0
|
|
||||||
LATEST_TAG=$(git tag --list 'v*' --sort=-v:refname | head -1)
|
|
||||||
if [ -z "$LATEST_TAG" ]; then
|
|
||||||
NEW_VERSION="0.1.0"
|
|
||||||
else
|
|
||||||
# Strip 'v' prefix, bump patch
|
|
||||||
BASE="${LATEST_TAG#v}"
|
|
||||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
|
|
||||||
PATCH=$((PATCH + 1))
|
|
||||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
|
||||||
fi
|
|
||||||
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
echo "New version: ${NEW_VERSION}"
|
|
||||||
|
|
||||||
# Update tauri.conf.json
|
|
||||||
jq --arg v "${NEW_VERSION}" '.version = $v' apps/tauri/tauri.conf.json > /tmp/tauri.conf.json && mv /tmp/tauri.conf.json apps/tauri/tauri.conf.json
|
|
||||||
|
|
||||||
# Update Cargo.toml
|
|
||||||
sed -i "s/^version = \".*\"/version = \"${NEW_VERSION}\"/" apps/tauri/Cargo.toml
|
|
||||||
|
|
||||||
echo "Updated tauri.conf.json and Cargo.toml to ${NEW_VERSION}"
|
|
||||||
|
|
||||||
- name: Init Tauri Android project
|
|
||||||
working-directory: apps/tauri
|
|
||||||
env:
|
|
||||||
JAVA_HOME: ${{ env.JAVA_HOME_21_X64 }}
|
|
||||||
ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }}
|
|
||||||
NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
|
|
||||||
run: |
|
|
||||||
rm -rf gen/android
|
|
||||||
bun tauri android init
|
|
||||||
|
|
||||||
- name: Generate Android launcher icons from SVG logo
|
|
||||||
working-directory: apps/tauri
|
|
||||||
run: bun run scripts/generate-icons.js
|
|
||||||
|
|
||||||
- name: Patch AndroidManifest (CAMERA permission + deep link)
|
|
||||||
working-directory: apps/tauri
|
|
||||||
run: bash scripts/patch-android-manifest.sh
|
|
||||||
|
|
||||||
- name: Build Tauri Android APK
|
|
||||||
working-directory: apps/tauri
|
|
||||||
env:
|
|
||||||
JAVA_HOME: ${{ env.JAVA_HOME_21_X64 }}
|
|
||||||
ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }}
|
|
||||||
NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
|
|
||||||
run: bun tauri android build --apk
|
|
||||||
|
|
||||||
- name: Decode keystore
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: |
|
|
||||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/tauri/zeavis.keystore
|
|
||||||
|
|
||||||
- name: Sign APK
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
APK_UNSIGNED=$(find apps/tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*-signed*' | head -1)
|
|
||||||
APK_SIGNED="apps/tauri/gen/android/app/build/outputs/apk/universal/release/zeavis-edu-v${{ steps.version.outputs.version }}.apk"
|
|
||||||
$ANDROID_SDK_ROOT/build-tools/36.0.0/apksigner sign \
|
|
||||||
--ks apps/tauri/zeavis.keystore \
|
|
||||||
--ks-pass "pass:${ANDROID_KEYSTORE_PASSWORD}" \
|
|
||||||
--ks-key-alias "${ANDROID_KEY_ALIAS}" \
|
|
||||||
--key-pass "pass:${ANDROID_KEY_PASSWORD}" \
|
|
||||||
--out "$APK_SIGNED" \
|
|
||||||
"$APK_UNSIGNED"
|
|
||||||
echo "signed_apk=${APK_SIGNED}" >> $GITHUB_ENV
|
|
||||||
echo "Signed APK: $APK_SIGNED"
|
|
||||||
ls -lh "$APK_SIGNED"
|
|
||||||
|
|
||||||
- name: Create Release and upload APK
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
id: release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: v${{ steps.version.outputs.version }}
|
|
||||||
name: v${{ steps.version.outputs.version }}
|
|
||||||
body: |
|
|
||||||
ZeaVis Edu Android APK v${{ steps.version.outputs.version }}
|
|
||||||
|
|
||||||
📦 Built from ${{ github.sha }}
|
|
||||||
🔗 Triggered by ${{ github.actor }}
|
|
||||||
|
|
||||||
### Install
|
|
||||||
Download the APK below and install on your Android device.
|
|
||||||
|
|
||||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
|
||||||
files: |
|
|
||||||
${{ env.signed_apk }}
|
|
||||||
apps/tauri/gen/android/app/build/outputs/bundle/**/*.aab
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
+110
-186
@@ -1,209 +1,133 @@
|
|||||||
name: Build and Deploy
|
name: Build & Deploy (Nix)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
concurrency:
|
||||||
REGISTRY: ghcr.io
|
group: zeavis-deploy
|
||||||
VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL || '' }}
|
cancel-in-progress: false
|
||||||
jobs:
|
|
||||||
build:
|
permissions:
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
id-token: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||||
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install deps (root workspace)
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Test API
|
||||||
|
working-directory: apps/api
|
||||||
|
run: bun test
|
||||||
|
|
||||||
|
build-and-deploy:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
max-parallel: 1
|
||||||
matrix:
|
matrix:
|
||||||
service:
|
service: [api, ml-service, web]
|
||||||
- name: web
|
|
||||||
dockerfile: apps/web/Dockerfile
|
|
||||||
- name: api
|
|
||||||
dockerfile: apps/api/Dockerfile
|
|
||||||
- name: ml
|
|
||||||
dockerfile: apps/ml-service/Dockerfile
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set image prefix
|
|
||||||
run: echo "IMAGE_PREFIX=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Set up Python for model download & export
|
|
||||||
if: matrix.service.name == 'ml'
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
fetch-depth: 0
|
||||||
|
submodules: false
|
||||||
|
|
||||||
- name: Download ONNX model from Hugging Face
|
- name: Install Nix
|
||||||
if: matrix.service.name == 'ml'
|
uses: DeterminateSystems/nix-installer-action@v22
|
||||||
working-directory: Machine_Learning
|
with:
|
||||||
env:
|
determinate: false
|
||||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
extra-conf: |
|
||||||
|
sandbox = false
|
||||||
|
accept-flake-config = true
|
||||||
|
|
||||||
|
- name: Cache Nix
|
||||||
|
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||||
|
with:
|
||||||
|
use-flakehub: false
|
||||||
|
|
||||||
|
- name: Build zeavis-${{ matrix.service }}
|
||||||
|
id: build
|
||||||
run: |
|
run: |
|
||||||
|
STORE_PATH=$(nix build .#${{ matrix.service }} --impure --option sandbox false --no-link --print-out-paths | tail -1)
|
||||||
|
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Build OK zeavis-${{ matrix.service }}: $STORE_PATH"
|
||||||
|
|
||||||
|
- name: Setup SSH key
|
||||||
|
env:
|
||||||
|
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
sed -i 's/\r$//' ~/.ssh/id_ed25519
|
||||||
|
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||||
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
- name: Deploy zeavis-${{ matrix.service }} to VPS
|
||||||
|
run: |
|
||||||
|
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
||||||
|
echo "=== Copying zeavis-${{ matrix.service }}: $STORE_PATH ==="
|
||||||
|
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
||||||
|
|
||||||
|
echo "=== Updating profile + restarting ==="
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" "
|
||||||
set -eu
|
set -eu
|
||||||
echo "::group::Install huggingface_hub"
|
if [ -d /nix/var/nix/profiles/zeavis-${{ matrix.service }} ] && [ ! -L /nix/var/nix/profiles/zeavis-${{ matrix.service }} ]; then
|
||||||
python -m pip install --upgrade pip -q
|
rm -rf /nix/var/nix/profiles/zeavis-${{ matrix.service }}
|
||||||
python -m pip install huggingface_hub -q
|
|
||||||
echo "::endgroup::"
|
|
||||||
|
|
||||||
echo "::group::Check HF_TOKEN"
|
|
||||||
if [ -z "${HF_TOKEN:-}" ]; then
|
|
||||||
echo "ERROR: HF_TOKEN secret is not set."
|
|
||||||
echo "Add it: https://github.com/ATLAS-PJK-GM007/ZeaVis-Edu/settings/secrets/actions"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
echo "HF_TOKEN is set (length: ${#HF_TOKEN})"
|
sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/zeavis-${{ matrix.service }} --set '$STORE_PATH'
|
||||||
echo "::endgroup::"
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable zeavis-${{ matrix.service }} 2>/dev/null || true
|
||||||
echo "::group::Download model files from Hugging Face"
|
sudo systemctl restart zeavis-${{ matrix.service }}
|
||||||
python -c "
|
for i in \$(seq 1 30); do
|
||||||
from huggingface_hub import hf_hub_download
|
systemctl is-active --quiet zeavis-${{ matrix.service }} && break
|
||||||
import os, shutil
|
sleep 1
|
||||||
repo = 'MythEclipse2737/zeavis-edu-corn-leaf-classifier'
|
done
|
||||||
token = os.environ['HF_TOKEN']
|
systemctl is-active zeavis-${{ matrix.service }} || {
|
||||||
base = os.path.abspath('.')
|
echo '=== SERVICE FAILED — journal ==='
|
||||||
|
journalctl -u zeavis-${{ matrix.service }} -n 40 --no-pager
|
||||||
# Files sit at root of HF repo → copy to correct subdirs
|
exit 1
|
||||||
# model.onnx goes to model/ for Docker COPY
|
}
|
||||||
os.makedirs(os.path.join(base, 'model'), exist_ok=True)
|
systemctl status zeavis-${{ matrix.service }} --no-pager 2>&1 | head -8
|
||||||
os.makedirs(os.path.join(base, 'best_model'), exist_ok=True)
|
|
||||||
|
|
||||||
# ONNX → model/model.onnx (Docker expects this path)
|
|
||||||
p = hf_hub_download(repo_id=repo, filename='model.onnx', token=token)
|
|
||||||
shutil.copy2(p, os.path.join(base, 'model', 'model.onnx'))
|
|
||||||
print('model/model.onnx OK')
|
|
||||||
|
|
||||||
# TFLite (optional, for edge)
|
|
||||||
p = hf_hub_download(repo_id=repo, filename='model.tflite', token=token)
|
|
||||||
shutil.copy2(p, os.path.join(base, 'model', 'model.tflite'))
|
|
||||||
print('model/model.tflite OK')
|
|
||||||
|
|
||||||
# Labels
|
|
||||||
p = hf_hub_download(repo_id=repo, filename='labels.json', token=token)
|
|
||||||
shutil.copy2(p, os.path.join(base, 'model', 'labels.json'))
|
|
||||||
print('model/labels.json OK')
|
|
||||||
|
|
||||||
# Keras model + calibration for re-export
|
|
||||||
p = hf_hub_download(repo_id=repo, filename='best_model.keras', token=token)
|
|
||||||
shutil.copy2(p, os.path.join(base, 'best_model', 'best_model.keras'))
|
|
||||||
print('best_model/best_model.keras OK')
|
|
||||||
|
|
||||||
p = hf_hub_download(repo_id=repo, filename='calibration.json', token=token)
|
|
||||||
shutil.copy2(p, os.path.join(base, 'best_model', 'calibration.json'))
|
|
||||||
print('best_model/calibration.json OK')
|
|
||||||
"
|
"
|
||||||
ls -lh model/model.onnx model/model.tflite model/labels.json best_model/best_model.keras 2>/dev/null
|
echo "✅ zeavis-${{ matrix.service }} deployed"
|
||||||
echo "::endgroup::"
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
cleanup:
|
||||||
uses: docker/login-action@v3
|
# Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama
|
||||||
with:
|
# + nix store gc. Profil yang sedang dipakai tidak disentuh.
|
||||||
registry: ${{ env.REGISTRY }}
|
needs: build-and-deploy
|
||||||
username: ${{ github.actor }}
|
if: always()
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.IMAGE_PREFIX }}/${{ matrix.service.name }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=sha
|
|
||||||
|
|
||||||
- name: Build and push image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ${{ matrix.service.dockerfile }}
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
build-args: |
|
|
||||||
VITE_API_BASE_URL=${{ env.VITE_API_BASE_URL }}
|
|
||||||
cache-from: type=gha,scope=${{ matrix.service.name }}
|
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.service.name }}
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: read
|
|
||||||
steps:
|
steps:
|
||||||
- name: Validate deploy secrets
|
- name: Nix GC on VPS
|
||||||
env:
|
env:
|
||||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||||
VPS_USER: ${{ secrets.VPS_USER }}
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_SSH_KEY" ]; then
|
mkdir -p ~/.ssh
|
||||||
echo "Missing deploy secrets: VPS_HOST, VPS_USER, VPS_SSH_KEY." >&2
|
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
exit 1
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
fi
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" "sudo /usr/local/bin/nix-gc-vps.sh" || echo "⚠️ Nix GC gagal (non-fatal)"
|
||||||
- name: Deploy to VPS
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.VPS_HOST }}
|
|
||||||
username: ${{ secrets.VPS_USER }}
|
|
||||||
key: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
passphrase: ${{ secrets.VPS_SSH_PASSPHRASE }}
|
|
||||||
port: ${{ secrets.VPS_PORT || 22 }}
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/ZeaVis-Edu}"
|
|
||||||
REPO_SLUG="$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
|
|
||||||
if [ ! -d "$DEPLOY_PATH/.git" ]; then
|
|
||||||
mkdir -p "$DEPLOY_PATH"
|
|
||||||
git clone https://github.com/${{ github.repository }}.git "$DEPLOY_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$DEPLOY_PATH"
|
|
||||||
git fetch origin main
|
|
||||||
git reset --hard origin/main
|
|
||||||
|
|
||||||
{
|
|
||||||
printf 'GITHUB_REPOSITORY=%s\n' "$REPO_SLUG"
|
|
||||||
cat << 'ENVEOF'
|
|
||||||
DATABASE_URL=${{ secrets.DATABASE_URL }}
|
|
||||||
SESSION_SECRET=${{ secrets.SESSION_SECRET }}
|
|
||||||
WEB_APP_URL=https://zeavisedu.asepharyana.my.id
|
|
||||||
ML_SERVICE_URL=http://zeavis-ml:8000
|
|
||||||
TS_IP=${{ vars.TS_IP || '100.96.248.86' }}
|
|
||||||
GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}
|
|
||||||
GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
|
|
||||||
GOOGLE_REDIRECT_URI=https://zeavisedu.asepharyana.my.id/api/v1/auth/google/callback
|
|
||||||
ENVEOF
|
|
||||||
} > .env
|
|
||||||
|
|
||||||
docker login ghcr.io -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
docker network create app-shared-net 2>/dev/null || true
|
|
||||||
docker network create telemetry-net 2>/dev/null || true
|
|
||||||
docker compose down --remove-orphans || true
|
|
||||||
docker rm -f zeavis-web zeavis-api zeavis-ml zeavis-node-exporter 2>/dev/null || true
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
# Wait for containers to be healthy (up to 60s)
|
|
||||||
wait_container() {
|
|
||||||
local name=$1
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
docker compose ps | grep -q "${name}.*Up" && return 0
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
wait_container zeavis-web || exit 1
|
|
||||||
wait_container zeavis-api || exit 1
|
|
||||||
wait_container zeavis-ml || exit 1
|
|
||||||
wait_container zeavis-node-exporter || echo "⚠️ node_exporter not running (non-fatal)"
|
|
||||||
docker compose ps
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Publish to FlakeHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
flakehub-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: DeterminateSystems/determinate-nix-action@main
|
||||||
|
- uses: DeterminateSystems/flakehub-push@main
|
||||||
|
with:
|
||||||
|
visibility: public
|
||||||
|
rolling: true
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Mirror to Gitea
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Mirror to Gitea
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git remote add gitea "https://oauth2:${GITEA_TOKEN}@git.imrnes.team/MythEclipse/zeavis-edu.git"
|
||||||
|
git push --mirror gitea
|
||||||
|
echo "✅ Mirrored to Gitea (MythEclipse/zeavis-edu)"
|
||||||
@@ -142,19 +142,31 @@ make telemetry-down
|
|||||||
|
|
||||||
## Telemetry architecture
|
## Telemetry architecture
|
||||||
|
|
||||||
The repository includes a full Prometheus → ClickHouse metric pipeline as a git submodule at `telemetry/`. Each ZeaVis Edu service exposes a `GET /metrics` endpoint:
|
The telemetry stack lives as a git submodule at `telemetry/` (repo `MythEclipse/Telemetry`). Architecture:
|
||||||
|
|
||||||
|
| Layer | Service | Role |
|
||||||
|
|-------|---------|------|
|
||||||
|
| Collector & Storage | **Prometheus** | Metric scraping & TSDB storage |
|
||||||
|
| System metrics | **Node Exporter** | CPU, memory, disk per host |
|
||||||
|
| Query | **Query Proxy** | REST API over Prometheus HTTP API |
|
||||||
|
| Visualization | **Grafana** | OSS dashboard & PromQL |
|
||||||
|
| Entry point | **Telemetry UI** | nginx + Vue 3 SPA |
|
||||||
|
|
||||||
|
Data flow: Node Exporter → Prometheus scrape (every 15s) → Grafana (PromQL) / Query Proxy (/api/metrics).
|
||||||
|
|
||||||
|
Each ZeaVis Edu service exposes a `GET /metrics` endpoint:
|
||||||
|
|
||||||
- **Web app** (`apps/web`): In dev mode, a Vite plugin serves client-side session metrics (page views, Web Vitals). In production, nginx proxies `/metrics` to the API service. Source: `apps/web/src/lib/telemetry.ts`, `apps/web/vite-plugin-metrics.ts`.
|
- **Web app** (`apps/web`): In dev mode, a Vite plugin serves client-side session metrics (page views, Web Vitals). In production, nginx proxies `/metrics` to the API service. Source: `apps/web/src/lib/telemetry.ts`, `apps/web/vite-plugin-metrics.ts`.
|
||||||
- **API** (`apps/api`): Uses `prom-client` for Node.js default metrics plus custom HTTP, auth, classification, and diagnosis counters/histograms. Source: `apps/api/src/lib/telemetry.ts`, exposed via `apps/api/src/routes/metrics.ts`.
|
- **API** (`apps/api`): Uses `prom-client` for Node.js default metrics plus custom HTTP, auth, classification, and diagnosis counters/histograms. Source: `apps/api/src/lib/telemetry.ts`, exposed via `apps/api/src/routes/metrics.ts`.
|
||||||
- **ML service** (`apps/ml-service`): Uses the `prometheus` Rust crate for HTTP metrics, prediction counts, and model load status. Source: `apps/ml-service/src/telemetry.rs`.
|
- **ML service** (`apps/ml-service`): Uses the `prometheus` Rust crate for HTTP metrics, prediction counts, and model load status. Source: `apps/ml-service/src/telemetry.rs`.
|
||||||
|
|
||||||
All three share the `zeavis_` metric prefix and are scraped by the Telemetry Prometheus instance via `file_sd_configs` (see `telemetry/prometheus/targets/zeavis-edu.json`).
|
All three share the `zeavis_` metric prefix and are scraped by Prometheus via `file_sd_configs` (see `telemetry/prometheus/targets/zeavis-edu.json`).
|
||||||
|
|
||||||
**IMPORTANT — Production architecture:** ZeaVis Edu apps and the Telemetry stack run on **separate VPS instances** connected via **Tailscale** (mesh VPN). Prometheus scrapes the API and ML service through their **Tailscale IPs** (e.g. `100.x.x.a:3000`), not via Docker hostnames. The target file `telemetry/prometheus/targets/zeavis-edu.json` has `__CHANGE_ME__` placeholders — before deploying, replace with the actual Tailscale IPs of the app VPS.
|
**IMPORTANT — Production architecture:** ZeaVis Edu apps and the Telemetry stack run on **separate VPS instances** connected via **Tailscale** (mesh VPN). Prometheus scrapes the API and ML service through their **Tailscale IPs** (e.g. `100.121.180.82:4006`), not via Docker hostnames. The target file has `__CHANGE_ME__` placeholders — replace with actual Tailscale IPs before deploying.
|
||||||
|
|
||||||
The telemetry stack is managed from the project root via `make telemetry-*` targets (see `Makefile`). The Docker Compose files in `telemetry/deploy/` define 6 services (Prometheus, Metric Ingester, Vector, ClickHouse, Query Proxy, Telemetry UI).
|
The telemetry stack is managed from the project root via `make telemetry-*` targets (see `Makefile`). Docker Compose defines 5 services (Prometheus, Node Exporter, Query Proxy, Grafana, Telemetry UI).
|
||||||
|
|
||||||
For **local single-host dev**, Prometheus can reach app services via a shared Docker network (`app-shared-net`). Use `make telemetry-up-local` for this mode — it includes the `docker-compose.telemetry.yml` override.
|
For **local single-host dev**, Prometheus can reach app services via a shared Docker network (`app-shared-net`). Use `make telemetry-up-local` for this mode.
|
||||||
|
|
||||||
## Fullstack application architecture
|
## Fullstack application architecture
|
||||||
|
|
||||||
@@ -191,6 +203,34 @@ The following files/directories are generated or externally supplied during the
|
|||||||
- `Machine_Learning/best_model/best_model.keras` — trained model downloaded from Colab/Google Drive.
|
- `Machine_Learning/best_model/best_model.keras` — trained model downloaded from Colab/Google Drive.
|
||||||
- `Machine_Learning/model/saved_model/`, `model/model.tflite`, `model/model.onnx`, and `model/tfjs_model/` — production exports.
|
- `Machine_Learning/model/saved_model/`, `model/model.tflite`, `model/model.onnx`, and `model/tfjs_model/` — production exports.
|
||||||
|
|
||||||
|
## Android Google OAuth (Tauri) — known issues & fixes
|
||||||
|
|
||||||
|
The Tauri Android app uses Chrome's `intent://` protocol to bounce back from Google's OAuth browser page. Three bugs were found and fixed in commit `c75cba2`:
|
||||||
|
|
||||||
|
### 1. API base URL falls back to `http://tauri.localhost`
|
||||||
|
|
||||||
|
**Symptom:** Google login button navigates to `http://tauri.localhost/api/v1/auth/google` → 404.
|
||||||
|
**Root cause:** `auth-form.tsx` used `import.meta.env.VITE_API_BASE_URL || window.location.origin`. In Android WebView the origin is `http://tauri.localhost` (Vite dev server), not the API server.
|
||||||
|
**Fix:** Import shared `apiBaseUrl` from `api-client.ts` which already has the correct fallback: `import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id'`.
|
||||||
|
|
||||||
|
### 2. `deep-link:get_current` IPC promise orphaned on SPA navigation
|
||||||
|
|
||||||
|
**Symptom:** `Cannot read properties of undefined (reading 'runCallback')` floods log; OAuth never completes.
|
||||||
|
**Root cause:** `plugin:deep-link|get_current` returns a JS promise that stays pending. When React Router's `navigate()` changes the URL (SPA, no page reload), the Tauri IPC bridge invalidates the pending callback reference — but the promise never resolves or rejects cleanly, so `.runCallback` is undefined.
|
||||||
|
**Fix (cold start):** `get_current` resolves via `window.location.href = target` (full reload). At boot there is no SPA state to lose, so a hard redirect is safe.
|
||||||
|
**Fix (warm start / `deep-link://new-url` event):** Store target in `sessionStorage` + dispatch a custom DOM event. A `<DeepLinkRouterHandler>` root layout route listens for the event and calls React Router's `navigate()`, keeping SPA state alive.
|
||||||
|
|
||||||
|
### 3. LoginPage `?token=` effect does not re-run on SPA navigation
|
||||||
|
|
||||||
|
**Symptom:** App navigates to `/login?token=xxx` but stays on the login form.
|
||||||
|
**Root cause:** The `useEffect` that reads `?token` and exchanges it for a session only listed `[setUser, queryClient, navigate]` as deps. React Router SPA navigation changes `location.search` but does not remount the component — so the effect never re-runs.
|
||||||
|
**Fix:** Added `location.search` to the effect's dependency array. Also added `visibilitychange` and `focus` event listeners as a backup — when the user returns from the Google OAuth browser tab, the app picks up the token from the URL even if the deep-link plugin's event was missed.
|
||||||
|
|
||||||
|
### Design rule for Tauri deep-link handlers
|
||||||
|
|
||||||
|
- **Cold start** (app was not running) → safe to use `window.location.href` (full reload). The React app has just booted, no state to lose.
|
||||||
|
- **Warm start** (app was running, user returns from system browser) → use React Router `navigate()` via custom events / sessionStorage. Do NOT use `window.location.href` — it triggers a full page unload which orphan Tauri IPC promises.
|
||||||
|
|
||||||
## Notes for future changes
|
## Notes for future changes
|
||||||
|
|
||||||
- Keep README command examples and this file in sync when changing the ML pipeline.
|
- Keep README command examples and this file in sync when changing the ML pipeline.
|
||||||
|
|||||||
+7
-7
@@ -10,15 +10,15 @@ application stack and the payload each service provides.
|
|||||||
| Service | Host (prod) | Metrics Endpoint | Port (local) |
|
| Service | Host (prod) | Metrics Endpoint | Port (local) |
|
||||||
|-----------------------|-----------------------------------|----------------------------|--------------|
|
|-----------------------|-----------------------------------|----------------------------|--------------|
|
||||||
| Web (Vite dev) | `zeavisedu.asepharyana.my.id` | `GET /metrics` | 5173 |
|
| Web (Vite dev) | `zeavisedu.asepharyana.my.id` | `GET /metrics` | 5173 |
|
||||||
| API (Elysia) | `api-zeavisedu.asepharyana.my.id` | `GET /metrics` | 3000 |
|
| API (Elysia) | `api-zeavisedu.asepharyana.my.id` | `GET /metrics` | 4006 |
|
||||||
| ML Service (Axum) | `ml-zeavisedu.asepharyana.my.id` | `GET /metrics` | 8000 |
|
| ML Service (Axum) | `ml-zeavisedu.asepharyana.my.id` | `GET /metrics` | 4012 |
|
||||||
| Prometheus Collector | — | `GET /metrics` (self) | 9090 |
|
| Prometheus Collector | — | `GET /metrics` (self) | 9090 |
|
||||||
|
|
||||||
> In production all metrics are scraped by the Prometheus collector running in the
|
> In production all metrics are scraped by the Prometheus collector running in the
|
||||||
> Telemetry stack on a **separate VPS** connected via **Tailscale**.
|
> Telemetry stack on a **separate VPS** connected via **Tailscale**.
|
||||||
> See [`telemetry/prometheus/targets/`](./telemetry/prometheus/targets/)
|
> See [`telemetry/prometheus/targets/`](./telemetry/prometheus/targets/)
|
||||||
> for the auto‑discovery configuration. Target files must use **Tailscale IPs**
|
> for the auto‑discovery configuration. Target files must use **Tailscale IPs**
|
||||||
> (e.g. `100.x.x.a:3000`), not Docker hostnames, because the services are on
|
> (e.g. `100.121.180.82:4006`), not Docker hostnames, because the services are on
|
||||||
> different hosts.
|
> different hosts.
|
||||||
>
|
>
|
||||||
> In production (nginx), the web app proxies `/metrics` to the API service:
|
> In production (nginx), the web app proxies `/metrics` to the API service:
|
||||||
@@ -101,11 +101,11 @@ The Telemetry submodule includes a Prometheus instance that uses
|
|||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"targets": ["100.x.x.a:3000"],
|
"targets": ["100.121.180.82:4006"],
|
||||||
"labels": { "service": "zeavis-api", "component": "backend", "env": "production" }
|
"labels": { "service": "zeavis-api", "component": "backend", "env": "production" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"targets": ["100.x.x.b:8000"],
|
"targets": ["100.121.180.82:4012"],
|
||||||
"labels": { "service": "zeavis-ml", "component": "inference", "env": "production" }
|
"labels": { "service": "zeavis-ml", "component": "inference", "env": "production" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -113,8 +113,8 @@ The Telemetry submodule includes a Prometheus instance that uses
|
|||||||
|
|
||||||
> ⚠️ **Cross-VPS:** Gunakan **IP Tailscale** (bukan Docker hostname) karena
|
> ⚠️ **Cross-VPS:** Gunakan **IP Tailscale** (bukan Docker hostname) karena
|
||||||
> Prometheus dan ZeaVis Edu berjalan di VPS berbeda. Pastikan port service
|
> Prometheus dan ZeaVis Edu berjalan di VPS berbeda. Pastikan port service
|
||||||
> (`:3000`, `:8000`) terekspos di `0.0.0.0` atau diizinkan oleh aturan
|
> (`:4006`, `:4012`) terekspos di `0.0.0.0` atau diizinkan oleh aturan
|
||||||
> `iptables`/`ufw` untuk interface Tailscale (`tailscale0`/`100.x.x.x/10`).
|
> `iptables`/`ufw` untuk interface Tailscale (`tailscale0`/`100.121.180.82`).
|
||||||
|
|
||||||
The Prometheus config (in `telemetry/prometheus/prometheus.yml`) will
|
The Prometheus config (in `telemetry/prometheus/prometheus.yml`) will
|
||||||
automatically pick up new files within its 15‑second scrape interval —
|
automatically pick up new files within its 15‑second scrape interval —
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Proyek ini menggabungkan **3 dataset** dari sumber berbeda untuk menghasilkan da
|
|||||||
### Dataset 1 — Kaggle (Corn Leaf Disease - Indonesia)
|
### Dataset 1 — Kaggle (Corn Leaf Disease - Indonesia)
|
||||||
> 🔗 https://www.kaggle.com/datasets/ndisan/corn-leaf-disease
|
> 🔗 https://www.kaggle.com/datasets/ndisan/corn-leaf-disease
|
||||||
|
|
||||||
Berisi gambar penyakit daun jagung dengan label dalam Bahasa Indonesia. Dataset ini memiliki **4 folder**, namun label **"Karat Daun" tidak digunakan** karena gambar di dalamnya tidak merepresentasikan penyakit karat yang sebenarnya.
|
Dataset ini berisi 4.000 citra RGB daun jagung yang terbagi ke dalam empat kelas, yaitu daun sehat, hawar daun, bercak daun, dan karat daun. Data dikumpulkan dari lahan jagung di Kabupaten Sampang menggunakan kamera ponsel 16 MP dengan teknik pengambilan gambar yang terkontrol untuk mendukung proses klasifikasi. Pelabelan dan validasi data dilakukan oleh pihak Dinas Pertanian dan POPT Kabupaten Sampang guna menjamin kualitas serta keakuratan dataset.
|
||||||
|
|
||||||
| Folder di Dataset 1 | Tindakan |
|
| Folder di Dataset 1 | Tindakan |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -104,6 +104,7 @@ Berisi gambar penyakit daun jagung dengan label dalam Bahasa Indonesia. Dataset
|
|||||||
### Dataset 2 — Kaggle (Corn or Maize Leaf Disease)
|
### Dataset 2 — Kaggle (Corn or Maize Leaf Disease)
|
||||||
> 🔗 https://www.kaggle.com/datasets/smaranjitghose/corn-or-maize-leaf-disease-dataset
|
> 🔗 https://www.kaggle.com/datasets/smaranjitghose/corn-or-maize-leaf-disease-dataset
|
||||||
|
|
||||||
|
Dataset Corn or Maize Leaf Disease Dataset berisi 4.188 citra RGB daun jagung yang terbagi ke dalam empat kelas, yaitu Common Rust, Gray Leaf Spot, Blight, dan Healthy. Dataset ini merupakan hasil penggabungan PlantVillage dan PlantDoc, sehingga cocok digunakan untuk penelitian klasifikasi penyakit daun jagung menggunakan metode Machine Learning maupun Deep Learning.
|
||||||
Digunakan untuk **menggantikan** data Karat Daun dari Dataset 1 dan menambah variasi gambar Daun Sehat.
|
Digunakan untuk **menggantikan** data Karat Daun dari Dataset 1 dan menambah variasi gambar Daun Sehat.
|
||||||
|
|
||||||
| Folder di Dataset 2 | Dipetakan ke Label |
|
| Folder di Dataset 2 | Dipetakan ke Label |
|
||||||
@@ -116,7 +117,7 @@ Digunakan untuk **menggantikan** data Karat Daun dari Dataset 1 dan menambah var
|
|||||||
### Dataset 3 — SciDB (China Agricultural Dataset)
|
### Dataset 3 — SciDB (China Agricultural Dataset)
|
||||||
> 🔗 https://www.scidb.cn/en/detail?dataSetId=19536c73f6d74946a212719a94f53ab3
|
> 🔗 https://www.scidb.cn/en/detail?dataSetId=19536c73f6d74946a212719a94f53ab3
|
||||||
|
|
||||||
Dataset dengan label berbahasa Mandarin. Digunakan untuk **menambah variasi data** pada tiga kelas utama. Pemetaan label dilakukan menggunakan file `desc.json` yang disertakan dalam dataset.
|
Dataset dengan label berbahasa Mandarin. Digunakan untuk **menambah variasi data** pada tiga kelas utama. Pemetaan label dilakukan menggunakan file `desc.json` yang disertakan dalam dataset. Dataset ini terdiri dari 1.653 pasangan data gambar dan deskripsi teks penyakit daun tanaman. Data gambar dikumpulkan dari berbagai sumber terbuka dan sumber internal, mencakup sembilan jenis penyakit daun. Sementara itu, data teks dibuat melalui anotasi manual berdasarkan literatur dan sumber ilmiah, yang memuat informasi mengenai jenis penyakit, ciri patologis, serta tingkat keparahannya.
|
||||||
|
|
||||||
| Label Mandarin | Dipetakan ke Label |
|
| Label Mandarin | Dipetakan ke Label |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
Binary file not shown.
@@ -10,10 +10,10 @@ import onnxruntime as ort
|
|||||||
import tensorflow as tf
|
import tensorflow as tf
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
# Definisi label kelas sesuai urutan output model klasifikasi
|
||||||
LABELS = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]
|
LABELS = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]
|
||||||
|
|
||||||
|
# Kelas eksepsi kustom untuk menangani ketidaksesuaian akurasi prediksi
|
||||||
class ParityError(RuntimeError):
|
class ParityError(RuntimeError):
|
||||||
"""Raised when Keras and ONNX predictions do not match."""
|
"""Raised when Keras and ONNX predictions do not match."""
|
||||||
pass
|
pass
|
||||||
@@ -33,16 +33,19 @@ def preprocess_image(image_path, input_size):
|
|||||||
Raises:
|
Raises:
|
||||||
ParityError: If image cannot be loaded or processed.
|
ParityError: If image cannot be loaded or processed.
|
||||||
"""
|
"""
|
||||||
|
# Penanganan error secara aman saat memuat gambar ke format RGB
|
||||||
try:
|
try:
|
||||||
img = Image.open(image_path).convert("RGB")
|
img = Image.open(image_path).convert("RGB")
|
||||||
except (FileNotFoundError, UnidentifiedImageError, OSError) as e:
|
except (FileNotFoundError, UnidentifiedImageError, OSError) as e:
|
||||||
raise ParityError(f"Failed to load image {image_path}: {e}")
|
raise ParityError(f"Failed to load image {image_path}: {e}")
|
||||||
|
|
||||||
|
# Penyesuaian resolusi gambar menggunakan metode interpolasi Bilinear
|
||||||
try:
|
try:
|
||||||
img = img.resize((input_size, input_size), Image.Resampling.BILINEAR)
|
img = img.resize((input_size, input_size), Image.Resampling.BILINEAR)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ParityError(f"Failed to resize image {image_path}: {e}")
|
raise ParityError(f"Failed to resize image {image_path}: {e}")
|
||||||
|
|
||||||
|
# Konversi ke matriks float32 dan penambahan dimensi batch (1, H, W, C)
|
||||||
img_array = np.array(img, dtype=np.float32)
|
img_array = np.array(img, dtype=np.float32)
|
||||||
img_batch = np.expand_dims(img_array, axis=0)
|
img_batch = np.expand_dims(img_array, axis=0)
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ def predict_keras(model, image_batch):
|
|||||||
Returns:
|
Returns:
|
||||||
Predictions array (1, num_classes).
|
Predictions array (1, num_classes).
|
||||||
"""
|
"""
|
||||||
|
# Eksekusi inferensi pada model TensorFlow/Keras tanpa log proses
|
||||||
predictions = model.predict(image_batch, verbose=0)
|
predictions = model.predict(image_batch, verbose=0)
|
||||||
return predictions
|
return predictions
|
||||||
|
|
||||||
@@ -75,6 +79,7 @@ def predict_onnx(session, image_batch):
|
|||||||
Returns:
|
Returns:
|
||||||
Predictions array (1, num_classes).
|
Predictions array (1, num_classes).
|
||||||
"""
|
"""
|
||||||
|
# Eksekusi inferensi secara dinamis pada model ONNX menggunakan sesi runtime
|
||||||
input_name = session.get_inputs()[0].name
|
input_name = session.get_inputs()[0].name
|
||||||
predictions = session.run(None, {input_name: image_batch})
|
predictions = session.run(None, {input_name: image_batch})
|
||||||
return predictions[0]
|
return predictions[0]
|
||||||
@@ -94,14 +99,18 @@ def validate_image(image_path, keras_model, onnx_session, input_size, atol):
|
|||||||
Raises:
|
Raises:
|
||||||
ParityError: If predictions do not match or image cannot be processed.
|
ParityError: If predictions do not match or image cannot be processed.
|
||||||
"""
|
"""
|
||||||
|
# Menyiapkan tensor gambar untuk pengujian
|
||||||
img_batch = preprocess_image(image_path, input_size)
|
img_batch = preprocess_image(image_path, input_size)
|
||||||
|
|
||||||
|
# Mengekstrak matriks probabilitas dari kedua format model
|
||||||
keras_pred = predict_keras(keras_model, img_batch)
|
keras_pred = predict_keras(keras_model, img_batch)
|
||||||
onnx_pred = predict_onnx(onnx_session, img_batch)
|
onnx_pred = predict_onnx(onnx_session, img_batch)
|
||||||
|
|
||||||
|
# Mendapatkan indeks kelas dengan probabilitas tertinggi (Top-1)
|
||||||
keras_label_idx = np.argmax(keras_pred[0])
|
keras_label_idx = np.argmax(keras_pred[0])
|
||||||
onnx_label_idx = np.argmax(onnx_pred[0])
|
onnx_label_idx = np.argmax(onnx_pred[0])
|
||||||
|
|
||||||
|
# Validasi keselarasan keputusan klasifikasi utama
|
||||||
if keras_label_idx != onnx_label_idx:
|
if keras_label_idx != onnx_label_idx:
|
||||||
keras_label = LABELS[keras_label_idx]
|
keras_label = LABELS[keras_label_idx]
|
||||||
onnx_label = LABELS[onnx_label_idx]
|
onnx_label = LABELS[onnx_label_idx]
|
||||||
@@ -110,6 +119,7 @@ def validate_image(image_path, keras_model, onnx_session, input_size, atol):
|
|||||||
f"Keras={keras_label}, ONNX={onnx_label}"
|
f"Keras={keras_label}, ONNX={onnx_label}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validasi selisih nilai desimal probabilitas menggunakan toleransi absolut
|
||||||
if not np.allclose(keras_pred, onnx_pred, atol=atol):
|
if not np.allclose(keras_pred, onnx_pred, atol=atol):
|
||||||
max_diff = np.max(np.abs(keras_pred - onnx_pred))
|
max_diff = np.max(np.abs(keras_pred - onnx_pred))
|
||||||
raise ParityError(
|
raise ParityError(
|
||||||
@@ -117,6 +127,7 @@ def validate_image(image_path, keras_model, onnx_session, input_size, atol):
|
|||||||
f"max difference={max_diff:.6e} (atol={atol})"
|
f"max difference={max_diff:.6e} (atol={atol})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Pencatatan log sistem jika kedua model presisi 100%
|
||||||
label = LABELS[keras_label_idx]
|
label = LABELS[keras_label_idx]
|
||||||
logging.info(f"PASS: {image_path} -> {label}")
|
logging.info(f"PASS: {image_path} -> {label}")
|
||||||
|
|
||||||
@@ -125,6 +136,7 @@ def main():
|
|||||||
"""Validate parity between Keras and ONNX models."""
|
"""Validate parity between Keras and ONNX models."""
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
|
||||||
|
# Inisialisasi parser argumen untuk antarmuka CLI (Command Line Interface)
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Validate parity between Keras and ONNX models"
|
description="Validate parity between Keras and ONNX models"
|
||||||
)
|
)
|
||||||
@@ -161,6 +173,7 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Pengecekan eksistensi berkas model sebelum memuat memori
|
||||||
if not args.keras_model.exists():
|
if not args.keras_model.exists():
|
||||||
msg = f"Keras model not found at {args.keras_model}"
|
msg = f"Keras model not found at {args.keras_model}"
|
||||||
logging.error(msg)
|
logging.error(msg)
|
||||||
@@ -171,15 +184,18 @@ def main():
|
|||||||
logging.error(msg)
|
logging.error(msg)
|
||||||
raise FileNotFoundError(msg)
|
raise FileNotFoundError(msg)
|
||||||
|
|
||||||
|
# Memuat model Keras (tanpa kompilasi agar lebih hemat beban komputasi)
|
||||||
logging.info(f"Loading Keras model from {args.keras_model}...")
|
logging.info(f"Loading Keras model from {args.keras_model}...")
|
||||||
keras_model = tf.keras.models.load_model(args.keras_model, compile=False)
|
keras_model = tf.keras.models.load_model(args.keras_model, compile=False)
|
||||||
|
|
||||||
|
# Memuat sesi ONNX dengan penyedia eksekusi CPU murni
|
||||||
logging.info(f"Loading ONNX model from {args.onnx_model}...")
|
logging.info(f"Loading ONNX model from {args.onnx_model}...")
|
||||||
onnx_session = ort.InferenceSession(
|
onnx_session = ort.InferenceSession(
|
||||||
str(args.onnx_model),
|
str(args.onnx_model),
|
||||||
providers=["CPUExecutionProvider"],
|
providers=["CPUExecutionProvider"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Iterasi pengujian paritas (kesetaraan performa) untuk setiap gambar
|
||||||
logging.info(f"Validating {len(args.images)} image(s)...")
|
logging.info(f"Validating {len(args.images)} image(s)...")
|
||||||
for image_path in args.images:
|
for image_path in args.images:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# ZeaVis Edu — Root Makefile
|
# ZeaVis Edu — Root Makefile
|
||||||
#
|
#
|
||||||
# Orchestrates the application stack (web, api, ml) and the telemetry
|
# Orchestrates the application stack (web, api, ml) and the telemetry
|
||||||
# metric pipeline (Prometheus → Ingester → Vector → ClickHouse).
|
# metric pipeline (Prometheus → Grafana).
|
||||||
#
|
#
|
||||||
# Telemetry commands operate on the submodule at telemetry/.
|
# Telemetry commands operate on the submodule at telemetry/.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Proyek ini merupakan **Capstone Project** dalam program **Pijak × IBM SkillsBui
|
|||||||
|
|
||||||
| NPM | Nama | Learning Path | Peran |
|
| NPM | Nama | Learning Path | Peran |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| APC246D6Y0028 | **Asep Haryana Saputra** | Back-End | Arsitektur sistem, RESTful API, deployment Docker/Cloud, keamanan upload stream |
|
| APC246D6Y0028 | **Asep Haryana Saputra** | Back-End | Arsitektur sistem, RESTful API, deployment Nix/Cloud, keamanan upload stream |
|
||||||
| APC013D6X0081 | **Selly Supriyatin** | Front-End | UI/UX responsif, mekanisme unggah gambar, modul edukasi (rekomendasi obat & penanganan) |
|
| APC013D6X0081 | **Selly Supriyatin** | Front-End | UI/UX responsif, mekanisme unggah gambar, modul edukasi (rekomendasi obat & penanganan) |
|
||||||
| APC013D6Y0091 | **Taufik Pathurrohman** | Machine Learning | Data Engineering — ekstraksi dataset, cleaning, augmentasi gambar |
|
| APC013D6Y0091 | **Taufik Pathurrohman** | Machine Learning | Data Engineering — ekstraksi dataset, cleaning, augmentasi gambar |
|
||||||
| APC414D6Y0138 | **Luhung Pandyaska Suyi** | Machine Learning | Model Architecture & Training — CNN, hyperparameter tuning |
|
| APC414D6Y0138 | **Luhung Pandyaska Suyi** | Machine Learning | Model Architecture & Training — CNN, hyperparameter tuning |
|
||||||
@@ -66,8 +66,11 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Arsitektur Model | **EfficientNetV2B0** — keseimbangan optimal antara akurasi dan efisiensi parameter |
|
| Arsitektur Model | **EfficientNetV2B0** — keseimbangan optimal antara akurasi dan efisiensi parameter |
|
||||||
| Metode Pelatihan | **Transfer Learning** pada Google Colab (GPU T4) |
|
| Metode Pelatihan | **Transfer Learning** pada Google Colab (GPU T4) |
|
||||||
| Sumber Dataset | Kaggle — [Corn Leaf Disease](https://www.kaggle.com/datasets/ndisan/corn-leaf-disease) |
|
| Sumber Dataset 1 | Kaggle — [Corn Leaf Disease](https://www.kaggle.com/datasets/ndisan/corn-leaf-disease) |
|
||||||
| Deployment | VPS dengan Docker, ONNX Runtime untuk inferensi real-time |
|
| Sumber Dataset 2 | Kaggle — [Corn or Maize Leaf Disease Dataset](https://www.kaggle.com/datasets/smaranjitghose/corn-or-maize-leaf-disease-dataset) |
|
||||||
|
| Sumber Dataset 3 | scidb — [Dataset of Corn Leaf Diseases based on Manual Annotation and Contrast Generation Model](https://www.scidb.cn/en/detail?dataSetId=19536c73f6d74946a212719a94f53ab3) |
|
||||||
|
|
||||||
|
| Deployment | VPS dengan Nix + systemd + Caddy, ONNX Runtime untuk inferensi real-time |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -98,7 +101,7 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
|
|||||||
| 1 | **Pengumpulan Data** | Dataset gambar 3 penyakit + 1 daun sehat dari Kaggle beserta pelabelan |
|
| 1 | **Pengumpulan Data** | Dataset gambar 3 penyakit + 1 daun sehat dari Kaggle beserta pelabelan |
|
||||||
| 2 | **Model ML** | Model Computer Vision terlatih di Google Colab, siap produksi |
|
| 2 | **Model ML** | Model Computer Vision terlatih di Google Colab, siap produksi |
|
||||||
| 3 | **UI Antarmuka** | Front-End berbasis React + Vite dengan fitur unggah gambar |
|
| 3 | **UI Antarmuka** | Front-End berbasis React + Vite dengan fitur unggah gambar |
|
||||||
| 4 | **Back-End Integration** | API + ML Service untuk inferensi real-time via Docker |
|
| 4 | **Back-End Integration** | API + ML Service untuk inferensi real-time via Nix + systemd |
|
||||||
| 5 | **Prototipe Akhir** | Aplikasi Web + Android (Tauri 2) dengan klasifikasi & modul edukasi (rekomendasi obat & penanganan) |
|
| 5 | **Prototipe Akhir** | Aplikasi Web + Android (Tauri 2) dengan klasifikasi & modul edukasi (rekomendasi obat & penanganan) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -120,7 +123,7 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
|
|||||||
| Risiko | Solusi |
|
| Risiko | Solusi |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Overfitting akibat imbalanced data** | Augmentasi tingkat lanjut (kecerahan, noise, rotasi) + confidence threshold < 75% → minta user foto ulang |
|
| **Overfitting akibat imbalanced data** | Augmentasi tingkat lanjut (kecerahan, noise, rotasi) + confidence threshold < 75% → minta user foto ulang |
|
||||||
| **Server downtime / latensi tinggi** | Batasan upload ≤ 5 MB + kompresi server-side + rate limiting + container Docker isolasi resource |
|
| **Server downtime / latensi tinggi** | Batasan upload ≤ 5 MB + kompresi server-side + rate limiting + isolasi resource per-service (systemd) |
|
||||||
| **Foto blur / objek bukan daun jagung** | Panduan visual (overlay) pada UI + validasi anomali + disclaimer "alat bantu edukasi, bukan pengganti POPT" |
|
| **Foto blur / objek bukan daun jagung** | Panduan visual (overlay) pada UI + validasi anomali + disclaimer "alat bantu edukasi, bukan pengganti POPT" |
|
||||||
| **Bottleneck integrasi ML ↔ API ↔ UI** | API Contract ketat di minggu ke-1 + integrasi bertahap (CI) mulai minggu ke-3 |
|
| **Bottleneck integrasi ML ↔ API ↔ UI** | API Contract ketat di minggu ke-1 + integrasi bertahap (CI) mulai minggu ke-3 |
|
||||||
|
|
||||||
@@ -141,7 +144,7 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
|
|||||||
│ └── README.md # ⤷ Panduan deployment multi-VPS
|
│ └── README.md # ⤷ Panduan deployment multi-VPS
|
||||||
├── packages/shared/ # Tipe & utilitas TypeScript bersama
|
├── packages/shared/ # Tipe & utilitas TypeScript bersama
|
||||||
├── telemetry/ # Submodule — Prometheus → ClickHouse pipeline
|
├── telemetry/ # Submodule — Prometheus → ClickHouse pipeline
|
||||||
├── docker-compose.yml # Konfigurasi deployment container
|
├── flake.nix # Konfigurasi deployment Nix (systemd services)
|
||||||
├── package.json # Root workspace Bun + Moon
|
├── package.json # Root workspace Bun + Moon
|
||||||
└── README.md # ⤷ Anda di sini
|
└── README.md # ⤷ Anda di sini
|
||||||
```
|
```
|
||||||
@@ -153,7 +156,7 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
|
|||||||
| API Backend | Bun, Elysia, Drizzle ORM, PostgreSQL | `apps/api/` |
|
| API Backend | Bun, Elysia, Drizzle ORM, PostgreSQL | `apps/api/` |
|
||||||
| ML Inference Engine | Rust, Axum, ONNX Runtime | [`apps/ml-service/README.md`](apps/ml-service/README.md) |
|
| ML Inference Engine | Rust, Axum, ONNX Runtime | [`apps/ml-service/README.md`](apps/ml-service/README.md) |
|
||||||
| ML Pipeline | Python, TensorFlow/Keras, EfficientNetV2B0 | [`Machine_Learning/README.md`](Machine_Learning/README.md) |
|
| ML Pipeline | Python, TensorFlow/Keras, EfficientNetV2B0 | [`Machine_Learning/README.md`](Machine_Learning/README.md) |
|
||||||
| Infrastruktur | Docker, Coolify, Traefik, Tailscale | [`infra/README.md`](infra/README.md) |
|
| Infrastruktur | Nix, systemd, Caddy, Tailscale | [`infra/README.md`](infra/README.md) |
|
||||||
| Telemetry | Prometheus, ClickHouse, Vector, Vue 3 | `telemetry/` |
|
| Telemetry | Prometheus, ClickHouse, Vector, Vue 3 | `telemetry/` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -174,7 +177,7 @@ Python • TensorFlow/Keras • EfficientNetV2B0 • Google Colab (GPU
|
|||||||
**Rust** • **Axum** • **ONNX Runtime** • TFLite • TensorFlow.js
|
**Rust** • **Axum** • **ONNX Runtime** • TFLite • TensorFlow.js
|
||||||
|
|
||||||
### DevOps & Infrastruktur
|
### DevOps & Infrastruktur
|
||||||
Docker • Docker Compose • Coolify • Traefik • Tailscale • GitHub Actions (CI/CD)
|
Nix • systemd • Caddy • Tailscale • GitHub Actions (CI/CD)
|
||||||
|
|
||||||
### Observabilitas
|
### Observabilitas
|
||||||
Prometheus • Metric Ingester (Go) • Vector • ClickHouse • Query Proxy (Go) • Telemetry UI (Vue 3)
|
Prometheus • Metric Ingester (Go) • Vector • ClickHouse • Query Proxy (Go) • Telemetry UI (Vue 3)
|
||||||
@@ -189,8 +192,8 @@ Prometheus • Metric Ingester (Go) • Vector • ClickHouse • Qu
|
|||||||
- **Python 3.9–3.11** — pipeline ML
|
- **Python 3.9–3.11** — pipeline ML
|
||||||
- **Rust & Cargo** — `apps/ml-service` (inference) & `apps/tauri` (Android)
|
- **Rust & Cargo** — `apps/ml-service` (inference) & `apps/tauri` (Android)
|
||||||
- **Java 21 + Android SDK** — build Android APK
|
- **Java 21 + Android SDK** — build Android APK
|
||||||
- **Docker & Docker Compose** — deployment & telemetry
|
- **Nix** — build & deployment produksi (flake.nix, systemd services)
|
||||||
- **PostgreSQL** — backend API
|
- **PostgreSQL (Neon)** — backend API (via pgbouncer pool imrnes `100.121.180.82:6432`)
|
||||||
|
|
||||||
### Instalasi
|
### Instalasi
|
||||||
|
|
||||||
@@ -206,7 +209,7 @@ bun install
|
|||||||
bun run dev # Semua service (web + api)
|
bun run dev # Semua service (web + api)
|
||||||
cd apps/web && bun run dev # Hanya frontend
|
cd apps/web && bun run dev # Hanya frontend
|
||||||
cd apps/api && bun run start # Hanya backend API
|
cd apps/api && bun run start # Hanya backend API
|
||||||
cd apps/ml-service && cargo run # ML inference engine (port 8000)
|
cd apps/ml-service && cargo run # ML inference engine (port 4012)
|
||||||
cd apps/tauri && bun run tauri dev # Tauri desktop dev
|
cd apps/tauri && bun run tauri dev # Tauri desktop dev
|
||||||
cd apps/tauri && bun run tauri android dev # Tauri Android dev
|
cd apps/tauri && bun run tauri android dev # Tauri Android dev
|
||||||
```
|
```
|
||||||
@@ -236,9 +239,14 @@ Salin `.env.example` ke `.env` dan isi:
|
|||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
|
Produksi: **Nix + systemd + Caddy** (Docker sudah dihapus dari produksi 2026-08-02).
|
||||||
|
Deploy via GitHub Actions → `nix build .#<service>` → `nix copy ssh://imrnes` → `systemctl restart zeavis-<service>`.
|
||||||
|
Reverse proxy: Caddy 2.11.4 (`systemd caddy.service`, auto-TLS Let's Encrypt, HTTP/3).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d # App services
|
# Port produksi: zeavis-api 4006, zeavis-web (nginx) 4011, zeavis-ml 4012
|
||||||
make telemetry-up # Telemetry stack
|
# Database: Neon via pgbouncer pool imrnes 100.121.180.82:6432
|
||||||
|
make telemetry-up # Telemetry stack (dev/local)
|
||||||
```
|
```
|
||||||
|
|
||||||
> 📖 **Panduan infrastruktur:** [`infra/README.md`](infra/README.md)
|
> 📖 **Panduan infrastruktur:** [`infra/README.md`](infra/README.md)
|
||||||
@@ -304,7 +312,7 @@ bun run tauri android build --apk # Build APK production
|
|||||||
| `bun install` gagal | `bun --version` — pastikan ≥ 1.x |
|
| `bun install` gagal | `bun --version` — pastikan ≥ 1.x |
|
||||||
| API perlu database | Isi `DATABASE_URL` di root `.env` |
|
| API perlu database | Isi `DATABASE_URL` di root `.env` |
|
||||||
| ML service gagal muat model | `ls Machine_Learning/model/model.onnx` — jalankan pipeline ML jika belum ada |
|
| ML service gagal muat model | `ls Machine_Learning/model/model.onnx` — jalankan pipeline ML jika belum ada |
|
||||||
| Docker Compose gagal | `docker network create app-shared-net` |
|
| Service tidak restart setelah deploy | `systemctl restart zeavis-api zeavis-web zeavis-ml` (Nix+systemd, bukan Docker) |
|
||||||
| Konversi TFJS gagal | `export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python` |
|
| Konversi TFJS gagal | `export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@ RUN bun install --production
|
|||||||
FROM oven/bun:1.3.14 AS runner
|
FROM oven/bun:1.3.14 AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV API_PORT=3000
|
ENV API_PORT=4006
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=deps /app/apps/api/node_modules apps/api/node_modules
|
COPY --from=deps /app/apps/api/node_modules apps/api/node_modules
|
||||||
@@ -20,5 +20,5 @@ COPY package.json bunfig.toml tsconfig.base.json ./
|
|||||||
COPY apps/api apps/api
|
COPY apps/api apps/api
|
||||||
COPY packages/shared packages/shared
|
COPY packages/shared packages/shared
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 4006
|
||||||
CMD ["bun", "apps/api/src/index.ts"]
|
CMD ["bun", "apps/api/src/index.ts"]
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
|||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL ?? 'postgres://postgres:postgres@localhost:5432/zeavis_edu',
|
url: process.env.DATABASE_URL ?? 'postgres://asephs:***@100.121.180.82:6432/zeavis_edu',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const allowedOrigins = [
|
|||||||
const secureCookies = Bun.env.SECURE_COOKIES === 'true' || webAppUrl.startsWith('https://');
|
const secureCookies = Bun.env.SECURE_COOKIES === 'true' || webAppUrl.startsWith('https://');
|
||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
port: Number(Bun.env.API_PORT ?? 3000),
|
port: Number(Bun.env.API_PORT ?? 4006),
|
||||||
databaseUrl: Bun.env.DATABASE_URL,
|
databaseUrl: Bun.env.DATABASE_URL,
|
||||||
sessionSecret: Bun.env.SESSION_SECRET,
|
sessionSecret: Bun.env.SESSION_SECRET,
|
||||||
uploaderBaseUrl: Bun.env.UPLOADER_BASE_URL ?? 'https://upload.asepharyana.my.id',
|
uploaderBaseUrl: Bun.env.UPLOADER_BASE_URL ?? 'https://upload.asepharyana.my.id',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MODEL_PATH=../../Machine_Learning/model/model.onnx
|
MODEL_PATH=../../Machine_Learning/model/model.onnx
|
||||||
MODEL_INPUT_SIZE=224
|
MODEL_INPUT_SIZE=224
|
||||||
ML_SERVICE_HOST=0.0.0.0
|
ML_SERVICE_HOST=0.0.0.0
|
||||||
ML_SERVICE_PORT=8001
|
ML_SERVICE_PORT=4012
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ WORKDIR /app
|
|||||||
ENV MODEL_PATH=/app/model/model.onnx
|
ENV MODEL_PATH=/app/model/model.onnx
|
||||||
ENV MODEL_INPUT_SIZE=224
|
ENV MODEL_INPUT_SIZE=224
|
||||||
ENV ML_SERVICE_HOST=0.0.0.0
|
ENV ML_SERVICE_HOST=0.0.0.0
|
||||||
ENV ML_SERVICE_PORT=8000
|
ENV ML_SERVICE_PORT=4012
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
|
|
||||||
RUN pacman -Syu --noconfirm ca-certificates 2>/dev/null
|
RUN pacman -Syu --noconfirm ca-certificates 2>/dev/null
|
||||||
@@ -19,5 +19,5 @@ RUN pacman -Syu --noconfirm ca-certificates 2>/dev/null
|
|||||||
COPY --from=builder /app/target/release/zeavis-ml-service /usr/local/bin/zeavis-ml-service
|
COPY --from=builder /app/target/release/zeavis-ml-service /usr/local/bin/zeavis-ml-service
|
||||||
COPY Machine_Learning/model/model.onnx /app/model/model.onnx
|
COPY Machine_Learning/model/model.onnx /app/model/model.onnx
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 4012
|
||||||
CMD ["zeavis-ml-service"]
|
CMD ["zeavis-ml-service"]
|
||||||
|
|||||||
+12
-12
@@ -48,7 +48,7 @@ Output build lokal berada di `target/` dan direktori tersebut diabaikan oleh Git
|
|||||||
|
|
||||||
Semua perintah di bawah dijalankan dari direktori `apps/ml-service`.
|
Semua perintah di bawah dijalankan dari direktori `apps/ml-service`.
|
||||||
|
|
||||||
### Opsi 1: Default (Port 8000)
|
### Opsi 1: Default (Port 4012)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run
|
cargo run
|
||||||
@@ -59,7 +59,7 @@ Service akan mencari model di path default:
|
|||||||
../../Machine_Learning/model/model.onnx
|
../../Machine_Learning/model/model.onnx
|
||||||
```
|
```
|
||||||
|
|
||||||
### Opsi 2: Local Development dengan .env.example (Port 8001)
|
### Opsi 2: Local Development dengan .env.example (Port 4012)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source .env.example
|
source .env.example
|
||||||
@@ -79,7 +79,7 @@ ML_SERVICE_PORT=9000 MODEL_PATH=/path/to/model.onnx cargo run
|
|||||||
| Variable | Default | Keterangan |
|
| Variable | Default | Keterangan |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ML_SERVICE_HOST` | `0.0.0.0` | Bind address |
|
| `ML_SERVICE_HOST` | `0.0.0.0` | Bind address |
|
||||||
| `ML_SERVICE_PORT` | `8000` | Bind port |
|
| `ML_SERVICE_PORT` | `4012` | Bind port |
|
||||||
| `MODEL_PATH` | `../../Machine_Learning/model/model.onnx` | Path ke file model ONNX |
|
| `MODEL_PATH` | `../../Machine_Learning/model/model.onnx` | Path ke file model ONNX |
|
||||||
| `MODEL_INPUT_SIZE` | `224` | Ukuran input gambar (224×224 untuk EfficientNetV2B0) |
|
| `MODEL_INPUT_SIZE` | `224` | Ukuran input gambar (224×224 untuk EfficientNetV2B0) |
|
||||||
| `RUST_LOG` | `info` | Level logging (debug, info, warn, error) |
|
| `RUST_LOG` | `info` | Level logging (debug, info, warn, error) |
|
||||||
@@ -91,7 +91,7 @@ ML_SERVICE_PORT=9000 MODEL_PATH=/path/to/model.onnx cargo run
|
|||||||
### Health Check
|
### Health Check
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/health
|
curl http://localhost:4012/health
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -104,7 +104,7 @@ curl http://localhost:8000/health
|
|||||||
### Metadata
|
### Metadata
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/metadata
|
curl http://localhost:4012/metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -123,7 +123,7 @@ curl http://localhost:8000/metadata
|
|||||||
Upload gambar daun jagung untuk klasifikasi:
|
Upload gambar daun jagung untuk klasifikasi:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/predict \
|
curl -X POST http://localhost:4012/predict \
|
||||||
-F "file=@/path/to/corn-leaf.jpg"
|
-F "file=@/path/to/corn-leaf.jpg"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -157,20 +157,20 @@ cargo build --release
|
|||||||
cargo test
|
cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verifikasi Manual (default port 8000)
|
### Verifikasi Manual (default port 4012)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Start service
|
# 1. Start service
|
||||||
cargo run
|
cargo run
|
||||||
|
|
||||||
# 2. Health check
|
# 2. Health check
|
||||||
curl http://localhost:8000/health
|
curl http://localhost:4012/health
|
||||||
|
|
||||||
# 3. Metadata
|
# 3. Metadata
|
||||||
curl http://localhost:8000/metadata
|
curl http://localhost:4012/metadata
|
||||||
|
|
||||||
# 4. Prediksi
|
# 4. Prediksi
|
||||||
curl -X POST http://localhost:8000/predict \
|
curl -X POST http://localhost:4012/predict \
|
||||||
-F "file=@../../Machine_Learning/dataset/Daun\ Sehat/sample.jpg"
|
-F "file=@../../Machine_Learning/dataset/Daun\ Sehat/sample.jpg"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ Service dapat di-deploy via Docker. Build dari root repository karena Dockerfile
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f apps/ml-service/Dockerfile -t zeavis-ml-service .
|
docker build -f apps/ml-service/Dockerfile -t zeavis-ml-service .
|
||||||
docker run -p 8000:8000 zeavis-ml-service
|
docker run -p 4012:4012 zeavis-ml-service
|
||||||
```
|
```
|
||||||
|
|
||||||
Pastikan `Machine_Learning/model/model.onnx` sudah dibuat sebelum build image.
|
Pastikan `Machine_Learning/model/model.onnx` sudah dibuat sebelum build image.
|
||||||
@@ -210,7 +210,7 @@ MODEL_PATH=/absolute/path/to/model.onnx cargo run
|
|||||||
```bash
|
```bash
|
||||||
ML_SERVICE_PORT=9000 cargo run
|
ML_SERVICE_PORT=9000 cargo run
|
||||||
# Cek port yang digunakan:
|
# Cek port yang digunakan:
|
||||||
lsof -i :8000
|
lsof -i :4012
|
||||||
```
|
```
|
||||||
|
|
||||||
### ONNX Runtime tidak kompatibel
|
### ONNX Runtime tidak kompatibel
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ impl Config {
|
|||||||
|
|
||||||
pub fn from_env_with_base_dir(base_dir: &Path) -> Result<Self> {
|
pub fn from_env_with_base_dir(base_dir: &Path) -> Result<Self> {
|
||||||
let host = env::var("ML_SERVICE_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
|
let host = env::var("ML_SERVICE_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
|
||||||
let port = parse_env_u16("ML_SERVICE_PORT", 8000)?;
|
let port = parse_env_u16("ML_SERVICE_PORT", 4012)?;
|
||||||
let input_size = parse_env_u32("MODEL_INPUT_SIZE", DEFAULT_INPUT_SIZE)?;
|
let input_size = parse_env_u32("MODEL_INPUT_SIZE", DEFAULT_INPUT_SIZE)?;
|
||||||
let model_path = env::var("MODEL_PATH").unwrap_or_else(|_| DEFAULT_MODEL_PATH.to_string());
|
let model_path = env::var("MODEL_PATH").unwrap_or_else(|_| DEFAULT_MODEL_PATH.to_string());
|
||||||
let temperature = parse_env_f32("MODEL_TEMPERATURE", DEFAULT_TEMPERATURE)?;
|
let temperature = parse_env_f32("MODEL_TEMPERATURE", DEFAULT_TEMPERATURE)?;
|
||||||
@@ -116,7 +116,7 @@ mod tests {
|
|||||||
let config = Config::from_env_with_base_dir(Path::new("/repo/apps/ml-service")).unwrap();
|
let config = Config::from_env_with_base_dir(Path::new("/repo/apps/ml-service")).unwrap();
|
||||||
|
|
||||||
assert_eq!(config.host, "0.0.0.0");
|
assert_eq!(config.host, "0.0.0.0");
|
||||||
assert_eq!(config.port, 8000);
|
assert_eq!(config.port, 4012);
|
||||||
assert_eq!(config.input_size, 224);
|
assert_eq!(config.input_size, 224);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.model_path,
|
config.model_path,
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ZeaVis Edu</title>
|
<title>ZeaVis Edu</title>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2322C55E' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z'/%3E%3Cpath d='M2 22l10-10'/%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://zeavis-api:3000/api/;
|
proxy_pass http://zeavis-api:4006/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -14,7 +14,7 @@ server {
|
|||||||
|
|
||||||
# Expose API metrics through the web endpoint (Prometheus scrape target)
|
# Expose API metrics through the web endpoint (Prometheus scrape target)
|
||||||
location /metrics {
|
location /metrics {
|
||||||
proxy_pass http://zeavis-api:3000/metrics;
|
proxy_pass http://zeavis-api:4006/metrics;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
+40
-5
@@ -3,6 +3,8 @@ import {
|
|||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
Navigate,
|
Navigate,
|
||||||
|
Outlet,
|
||||||
|
useNavigate,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { AuthInitializer } from "@/components/auth-initializer";
|
import { AuthInitializer } from "@/components/auth-initializer";
|
||||||
import { AuthGuard } from "@/components/auth-guard";
|
import { AuthGuard } from "@/components/auth-guard";
|
||||||
@@ -18,10 +20,10 @@ import { TelemetryPage } from "@/pages/telemetry-page";
|
|||||||
import { LoginPage } from "@/pages/login-page";
|
import { LoginPage } from "@/pages/login-page";
|
||||||
import { RegisterPage } from "@/pages/register-page";
|
import { RegisterPage } from "@/pages/register-page";
|
||||||
import { MainLayout } from "@/components/layout/main-layout";
|
import { MainLayout } from "@/components/layout/main-layout";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useAuthStore } from "@/store/auth-store";
|
import { useAuthStore } from "@/store/auth-store";
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { setupDeepLinkHandler } from "@/lib/tauri";
|
import { setupDeepLinkHandler, consumeDeepLinkTarget } from "@/lib/tauri";
|
||||||
|
|
||||||
function LogoutProses() {
|
function LogoutProses() {
|
||||||
const setUser = useAuthStore((state) => state.setUser);
|
const setUser = useAuthStore((state) => state.setUser);
|
||||||
@@ -43,6 +45,9 @@ import { trackPageView, trackError } from "./lib/telemetry";
|
|||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
element: <DeepLinkRouterHandler />,
|
||||||
|
children: [
|
||||||
{ path: "/", element: <Navigate to="/login" replace /> },
|
{ path: "/", element: <Navigate to="/login" replace /> },
|
||||||
{ path: "/login", element: <LoginPage /> },
|
{ path: "/login", element: <LoginPage /> },
|
||||||
{ path: "/register", element: <RegisterPage /> },
|
{ path: "/register", element: <RegisterPage /> },
|
||||||
@@ -128,9 +133,7 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/logout",
|
path: "/logout",
|
||||||
element: (
|
element: <LogoutProses />,
|
||||||
<LogoutProses />
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/telemetry",
|
path: "/telemetry",
|
||||||
@@ -140,8 +143,40 @@ const router = createBrowserRouter([
|
|||||||
</MainLayout>
|
</MainLayout>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for deep-link custom events and routes via React Router's navigate(),
|
||||||
|
* avoiding full page reloads that break the Tauri IPC bridge.
|
||||||
|
*/
|
||||||
|
function DeepLinkRouterHandler() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handled = useRef(new Set<string>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check for cold-start pending deep link
|
||||||
|
const pending = consumeDeepLinkTarget();
|
||||||
|
if (pending && !handled.current.has(pending)) {
|
||||||
|
handled.current.add(pending);
|
||||||
|
navigate(pending, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for warm-start deep links
|
||||||
|
const handler = (e: CustomEvent<string>) => {
|
||||||
|
const target = e.detail;
|
||||||
|
if (handled.current.has(target)) return;
|
||||||
|
handled.current.add(target);
|
||||||
|
navigate(target, { replace: true });
|
||||||
|
};
|
||||||
|
window.addEventListener('zeavis:deeplink', handler as EventListener);
|
||||||
|
return () => window.removeEventListener('zeavis:deeplink', handler as EventListener);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
function PageViewTracker() {
|
function PageViewTracker() {
|
||||||
const location = window.location;
|
const location = window.location;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
import { FormEvent, useState, useCallback } from 'react';
|
import { FormEvent, useState, useCallback } from "react";
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import {
|
||||||
import { Input } from '@/components/ui/input';
|
Card,
|
||||||
import { Label } from '@/components/ui/label';
|
CardContent,
|
||||||
import { isTauri, openUrl } from '@/lib/tauri';
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { apiBaseUrl } from "@/lib/api-client";
|
||||||
|
import { isTauri, openUrl } from "@/lib/tauri";
|
||||||
|
|
||||||
type AuthFormProps = {
|
type AuthFormProps = {
|
||||||
mode: 'login' | 'register';
|
mode: "login" | "register";
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
googleOAuthEnabled: boolean;
|
googleOAuthEnabled: boolean;
|
||||||
onSubmit: (payload: { name?: string; email: string; password: string }) => Promise<unknown>;
|
onSubmit: (payload: {
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}) => Promise<unknown>;
|
||||||
onFieldChange?: () => void;
|
onFieldChange?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubmit, onFieldChange }: AuthFormProps) {
|
export function AuthForm({
|
||||||
const [name, setName] = useState('');
|
mode,
|
||||||
const [email, setEmail] = useState('');
|
isSubmitting,
|
||||||
const [password, setPassword] = useState('');
|
error,
|
||||||
|
googleOAuthEnabled,
|
||||||
|
onSubmit,
|
||||||
|
onFieldChange,
|
||||||
|
}: AuthFormProps) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
@@ -28,70 +46,119 @@ export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubm
|
|||||||
|
|
||||||
const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => {
|
const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const platform = isTauri() ? 'tauri' : 'web';
|
const platform = isTauri() ? "tauri" : "web";
|
||||||
// Use API base URL, not window.location.origin — on Tauri Android
|
const googleUrl = `${apiBaseUrl}/api/v1/auth/google?platform=${platform}`;
|
||||||
// the origin is http://tauri.localhost which is not the API server.
|
|
||||||
const apiBase = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
|
||||||
const googleUrl = `${apiBase}/api/v1/auth/google?platform=${platform}`;
|
|
||||||
await openUrl(googleUrl);
|
await openUrl(googleUrl);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mx-auto w-full max-w-md bg-transparent border-none shadow-none">
|
<Card className="mx-auto w-full max-w-md bg-transparent border-none shadow-none">
|
||||||
<CardHeader className="text-center space-y-2">
|
<CardHeader className="text-center space-y-2">
|
||||||
<CardTitle className="text-2xl font-bold text-emerald-900">{mode === 'login' ? 'Masuk Akun ZeaVis Edu' : 'Buat akun ZeaVis Edu'}</CardTitle>
|
<CardTitle className="text-2xl font-bold text-emerald-900">
|
||||||
|
{mode === "login" ? "Masuk Akun ZeaVis Edu" : "Buat akun ZeaVis Edu"}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription className="text-sm text-emerald-800/80">
|
<CardDescription className="text-sm text-emerald-800/80">
|
||||||
{mode === 'login'
|
{mode === "login"
|
||||||
? 'Masuk untuk menyimpan diagnosis dan mengikuti review pakar.'
|
? "Masuk untuk menyimpan diagnosis dan mengikuti review pakar."
|
||||||
: 'Daftar untuk menyimpan diagnosis dan mengikuti review pakar.'}
|
: "Daftar untuk menyimpan diagnosis dan mengikuti review pakar."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
{mode === 'register' && (
|
{mode === "register" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Nama</Label>
|
<Label htmlFor="name">Nama</Label>
|
||||||
<Input id="name" value={name} onChange={(event) => {
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="Masukkan nama Anda"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => {
|
||||||
setName(event.target.value);
|
setName(event.target.value);
|
||||||
onFieldChange?.();
|
onFieldChange?.();
|
||||||
}} required />
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input id="email" type="email" value={email} onChange={(event) => {
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Masukkan email Anda"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => {
|
||||||
setEmail(event.target.value);
|
setEmail(event.target.value);
|
||||||
onFieldChange?.();
|
onFieldChange?.();
|
||||||
}} required />
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input id="password" type={showPassword ? "text" : "password"} minLength={8} value={password} onChange={(event) => {
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Password minimal 8 karakter"
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => {
|
||||||
setPassword(event.target.value);
|
setPassword(event.target.value);
|
||||||
onFieldChange?.();
|
onFieldChange?.();
|
||||||
}} required />
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground focus:outline-none"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground focus:outline-none"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-600" role="alert">{error}</p>}
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Button className="w-full" type="submit" disabled={isSubmitting}>
|
<Button className="w-full" type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting ? 'Memproses...' : mode === 'login' ? 'Masuk' : 'Daftar'}
|
{isSubmitting
|
||||||
|
? "Memproses..."
|
||||||
|
: mode === "login"
|
||||||
|
? "Masuk"
|
||||||
|
: "Daftar"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{googleOAuthEnabled && (
|
{googleOAuthEnabled && (
|
||||||
<Button className="mt-3 w-full flex items-center justify-center gap-2.5" variant="outline" onClick={handleGoogleLogin} type="button">
|
<Button
|
||||||
|
className="mt-3 w-full flex items-center justify-center gap-2.5"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden="true">
|
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden="true">
|
||||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
|
<path
|
||||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
fill="#4285F4"
|
||||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
<path fill="none" d="M1 1h22v22H1z" />
|
<path fill="none" d="M1 1h22v22H1z" />
|
||||||
</svg>
|
</svg>
|
||||||
Masuk dengan Google
|
Masuk dengan Google
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
} from '@zeavis/shared';
|
} from '@zeavis/shared';
|
||||||
import { recordApiCall } from './telemetry';
|
import { recordApiCall } from './telemetry';
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id';
|
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id';
|
||||||
|
|
||||||
const AUTH_TOKEN_KEY = 'zeavis_auth_token';
|
const AUTH_TOKEN_KEY = 'zeavis_auth_token';
|
||||||
|
|
||||||
|
|||||||
+55
-20
@@ -1,6 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Lightweight Tauri environment detection and utilities.
|
* Lightweight Tauri environment detection and utilities.
|
||||||
* Uses raw __TAURI_INTERNALS__ IPC to avoid bundling/import issues on Android.
|
* Uses raw __TAURI_INTERNALS__ IPC to avoid bundling/import issues on Android.
|
||||||
|
*
|
||||||
|
* Deep-link flow (no full page reloads — uses React Router navigate()):
|
||||||
|
* 1. Tauri deep-link plugin receives URL via intent/custom-scheme.
|
||||||
|
* 2. processDeepLinkUrl stores the target path in sessionStorage +
|
||||||
|
* dispatches a custom DOM event.
|
||||||
|
* 3. <DeepLinkRouterHandler /> inside <RouterProvider> picks it up and
|
||||||
|
* calls navigate(), keeping the React app alive.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let _isTauri: boolean | null = null;
|
let _isTauri: boolean | null = null;
|
||||||
@@ -22,7 +29,6 @@ function tauriInvoke(): (cmd: string, args?: Record<string, unknown>) => Promise
|
|||||||
|
|
||||||
export async function openUrl(url: string): Promise<void> {
|
export async function openUrl(url: string): Promise<void> {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
// Not in Tauri — normal browser navigation
|
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -31,47 +37,76 @@ export async function openUrl(url: string): Promise<void> {
|
|||||||
await invoke('plugin:opener|open_url', { url });
|
await invoke('plugin:opener|open_url', { url });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Tauri openUrl failed, trying fallback:', err);
|
console.error('Tauri openUrl failed, trying fallback:', err);
|
||||||
// Fallback: navigate the WebView (Google will block, but best effort)
|
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function processDeepLinkUrl(url: string): void {
|
// ── Deep link handling (no full reload) ─────────────────────────────────
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
const target = u.pathname + u.search + u.hash;
|
|
||||||
if (target && target !== '/') {
|
|
||||||
window.location.href = target;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch { /* fall through */ }
|
|
||||||
|
|
||||||
// Fallback: handle both :// and :/ custom schemes
|
const DEEP_LINK_KEY = 'zeavis_pending_deeplink';
|
||||||
let match = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/);
|
const DEEP_LINK_EVENT = 'zeavis:deeplink';
|
||||||
if (!match) match = url.match(/^[^:]+:\/(\/.*)?$/);
|
|
||||||
if (match?.[1]) window.location.href = match[1];
|
/** Store a target path for the React Router to pick up without page reload. */
|
||||||
|
function storeDeepLinkTarget(target: string): void {
|
||||||
|
try { sessionStorage.setItem(DEEP_LINK_KEY, target); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read and clear the stored deep link target. */
|
||||||
|
export function consumeDeepLinkTarget(): string | null {
|
||||||
|
try {
|
||||||
|
const v = sessionStorage.getItem(DEEP_LINK_KEY);
|
||||||
|
if (v) sessionStorage.removeItem(DEEP_LINK_KEY);
|
||||||
|
return v;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function setupDeepLinkHandler(): Promise<void> {
|
export async function setupDeepLinkHandler(): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const invoke = tauriInvoke();
|
const invoke = tauriInvoke();
|
||||||
|
|
||||||
// Cold-start: app just opened via intent:// or custom scheme
|
// Cold-start: app opened via intent:// (e.g. from Google OAuth callback).
|
||||||
|
// Use window.location.href for this (full page reload) — at cold start there
|
||||||
|
// is no SPA state to lose, so redirecting via location.href avoids orphaned
|
||||||
|
// IPC promises that cause "Cannot read properties of undefined (reading 'runCallback')".
|
||||||
invoke('plugin:deep-link|get_current')
|
invoke('plugin:deep-link|get_current')
|
||||||
.then((urls: any) => {
|
.then((urls: any) => {
|
||||||
if (urls?.[0]) processDeepLinkUrl(urls[0]);
|
if (!urls?.[0]) return;
|
||||||
|
const target = extractDeepLinkTarget(urls[0]);
|
||||||
|
if (target && target !== window.location.pathname + window.location.search + window.location.hash) {
|
||||||
|
window.location.href = target;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { /* plugin may not be registered yet */ });
|
.catch(() => {});
|
||||||
|
|
||||||
// Warm-start: listen for new URLs while app is running
|
// Warm-start: listen for new URLs (already running app).
|
||||||
|
// Use React Router navigate() here since we have SPA state.
|
||||||
const { listen } = await import('@tauri-apps/api/event');
|
const { listen } = await import('@tauri-apps/api/event');
|
||||||
listen('deep-link://new-url', (event: any) => {
|
listen('deep-link://new-url', (event: any) => {
|
||||||
const urls = event.payload as string[];
|
const urls = event.payload as string[];
|
||||||
for (const url of urls) processDeepLinkUrl(url);
|
for (const url of urls) {
|
||||||
|
const target = extractDeepLinkTarget(url);
|
||||||
|
if (target) {
|
||||||
|
storeDeepLinkTarget(target);
|
||||||
|
window.dispatchEvent(new CustomEvent(DEEP_LINK_EVENT, { detail: target }));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Tauri deep-link setup failed:', err);
|
console.error('Tauri deep-link setup failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extract path+query+hash from a deep-link URL. */
|
||||||
|
function extractDeepLinkTarget(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.pathname + u.search + u.hash;
|
||||||
|
} catch {
|
||||||
|
let m = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/);
|
||||||
|
if (!m) m = url.match(/^[^:]+:\/(\/.*)?$/);
|
||||||
|
return m?.[1] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AuthForm } from "@/components/auth-form";
|
import { AuthForm } from "@/components/auth-form";
|
||||||
import { apiClient, setAuthToken } from "@/lib/api-client";
|
import { apiClient, setAuthToken } from "@/lib/api-client";
|
||||||
import { useAuthStore } from "@/store/auth-store";
|
import { useAuthStore } from "@/store/auth-store";
|
||||||
|
import { isTauri } from "@/lib/tauri";
|
||||||
|
|
||||||
function getUrlParam(name: string): string | null {
|
function getUrlParam(name: string): string | null {
|
||||||
return new URLSearchParams(window.location.search).get(name);
|
return new URLSearchParams(window.location.search).get(name);
|
||||||
@@ -13,11 +14,13 @@ export function LoginPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const setUser = useAuthStore((state) => state.setUser);
|
const setUser = useAuthStore((state) => state.setUser);
|
||||||
|
const location = useLocation();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const oauthTokenConsumed = useRef(false);
|
const oauthTokenConsumed = useRef(false);
|
||||||
const [oauthProcessing, setOauthProcessing] = useState(false);
|
const [oauthProcessing, setOauthProcessing] = useState(false);
|
||||||
|
|
||||||
// Handle OAuth callback: the API redirects to /login?token=<session_token>
|
// Handle OAuth callback: the API redirects to /login?token=<session_token>
|
||||||
|
// Must re-run on location.search change (SPA navigates to /login?token=xxx)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getUrlParam("token");
|
const token = getUrlParam("token");
|
||||||
if (!token || oauthTokenConsumed.current) return;
|
if (!token || oauthTokenConsumed.current) return;
|
||||||
@@ -39,7 +42,36 @@ export function LoginPage() {
|
|||||||
setOauthProcessing(false);
|
setOauthProcessing(false);
|
||||||
setError(err instanceof Error ? err.message : "Google login gagal");
|
setError(err instanceof Error ? err.message : "Google login gagal");
|
||||||
});
|
});
|
||||||
}, [setUser, queryClient, navigate]);
|
}, [setUser, queryClient, navigate, location.search]);
|
||||||
|
|
||||||
|
// Backup: when app returns from background (e.g. after Google OAuth browser)
|
||||||
|
// re-check URL params — the deep-link event may have been missed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return;
|
||||||
|
if (getUrlParam("token") || oauthTokenConsumed.current) return;
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
const token = getUrlParam("token");
|
||||||
|
if (token && !oauthTokenConsumed.current) {
|
||||||
|
setOauthProcessing(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
const token = getUrlParam("token");
|
||||||
|
if (token && !oauthTokenConsumed.current) {
|
||||||
|
setOauthProcessing(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
window.removeEventListener("focus", onFocus);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Show OAuth error from query param
|
// Show OAuth error from query param
|
||||||
const oauthError = getUrlParam("error");
|
const oauthError = getUrlParam("error");
|
||||||
@@ -48,6 +80,13 @@ export function LoginPage() {
|
|||||||
queryFn: () => apiClient.getMe(),
|
queryFn: () => apiClient.getMe(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Already authenticated — redirect to dashboard
|
||||||
|
useEffect(() => {
|
||||||
|
if (!meQuery.isLoading && meQuery.data?.user) {
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
}
|
||||||
|
}, [meQuery.data, meQuery.isLoading, navigate]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: apiClient.login,
|
mutationFn: apiClient.login,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function ScanPage() {
|
|||||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
// Camera mode state
|
// Camera mode state
|
||||||
const [useCamera, setUseCamera] = useState(false);
|
const [useCamera, setUseCamera] = useState(false);
|
||||||
@@ -164,8 +165,25 @@ export function ScanPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="w-full border-2 border-dashed border-green-300 rounded-md p-10 h-60 text-center cursor-pointer"
|
className={`w-full border-2 border-dashed rounded-md p-10 h-60 text-center cursor-pointer transition-colors duration-200 ${
|
||||||
|
isDragging
|
||||||
|
? "border-blue-500 bg-blue-100"
|
||||||
|
: "border-green-300"
|
||||||
|
}`}
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setIsDragging(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile) {
|
||||||
|
handleFile(droppedFile);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Upload
|
<Upload
|
||||||
className="mx-auto text-green-500 mb-3"
|
className="mx-auto text-green-500 mb-3"
|
||||||
|
|||||||
@@ -225,25 +225,25 @@ export function TelemetryPage() {
|
|||||||
queryInstant(`rate(node_network_receive_bytes_total{instance="${INST}:9100",device="eth0"}[5m])`),
|
queryInstant(`rate(node_network_receive_bytes_total{instance="${INST}:9100",device="eth0"}[5m])`),
|
||||||
queryInstant(`rate(node_network_transmit_bytes_total{instance="${INST}:9100",device="eth0"}[5m])`),
|
queryInstant(`rate(node_network_transmit_bytes_total{instance="${INST}:9100",device="eth0"}[5m])`),
|
||||||
// API
|
// API
|
||||||
queryInstant(`zeavis_api_http_requests_total{instance="${INST}:3000"}`),
|
queryInstant(`zeavis_api_http_requests_total{instance="${INST}:4006"}`),
|
||||||
queryInstant(`zeavis_api_http_requests_active{instance="${INST}:3000"}`),
|
queryInstant(`zeavis_api_http_requests_active{instance="${INST}:4006"}`),
|
||||||
queryInstant(`zeavis_api_http_request_duration_seconds_sum{instance="${INST}:3000"} / zeavis_api_http_request_duration_seconds_count{instance="${INST}:3000"}`),
|
queryInstant(`zeavis_api_http_request_duration_seconds_sum{instance="${INST}:4006"} / zeavis_api_http_request_duration_seconds_count{instance="${INST}:4006"}`),
|
||||||
queryRange(`zeavis_api_http_requests_total{instance="${INST}:3000"}`, 60),
|
queryRange(`zeavis_api_http_requests_total{instance="${INST}:4006"}`, 60),
|
||||||
queryRange(`zeavis_api_http_request_duration_seconds_sum{instance="${INST}:3000"} / zeavis_api_http_request_duration_seconds_count{instance="${INST}:3000"}`, 60),
|
queryRange(`zeavis_api_http_request_duration_seconds_sum{instance="${INST}:4006"} / zeavis_api_http_request_duration_seconds_count{instance="${INST}:4006"}`, 60),
|
||||||
// ML
|
// ML
|
||||||
queryInstant(`zeavis_ml_zeavis_ml_model_load_status{instance="${INST}:8000"}`),
|
queryInstant(`zeavis_ml_zeavis_ml_model_load_status{instance="${INST}:4012"}`),
|
||||||
// NodeJS
|
// NodeJS
|
||||||
queryInstant(`nodejs_heap_size_used_bytes{instance="${INST}:3000"}`),
|
queryInstant(`nodejs_heap_size_used_bytes{instance="${INST}:4006"}`),
|
||||||
queryInstant(`nodejs_heap_size_total_bytes{instance="${INST}:3000"}`),
|
queryInstant(`nodejs_heap_size_total_bytes{instance="${INST}:4006"}`),
|
||||||
queryInstant(`nodejs_eventloop_lag_seconds{instance="${INST}:3000"}`),
|
queryInstant(`nodejs_eventloop_lag_seconds{instance="${INST}:4006"}`),
|
||||||
queryInstant(`nodejs_active_handles_total{instance="${INST}:3000"}`),
|
queryInstant(`nodejs_active_handles_total{instance="${INST}:4006"}`),
|
||||||
queryInstant(`nodejs_active_requests_total{instance="${INST}:3000"}`),
|
queryInstant(`nodejs_active_requests_total{instance="${INST}:4006"}`),
|
||||||
queryRange(`nodejs_heap_size_used_bytes{instance="${INST}:3000"}`, 60),
|
queryRange(`nodejs_heap_size_used_bytes{instance="${INST}:4006"}`, 60),
|
||||||
queryRange(`nodejs_eventloop_lag_seconds{instance="${INST}:3000"}`, 60),
|
queryRange(`nodejs_eventloop_lag_seconds{instance="${INST}:4006"}`, 60),
|
||||||
// Process
|
// Process
|
||||||
queryInstant(`rate(process_cpu_seconds_total{instance="${INST}:3000"}[5m])`),
|
queryInstant(`rate(process_cpu_seconds_total{instance="${INST}:4006"}[5m])`),
|
||||||
queryInstant(`process_resident_memory_bytes{instance="${INST}:3000"}`),
|
queryInstant(`process_resident_memory_bytes{instance="${INST}:4006"}`),
|
||||||
queryInstant(`process_open_fds{instance="${INST}:3000"}`),
|
queryInstant(`process_open_fds{instance="${INST}:4006"}`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setCpuData(cpuR); setMemData(memR); setDiskData(diskR);
|
setCpuData(cpuR); setMemData(memR); setDiskData(diskR);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { metricsPlugin } from './vite-plugin-metrics';
|
|||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), '');
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:3000';
|
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:4006';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
+6
-6
@@ -32,21 +32,21 @@ services:
|
|||||||
- app-shared-net
|
- app-shared-net
|
||||||
- telemetry-net
|
- telemetry-net
|
||||||
ports:
|
ports:
|
||||||
- "${TS_IP:-0.0.0.0}:3000:3000"
|
- "${TS_IP:-0.0.0.0}:4006:4006"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
API_PORT: "3000"
|
API_PORT: "4006"
|
||||||
WEB_APP_URL: https://zeavisedu.asepharyana.my.id
|
WEB_APP_URL: https://zeavisedu.asepharyana.my.id
|
||||||
ML_SERVICE_URL: ${ML_SERVICE_URL:-http://zeavis-ml:8000}
|
ML_SERVICE_URL: ${ML_SERVICE_URL:-http://zeavis-ml:4012}
|
||||||
labels:
|
labels:
|
||||||
traefik.enable: "true"
|
traefik.enable: "true"
|
||||||
traefik.http.routers.zeavis-api.rule: Host(`api-zeavisedu.asepharyana.my.id`)
|
traefik.http.routers.zeavis-api.rule: Host(`api-zeavisedu.asepharyana.my.id`)
|
||||||
traefik.http.routers.zeavis-api.entrypoints: websecure
|
traefik.http.routers.zeavis-api.entrypoints: websecure
|
||||||
traefik.http.routers.zeavis-api.tls: "true"
|
traefik.http.routers.zeavis-api.tls: "true"
|
||||||
traefik.http.routers.zeavis-api.tls.certresolver: cloudflare
|
traefik.http.routers.zeavis-api.tls.certresolver: cloudflare
|
||||||
traefik.http.services.zeavis-api.loadbalancer.server.port: "3000"
|
traefik.http.services.zeavis-api.loadbalancer.server.port: "4006"
|
||||||
|
|
||||||
# Node Exporter — expose system metrics (CPU, RAM, disk) for Prometheus scraping
|
# Node Exporter — expose system metrics (CPU, RAM, disk) for Prometheus scraping
|
||||||
node_exporter:
|
node_exporter:
|
||||||
@@ -73,7 +73,7 @@ services:
|
|||||||
- app-shared-net
|
- app-shared-net
|
||||||
- telemetry-net
|
- telemetry-net
|
||||||
ports:
|
ports:
|
||||||
- "${TS_IP:-0.0.0.0}:8000:8000"
|
- "${TS_IP:-0.0.0.0}:4012:4012"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -85,4 +85,4 @@ services:
|
|||||||
traefik.http.routers.zeavis-ml.entrypoints: websecure
|
traefik.http.routers.zeavis-ml.entrypoints: websecure
|
||||||
traefik.http.routers.zeavis-ml.tls: "true"
|
traefik.http.routers.zeavis-ml.tls: "true"
|
||||||
traefik.http.routers.zeavis-ml.tls.certresolver: cloudflare
|
traefik.http.routers.zeavis-ml.tls.certresolver: cloudflare
|
||||||
traefik.http.services.zeavis-ml.loadbalancer.server.port: "8000"
|
traefik.http.services.zeavis-ml.loadbalancer.server.port: "4012"
|
||||||
|
|||||||
@@ -532,3 +532,7 @@ Verified:
|
|||||||
- ML service health: <actual result if run>
|
- ML service health: <actual result if run>
|
||||||
- classifyImage against running ML service: <actual result if run>
|
- classifyImage against running ML service: <actual result if run>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -631,3 +631,7 @@ Open `/dashboard`. Confirm the image classification form renders. If no database
|
|||||||
- Spec coverage: backend TFJS inference, uploader integration, DB persistence, API routes, shared types, frontend upload/result/history, and verification are covered.
|
- Spec coverage: backend TFJS inference, uploader integration, DB persistence, API routes, shared types, frontend upload/result/history, and verification are covered.
|
||||||
- Placeholder scan: no TBD/TODO/fill-later placeholders remain; every file and route has explicit behavior.
|
- Placeholder scan: no TBD/TODO/fill-later placeholders remain; every file and route has explicit behavior.
|
||||||
- Type consistency: `ImageClassificationRecord`, `PredictionProbability`, and `UploaderMetadata` are defined once in shared and used consistently across API and web.
|
- Type consistency: `ImageClassificationRecord`, `PredictionProbability`, and `UploaderMetadata` are defined once in shared and used consistently across API and web.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -289,6 +289,8 @@ git commit -m "feat: add ML service Docker image"
|
|||||||
|
|
||||||
## Task 5: Add production Docker Compose
|
## Task 5: Add production Docker Compose
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `docker-compose.yml`
|
- Create: `docker-compose.yml`
|
||||||
|
|
||||||
|
|||||||
@@ -802,3 +802,7 @@ If no fixes were required, do not create an empty commit.
|
|||||||
- Spec coverage: shared contract, backend schema/routes, frontend pages/manual flow, error states, and verification are all covered.
|
- Spec coverage: shared contract, backend schema/routes, frontend pages/manual flow, error states, and verification are all covered.
|
||||||
- Placeholder scan: no TBD/TODO/fill-later placeholders are present. Task 4 uses explicit behavior requirements for page files because page markup is lengthy, but all required states and wiring are specified.
|
- Placeholder scan: no TBD/TODO/fill-later placeholders are present. Task 4 uses explicit behavior requirements for page files because page markup is lengthy, but all required states and wiring are specified.
|
||||||
- Type consistency: shared names (`DiseaseSlug`, `DiseaseCatalogItem`, `ManualClassificationRequest`, `ManualClassificationRecord`, `DashboardSummary`) are consistent across tasks.
|
- Type consistency: shared names (`DiseaseSlug`, `DiseaseCatalogItem`, `ManualClassificationRequest`, `ManualClassificationRecord`, `DashboardSummary`) are consistent across tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -1323,3 +1323,7 @@ git commit -m "Document fullstack app commands"
|
|||||||
- Placeholder scan: no TBD/TODO placeholders are present; deferred features are explicitly listed in the design and not implemented.
|
- Placeholder scan: no TBD/TODO placeholders are present; deferred features are explicitly listed in the design and not implemented.
|
||||||
- Type consistency: `AppStatus`, `createAppStatus`, route paths, package names, and project paths are consistent across tasks.
|
- Type consistency: `AppStatus`, `createAppStatus`, route paths, package names, and project paths are consistent across tasks.
|
||||||
- Known execution note: Task 4 requires adding `@radix-ui/react-slot` because the shadcn-style `Button` uses `Slot` for `asChild` support.
|
- Known execution note: Task 4 requires adding `@radix-ui/react-slot` because the shadcn-style `Button` uses `Slot` for `asChild` support.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -197,3 +197,7 @@ Do not claim completion unless the final verification command passed.
|
|||||||
- Spec coverage: The plan updates only JS/TS manifests, regenerates `bun.lock`, allows minimal compatibility refactors, and verifies with `bun run typecheck` and `bun run build`.
|
- Spec coverage: The plan updates only JS/TS manifests, regenerates `bun.lock`, allows minimal compatibility refactors, and verifies with `bun run typecheck` and `bun run build`.
|
||||||
- Placeholder scan: No TODO/TBD placeholders remain.
|
- Placeholder scan: No TODO/TBD placeholders remain.
|
||||||
- Scope check: Python/ML dependencies are explicitly out of scope and verified unchanged.
|
- Scope check: Python/ML dependencies are explicitly out of scope and verified unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -581,3 +581,7 @@ Verified:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Expected: final response only claims checks that were actually run.
|
Expected: final response only claims checks that were actually run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -2490,3 +2490,7 @@ Spec coverage:
|
|||||||
Red-flag scan: no unresolved planning markers are intentionally present. The only implementation choice left to workers is resolving compile errors revealed by real typecheck output, which must be fixed directly before completing each task.
|
Red-flag scan: no unresolved planning markers are intentionally present. The only implementation choice left to workers is resolving compile errors revealed by real typecheck output, which must be fixed directly before completing each task.
|
||||||
|
|
||||||
Type consistency: shared DTO names are introduced first and reused by backend/frontend tasks. Diagnosis status strings match the design spec.
|
Type consistency: shared DTO names are introduced first and reused by backend/frontend tasks. Diagnosis status strings match the design spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ curl -X POST http://localhost:8001/predict -F "file=@/path/to/corn-leaf.jpg"
|
|||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|
||||||
File `docker-compose.yml` di root menyiapkan tiga service produksi:
|
File `docker-compose.yml` di root menyiapkan tiga service produksi:
|
||||||
|
|
||||||
- `web` untuk frontend
|
- `web` untuk frontend
|
||||||
|
|||||||
@@ -1474,6 +1474,8 @@ git commit -m "test: add ONNX parity validation script"
|
|||||||
|
|
||||||
## Task 10: Update Docker image for Rust ML service
|
## Task 10: Update Docker image for Rust ML service
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `apps/ml-service/Dockerfile`
|
- Modify: `apps/ml-service/Dockerfile`
|
||||||
|
|
||||||
|
|||||||
@@ -99,3 +99,7 @@ Required verification after implementation:
|
|||||||
- Exercise `classifyImage(file)` against the running ML service with a local image file or synthetic image and confirm it returns `predictedDiseaseSlug`, `confidence`, and sorted probabilities.
|
- Exercise `classifyImage(file)` against the running ML service with a local image file or synthetic image and confirm it returns `predictedDiseaseSlug`, `confidence`, and sorted probabilities.
|
||||||
|
|
||||||
If full API route testing is blocked by external database or upload service requirements, report that explicitly and include the lower-level verification evidence.
|
If full API route testing is blocked by external database or upload service requirements, report that explicitly and include the lower-level verification evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -88,3 +88,7 @@ The implementation should pass:
|
|||||||
- `bun run build`
|
- `bun run build`
|
||||||
|
|
||||||
Manual verification should launch API and web locally, open the dashboard, select an image, submit it, and verify that uploader/model/database success or structured error states render without crashing.
|
Manual verification should launch API and web locally, open the dashboard, select an image, submit it, and verify that uploader/model/database success or structured error states render without crashing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ Each image will also receive a SHA tag for traceability.
|
|||||||
|
|
||||||
## Compose deployment
|
## Compose deployment
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|
||||||
The VPS will run `docker compose` from `/opt/ZeaVis-Edu`.
|
The VPS will run `docker compose` from `/opt/ZeaVis-Edu`.
|
||||||
|
|
||||||
The compose file will define:
|
The compose file will define:
|
||||||
|
|||||||
@@ -68,3 +68,7 @@ The implementation should pass:
|
|||||||
- `bun run build`
|
- `bun run build`
|
||||||
|
|
||||||
Because this includes frontend behavior, the app should also be launched locally and the main pages/manual flow should be checked in a browser if the environment allows it.
|
Because this includes frontend behavior, the app should also be launched locally and the main pages/manual flow should be checked in a browser if the environment allows it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -74,3 +74,7 @@ No test framework is added in this scaffold. Tests should be introduced with the
|
|||||||
- Real dashboard data.
|
- Real dashboard data.
|
||||||
- Database migrations for domain entities.
|
- Database migrations for domain entities.
|
||||||
- Deployment configuration.
|
- Deployment configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -33,3 +33,7 @@ If either command fails due to dependency updates, fix the underlying compatibil
|
|||||||
- ML pipeline changes
|
- ML pipeline changes
|
||||||
- UI redesigns or feature additions
|
- UI redesigns or feature additions
|
||||||
- Database schema changes unless a dependency update requires a generated type/config compatibility fix
|
- Database schema changes unless a dependency update requires a generated type/config compatibility fix
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -82,3 +82,7 @@ Manual verification for the initial implementation:
|
|||||||
- Call `POST /predict` with a real image file when an example corn leaf image is available.
|
- Call `POST /predict` with a real image file when an example corn leaf image is available.
|
||||||
|
|
||||||
The repository does not currently have a Python test suite for this new service. Automated tests can be added later if the service grows beyond the initial capstone scope.
|
The repository does not currently have a Python test suite for this new service. Automated tests can be added later if the service grows beyond the initial capstone scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -283,3 +283,7 @@ Manual error path:
|
|||||||
2. Call a diagnosis endpoint while logged out and confirm unauthorized response.
|
2. Call a diagnosis endpoint while logged out and confirm unauthorized response.
|
||||||
3. Access expert review as a non-expert and confirm forbidden response.
|
3. Access expert review as a non-expert and confirm forbidden response.
|
||||||
4. Temporarily omit Google OAuth env and confirm Google login is hidden.
|
4. Temporarily omit Google OAuth env and confirm Google login is hidden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ The repository has no existing global test suite, so the Rust service checks bec
|
|||||||
|
|
||||||
## Documentation and deployment updates
|
## Documentation and deployment updates
|
||||||
|
|
||||||
|
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||||
|
|
||||||
Update documentation so runtime serving no longer describes FastAPI/TensorFlow as the production ML service. Keep Python/TensorFlow documentation for training and export.
|
Update documentation so runtime serving no longer describes FastAPI/TensorFlow as the production ML service. Keep Python/TensorFlow documentation for training and export.
|
||||||
|
|
||||||
Update:
|
Update:
|
||||||
|
|||||||
Generated
+61
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785301185,
|
||||||
|
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
description = "ZeaVis Edu — Bun API + Rust ML + Vite web (Nix build)";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = {
|
||||||
|
# ── API: Bun + Elysia + Drizzle (workspace) ────────────────
|
||||||
|
api = pkgs.stdenvNoCC.mkDerivation {
|
||||||
|
pname = "zeavis-api";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
nativeBuildInputs = [ pkgs.bun ];
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
export HOME="$TMPDIR"
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run --cwd packages/shared build
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out/bin $out/lib/zeavis-api
|
||||||
|
cp -r package.json bun.lock bunfig.toml tsconfig.base.json $out/lib/zeavis-api/
|
||||||
|
cp -r node_modules $out/lib/zeavis-api/node_modules
|
||||||
|
cp -r apps $out/lib/zeavis-api/apps
|
||||||
|
cp -r packages $out/lib/zeavis-api/packages
|
||||||
|
cat > $out/bin/zeavis-api << WRAPPER
|
||||||
|
#!${pkgs.runtimeShell}
|
||||||
|
cd $out/lib/zeavis-api
|
||||||
|
exec ${pkgs.bun}/bin/bun apps/api/src/index.ts
|
||||||
|
WRAPPER
|
||||||
|
chmod +x $out/bin/zeavis-api
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── ML Service: Rust (axum + ort/onnxruntime) ──────────────
|
||||||
|
ml-service = pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "zeavis-ml-service";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
nativeBuildInputs = [ pkgs.rustc pkgs.cargo pkgs.pkg-config pkgs.cacert ];
|
||||||
|
buildInputs = [ pkgs.openssl ];
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
export HOME="$TMPDIR" CARGO_HOME="$TMPDIR/.cargo"
|
||||||
|
export SRC_ROOT="$PWD"
|
||||||
|
cd apps/ml-service
|
||||||
|
cargo build --locked --release
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
cd "$SRC_ROOT"
|
||||||
|
mkdir -p $out/bin $out/share/zeavis-ml
|
||||||
|
cp apps/ml-service/target/release/zeavis-ml-service $out/bin/.zeavis-ml-service
|
||||||
|
cp Machine_Learning/model/model.onnx $out/share/zeavis-ml/model.onnx
|
||||||
|
cat > $out/bin/zeavis-ml-service << WRAPPER
|
||||||
|
#!${pkgs.runtimeShell}
|
||||||
|
export MODEL_PATH="$out/share/zeavis-ml/model.onnx"
|
||||||
|
export MODEL_INPUT_SIZE="224"
|
||||||
|
export ML_SERVICE_HOST="0.0.0.0"
|
||||||
|
export ML_SERVICE_PORT="4012"
|
||||||
|
export RUST_LOG="info"
|
||||||
|
exec $out/bin/.zeavis-ml-service
|
||||||
|
WRAPPER
|
||||||
|
chmod +x $out/bin/zeavis-ml-service
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── Web: Vite static + nginx ───────────────────────────────
|
||||||
|
web = pkgs.stdenvNoCC.mkDerivation {
|
||||||
|
pname = "zeavis-web";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
nativeBuildInputs = [ pkgs.bun ];
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
export HOME="$TMPDIR"
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run --cwd packages/shared build
|
||||||
|
bun run --cwd apps/web build
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out/bin $out/etc $out/share/zeavis-web/html
|
||||||
|
cp -r apps/web/dist/* $out/share/zeavis-web/html/
|
||||||
|
cat > $out/etc/nginx.conf << CONF
|
||||||
|
error_log /var/lib/zeavis-web/nginx-error.log;
|
||||||
|
pid /var/lib/zeavis-web/nginx.pid;
|
||||||
|
events {}
|
||||||
|
http {
|
||||||
|
include ${pkgs.nginx}/conf/mime.types;
|
||||||
|
access_log /var/lib/zeavis-web/nginx-access.log;
|
||||||
|
server {
|
||||||
|
listen 4011;
|
||||||
|
server_name _;
|
||||||
|
root $out/share/zeavis-web/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:4006/api/;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /metrics {
|
||||||
|
proxy_pass http://127.0.0.1:4006/metrics;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CONF
|
||||||
|
cat > $out/bin/zeavis-web << WRAPPER
|
||||||
|
#!${pkgs.runtimeShell}
|
||||||
|
mkdir -p /var/lib/zeavis-web
|
||||||
|
exec ${pkgs.nginx}/bin/nginx -c $out/etc/nginx.conf -p /var/lib/zeavis-web -g "daemon off;"
|
||||||
|
WRAPPER
|
||||||
|
chmod +x $out/bin/zeavis-web
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
default = self.packages.${system}.api;
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = [ pkgs.bun pkgs.nodejs_22 pkgs.rustc pkgs.cargo ];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
+21
-22
@@ -1,6 +1,8 @@
|
|||||||
# Infrastruktur — ZeaVis Edu
|
# Infrastruktur — ZeaVis Edu
|
||||||
|
|
||||||
> Arsitektur multi-VPS untuk deployment produksi ZeaVis Edu dengan Tailscale mesh VPN dan observabilitas penuh.
|
> Arsitektur multi-VPS untuk deployment produksi ZeaVis Edu dengan Tailscale mesh VPN dan observabilitas penuh.
|
||||||
|
>
|
||||||
|
> > **Catatan (2026-08-02):** Produksi kini memakai **Nix + systemd + Caddy 2.11.4** (Docker/Traefik/Coolify dihapus). Deploy: GitHub Actions → `nix build` → `nix copy ssh://` → `systemctl restart`.
|
||||||
|
|
||||||
← [Kembali ke README utama](../README.md)
|
← [Kembali ke README utama](../README.md)
|
||||||
|
|
||||||
@@ -24,12 +26,12 @@ ZeaVis Edu berjalan di **dua VPS terpisah** yang terhubung melalui **Tailscale**
|
|||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
|
||||||
│ App VPS (imrnes) │ │ Telemetry VPS (orange) │
|
│ App VPS (imrnes) │ │ Telemetry VPS (orange) │
|
||||||
│ 100.108.1.124 │ │ 100.96.248.86 │
|
│ 100.121.180.82 │ │ 100.96.248.86 │
|
||||||
│ Arch Linux │ │ Ubuntu │
|
│ Arch Linux │ │ Ubuntu │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ ┌──────────┐ ┌──────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ ┌──────────┐ ┌──────────────┐ │
|
||||||
│ │ Web │ │ API │ │ ML │ │ │ │Prometheus│ │Metric │ │
|
│ │ Web │ │ API │ │ ML │ │ │ │Prometheus│ │Metric │ │
|
||||||
│ │:80 │ │:3000 │ │:8000 │ │ │ │:9090 │ │Ingester │ │
|
│ │:4011 │ │:4006 │ │:4012 │ │ │ │:9090 │ │Ingester │ │
|
||||||
│ │/metrics │ │/metrics │ │/metrics │ │ │ │ │ │:9091 │ │
|
│ │/metrics │ │/metrics │ │/metrics │ │ │ │ │ │:9091 │ │
|
||||||
│ └──────────┘ └──────────┘ └──────────┘ │ │ └────┬─────┘ └──────┬───────┘ │
|
│ └──────────┘ └──────────┘ └──────────┘ │ │ └────┬─────┘ └──────┬───────┘ │
|
||||||
│ ┌──────────────────────────────────────┐ │ │ │ │ │
|
│ ┌──────────────────────────────────────┐ │ │ │ │ │
|
||||||
@@ -38,8 +40,8 @@ ZeaVis Edu berjalan di **dua VPS terpisah** yang terhubung melalui **Tailscale**
|
|||||||
│ └──────────────────────────────────────┘ │ │ ┌──────────────────────────────────────┐ │
|
│ └──────────────────────────────────────┘ │ │ ┌──────────────────────────────────────┐ │
|
||||||
│ │ │ │ Vector │ │
|
│ │ │ │ Vector │ │
|
||||||
│ ┌──────────────┐ │ │ │ :9001 │ │
|
│ ┌──────────────┐ │ │ │ :9001 │ │
|
||||||
│ │ Traefik │ │ │ └────────────────┬─────────────────────┘ │
|
│ │ Caddy │ │ │ └────────────────┬─────────────────────┘ │
|
||||||
│ │ (Coolify) │ │ │ │ │
|
│ │ 2.11.4 │ │ │ │ │
|
||||||
│ └──────────────┘ │ │ ▼ │
|
│ └──────────────┘ │ │ ▼ │
|
||||||
│ │ │ ┌──────────────────────────────────────┐ │
|
│ │ │ ┌──────────────────────────────────────┐ │
|
||||||
│ ZeaVis Edu Apps via │ │ │ ClickHouse │ │
|
│ ZeaVis Edu Apps via │ │ │ ClickHouse │ │
|
||||||
@@ -58,14 +60,14 @@ ZeaVis Edu berjalan di **dua VPS terpisah** yang terhubung melalui **Tailscale**
|
|||||||
│ │ │ │ :8181 │ │
|
│ │ │ │ :8181 │ │
|
||||||
│ │ │ └──────────────────────────────────────┘ │
|
│ │ │ └──────────────────────────────────────┘ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ │ Coolify + Traefik handles: │
|
│ │ │ Caddy handles: │
|
||||||
│ │ │ telemetry.zeavisedu.asepharyana.my.id │
|
│ │ │ telemetry.zeavisedu.asepharyana.my.id │
|
||||||
└─────────────────────────────────────────────┘ └──────────────────────────────────────────────┘
|
└─────────────────────────────────────────────┘ └──────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
| VPS | Hostname | OS | Peran |
|
| VPS | Hostname | OS | Peran |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **App VPS** | `imrnes` | Arch Linux | Web (:80), API (:3000), ML Service (:8000) |
|
| **App VPS** | `imrnes` | Arch Linux | Web nginx (:4011), API (:4006), ML Service (:4012) |
|
||||||
| **Telemetry VPS** | `orange` | Ubuntu | Prometheus, ClickHouse, Telemetry UI |
|
| **Telemetry VPS** | `orange` | Ubuntu | Prometheus, ClickHouse, Telemetry UI |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -76,7 +78,7 @@ ZeaVis Edu berjalan di **dua VPS terpisah** yang terhubung melalui **Tailscale**
|
|||||||
|
|
||||||
| Secret | Keterangan |
|
| Secret | Keterangan |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `VPS_HOST` | `100.108.1.124` (imrnes) |
|
| `VPS_HOST` | `100.121.180.82` (imrnes) |
|
||||||
| `VPS_USER` | `mytheclipse` |
|
| `VPS_USER` | `mytheclipse` |
|
||||||
| `VPS_SSH_KEY` | Private SSH key untuk imrnes |
|
| `VPS_SSH_KEY` | Private SSH key untuk imrnes |
|
||||||
| `VPS_PORT` | `22` |
|
| `VPS_PORT` | `22` |
|
||||||
@@ -97,14 +99,12 @@ ZeaVis Edu berjalan di **dua VPS terpisah** yang terhubung melalui **Tailscale**
|
|||||||
|
|
||||||
## 3. Setup VPS
|
## 3. Setup VPS
|
||||||
|
|
||||||
### App VPS (imrnes — 100.108.1.124)
|
### App VPS (imrnes — 100.121.180.82)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create Docker network
|
# Semua service dikelola Nix + systemd — deploy otomatis via GitHub Actions:
|
||||||
docker network create app-shared-net
|
# nix build .#<service> → nix copy ssh://imrnes → systemctl restart zeavis-<service>
|
||||||
docker network create telemetry-net
|
# Reverse proxy: Caddy 2.11.4 (systemd caddy.service, /etc/caddy/Caddyfile, auto-TLS LE)
|
||||||
|
|
||||||
# ZeaVis Edu apps deploy automatically via GitHub Actions
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Telemetry VPS (orange — 100.96.248.86)
|
### Telemetry VPS (orange — 100.96.248.86)
|
||||||
@@ -113,10 +113,8 @@ Deploy via GitHub Actions atau manual:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh mytheclipse@100.96.248.86
|
ssh mytheclipse@100.96.248.86
|
||||||
mkdir -p /opt/telemetry
|
# Telemetry stack juga Nix + systemd (Docker dihapus dari produksi 2026-08-02)
|
||||||
cd /opt/telemetry
|
# Deploy otomatis via GitHub Actions → nix build → nix copy ssh:// → systemctl restart
|
||||||
docker compose up -d
|
|
||||||
bash clickhouse/init.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -127,16 +125,17 @@ bash clickhouse/init.sh
|
|||||||
|
|
||||||
| Port | Service | Akses |
|
| Port | Service | Akses |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 80/443 | Web (via Traefik/Coolify) | Public |
|
| 80/443 | Web entry (Caddy, auto-TLS LE) | Public |
|
||||||
| 3000 | API metrics | Tailscale-only |
|
| 4011 | Web nginx (`zeavisedu.asepharyana.my.id`) | Public (via Caddy) |
|
||||||
| 8000 | ML service metrics | Tailscale-only |
|
| 4006 | API metrics (zeavis-api) | via Caddy / Tailscale-only |
|
||||||
|
| 4012 | ML service metrics (zeavis-ml) | Tailscale-only |
|
||||||
| 9100 | Node Exporter | Tailscale-only |
|
| 9100 | Node Exporter | Tailscale-only |
|
||||||
|
|
||||||
### Telemetry VPS (orange)
|
### Telemetry VPS (orange)
|
||||||
|
|
||||||
| Port | Service | Akses |
|
| Port | Service | Akses |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 80/443 | Telemetry UI (via Coolify Traefik) | Public |
|
| 80/443 | Telemetry UI (via Caddy) | Public |
|
||||||
| 8181 | Telemetry UI (direct) | Tailscale-only |
|
| 8181 | Telemetry UI (direct) | Tailscale-only |
|
||||||
| 9090 | Prometheus | Tailscale-only |
|
| 9090 | Prometheus | Tailscale-only |
|
||||||
| 9091 | Metric Ingester | Tailscale-only |
|
| 9091 | Metric Ingester | Tailscale-only |
|
||||||
@@ -149,7 +148,7 @@ bash clickhouse/init.sh
|
|||||||
## 5. Metrics Flow
|
## 5. Metrics Flow
|
||||||
|
|
||||||
1. **App services** mengekspos `GET /metrics` di port masing-masing
|
1. **App services** mengekspos `GET /metrics` di port masing-masing
|
||||||
2. **Prometheus** di orange VPS scrape via Tailscale IP (`100.108.1.124:PORT`)
|
2. **Prometheus** di orange VPS scrape via Tailscale IP (`100.121.180.82:PORT`)
|
||||||
3. Prometheus forward ke **Metric Ingester** via `remote_write`
|
3. Prometheus forward ke **Metric Ingester** via `remote_write`
|
||||||
4. Metric Ingester enrich → filter → forward ke **Vector**
|
4. Metric Ingester enrich → filter → forward ke **Vector**
|
||||||
5. Vector buffer → write ke **ClickHouse**
|
5. Vector buffer → write ke **ClickHouse**
|
||||||
|
|||||||
+1
-1
Submodule telemetry updated: 2582a53592...89d560baf8
Reference in New Issue
Block a user