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 | ||
|
|
c010d16aaa | ||
|
|
94ed062026 | ||
|
|
b4bd114eed | ||
|
|
7423e4d83b | ||
|
|
4b252a1d5d | ||
|
|
7148e1026e | ||
|
|
96f337ef46 | ||
|
|
3b5839ebd5 | ||
|
|
e2742a7a60 | ||
|
|
ae22adee40 | ||
|
|
ee02bd4f9a | ||
|
|
310d8adc8d | ||
|
|
ccbcd99053 | ||
|
|
52e60b99dd | ||
|
|
e19321a2b7 | ||
|
|
d1275b5b65 | ||
|
|
12162fd6f7 | ||
|
|
9ab8d41adf | ||
|
|
6f07fc05ac | ||
|
|
cbcc7da33d | ||
|
|
d1282364e7 | ||
|
|
254fe19bb6 | ||
|
|
99acc43ff0 | ||
|
|
1afd2e013c | ||
|
|
e1732f5111 | ||
|
|
fc832b404e | ||
|
|
6ba3deb149 | ||
|
|
ac59548337 | ||
|
|
d292bc4b3c | ||
|
|
787acf077f | ||
|
|
250355562f | ||
|
|
d47c73308e | ||
|
|
4b1d70d1c4 | ||
|
|
c9ae90a042 | ||
|
|
caca13e32c | ||
|
|
a55b1521ea | ||
|
|
2eabf12b36 | ||
|
|
b18b98e1a7 | ||
|
|
a36b74a1cf | ||
|
|
9cfbef4598 | ||
|
|
8f1308c8af | ||
|
|
c881e6a2b2 | ||
|
|
7693a02c98 | ||
|
|
528a1622d0 | ||
|
|
51a4cb9ed2 | ||
|
|
22307d44de | ||
|
|
41e0501e6b | ||
|
|
f675b86dc8 | ||
|
|
8ed1b57f1d |
+2
-2
@@ -1,6 +1,6 @@
|
||||
WEB_PORT=5173
|
||||
API_PORT=3000
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/zeavis_edu
|
||||
API_PORT=4006
|
||||
DATABASE_URL=postgres://asephs:***@100.121.180.82:6432/zeavis_edu
|
||||
|
||||
# ── Telemetry / ClickHouse ──────────────────────────────────────────
|
||||
# These credentials are used by the telemetry Docker Compose stack.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
|
||||
<!-- Background circle -->
|
||||
<circle cx="64" cy="64" r="62" fill="#166534" stroke="#22c55e" stroke-width="3"/>
|
||||
<circle cx="64" cy="64" r="56" fill="#15803d"/>
|
||||
|
||||
<!-- Corn leaf shape -->
|
||||
<path d="M38 75 C32 55 30 35 45 22 C55 14 68 10 80 18 C85 22 90 26 95 30
|
||||
C98 34 96 38 90 36 C84 34 78 32 72 34
|
||||
C74 38 78 42 78 48 C78 56 72 62 64 64
|
||||
C56 66 50 70 44 76 C42 78 38 80 38 75Z"
|
||||
fill="#86efac" opacity="0.9"/>
|
||||
|
||||
<!-- Leaf vein -->
|
||||
<path d="M46 56 C52 52 60 48 68 50" stroke="#166534" stroke-width="1.5" fill="none" opacity="0.6"/>
|
||||
<path d="M50 62 C56 58 62 55 68 56" stroke="#166534" stroke-width="1.5" fill="none" opacity="0.6"/>
|
||||
|
||||
<!-- Magnifying glass / AI overlay -->
|
||||
<circle cx="78" cy="68" r="18" fill="none" stroke="#fbbf24" stroke-width="3"/>
|
||||
<line x1="91" y1="81" x2="98" y2="88" stroke="#fbbf24" stroke-width="3" stroke-linecap="round"/>
|
||||
|
||||
<!-- AI sparkle dots -->
|
||||
<circle cx="70" cy="60" r="2" fill="#fbbf24"/>
|
||||
<circle cx="82" cy="56" r="1.5" fill="#fbbf24"/>
|
||||
<circle cx="76" cy="74" r="2" fill="#fbbf24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,195 +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: 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
|
||||
+107
-170
@@ -1,196 +1,133 @@
|
||||
name: Build and Deploy
|
||||
name: Build & Deploy (Nix)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: zeavis-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL || '' }}
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
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
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
service:
|
||||
- name: web
|
||||
dockerfile: apps/web/Dockerfile
|
||||
- name: api
|
||||
dockerfile: apps/api/Dockerfile
|
||||
- name: ml
|
||||
dockerfile: apps/ml-service/Dockerfile
|
||||
service: [api, ml-service, web]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
fetch-depth: 0
|
||||
submodules: false
|
||||
|
||||
- name: Download ONNX model from Hugging Face
|
||||
if: matrix.service.name == 'ml'
|
||||
working-directory: Machine_Learning
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v22
|
||||
with:
|
||||
determinate: false
|
||||
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: |
|
||||
set -eu
|
||||
echo "::group::Install huggingface_hub"
|
||||
python -m pip install --upgrade pip -q
|
||||
python -m pip install huggingface_hub -q
|
||||
echo "::endgroup::"
|
||||
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"
|
||||
|
||||
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
|
||||
echo "HF_TOKEN is set (length: ${#HF_TOKEN})"
|
||||
echo "::endgroup::"
|
||||
- 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
|
||||
|
||||
echo "::group::Download model files from Hugging Face"
|
||||
python -c "
|
||||
from huggingface_hub import hf_hub_download
|
||||
import os, shutil
|
||||
repo = 'MythEclipse2737/zeavis-edu-corn-leaf-classifier'
|
||||
token = os.environ['HF_TOKEN']
|
||||
base = os.path.abspath('.')
|
||||
- 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"
|
||||
|
||||
# Files sit at root of HF repo → copy to correct subdirs
|
||||
# model.onnx goes to model/ for Docker COPY
|
||||
os.makedirs(os.path.join(base, 'model'), exist_ok=True)
|
||||
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')
|
||||
echo "=== Updating profile + restarting ==="
|
||||
ssh "$VPS_USER@$VPS_HOST" "
|
||||
set -eu
|
||||
if [ -d /nix/var/nix/profiles/zeavis-${{ matrix.service }} ] && [ ! -L /nix/var/nix/profiles/zeavis-${{ matrix.service }} ]; then
|
||||
rm -rf /nix/var/nix/profiles/zeavis-${{ matrix.service }}
|
||||
fi
|
||||
sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/zeavis-${{ matrix.service }} --set '$STORE_PATH'
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable zeavis-${{ matrix.service }} 2>/dev/null || true
|
||||
sudo systemctl restart zeavis-${{ matrix.service }}
|
||||
for i in \$(seq 1 30); do
|
||||
systemctl is-active --quiet zeavis-${{ matrix.service }} && break
|
||||
sleep 1
|
||||
done
|
||||
systemctl is-active zeavis-${{ matrix.service }} || {
|
||||
echo '=== SERVICE FAILED — journal ==='
|
||||
journalctl -u zeavis-${{ matrix.service }} -n 40 --no-pager
|
||||
exit 1
|
||||
}
|
||||
systemctl status zeavis-${{ matrix.service }} --no-pager 2>&1 | head -8
|
||||
"
|
||||
ls -lh model/model.onnx model/model.tflite model/labels.json best_model/best_model.keras 2>/dev/null
|
||||
echo "::endgroup::"
|
||||
echo "✅ zeavis-${{ matrix.service }} deployed"
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
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
|
||||
cleanup:
|
||||
# Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama
|
||||
# + nix store gc. Profil yang sedang dipakai tidak disentuh.
|
||||
needs: build-and-deploy
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- name: Validate deploy secrets
|
||||
- name: Nix GC on VPS
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
||||
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_SSH_KEY" ]; then
|
||||
echo "Missing deploy secrets: VPS_HOST, VPS_USER, VPS_SSH_KEY." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- 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
|
||||
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
|
||||
docker compose ps
|
||||
docker compose ps | grep -q "zeavis-web.*Up" || exit 1
|
||||
docker compose ps | grep -q "zeavis-api.*Up" || exit 1
|
||||
docker compose ps | grep -q "zeavis-ml.*Up" || exit 1
|
||||
docker compose ps | grep -q "zeavis-node-exporter.*Up" || echo "⚠️ node_exporter not running (non-fatal)"
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
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`.
|
||||
- **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`.
|
||||
|
||||
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
|
||||
|
||||
@@ -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/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
|
||||
|
||||
- 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) |
|
||||
|-----------------------|-----------------------------------|----------------------------|--------------|
|
||||
| Web (Vite dev) | `zeavisedu.asepharyana.my.id` | `GET /metrics` | 5173 |
|
||||
| API (Elysia) | `api-zeavisedu.asepharyana.my.id` | `GET /metrics` | 3000 |
|
||||
| ML Service (Axum) | `ml-zeavisedu.asepharyana.my.id` | `GET /metrics` | 8000 |
|
||||
| API (Elysia) | `api-zeavisedu.asepharyana.my.id` | `GET /metrics` | 4006 |
|
||||
| ML Service (Axum) | `ml-zeavisedu.asepharyana.my.id` | `GET /metrics` | 4012 |
|
||||
| Prometheus Collector | — | `GET /metrics` (self) | 9090 |
|
||||
|
||||
> In production all metrics are scraped by the Prometheus collector running in the
|
||||
> Telemetry stack on a **separate VPS** connected via **Tailscale**.
|
||||
> See [`telemetry/prometheus/targets/`](./telemetry/prometheus/targets/)
|
||||
> 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.
|
||||
>
|
||||
> 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
|
||||
[
|
||||
{
|
||||
"targets": ["100.x.x.a:3000"],
|
||||
"targets": ["100.121.180.82:4006"],
|
||||
"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" }
|
||||
}
|
||||
]
|
||||
@@ -113,8 +113,8 @@ The Telemetry submodule includes a Prometheus instance that uses
|
||||
|
||||
> ⚠️ **Cross-VPS:** Gunakan **IP Tailscale** (bukan Docker hostname) karena
|
||||
> Prometheus dan ZeaVis Edu berjalan di VPS berbeda. Pastikan port service
|
||||
> (`:3000`, `:8000`) terekspos di `0.0.0.0` atau diizinkan oleh aturan
|
||||
> `iptables`/`ufw` untuk interface Tailscale (`tailscale0`/`100.x.x.x/10`).
|
||||
> (`:4006`, `:4012`) terekspos di `0.0.0.0` atau diizinkan oleh aturan
|
||||
> `iptables`/`ufw` untuk interface Tailscale (`tailscale0`/`100.121.180.82`).
|
||||
|
||||
The Prometheus config (in `telemetry/prometheus/prometheus.yml`) will
|
||||
automatically pick up new files within its 15‑second scrape interval —
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Corn Leaf Disease Classification
|
||||
# Pipeline Machine Learning — ZeaVis Edu
|
||||
|
||||
Pipeline lengkap untuk klasifikasi penyakit daun jagung menggunakan **EfficientNetV2B0**, mulai dari persiapan dataset, pelatihan di Google Colab, hingga ekspor model ke format **TFLite** dan **TensorFlow.js** untuk kebutuhan produksi.
|
||||
> Panduan lengkap: preprocessing dataset, pelatihan di Google Colab, ekspor model ke TFLite, TensorFlow.js, dan ONNX.
|
||||
|
||||
← [Kembali ke README utama](../README.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -90,7 +92,7 @@ Proyek ini menggabungkan **3 dataset** dari sumber berbeda untuk menghasilkan da
|
||||
### Dataset 1 — Kaggle (Corn Leaf Disease - Indonesia)
|
||||
> 🔗 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 |
|
||||
|---|---|
|
||||
@@ -102,6 +104,7 @@ Berisi gambar penyakit daun jagung dengan label dalam Bahasa Indonesia. Dataset
|
||||
### Dataset 2 — Kaggle (Corn or Maize Leaf Disease)
|
||||
> 🔗 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.
|
||||
|
||||
| Folder di Dataset 2 | Dipetakan ke Label |
|
||||
@@ -114,7 +117,7 @@ Digunakan untuk **menggantikan** data Karat Daun dari Dataset 1 dan menambah var
|
||||
### Dataset 3 — SciDB (China Agricultural Dataset)
|
||||
> 🔗 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 |
|
||||
|---|---|
|
||||
@@ -390,4 +393,8 @@ export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python
|
||||
---
|
||||
|
||||
### Sesi Colab terputus saat training
|
||||
**Solusi:** Gunakan callback `ModelCheckpoint` di notebook untuk menyimpan checkpoint secara berkala ke Google Drive, sehingga training bisa dilanjutkan dari checkpoint terakhir tanpa mengulang dari awal.
|
||||
**Solusi:** Gunakan callback `ModelCheckpoint` di notebook untuk menyimpan checkpoint secara berkala ke Google Drive, sehingga training bisa dilanjutkan dari checkpoint terakhir tanpa mengulang dari awal.
|
||||
|
||||
---
|
||||
|
||||
← [Kembali ke README utama](../README.md) • [ML Service →](../apps/ml-service/README.md)
|
||||
|
||||
Binary file not shown.
@@ -10,10 +10,10 @@ import onnxruntime as ort
|
||||
import tensorflow as tf
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
|
||||
# Definisi label kelas sesuai urutan output model klasifikasi
|
||||
LABELS = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]
|
||||
|
||||
|
||||
# Kelas eksepsi kustom untuk menangani ketidaksesuaian akurasi prediksi
|
||||
class ParityError(RuntimeError):
|
||||
"""Raised when Keras and ONNX predictions do not match."""
|
||||
pass
|
||||
@@ -33,16 +33,19 @@ def preprocess_image(image_path, input_size):
|
||||
Raises:
|
||||
ParityError: If image cannot be loaded or processed.
|
||||
"""
|
||||
# Penanganan error secara aman saat memuat gambar ke format RGB
|
||||
try:
|
||||
img = Image.open(image_path).convert("RGB")
|
||||
except (FileNotFoundError, UnidentifiedImageError, OSError) as e:
|
||||
raise ParityError(f"Failed to load image {image_path}: {e}")
|
||||
|
||||
# Penyesuaian resolusi gambar menggunakan metode interpolasi Bilinear
|
||||
try:
|
||||
img = img.resize((input_size, input_size), Image.Resampling.BILINEAR)
|
||||
except Exception as 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_batch = np.expand_dims(img_array, axis=0)
|
||||
|
||||
@@ -60,6 +63,7 @@ def predict_keras(model, image_batch):
|
||||
Returns:
|
||||
Predictions array (1, num_classes).
|
||||
"""
|
||||
# Eksekusi inferensi pada model TensorFlow/Keras tanpa log proses
|
||||
predictions = model.predict(image_batch, verbose=0)
|
||||
return predictions
|
||||
|
||||
@@ -75,6 +79,7 @@ def predict_onnx(session, image_batch):
|
||||
Returns:
|
||||
Predictions array (1, num_classes).
|
||||
"""
|
||||
# Eksekusi inferensi secara dinamis pada model ONNX menggunakan sesi runtime
|
||||
input_name = session.get_inputs()[0].name
|
||||
predictions = session.run(None, {input_name: image_batch})
|
||||
return predictions[0]
|
||||
@@ -94,14 +99,18 @@ def validate_image(image_path, keras_model, onnx_session, input_size, atol):
|
||||
Raises:
|
||||
ParityError: If predictions do not match or image cannot be processed.
|
||||
"""
|
||||
# Menyiapkan tensor gambar untuk pengujian
|
||||
img_batch = preprocess_image(image_path, input_size)
|
||||
|
||||
# Mengekstrak matriks probabilitas dari kedua format model
|
||||
keras_pred = predict_keras(keras_model, 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])
|
||||
onnx_label_idx = np.argmax(onnx_pred[0])
|
||||
|
||||
# Validasi keselarasan keputusan klasifikasi utama
|
||||
if keras_label_idx != onnx_label_idx:
|
||||
keras_label = LABELS[keras_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}"
|
||||
)
|
||||
|
||||
# Validasi selisih nilai desimal probabilitas menggunakan toleransi absolut
|
||||
if not np.allclose(keras_pred, onnx_pred, atol=atol):
|
||||
max_diff = np.max(np.abs(keras_pred - onnx_pred))
|
||||
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})"
|
||||
)
|
||||
|
||||
# Pencatatan log sistem jika kedua model presisi 100%
|
||||
label = LABELS[keras_label_idx]
|
||||
logging.info(f"PASS: {image_path} -> {label}")
|
||||
|
||||
@@ -125,6 +136,7 @@ def main():
|
||||
"""Validate parity between Keras and ONNX models."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
|
||||
# Inisialisasi parser argumen untuk antarmuka CLI (Command Line Interface)
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate parity between Keras and ONNX models"
|
||||
)
|
||||
@@ -161,6 +173,7 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Pengecekan eksistensi berkas model sebelum memuat memori
|
||||
if not args.keras_model.exists():
|
||||
msg = f"Keras model not found at {args.keras_model}"
|
||||
logging.error(msg)
|
||||
@@ -171,15 +184,18 @@ def main():
|
||||
logging.error(msg)
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
# Memuat model Keras (tanpa kompilasi agar lebih hemat beban komputasi)
|
||||
logging.info(f"Loading Keras model from {args.keras_model}...")
|
||||
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}...")
|
||||
onnx_session = ort.InferenceSession(
|
||||
str(args.onnx_model),
|
||||
providers=["CPUExecutionProvider"],
|
||||
)
|
||||
|
||||
# Iterasi pengujian paritas (kesetaraan performa) untuk setiap gambar
|
||||
logging.info(f"Validating {len(args.images)} image(s)...")
|
||||
for image_path in args.images:
|
||||
try:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# ZeaVis Edu — Root Makefile
|
||||
#
|
||||
# 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/.
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,482 +1,334 @@
|
||||
# ZeaVis Edu
|
||||
<p align="center">
|
||||
<br>
|
||||
<img src=".github/assets/zeavis-logo.svg" alt="ZeaVis Edu" width="128"><br>
|
||||
<h1 align="center">ZeaVis Edu</h1>
|
||||
<p align="center">
|
||||
<strong>Asisten Edukasi Interaktif untuk Deteksi Penyakit Daun Jagung</strong><br>
|
||||
<em>Computer Vision • EfficientNetV2B0 • Rust ONNX Runtime • Tauri 2 Android</em>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
ZeaVis Edu adalah aplikasi edukasi untuk membantu mengenali penyakit daun jagung melalui klasifikasi gambar berbasis machine learning. Repositori ini menggabungkan aplikasi web, API backend, layanan inferensi ML, serta pipeline pelatihan dan ekspor model EfficientNetV2B0.
|
||||
<p align="center">
|
||||
<a href="#-tentang"><b>Tentang</b></a> •
|
||||
<a href="#-tim"><b>Tim</b></a> •
|
||||
<a href="#-ringkasan-eksekutif"><b>Ringkasan</b></a> •
|
||||
<a href="#-cakupan--deliverables"><b>Cakupan</b></a> •
|
||||
<a href="#-jadwal"><b>Jadwal</b></a> •
|
||||
<a href="#-tech-stack"><b>Tech Stack</b></a> •
|
||||
<a href="#-memulai"><b>Memulai</b></a> •
|
||||
<a href="#-platform"><b>Platform</b></a> •
|
||||
<a href="#-dokumentasi"><b>Dokumentasi</b></a>
|
||||
</p>
|
||||
|
||||
## Fitur Utama
|
||||
<br>
|
||||
|
||||
- Aplikasi web untuk pengalaman pengguna dan interaksi edukatif.
|
||||
- API backend untuk status layanan, integrasi data, dan komunikasi dengan layanan ML.
|
||||
- ML service berbasis Rust/Axum dengan ONNX Runtime untuk inferensi penyakit daun jagung dari gambar.
|
||||
- Pipeline machine learning untuk preprocessing dataset, training di Google Colab, dan ekspor model produksi.
|
||||
- Dukungan Docker untuk deployment web, API, dan ML service.
|
||||
- Workspace monorepo berbasis Bun dan Moon untuk menjalankan task development, typecheck, dan build secara terpusat.
|
||||
---
|
||||
|
||||
## Kelas Penyakit
|
||||
## 🌽 Tentang
|
||||
|
||||
Model klasifikasi menargetkan empat label berbahasa Indonesia:
|
||||
**ZeaVis Edu** adalah aplikasi edukasi berbasis **Computer Vision** yang membantu petani, mahasiswa pertanian, dan penyuluh lapangan mengidentifikasi penyakit daun jagung secara mandiri — cukup dengan mengunggah foto daun jagung.
|
||||
|
||||
| Label | Deskripsi |
|
||||
Proyek ini merupakan **Capstone Project** dalam program **Pijak × IBM SkillsBuild** dengan tema **"AI for Smart Education"**, dirancang untuk menjembatani kesenjangan antara pengetahuan teori pertanian dan kebutuhan praktis di lapangan.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Tim
|
||||
|
||||
| NPM | Nama | Learning Path | Peran |
|
||||
|---|---|---|---|
|
||||
| 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) |
|
||||
| APC013D6Y0091 | **Taufik Pathurrohman** | Machine Learning | Data Engineering — ekstraksi dataset, cleaning, augmentasi gambar |
|
||||
| APC414D6Y0138 | **Luhung Pandyaska Suyi** | Machine Learning | Model Architecture & Training — CNN, hyperparameter tuning |
|
||||
| APC013D6Y0269 | **Ardian** | Machine Learning | Model Evaluation & Deployment Prep — confusion matrix, konversi ke production-ready |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Ringkasan Eksekutif
|
||||
|
||||
### Masalah
|
||||
|
||||
Data BPS menunjukkan penurunan luas panen jagung dari **2.764.366 Ha (2022)** menjadi **2.487.191 Ha (2023)**. Salah satu penyebab utamanya adalah penyakit daun seperti **Hawar Daun**, **Karat Daun**, dan **Bercak Daun Abu-abu** yang menyebabkan nekrosis dan menghambat fotosintesis.
|
||||
|
||||
Petani sering kesulitan mengidentifikasi penyakit secara kasat mata dan memiliki **ketergantungan tinggi pada POPT** (Petugas Pengendali Organisme Pengganggu Tumbuhan) akibat minimnya media pembelajaran interaktif.
|
||||
|
||||
### Solusi
|
||||
|
||||
ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
|
||||
|
||||
1. 📸 **Unggah** foto daun jagung yang diduga terinfeksi
|
||||
2. 🤖 **Deteksi otomatis** penyakit oleh model AI (EfficientNetV2B0)
|
||||
3. 📚 **Dapatkan** informasi detail penyakit, panduan pencegahan, dan rekomendasi obat secara mandiri
|
||||
|
||||
### Metode Teknis
|
||||
|
||||
| Komponen | Pilihan |
|
||||
|---|---|
|
||||
| Bercak Daun | Gray Leaf Spot |
|
||||
| Hawar Daun | Northern/Southern Leaf Blight |
|
||||
| Karat Daun | Common Rust |
|
||||
| Daun Sehat | Daun jagung tanpa gejala penyakit |
|
||||
| Arsitektur Model | **EfficientNetV2B0** — keseimbangan optimal antara akurasi dan efisiensi parameter |
|
||||
| Metode Pelatihan | **Transfer Learning** pada Google Colab (GPU T4) |
|
||||
| Sumber Dataset 1 | Kaggle — [Corn Leaf Disease](https://www.kaggle.com/datasets/ndisan/corn-leaf-disease) |
|
||||
| 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) |
|
||||
|
||||
## Struktur Proyek
|
||||
| Deployment | VPS dengan Nix + systemd + Caddy, ONNX Runtime untuk inferensi real-time |
|
||||
|
||||
```text
|
||||
---
|
||||
|
||||
## 🎯 Cakupan & Deliverables
|
||||
|
||||
### Cakupan
|
||||
|
||||
| ✅ Dalam Cakupan | ❌ Di Luar Cakupan |
|
||||
|---|---|
|
||||
| Klasifikasi 3 penyakit + 1 daun sehat | Penyakit pada batang atau buah jagung |
|
||||
| Deteksi berbasis unggah gambar daun | Prediksi tanpa input gambar |
|
||||
| Rekomendasi obat & penanganan | Diagnosis pengganti ahli/POPT |
|
||||
| Aplikasi Web + Android (Tauri 2) | Aplikasi iOS |
|
||||
|
||||
### 4 Kelas yang Diklasifikasikan
|
||||
|
||||
| Label | Nama Ilmiah | Gejala |
|
||||
|---|---|---|
|
||||
| **Hawar Daun** | *Northern/Southern Leaf Blight* | Hawar coklat memanjang pada daun |
|
||||
| **Karat Daun** | *Common Rust* | Bintik coklat kemerahan berbentuk pustula |
|
||||
| **Bercak Daun** | *Gray Leaf Spot* | Bercak abu-abu memanjang |
|
||||
| **Daun Sehat** | — | Tanpa gejala penyakit |
|
||||
|
||||
### Deliverables Proyek
|
||||
|
||||
| No | Tahapan | Deskripsi |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 3 | **UI Antarmuka** | Front-End berbasis React + Vite dengan fitur unggah gambar |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## 📅 Jadwal
|
||||
|
||||
| Minggu | Tanggal | Fase | Aktivitas |
|
||||
|---|---|---|---|
|
||||
| **1** | 11–17 Mei 2026 | Inisiasi & Data | Spesifikasi teknis (Asep) • Dataset dari Kaggle + preprocessing (Taufik) • Wireframe UI/UX (Selly) |
|
||||
| **2** | 18–24 Mei 2026 | Training & Dev Awal | Implementasi EfficientNetV2B0 di Colab (Luhung) • Slicing UI ke React (Selly) • Setup server, database, routing API (Asep) |
|
||||
| **3** | 25–31 Mei 2026 | Evaluasi & Modul Edukasi | Evaluasi akurasi + konversi model ke ONNX/TFLite (Ardian) • Halaman edukasi obat & penanganan (Selly) • RESTful API untuk image upload & inferensi (Asep) |
|
||||
| **4** | 1–7 Juni 2026 | Integrasi & Testing | Integrasi penuh Front-End ↔ API ↔ Model ML • Pengujian end-to-end • Stress testing & error handling (Semua) |
|
||||
| **5** | 8–14 Juni 2026 | Deployment & Finalisasi | Deployment ke VPS (Asep) • Bug fixing & optimalisasi UI/UX (Selly) • Dokumentasi teknis & materi presentasi (Semua) |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Manajemen Risiko
|
||||
|
||||
| Risiko | Solusi |
|
||||
|---|---|
|
||||
| **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 + 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" |
|
||||
| **Bottleneck integrasi ML ↔ API ↔ UI** | API Contract ketat di minggu ke-1 + integrasi bertahap (CI) mulai minggu ke-3 |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Arsitektur Proyek
|
||||
|
||||
```
|
||||
.
|
||||
├── apps/
|
||||
│ ├── api/ # Backend Elysia/Bun
|
||||
│ ├── ml-service/ # Layanan inferensi Rust/Axum + ONNX Runtime
|
||||
│ └── web/ # Frontend React + Vite
|
||||
├── Machine_Learning/ # Pipeline dataset, training, dan ekspor model
|
||||
├── packages/
|
||||
│ └── shared/ # Tipe dan utilitas bersama TypeScript
|
||||
├── docker-compose.yml # Konfigurasi deployment container
|
||||
├── package.json # Script dan workspace root Bun
|
||||
└── README.md # Dokumentasi utama proyek
|
||||
│ ├── api/ # Backend Elysia/Bun + Drizzle ORM + PostgreSQL
|
||||
│ ├── ml-service/ # Rust/Axum + ONNX Runtime inference engine
|
||||
│ ├── tauri/ # Tauri 2 mobile wrapper → Android APK
|
||||
│ └── web/ # Frontend React + Vite + Tailwind CSS
|
||||
├── Machine_Learning/ # Pipeline dataset, training Colab, ekspor model
|
||||
│ └── README.md # ⤷ Panduan lengkap pipeline ML
|
||||
├── infra/
|
||||
│ └── README.md # ⤷ Panduan deployment multi-VPS
|
||||
├── packages/shared/ # Tipe & utilitas TypeScript bersama
|
||||
├── telemetry/ # Submodule — Prometheus → ClickHouse pipeline
|
||||
├── flake.nix # Konfigurasi deployment Nix (systemd services)
|
||||
├── package.json # Root workspace Bun + Moon
|
||||
└── README.md # ⤷ Anda di sini
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
| Komponen | Teknologi | Dokumentasi |
|
||||
|---|---|---|
|
||||
| Web Frontend | React, Vite, Tailwind, Zustand, TanStack Query | `apps/web/` |
|
||||
| Android App | Tauri 2, Rust, WebView, Deep Link OAuth | `apps/tauri/` |
|
||||
| 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 Pipeline | Python, TensorFlow/Keras, EfficientNetV2B0 | [`Machine_Learning/README.md`](Machine_Learning/README.md) |
|
||||
| Infrastruktur | Nix, systemd, Caddy, Tailscale | [`infra/README.md`](infra/README.md) |
|
||||
| Telemetry | Prometheus, ClickHouse, Vector, Vue 3 | `telemetry/` |
|
||||
|
||||
### Frontend
|
||||
---
|
||||
|
||||
- React
|
||||
- Vite
|
||||
- TypeScript
|
||||
- React Router
|
||||
- TanStack Query
|
||||
- Zustand
|
||||
- Tailwind CSS
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
### Frontend & Mobile
|
||||
React • Vite • TypeScript • React Router • TanStack Query • Zustand • Tailwind CSS
|
||||
**Tauri 2** (Android) • Rust • WebView • Deep Link OAuth
|
||||
|
||||
### Backend API
|
||||
|
||||
- Bun
|
||||
- Elysia
|
||||
- Drizzle ORM
|
||||
- PostgreSQL
|
||||
Bun • Elysia • Drizzle ORM • PostgreSQL • prom-client
|
||||
|
||||
### Machine Learning
|
||||
Python • TensorFlow/Keras • EfficientNetV2B0 • Google Colab (GPU T4)
|
||||
|
||||
- Python (preprocessing, training, export)
|
||||
- TensorFlow/Keras
|
||||
- EfficientNetV2B0
|
||||
- Rust
|
||||
- Axum
|
||||
- ONNX Runtime
|
||||
- TFLite
|
||||
- TensorFlow.js
|
||||
### Inference Engine
|
||||
**Rust** • **Axum** • **ONNX Runtime** • TFLite • TensorFlow.js
|
||||
|
||||
### Tooling & Deployment
|
||||
### DevOps & Infrastruktur
|
||||
Nix • systemd • Caddy • Tailscale • GitHub Actions (CI/CD)
|
||||
|
||||
- Bun workspaces
|
||||
- Moon task runner
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- GitHub Container Registry
|
||||
- Traefik labels untuk routing deployment
|
||||
### Observabilitas
|
||||
Prometheus • Metric Ingester (Go) • Vector • ClickHouse • Query Proxy (Go) • Telemetry UI (Vue 3)
|
||||
|
||||
### Telemetry & Observability
|
||||
---
|
||||
|
||||
- Prometheus — metric scraping & remote_write
|
||||
- Metric Ingester (Go) — enrichment, filtering, aggregation
|
||||
- Vector — buffering & backpressure
|
||||
- ClickHouse — columnar analytical storage
|
||||
- Query Proxy (Go) — read-only SQL proxy
|
||||
- Telemetry UI (Vue 3) — metrics dashboard
|
||||
- Semua service ZeaVis Edu (web, api, ml-service) mengekspos metrik Prometheus di `/metrics`
|
||||
- Client-side Web Vitals (CLS, FCP, INP, LCP, TTFB) dikumpulkan di frontend
|
||||
## 🚀 Memulai
|
||||
|
||||
## Prasyarat
|
||||
### Prasyarat
|
||||
|
||||
Untuk menjalankan seluruh project secara lokal, siapkan:
|
||||
- **Bun** — runtime & package manager
|
||||
- **Python 3.9–3.11** — pipeline ML
|
||||
- **Rust & Cargo** — `apps/ml-service` (inference) & `apps/tauri` (Android)
|
||||
- **Java 21 + Android SDK** — build Android APK
|
||||
- **Nix** — build & deployment produksi (flake.nix, systemd services)
|
||||
- **PostgreSQL (Neon)** — backend API (via pgbouncer pool imrnes `100.121.180.82:6432`)
|
||||
|
||||
- Bun
|
||||
- Python 3.9–3.11 untuk pipeline ML
|
||||
- Rust dan Cargo untuk `apps/ml-service`
|
||||
- Docker dan Docker Compose jika ingin menjalankan/deploy via container
|
||||
- PostgreSQL jika fitur backend yang membutuhkan database digunakan
|
||||
- File model `Machine_Learning/model/model.onnx` untuk inferensi ML lokal
|
||||
|
||||
## Instalasi Root Workspace
|
||||
|
||||
Jalankan dari root repository:
|
||||
### Instalasi
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ATLAS-PJK-GM007/ZeaVis-Edu.git
|
||||
cd ZeaVis-Edu
|
||||
bun install
|
||||
```
|
||||
|
||||
## Menjalankan Project Lokal
|
||||
|
||||
### Menjalankan Semua Task Development
|
||||
### Menjalankan Development
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
bun run dev # Semua service (web + api)
|
||||
cd apps/web && bun run dev # Hanya frontend
|
||||
cd apps/api && bun run start # Hanya backend API
|
||||
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 android dev # Tauri Android dev
|
||||
```
|
||||
|
||||
Script ini menjalankan task `dev` melalui Moon untuk workspace yang tersedia.
|
||||
### Environment Variables
|
||||
|
||||
### Type Check
|
||||
Salin `.env.example` ke `.env` dan isi:
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
### Build Produksi
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
## Menjalankan Service Secara Terpisah
|
||||
|
||||
### Web App
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Secara default Vite akan menjalankan server development dan menampilkan URL lokal di terminal.
|
||||
|
||||
### API Backend
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
bun run start
|
||||
```
|
||||
|
||||
API membaca konfigurasi dari file `.env` di root repository melalui script Bun.
|
||||
|
||||
Script lain yang tersedia:
|
||||
|
||||
```bash
|
||||
bun run db:generate
|
||||
bun run db:migrate
|
||||
bun run db:seed
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
### ML Service
|
||||
|
||||
```bash
|
||||
cd apps/ml-service
|
||||
cargo run
|
||||
```
|
||||
|
||||
Default path model adalah:
|
||||
|
||||
```text
|
||||
../../Machine_Learning/model/model.onnx
|
||||
```
|
||||
|
||||
Jika model berada di lokasi lain, gunakan environment variable `MODEL_PATH`:
|
||||
|
||||
```bash
|
||||
MODEL_PATH=/path/to/model.onnx cargo run
|
||||
```
|
||||
|
||||
**Port Configuration:**
|
||||
|
||||
- **Default (tanpa .env):** Service mendengarkan di `http://localhost:8000`
|
||||
- **Local development (dengan .env.example):** Service mendengarkan di `http://localhost:8001`
|
||||
```bash
|
||||
cd apps/ml-service
|
||||
source .env.example
|
||||
cargo run
|
||||
```
|
||||
- **Docker container:** Service mendengarkan di port `8000`
|
||||
|
||||
Lihat `apps/ml-service/README.md` untuk detail lengkap tentang konfigurasi port dan contoh curl.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
File `docker-compose.yml` di root menyiapkan tiga service produksi:
|
||||
|
||||
- `web` untuk frontend
|
||||
- `api` untuk backend
|
||||
- `ml` untuk layanan inferensi machine learning
|
||||
|
||||
Konfigurasi compose menggunakan image dari GitHub Container Registry:
|
||||
|
||||
```text
|
||||
ghcr.io/${GITHUB_REPOSITORY:-mytheclipse/zeavis-edu}/web:main
|
||||
ghcr.io/${GITHUB_REPOSITORY:-mytheclipse/zeavis-edu}/api:main
|
||||
ghcr.io/${GITHUB_REPOSITORY:-mytheclipse/zeavis-edu}/ml:main
|
||||
```
|
||||
|
||||
Compose juga mengasumsikan network eksternal bernama `app-shared-net` dan routing Traefik untuk domain produksi. Service `ml` berjalan pada port `8000` di dalam container.
|
||||
|
||||
Contoh menjalankan compose setelah environment dan network siap:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Telemetry Stack
|
||||
|
||||
Proyek ini menyertakan pipeline telemetry metric sebagai git submodule di `telemetry/`. Pipeline mengalirkan metrik dari seluruh service ZeaVis Edu ke ClickHouse untuk analisis dan visualisasi jangka panjang.
|
||||
|
||||
### Arsitektur (Production)
|
||||
|
||||
Di production, aplikasi dan telemetry berjalan di **VPS terpisah** dan terhubung via **Tailscale** (mesh VPN). Prometheus di VPS telemetry melakukan scrape ke service ZeaVis Edu melalui IP Tailscale masing-masing.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph VPS1["VPS — ZeaVis Edu (App)"]
|
||||
W[Web / React<br/>api-zeavisedu.asepharyana.id]
|
||||
A[API / Elysia<br/>:3000]
|
||||
M[ML Service / Axum<br/>:8000]
|
||||
end
|
||||
|
||||
subgraph VPS2["VPS — Telemetry Stack"]
|
||||
P[Prometheus<br/>:9090]
|
||||
MI[Metric Ingester<br/>:9091]
|
||||
V[Vector<br/>:9001]
|
||||
CH[ClickHouse<br/>:8123]
|
||||
QP[Query Proxy<br/>:9092]
|
||||
TUI[Telemetry UI<br/>:8181]
|
||||
end
|
||||
|
||||
P -.->|"scrape via Tailscale IP<br/>100.x.x.a:3000/metrics"| A
|
||||
P -.->|"scrape via Tailscale IP<br/>100.x.x.a:8000/metrics"| M
|
||||
P -->|remote_write| MI
|
||||
MI --> V
|
||||
V --> CH
|
||||
QP --> CH
|
||||
TUI --> QP
|
||||
```
|
||||
|
||||
Setiap service ZeaVis Edu mengekspos endpoint `/metrics` dalam format Prometheus text:
|
||||
|
||||
| Service | Endpoint | Port (lokal) |
|
||||
|-----------------------|--------------------|--------------|
|
||||
| Web (Vite dev) | `GET /metrics` | 5173 |
|
||||
| API (Elysia) | `GET /metrics` | 3000 |
|
||||
| ML Service (Axum) | `GET /metrics` | 8000 |
|
||||
|
||||
Prometheus di VPS telemetry melakukan **scrape langsung** ke API dan ML service melalui IP Tailscale mereka, bukan melalui domain publik. Konfigurasi target ada di `telemetry/prometheus/targets/zeavis-edu.json` — isi dengan IP Tailscale dari service yang dituju.
|
||||
|
||||
Lihat [`METRICS.md`](./METRICS.md) untuk daftar lengkap metrik yang diekspos.
|
||||
|
||||
### Service Telemetry
|
||||
|
||||
| # | Service | Peran | Port |
|
||||
|---|---------|------|------|
|
||||
| 1 | **Prometheus** | Metric scraping & remote_write | 9090 |
|
||||
| 2 | **Metric Ingester** | Enrichment, filtering, aggregation | 9091 |
|
||||
| 3 | **Vector** | Buffering, backpressure, retry | 9001 |
|
||||
| 4 | **ClickHouse** | Columnar analytical storage | 8123 / 9000 |
|
||||
| 5 | **Query Proxy** | Read-only SQL proxy, tenant isolation | 9092 |
|
||||
| 6 | **Telemetry UI** | Vue 3 metrics dashboard | 8181 |
|
||||
|
||||
### Arsitektur (Local Dev)
|
||||
|
||||
Untuk development lokal di satu mesin, telemetry dan app bisa jalan bareng di satu Docker host. Prometheus bisa scrape service lewat Docker network yang sama.
|
||||
|
||||
```bash
|
||||
# Setup network
|
||||
docker network create app-shared-net
|
||||
|
||||
# Build & start telemetry (dengan network sharing)
|
||||
make telemetry-up-local
|
||||
```
|
||||
|
||||
### Menjalankan Telemetry Stack
|
||||
|
||||
Semua operasi telemetry dijalankan dari **root proyek** melalui Makefile:
|
||||
|
||||
```bash
|
||||
# Build komponen telemetry (metric-ingester + telemetry-ui)
|
||||
make telemetry-build
|
||||
|
||||
# Start semua service telemetry (mode produksi, via Tailscale)
|
||||
make telemetry-up
|
||||
|
||||
# Start semua service telemetry (mode lokal — port langsung terbuka)
|
||||
make telemetry-up-local
|
||||
|
||||
# Cek status kesehatan semua service
|
||||
make telemetry-status
|
||||
|
||||
# Lihat log (semua service, atau filter dengan s=)
|
||||
make telemetry-logs
|
||||
make telemetry-logs s=metric-ingester
|
||||
|
||||
# Restart service tertentu
|
||||
make telemetry-restart s=prometheus
|
||||
|
||||
# Kirim test metric
|
||||
make telemetry-test-metric
|
||||
|
||||
# Stop semua service
|
||||
make telemetry-down
|
||||
```
|
||||
|
||||
Untuk development lokal:
|
||||
|
||||
```bash
|
||||
# Setup network jika belum ada
|
||||
docker network create telemetry-net
|
||||
docker network create app-shared-net
|
||||
|
||||
# Build & start
|
||||
make telemetry-build
|
||||
make telemetry-up-local
|
||||
|
||||
# Buka dashboard di http://localhost:8181
|
||||
```
|
||||
|
||||
### Prometheus Auto-Discovery
|
||||
|
||||
Prometheus menggunakan `file_sd_configs` untuk menemukan target secara dinamis. Cukup letakkan file JSON di `telemetry/prometheus/targets/` dan Prometheus akan otomatis mendeteksinya dalam 15 detik — tanpa restart.
|
||||
|
||||
File template sudah tersedia di [`telemetry/prometheus/targets/zeavis-edu.json`](telemetry/prometheus/targets/zeavis-edu.json). **Sebelum production, isi `__CHANGE_ME__` dengan IP Tailscale masing-masing service:**
|
||||
|
||||
```json
|
||||
[
|
||||
{ "targets": ["100.x.x.a:3000"], "labels": { "service": "zeavis-api", "component": "backend", "env": "production" } },
|
||||
{ "targets": ["100.x.x.a:8000"], "labels": { "service": "zeavis-ml", "component": "inference", "env": "production" } }
|
||||
]
|
||||
```
|
||||
|
||||
> **Catatan:** Aplikasi ZeaVis Edu mengekspose port Docker-nya (`:3000`, `:8000`) langsung ke host via `docker-compose.yml`. Pastikan port-port tersebut terbuka di network Tailscale (biasanya iptables Tailscale mengizinkan koneksi ke port localhost).
|
||||
|
||||
### Environment Variables Telemetry
|
||||
|
||||
| Variable | Default | Deskripsi |
|
||||
|----------|---------|-----------|
|
||||
| `CLICKHOUSE_USER` | `telemetry` | User ClickHouse |
|
||||
| `CLICKHOUSE_PASSWORD` | `telemetry` | Password ClickHouse |
|
||||
|
||||
## Workflow Machine Learning
|
||||
|
||||
Detail lengkap tersedia di [`Machine_Learning/README.md`](Machine_Learning/README.md). Ringkasnya:
|
||||
|
||||
1. Unduh `dataset_1.zip`, `dataset_2.zip`, dan `dataset_3.zip` lalu letakkan di `Machine_Learning/`.
|
||||
2. Jalankan preprocessing lokal:
|
||||
|
||||
```bash
|
||||
cd Machine_Learning
|
||||
python preprocessing.py
|
||||
```
|
||||
|
||||
3. Upload `dataset.zip` ke Google Drive.
|
||||
4. Jalankan `notebook.ipynb` di Google Colab dengan GPU.
|
||||
5. Download model terbaik sebagai `best_model/best_model.keras`.
|
||||
6. Ekspor model produksi:
|
||||
|
||||
```bash
|
||||
python save_model.py
|
||||
```
|
||||
|
||||
7. Konversi TensorFlow.js via CLI:
|
||||
|
||||
```bash
|
||||
export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python
|
||||
tensorflowjs_converter \
|
||||
--input_format=tf_saved_model \
|
||||
--output_format=tfjs_graph_model \
|
||||
--signature_name=serving_default \
|
||||
--saved_model_tags=serve \
|
||||
model/saved_model \
|
||||
model/tfjs_model
|
||||
```
|
||||
|
||||
Output utama pipeline ML:
|
||||
|
||||
| Path | Kegunaan |
|
||||
| Variable | Keterangan |
|
||||
|---|---|
|
||||
| `Machine_Learning/dataset.zip` | Dataset siap upload ke Colab |
|
||||
| `Machine_Learning/best_model/best_model.keras` | Model Keras hasil training |
|
||||
| `Machine_Learning/model/saved_model/` | TensorFlow SavedModel |
|
||||
| `Machine_Learning/model/model.tflite` | Model untuk mobile/TFLite |
|
||||
| `Machine_Learning/model/model.onnx` | Model untuk Rust ONNX Runtime |
|
||||
| `Machine_Learning/model/tfjs_model/` | Model untuk TensorFlow.js |
|
||||
| `DATABASE_URL` | URL koneksi PostgreSQL |
|
||||
| `SESSION_SECRET` | Secret untuk session auth |
|
||||
| `WEB_APP_URL` | URL frontend (untuk CORS) |
|
||||
| `ML_SERVICE_URL` | URL layanan inferensi ML |
|
||||
|
||||
## Artifact Lokal dan Generated Files
|
||||
### Pipeline ML (Ringkasan)
|
||||
|
||||
Beberapa file tidak tersedia di fresh clone karena berukuran besar, dihasilkan lokal, atau berasal dari sumber eksternal:
|
||||
1. Unduh 3 dataset ZIP → letakkan di `Machine_Learning/`
|
||||
2. `python preprocessing.py` — gabungkan & bersihkan dataset
|
||||
3. Upload `dataset.zip` ke Google Drive
|
||||
4. Jalankan `notebook.ipynb` di Google Colab (GPU T4)
|
||||
5. Download `best_model.keras`
|
||||
6. `python save_model.py` → TFLite + SavedModel
|
||||
7. Konversi ke TFJS & ONNX
|
||||
|
||||
- `Machine_Learning/dataset_1.zip`
|
||||
- `Machine_Learning/dataset_2.zip`
|
||||
- `Machine_Learning/dataset_3.zip`
|
||||
- `Machine_Learning/dataset/`
|
||||
- `Machine_Learning/dataset.zip`
|
||||
- `Machine_Learning/best_model/best_model.keras`
|
||||
- `Machine_Learning/model/saved_model/`
|
||||
- `Machine_Learning/model/model.tflite`
|
||||
- `Machine_Learning/model/model.onnx`
|
||||
- `Machine_Learning/model/tfjs_model/`
|
||||
> 📖 **Panduan lengkap:** [`Machine_Learning/README.md`](Machine_Learning/README.md)
|
||||
|
||||
## Environment Variable Penting
|
||||
### Deployment
|
||||
|
||||
| Variable | Digunakan oleh | Keterangan |
|
||||
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
|
||||
# Port produksi: zeavis-api 4006, zeavis-web (nginx) 4011, zeavis-ml 4012
|
||||
# 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)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Platform
|
||||
|
||||
ZeaVis Edu tersedia di **dua platform** dari satu codebase:
|
||||
|
||||
| Platform | Teknologi | Build |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | API | URL koneksi PostgreSQL untuk Drizzle |
|
||||
| `API_PORT` | API | Port backend produksi |
|
||||
| `WEB_APP_URL` | API | URL frontend untuk konfigurasi CORS/integrasi |
|
||||
| `ML_SERVICE_URL` | API | URL layanan ML |
|
||||
| `MODEL_PATH` | ML Service | Lokasi file model ONNX, default `../../Machine_Learning/model/model.onnx` |
|
||||
| `MODEL_INPUT_SIZE` | ML Service | Ukuran input model, default produksi `224` |
|
||||
| **Web** | React + Vite → Static SPA | `bun run build` |
|
||||
| **Android** | Tauri 2 + Rust → WebView APK | `cd apps/tauri && bun run tauri android build --apk` |
|
||||
|
||||
## Troubleshooting
|
||||
### Tauri 2 Android
|
||||
|
||||
### `bun run dev` gagal karena dependency belum tersedia
|
||||
Aplikasi Android membungkus frontend web yang sama dalam **WebView native** menggunakan **Tauri 2**, memberikan akses ke API native Android tanpa menulis ulang UI.
|
||||
|
||||
Jalankan ulang instalasi dari root repository:
|
||||
**Fitur Android:**
|
||||
- **Google OAuth** — Login via system browser + deep link `zeavisedu://` kembali ke app
|
||||
- **Kamera** — Izin `CAMERA` untuk unggah foto daun jagung langsung dari kamera
|
||||
- **Tauri Plugin Opener** — Buka URL eksternal di system browser
|
||||
- **Tauri Plugin Deep Link** — Tangkap OAuth callback tanpa memerlukan server redirect
|
||||
|
||||
**CI/CD Android:**
|
||||
- GitHub Actions workflow `.github/workflows/android.yml`
|
||||
- Build otomatis di setiap push/PR ke `main`
|
||||
- Patch `AndroidManifest.xml` untuk menambahkan izin kamera + intent filter deep link
|
||||
- APK ditandatangani (signed) via `apksigner` + release ke GitHub Releases
|
||||
|
||||
```bash
|
||||
bun install
|
||||
# Development Android (butuh Android SDK + emulator/device)
|
||||
cd apps/tauri
|
||||
bun run tauri android init # Init project Android
|
||||
bun run tauri android dev # Dev dengan hot reload
|
||||
bun run tauri android build --apk # Build APK production
|
||||
|
||||
# CI/CD — dijalankan otomatis via GitHub Actions
|
||||
.github/workflows/android.yml
|
||||
```
|
||||
|
||||
### API membutuhkan database
|
||||
> Konfigurasi: `apps/tauri/tauri.conf.json` • `apps/tauri/gen/android/`
|
||||
|
||||
Pastikan `DATABASE_URL` tersedia di `.env` root dan PostgreSQL dapat diakses oleh aplikasi.
|
||||
---
|
||||
|
||||
### ML service gagal memuat model
|
||||
## 📚 Dokumentasi
|
||||
|
||||
Pastikan file model tersedia di path default:
|
||||
| Dokumen | Isi |
|
||||
|---|---|
|
||||
| [`Machine_Learning/README.md`](Machine_Learning/README.md) | Pipeline ML lengkap — preprocessing, training Colab, ekspor TFLite/TFJS/ONNX |
|
||||
| [`apps/ml-service/README.md`](apps/ml-service/README.md) | ML Inference Service — setup, endpoint API, konfigurasi |
|
||||
| [`infra/README.md`](infra/README.md) | Arsitektur multi-VPS — diagram, GitHub Secrets, port, metrics flow |
|
||||
| [`METRICS.md`](METRICS.md) | Daftar lengkap metrik Prometheus |
|
||||
| `telemetry/` (submodule) | Source code telemetry stack |
|
||||
|
||||
```text
|
||||
Machine_Learning/model/model.onnx
|
||||
```
|
||||
---
|
||||
|
||||
Atau set path khusus:
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
```bash
|
||||
MODEL_PATH=/path/to/model.onnx cargo run
|
||||
```
|
||||
| Masalah | Solusi |
|
||||
|---|---|
|
||||
| `bun install` gagal | `bun --version` — pastikan ≥ 1.x |
|
||||
| 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 |
|
||||
| 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` |
|
||||
|
||||
### Docker Compose gagal karena network tidak ditemukan
|
||||
---
|
||||
|
||||
`docker-compose.yml` menggunakan network eksternal `app-shared-net`. Buat network tersebut jika belum ada:
|
||||
## 📖 Daftar Pustaka
|
||||
|
||||
```bash
|
||||
docker network create app-shared-net
|
||||
```
|
||||
1. Prayogi, A. et al. *"Klasifikasi Penyakit Daun Jagung Menggunakan CNN"* — [SISTEMATIS](https://ejournal.rizaniamedia.com/index.php/sistematis/article/view/87/49)
|
||||
2. Nugroho, A. et al. *"Deteksi Penyakit Daun Jagung dengan Deep Learning"* — [MIND Journal](https://ejurnal.itenas.ac.id/index.php/mindjournal/article/view/14032/4209)
|
||||
3. Ramadhan, F. et al. *"Identifikasi Penyakit Jagung Berbasis Citra Digital"* — [Informa](https://www.informa.poltekindonusa.ac.id/index.php/informa/article/view/199/170)
|
||||
4. Corteva Agriscience. *"Kenali Ragam Jenis Penyakit Jagung dan Cara Mengatasinya"* — [corteva.com](https://www.corteva.com/id/berita/Kenali-Ragam-Jenis-Penyakit-Jagung-dan-Cara-Mengatasinya.html)
|
||||
|
||||
### Konversi TensorFlow.js gagal karena konflik protobuf
|
||||
---
|
||||
|
||||
Jalankan konversi melalui CLI dan set environment variable berikut:
|
||||
|
||||
```bash
|
||||
export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python
|
||||
```
|
||||
|
||||
## Pengembangan
|
||||
|
||||
Alur umum pengembangan:
|
||||
|
||||
1. Install dependency dengan `bun install`.
|
||||
2. Jalankan service yang dibutuhkan secara lokal.
|
||||
3. Jalankan `bun run typecheck` sebelum membuat commit.
|
||||
4. Jalankan `bun run build` untuk memverifikasi build produksi.
|
||||
5. Untuk perubahan ML, ikuti dokumentasi detail di `Machine_Learning/README.md`.
|
||||
6. Untuk perubahan ML service, cek juga `apps/ml-service/README.md`.
|
||||
|
||||
## Dokumentasi Terkait
|
||||
|
||||
- [`Machine_Learning/README.md`](Machine_Learning/README.md) — panduan lengkap dataset, training, dan ekspor model.
|
||||
- [`apps/ml-service/README.md`](apps/ml-service/README.md) — panduan menjalankan dan memverifikasi layanan inferensi ML.
|
||||
<p align="center">
|
||||
<sub>
|
||||
Capstone Project • Pijak × IBM SkillsBuild • AI for Smart Education<br>
|
||||
© 2026 ZeaVis Edu Team
|
||||
</sub>
|
||||
</p>
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ RUN bun install --production
|
||||
FROM oven/bun:1.3.14 AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV API_PORT=3000
|
||||
ENV API_PORT=4006
|
||||
|
||||
COPY --from=deps /app/node_modules ./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 packages/shared packages/shared
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 4006
|
||||
CMD ["bun", "apps/api/src/index.ts"]
|
||||
|
||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,10 +8,17 @@ const googleOAuthEnabled = Boolean(
|
||||
);
|
||||
|
||||
const webAppUrl = Bun.env.WEB_APP_URL ?? 'http://localhost:5173';
|
||||
const allowedOrigins = [
|
||||
webAppUrl,
|
||||
'https://tauri.localhost',
|
||||
'http://tauri.localhost',
|
||||
'tauri://localhost',
|
||||
'http://localhost:5173',
|
||||
];
|
||||
const secureCookies = Bun.env.SECURE_COOKIES === 'true' || webAppUrl.startsWith('https://');
|
||||
|
||||
export const env = {
|
||||
port: Number(Bun.env.API_PORT ?? 3000),
|
||||
port: Number(Bun.env.API_PORT ?? 4006),
|
||||
databaseUrl: Bun.env.DATABASE_URL,
|
||||
sessionSecret: Bun.env.SESSION_SECRET,
|
||||
uploaderBaseUrl: Bun.env.UPLOADER_BASE_URL ?? 'https://upload.asepharyana.my.id',
|
||||
@@ -24,6 +31,7 @@ export const env = {
|
||||
googleClientSecret: Bun.env.GOOGLE_CLIENT_SECRET,
|
||||
googleRedirectUri: Bun.env.GOOGLE_REDIRECT_URI,
|
||||
webAppUrl,
|
||||
allowedOrigins,
|
||||
secureCookies,
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ assertRequiredEnv();
|
||||
|
||||
const app = new Elysia()
|
||||
.use(cors({
|
||||
origin: env.webAppUrl,
|
||||
origin: env.allowedOrigins,
|
||||
credentials: true,
|
||||
}))
|
||||
.use(metricsRoutes)
|
||||
|
||||
@@ -28,14 +28,26 @@ function hashToken(token: string) {
|
||||
return createHash('sha256').update(`${env.sessionSecret}:${token}`).digest('hex');
|
||||
}
|
||||
|
||||
export function createSessionCookie(token: string) {
|
||||
function isSecureRequest(headers?: { get(name: string): string | null }) {
|
||||
if (env.secureCookies) return true;
|
||||
// Detect HTTPS behind proxy (X-Forwarded-Proto)
|
||||
const proto = headers?.get('x-forwarded-proto');
|
||||
if (proto === 'https') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildSameSite(headers?: { get(name: string): string | null }) {
|
||||
return isSecureRequest(headers) ? 'SameSite=None; Secure' : 'SameSite=Lax';
|
||||
}
|
||||
|
||||
export function createSessionCookie(token: string, headers?: { get(name: string): string | null }) {
|
||||
const maxAge = 60 * 60 * 24 * 30;
|
||||
const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax';
|
||||
const sameSite = buildSameSite(headers);
|
||||
return `${sessionCookieName}=${token}; HttpOnly; Path=/; ${sameSite}; Max-Age=${maxAge}`;
|
||||
}
|
||||
|
||||
export function clearSessionCookie() {
|
||||
const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax';
|
||||
export function clearSessionCookie(headers?: { get(name: string): string | null }) {
|
||||
const sameSite = buildSameSite(headers);
|
||||
return `${sessionCookieName}=; HttpOnly; Path=/; ${sameSite}; Max-Age=0`;
|
||||
}
|
||||
|
||||
@@ -49,6 +61,19 @@ export function readSessionToken(cookieHeader: string | null | undefined) {
|
||||
return decodeURIComponent(sessionCookie.slice(sessionCookieName.length + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bearer token from Authorization header.
|
||||
* Used as fallback when cookies are blocked (e.g. Android WebView third-party blocking).
|
||||
*/
|
||||
export function readBearerToken(headers?: { get(name: string): string | null }) {
|
||||
if (!headers) return null;
|
||||
const auth = headers.get('authorization');
|
||||
if (!auth) return null;
|
||||
const parts = auth.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
export async function createSession(userId: string) {
|
||||
const db = createDbClient();
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
@@ -71,8 +96,12 @@ export async function deleteSession(token: string | null) {
|
||||
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
|
||||
}
|
||||
|
||||
export async function getCurrentUser(cookieHeader: string | null | undefined): Promise<CurrentUser | null> {
|
||||
const token = readSessionToken(cookieHeader);
|
||||
export async function getCurrentUser(
|
||||
cookieHeader: string | null | undefined,
|
||||
headers?: { get(name: string): string | null },
|
||||
): Promise<CurrentUser | null> {
|
||||
// Try cookie first, then Authorization header (for Android WebView where 3rd-party cookies are blocked)
|
||||
const token = readSessionToken(cookieHeader) ?? readBearerToken(headers);
|
||||
if (!token) return null;
|
||||
|
||||
const db = createDbClient();
|
||||
|
||||
+201
-11
@@ -18,6 +18,103 @@ import {
|
||||
import { env } from '../config/env';
|
||||
import { authCounter } from '../lib/telemetry';
|
||||
|
||||
// ── Google OAuth Helpers ──────────────────────────────────────────────
|
||||
|
||||
interface GoogleTokenResponse {
|
||||
access_token: string;
|
||||
id_token: string;
|
||||
}
|
||||
|
||||
interface GoogleIdPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
email_verified: boolean;
|
||||
name: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
async function exchangeGoogleCode(code: string): Promise<GoogleTokenResponse> {
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: env.googleClientId!,
|
||||
client_secret: env.googleClientSecret!,
|
||||
redirect_uri: env.googleRedirectUri!,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Google token exchange failed: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<GoogleTokenResponse>;
|
||||
}
|
||||
|
||||
function decodeGoogleIdToken(idToken: string): GoogleIdPayload {
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid id_token format');
|
||||
}
|
||||
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8');
|
||||
return JSON.parse(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a page for the Android system browser that uses Chrome's native
|
||||
* `intent://` protocol to open the Tauri app with the session URL.
|
||||
* Falls back to a clickable button if the intent is blocked.
|
||||
*/
|
||||
function renderTauriDeepLinkPage(targetUrl: string): Response {
|
||||
// Extract the path + query from the full URL for the intent
|
||||
let pathAndQuery = '/login';
|
||||
try {
|
||||
const u = new URL(targetUrl);
|
||||
pathAndQuery = u.pathname + u.search + u.hash;
|
||||
} catch { /* use default */ }
|
||||
|
||||
const displayUrl = targetUrl.replace(/"/g, '"');
|
||||
const escapedPath = pathAndQuery.replace(/"/g, '"');
|
||||
// intent:// scheme: Chrome on Android opens the target app by package name
|
||||
// browser_fallback_url: shown if the app isn't installed
|
||||
// Use intent://login/... to produce data URI zeavisedu://login/login?token=xxx
|
||||
// which new URL() can parse (single-slash non-hierarchical URLs break WebView)
|
||||
const intentUrl = `intent://login${escapedPath}#Intent;scheme=zeavisedu;package=com.zeavis.edu;S.browser_fallback_url=${encodeURIComponent(targetUrl)};end`;
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Kembali ke ZeaVis Edu</title></head>
|
||||
<body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0fdf4">
|
||||
<div style="text-align:center;padding:2rem;max-width:360px">
|
||||
<p style="color:#166534;font-size:1.1rem;margin-bottom:1.5rem">Login Google berhasil!<br>Kembali ke aplikasi...</p>
|
||||
<a href="${intentUrl.replace(/"/g, '"')}" id="open-app" style="display:inline-block;background:#16a34a;color:white;padding:0.75rem 2rem;border-radius:0.5rem;text-decoration:none;font-weight:600;font-size:1rem;margin-bottom:1rem">Buka ZeaVis Edu</a>
|
||||
<p style="color:#6b7280;font-size:0.8rem">Jika tombol di atas tidak berfungsi, salin dan buka URL ini di aplikasi ZeaVis Edu:</p>
|
||||
<code style="display:block;word-break:break-all;font-size:0.7rem;color:#4b5563;background:#e5e7eb;padding:0.5rem;border-radius:0.25rem;margin-top:0.5rem">${displayUrl.replace(/</g, '<').replace(/>/g, '>')}</code>
|
||||
</div>
|
||||
<script>
|
||||
// Auto-open the intent
|
||||
window.location.href = ${JSON.stringify(intentUrl)};
|
||||
</script>
|
||||
</body></html>`;
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html;charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePlatform(stateRaw: string | undefined): string {
|
||||
try {
|
||||
if (stateRaw) {
|
||||
const parsed = JSON.parse(Buffer.from(stateRaw, 'base64url').toString('utf-8'));
|
||||
return parsed.platform ?? 'web';
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return 'web';
|
||||
}
|
||||
|
||||
function normalizeEmail(email: unknown) {
|
||||
return typeof email === 'string' ? email.trim().toLowerCase() : '';
|
||||
}
|
||||
@@ -32,13 +129,13 @@ function validatePassword(password: unknown) {
|
||||
|
||||
export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
||||
.get('/me', async ({ request }) => {
|
||||
const user = await getCurrentUser(request.headers.get('cookie'));
|
||||
const user = await getCurrentUser(request.headers.get('cookie'), request.headers);
|
||||
return {
|
||||
user,
|
||||
features: getAuthFeatures(),
|
||||
};
|
||||
})
|
||||
.post('/register', async ({ body, set }) => {
|
||||
.post('/register', async ({ body, set, request }) => {
|
||||
const req = body as Partial<RegisterRequest> | undefined;
|
||||
const email = normalizeEmail(req?.email);
|
||||
const name = normalizeName(req?.name);
|
||||
@@ -62,7 +159,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
||||
|
||||
const user = inserted[0];
|
||||
const token = await createSession(user.id);
|
||||
set.headers['Set-Cookie'] = createSessionCookie(token);
|
||||
set.headers['Set-Cookie'] = createSessionCookie(token, request.headers);
|
||||
|
||||
authCounter.labels('register', 'true').inc();
|
||||
|
||||
@@ -73,13 +170,14 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
||||
name: user.name,
|
||||
role: 'user' as const,
|
||||
},
|
||||
token,
|
||||
features: getAuthFeatures(),
|
||||
};
|
||||
} catch (error) {
|
||||
return serviceUnavailable('Database unavailable');
|
||||
}
|
||||
})
|
||||
.post('/login', async ({ body, set }) => {
|
||||
.post('/login', async ({ body, set, request }) => {
|
||||
const req = body as Partial<AuthRequest> | undefined;
|
||||
const email = normalizeEmail(req?.email);
|
||||
|
||||
@@ -98,7 +196,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
||||
}
|
||||
|
||||
const token = await createSession(user.id);
|
||||
set.headers['Set-Cookie'] = createSessionCookie(token);
|
||||
set.headers['Set-Cookie'] = createSessionCookie(token, request.headers);
|
||||
|
||||
authCounter.labels('login', 'true').inc();
|
||||
|
||||
@@ -109,6 +207,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
||||
name: user.name,
|
||||
role: user.role === 'expert' ? 'expert' as const : 'user' as const,
|
||||
},
|
||||
token,
|
||||
features: getAuthFeatures(),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -116,16 +215,20 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
||||
}
|
||||
})
|
||||
.post('/logout', async ({ request, set }) => {
|
||||
await deleteSession(readSessionToken(request.headers.get('cookie')));
|
||||
set.headers['Set-Cookie'] = clearSessionCookie();
|
||||
const cookieHeader = request.headers.get('cookie');
|
||||
await deleteSession(readSessionToken(cookieHeader));
|
||||
set.headers['Set-Cookie'] = clearSessionCookie(request.headers);
|
||||
return { ok: true };
|
||||
})
|
||||
.get('/google', ({ set }) => {
|
||||
.get('/google', ({ query, set }) => {
|
||||
if (!env.googleOAuthEnabled) {
|
||||
set.status = 404;
|
||||
return { error: 'Google OAuth is not configured' };
|
||||
}
|
||||
|
||||
const platform = (query as Record<string, string>).platform ?? 'web';
|
||||
const state = Buffer.from(JSON.stringify({ platform })).toString('base64url');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: env.googleClientId!,
|
||||
redirect_uri: env.googleRedirectUri!,
|
||||
@@ -133,15 +236,102 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
|
||||
scope: 'openid email profile',
|
||||
access_type: 'offline',
|
||||
prompt: 'select_account',
|
||||
state,
|
||||
});
|
||||
|
||||
set.redirect = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
||||
set.status = 302;
|
||||
set.headers['Location'] = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
||||
})
|
||||
.get('/google/callback', ({ set }) => {
|
||||
.get('/google/callback', async ({ query, set, request }) => {
|
||||
if (!env.googleOAuthEnabled) {
|
||||
set.status = 404;
|
||||
return { error: 'Google OAuth is not configured' };
|
||||
}
|
||||
|
||||
set.redirect = `${env.webAppUrl}/login?oauth=not-implemented`;
|
||||
const q = query as Record<string, string>;
|
||||
const code = q.code;
|
||||
const error = q.error;
|
||||
const platform = resolvePlatform(q.state);
|
||||
|
||||
// User denied or Google returned an error
|
||||
const makeErrorUrl = (msg: string) =>
|
||||
`${env.webAppUrl}/login?error=${encodeURIComponent(msg)}`;
|
||||
|
||||
if (error || !code) {
|
||||
const url = makeErrorUrl(error ?? 'missing_code');
|
||||
if (platform === 'tauri') return renderTauriDeepLinkPage(url);
|
||||
set.status = 302;
|
||||
set.headers['Location'] = url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
let idPayload: GoogleIdPayload;
|
||||
try {
|
||||
const tokens = await exchangeGoogleCode(code);
|
||||
idPayload = decodeGoogleIdToken(tokens.id_token);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Google auth failed';
|
||||
const url = makeErrorUrl(msg);
|
||||
if (platform === 'tauri') return renderTauriDeepLinkPage(url);
|
||||
set.status = 302;
|
||||
set.headers['Location'] = url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email
|
||||
if (!idPayload.email_verified || !idPayload.email) {
|
||||
const url = makeErrorUrl('Email not verified by Google');
|
||||
if (platform === 'tauri') return renderTauriDeepLinkPage(url);
|
||||
set.status = 302;
|
||||
set.headers['Location'] = url;
|
||||
return;
|
||||
}
|
||||
|
||||
const googleId = idPayload.sub;
|
||||
const email = idPayload.email.trim().toLowerCase();
|
||||
const name = idPayload.name?.trim() ?? email.split('@')[0];
|
||||
|
||||
try {
|
||||
const db = createDbClient();
|
||||
|
||||
let user = await db.select().from(users).where(eq(users.googleId, googleId)).limit(1).then(r => r[0] ?? null);
|
||||
|
||||
if (!user) {
|
||||
user = await db.select().from(users).where(eq(users.email, email)).limit(1).then(r => r[0] ?? null);
|
||||
if (user) {
|
||||
await db.update(users).set({ googleId }).where(eq(users.id, user.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const inserted = await db
|
||||
.insert(users)
|
||||
.values({ email, name, googleId, role: 'user' })
|
||||
.returning();
|
||||
user = inserted[0];
|
||||
authCounter.labels('register', 'true').inc();
|
||||
}
|
||||
|
||||
const token = await createSession(user.id);
|
||||
const sessionCookie = createSessionCookie(token, request.headers);
|
||||
|
||||
authCounter.labels('login', 'true').inc();
|
||||
|
||||
const successUrl = `${env.webAppUrl}/login?token=${encodeURIComponent(token)}`;
|
||||
if (platform === 'tauri') {
|
||||
// Inject Set-Cookie into the response so the browser gets it on redirect
|
||||
const resp = renderTauriDeepLinkPage(successUrl);
|
||||
resp.headers.set('Set-Cookie', sessionCookie);
|
||||
return resp;
|
||||
}
|
||||
set.headers['Set-Cookie'] = sessionCookie;
|
||||
set.status = 302;
|
||||
set.headers['Location'] = successUrl;
|
||||
} catch (err) {
|
||||
const url = makeErrorUrl('Database unavailable');
|
||||
if (platform === 'tauri') return renderTauriDeepLinkPage(url);
|
||||
set.status = 302;
|
||||
set.headers['Location'] = url;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ export const dashboardRoutes = new Elysia({ prefix: '/api/v1' })
|
||||
.get('/dashboard/summary', async ({ request }) => {
|
||||
try {
|
||||
const db = createDbClient();
|
||||
const user = await getCurrentUser(request.headers.get('cookie'));
|
||||
const user = await getCurrentUser(request.headers.get('cookie'), request.headers);
|
||||
|
||||
const diseases = await db.select().from(diseaseCatalog).orderBy(diseaseCatalog.displayOrder);
|
||||
const manualRows = await db.select().from(manualClassifications).orderBy(desc(manualClassifications.createdAt)).limit(1);
|
||||
|
||||
@@ -175,7 +175,7 @@ function getFileFromBody(body: unknown): File | null {
|
||||
|
||||
export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' })
|
||||
.post('/diagnoses', async ({ body, request }) => {
|
||||
const user = await getCurrentUser(request.headers.get('cookie'));
|
||||
const user = await getCurrentUser(request.headers.get('cookie'), request.headers);
|
||||
if (!user) return unauthorized('Authentication required');
|
||||
|
||||
const file = getFileFromBody(body);
|
||||
@@ -251,7 +251,7 @@ export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' })
|
||||
}
|
||||
})
|
||||
.get('/diagnoses', async ({ request }) => {
|
||||
const user = await getCurrentUser(request.headers.get('cookie'));
|
||||
const user = await getCurrentUser(request.headers.get('cookie'), request.headers);
|
||||
if (!user) return unauthorized('Authentication required');
|
||||
|
||||
try {
|
||||
@@ -270,7 +270,7 @@ export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' })
|
||||
}
|
||||
})
|
||||
.get('/diagnoses/:id', async ({ params, request }) => {
|
||||
const user = await getCurrentUser(request.headers.get('cookie'));
|
||||
const user = await getCurrentUser(request.headers.get('cookie'), request.headers);
|
||||
if (!user) return unauthorized('Authentication required');
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,7 @@ function isDiagnosisRecordOrNull(record: unknown): record is DiagnosisRecord {
|
||||
|
||||
export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' })
|
||||
.get('/reviews', async ({ request }) => {
|
||||
const user = await getCurrentUser(request.headers.get('cookie'));
|
||||
const user = await getCurrentUser(request.headers.get('cookie'), request.headers);
|
||||
if (!user) return unauthorized('Authentication required');
|
||||
if (user.role !== 'expert') return forbidden('Expert role required');
|
||||
|
||||
@@ -36,7 +36,7 @@ export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' })
|
||||
}
|
||||
})
|
||||
.post('/reviews/:diagnosisId', async ({ params, body, request }) => {
|
||||
const user = await getCurrentUser(request.headers.get('cookie'));
|
||||
const user = await getCurrentUser(request.headers.get('cookie'), request.headers);
|
||||
if (!user) return unauthorized('Authentication required');
|
||||
if (user.role !== 'expert') return forbidden('Expert role required');
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MODEL_PATH=../../Machine_Learning/model/model.onnx
|
||||
MODEL_INPUT_SIZE=224
|
||||
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_INPUT_SIZE=224
|
||||
ENV ML_SERVICE_HOST=0.0.0.0
|
||||
ENV ML_SERVICE_PORT=8000
|
||||
ENV ML_SERVICE_PORT=4012
|
||||
ENV RUST_LOG=info
|
||||
|
||||
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 Machine_Learning/model/model.onnx /app/model/model.onnx
|
||||
|
||||
EXPOSE 8000
|
||||
EXPOSE 4012
|
||||
CMD ["zeavis-ml-service"]
|
||||
|
||||
+79
-145
@@ -1,8 +1,25 @@
|
||||
# ML Service — Rust Axum ONNX Runtime
|
||||
# ML Inference Service — ZeaVis Edu
|
||||
|
||||
Layanan inferensi machine learning berbasis Rust dengan Axum web framework dan ONNX Runtime untuk klasifikasi penyakit daun jagung. Service ini menyediakan endpoint HTTP untuk prediksi real-time dengan performa tinggi dan konsumsi resource minimal.
|
||||
> Layanan inferensi machine learning berbasis Rust/Axum + ONNX Runtime untuk klasifikasi penyakit daun jagung.
|
||||
|
||||
## Fitur
|
||||
← [Kembali ke README utama](../../README.md)
|
||||
|
||||
---
|
||||
|
||||
## Daftar Isi
|
||||
|
||||
1. [Fitur](#1-fitur)
|
||||
2. [Prasyarat & Instalasi](#2-prasyarat--instalasi)
|
||||
3. [Menjalankan Service](#3-menjalankan-service)
|
||||
4. [Environment Variables](#4-environment-variables)
|
||||
5. [Endpoint API](#5-endpoint-api)
|
||||
6. [Verifikasi & Testing](#6-verifikasi--testing)
|
||||
7. [Docker Deployment](#7-docker-deployment)
|
||||
8. [Troubleshooting](#8-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 1. Fitur
|
||||
|
||||
- **Framework:** Axum (async Rust web framework)
|
||||
- **Runtime Inferensi:** ONNX Runtime untuk kompatibilitas lintas platform
|
||||
@@ -10,15 +27,13 @@ Layanan inferensi machine learning berbasis Rust dengan Axum web framework dan O
|
||||
- **Endpoint:** Health check, metadata, dan prediksi gambar
|
||||
- **Multipart Upload:** Dukungan upload gambar langsung via HTTP POST
|
||||
|
||||
## Prasyarat
|
||||
---
|
||||
|
||||
## 2. Prasyarat & Instalasi
|
||||
|
||||
- Rust 1.70+ dan Cargo
|
||||
- Model ONNX di `../../Machine_Learning/model/model.onnx` (atau path custom via `MODEL_PATH`)
|
||||
|
||||
## Instalasi & Setup
|
||||
|
||||
### Instalasi Dependensi
|
||||
|
||||
Dependensi Rust sudah terdaftar di `Cargo.toml`. Cargo akan mengunduh dan mengkompilasi otomatis saat pertama kali build.
|
||||
|
||||
```bash
|
||||
@@ -27,75 +42,58 @@ cargo build
|
||||
|
||||
Output build lokal berada di `target/` dan direktori tersebut diabaikan oleh Git.
|
||||
|
||||
## Menjalankan Service Lokal
|
||||
---
|
||||
|
||||
## 3. Menjalankan Service
|
||||
|
||||
Semua perintah di bawah dijalankan dari direktori `apps/ml-service`.
|
||||
|
||||
### Opsi 1: Default (Port 8000, Model dari Machine_Learning/)
|
||||
### Opsi 1: Default (Port 4012)
|
||||
|
||||
```bash
|
||||
cd apps/ml-service
|
||||
cargo run
|
||||
```
|
||||
|
||||
Service akan mencari model di path default dan mendengarkan di `http://localhost:8000`:
|
||||
Service akan mencari model di path default:
|
||||
```
|
||||
../../Machine_Learning/model/model.onnx
|
||||
```
|
||||
|
||||
### Opsi 2: Local Development dengan .env.example (Port 8001)
|
||||
|
||||
Untuk development lokal dengan port 8001 (sesuai `.env.example`):
|
||||
### Opsi 2: Local Development dengan .env.example (Port 4012)
|
||||
|
||||
```bash
|
||||
cd apps/ml-service
|
||||
source .env.example
|
||||
cargo run
|
||||
```
|
||||
|
||||
Service akan mendengarkan di `http://localhost:8001` karena `ML_SERVICE_PORT=8001` di `.env.example`.
|
||||
|
||||
### Opsi 3: Custom Model Path
|
||||
|
||||
Jika model berada di lokasi lain, gunakan environment variable `MODEL_PATH`:
|
||||
### Opsi 3: Custom Model Path & Port
|
||||
|
||||
```bash
|
||||
cd apps/ml-service
|
||||
MODEL_PATH=/path/to/model.onnx cargo run
|
||||
```
|
||||
|
||||
Atau kombinasikan dengan port custom:
|
||||
|
||||
```bash
|
||||
cd apps/ml-service
|
||||
ML_SERVICE_PORT=9000 MODEL_PATH=/path/to/model.onnx cargo run
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
---
|
||||
|
||||
## 4. Environment Variables
|
||||
|
||||
| Variable | Default | Keterangan |
|
||||
|---|---|---|
|
||||
| `ML_SERVICE_HOST` | `0.0.0.0` | Bind address |
|
||||
| `ML_SERVICE_PORT` | `8000` | Bind port (override untuk local dev dengan `.env.example`) |
|
||||
| `ML_SERVICE_PORT` | `4012` | Bind port |
|
||||
| `MODEL_PATH` | `../../Machine_Learning/model/model.onnx` | Path ke file model ONNX |
|
||||
| `MODEL_INPUT_SIZE` | `224` | Ukuran input gambar (224x224 untuk EfficientNetV2B0) |
|
||||
| `MODEL_INPUT_SIZE` | `224` | Ukuran input gambar (224×224 untuk EfficientNetV2B0) |
|
||||
| `RUST_LOG` | `info` | Level logging (debug, info, warn, error) |
|
||||
|
||||
## Endpoint API
|
||||
---
|
||||
|
||||
### 1. Health Check
|
||||
## 5. Endpoint API
|
||||
|
||||
### Health Check
|
||||
|
||||
**Default (port 8000):**
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:4012/health
|
||||
```
|
||||
|
||||
**Local dev dengan .env.example (port 8001):**
|
||||
```bash
|
||||
curl http://localhost:8001/health
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
@@ -103,19 +101,12 @@ curl http://localhost:8001/health
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Metadata
|
||||
### Metadata
|
||||
|
||||
**Default (port 8000):**
|
||||
```bash
|
||||
curl http://localhost:8000/metadata
|
||||
curl http://localhost:4012/metadata
|
||||
```
|
||||
|
||||
**Local dev dengan .env.example (port 8001):**
|
||||
```bash
|
||||
curl http://localhost:8001/metadata
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"service_name": "zeavis-ml-service",
|
||||
@@ -123,32 +114,19 @@ curl http://localhost:8001/metadata
|
||||
"model_path": "../../Machine_Learning/model/model.onnx",
|
||||
"model_loaded": true,
|
||||
"input_size": 224,
|
||||
"labels": [
|
||||
"Bercak Daun",
|
||||
"Daun Sehat",
|
||||
"Karat Daun",
|
||||
"Hawar Daun"
|
||||
]
|
||||
"labels": ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Prediksi
|
||||
### Prediksi
|
||||
|
||||
Upload gambar daun jagung untuk klasifikasi:
|
||||
|
||||
**Default (port 8000):**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/predict \
|
||||
curl -X POST http://localhost:4012/predict \
|
||||
-F "file=@/path/to/corn-leaf.jpg"
|
||||
```
|
||||
|
||||
**Local dev dengan .env.example (port 8001):**
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/predict \
|
||||
-F "file=@/path/to/corn-leaf.jpg"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"label": "Daun Sehat",
|
||||
@@ -162,86 +140,65 @@ curl -X POST http://localhost:8001/predict \
|
||||
}
|
||||
```
|
||||
|
||||
## Verifikasi & Testing
|
||||
---
|
||||
|
||||
## 6. Verifikasi & Testing
|
||||
|
||||
### Build Produksi
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
# Binary di target/release/zeavis-ml-service
|
||||
```
|
||||
|
||||
Output binary akan tersedia di `target/release/zeavis-ml-service`.
|
||||
|
||||
### Menjalankan Tests
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
Tests mencakup validasi loading model, preprocessing gambar, dan output prediksi.
|
||||
### Verifikasi Manual (default port 4012)
|
||||
|
||||
### Verifikasi Manual
|
||||
```bash
|
||||
# 1. Start service
|
||||
cargo run
|
||||
|
||||
#### Dengan default port 8000:
|
||||
# 2. Health check
|
||||
curl http://localhost:4012/health
|
||||
|
||||
1. Jalankan service:
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
# 3. Metadata
|
||||
curl http://localhost:4012/metadata
|
||||
|
||||
2. Di terminal lain, test health endpoint:
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
# 4. Prediksi
|
||||
curl -X POST http://localhost:4012/predict \
|
||||
-F "file=@../../Machine_Learning/dataset/Daun\ Sehat/sample.jpg"
|
||||
```
|
||||
|
||||
3. Test metadata:
|
||||
```bash
|
||||
curl http://localhost:8000/metadata
|
||||
```
|
||||
---
|
||||
|
||||
4. Test prediksi dengan gambar sample:
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/predict \
|
||||
-F "file=@../../Machine_Learning/dataset/Daun\ Sehat/sample.jpg"
|
||||
```
|
||||
## 7. Docker Deployment
|
||||
|
||||
#### Dengan local dev port 8001 (.env.example):
|
||||
Service dapat di-deploy via Docker. Build dari root repository karena Dockerfile menyalin source service dan artifact ONNX dari beberapa direktori repo.
|
||||
|
||||
1. Jalankan service dengan .env.example:
|
||||
```bash
|
||||
source .env.example
|
||||
cargo run
|
||||
```
|
||||
```bash
|
||||
docker build -f apps/ml-service/Dockerfile -t zeavis-ml-service .
|
||||
docker run -p 4012:4012 zeavis-ml-service
|
||||
```
|
||||
|
||||
2. Di terminal lain, test health endpoint:
|
||||
```bash
|
||||
curl http://localhost:8001/health
|
||||
```
|
||||
Pastikan `Machine_Learning/model/model.onnx` sudah dibuat sebelum build image.
|
||||
|
||||
3. Test metadata:
|
||||
```bash
|
||||
curl http://localhost:8001/metadata
|
||||
```
|
||||
---
|
||||
|
||||
4. Test prediksi dengan gambar sample:
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/predict \
|
||||
-F "file=@../../Machine_Learning/dataset/Daun\ Sehat/sample.jpg"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
## 8. Troubleshooting
|
||||
|
||||
### Model tidak ditemukan
|
||||
|
||||
**Error:** `Failed to load model: No such file or directory`
|
||||
|
||||
**Solusi:** Pastikan file model tersedia di path yang benar:
|
||||
**Solusi:**
|
||||
```bash
|
||||
ls -la ../../Machine_Learning/model/model.onnx
|
||||
```
|
||||
|
||||
Atau set path custom:
|
||||
```bash
|
||||
# Atau set path custom:
|
||||
MODEL_PATH=/absolute/path/to/model.onnx cargo run
|
||||
```
|
||||
|
||||
@@ -249,46 +206,23 @@ MODEL_PATH=/absolute/path/to/model.onnx cargo run
|
||||
|
||||
**Error:** `Address already in use`
|
||||
|
||||
**Solusi:** Service menggunakan port 8000 secara default. Jika port sudah digunakan, ubah dengan environment variable:
|
||||
|
||||
**Solusi:**
|
||||
```bash
|
||||
ML_SERVICE_PORT=9000 cargo run
|
||||
```
|
||||
|
||||
Atau jika menggunakan `.env.example` (port 8001), pastikan tidak ada service lain di port tersebut:
|
||||
|
||||
```bash
|
||||
lsof -i :8001
|
||||
# Cek port yang digunakan:
|
||||
lsof -i :4012
|
||||
```
|
||||
|
||||
### ONNX Runtime tidak kompatibel
|
||||
|
||||
**Error:** `ONNX Runtime initialization failed`
|
||||
|
||||
**Solusi:** Pastikan ONNX Runtime binary kompatibel dengan sistem operasi. Cargo akan mengunduh binary yang sesuai otomatis. Jika masalah persisten, coba rebuild:
|
||||
**Solusi:** Pastikan binary ONNX Runtime kompatibel dengan sistem operasi. Jika masalah persisten:
|
||||
```bash
|
||||
cargo clean
|
||||
cargo build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
---
|
||||
|
||||
### Docker
|
||||
|
||||
Service dapat di-deploy via Docker. Jalankan build dari root repository karena Dockerfile menyalin source service dan artifact ONNX dari beberapa direktori repo.
|
||||
|
||||
```bash
|
||||
docker build -f apps/ml-service/Dockerfile -t zeavis-ml-service .
|
||||
docker run -p 8000:8000 zeavis-ml-service
|
||||
```
|
||||
|
||||
Pastikan `Machine_Learning/model/model.onnx` sudah dibuat sebelum build image.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Lihat `docker-compose.yml` di root repository untuk deployment lengkap dengan web, API, dan ML service.
|
||||
|
||||
## Dokumentasi Terkait
|
||||
|
||||
- [`Machine_Learning/README.md`](../../Machine_Learning/README.md) — Panduan training dan ekspor model ONNX
|
||||
- [`README.md`](../../README.md) — Dokumentasi proyek utama
|
||||
← [Kembali ke README utama](../../README.md) • [Pipeline ML →](../../Machine_Learning/README.md) • [Infra →](../../infra/README.md)
|
||||
|
||||
@@ -30,7 +30,7 @@ impl Config {
|
||||
|
||||
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 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 model_path = env::var("MODEL_PATH").unwrap_or_else(|_| DEFAULT_MODEL_PATH.to_string());
|
||||
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();
|
||||
|
||||
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.model_path,
|
||||
|
||||
Generated
+3
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
+1870
File diff suppressed because it is too large
Load Diff
Generated
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DiscordProjectSettings">
|
||||
<option name="show" value="ASK" />
|
||||
<option name="description" value="" />
|
||||
<option name="applicationTheme" value="default" />
|
||||
<option name="iconsTheme" value="default" />
|
||||
<option name="button1Title" value="" />
|
||||
<option name="button1Url" value="" />
|
||||
<option name="button2Title" value="" />
|
||||
<option name="button2Url" value="" />
|
||||
<option name="customApplicationId" value="" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/tauri.iml" filepath="$PROJECT_DIR$/.idea/tauri.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+616
@@ -32,6 +32,137 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -127,6 +258,19 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
@@ -295,6 +439,35 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -378,6 +551,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -579,6 +758,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@@ -665,6 +853,33 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -682,6 +897,37 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -809,6 +1055,19 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
@@ -1147,6 +1406,12 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1174,6 +1439,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -1459,6 +1730,25 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1640,6 +1930,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
@@ -2003,12 +2299,44 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2034,6 +2362,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2057,6 +2391,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -2122,6 +2462,17 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
@@ -2167,6 +2518,20 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -2399,6 +2764,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -2414,6 +2789,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -2690,6 +3078,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -3042,6 +3440,65 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"open",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"windows",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.2"
|
||||
@@ -3141,6 +3598,19 @@ dependencies = [
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.0"
|
||||
@@ -3221,6 +3691,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -3445,9 +3924,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -3497,6 +3988,17 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
@@ -4033,6 +4535,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -4457,6 +4970,67 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.3",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeavis-edu-tauri"
|
||||
version = "0.1.0"
|
||||
@@ -4465,6 +5039,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-opener",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4526,3 +5102,43 @@ name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.117",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
@@ -12,5 +12,7 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", default-features = false, features = ["wry", "common-controls-v6", "dynamic-acl", "x11", "dbus", "custom-protocol"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"opener:allow-open-url",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
},
|
||||
"dependencies": {
|
||||
"sharp": "0.35.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate Android launcher icons from the ZeaVis Edu logo SVG.
|
||||
* Produces PNGs at all required densities and replaces Tauri's default icons.
|
||||
*
|
||||
* Usage: node scripts/generate-icons.js
|
||||
* Requires: bun add sharp (already in devDependencies)
|
||||
*/
|
||||
|
||||
const sharp = require('sharp');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOGO = path.resolve(__dirname, '../../../.github/assets/zeavis-logo.svg');
|
||||
const RES = path.resolve(__dirname, '../gen/android/app/src/main/res');
|
||||
|
||||
// Android density buckets: [folder, size]
|
||||
const DENSITIES = [
|
||||
['mipmap-mdpi', 48],
|
||||
['mipmap-hdpi', 72],
|
||||
['mipmap-xhdpi', 96],
|
||||
['mipmap-xxhdpi', 144],
|
||||
['mipmap-xxxhdpi', 192],
|
||||
];
|
||||
|
||||
async function generate() {
|
||||
if (!fs.existsSync(LOGO)) {
|
||||
console.error(`ERROR: Logo not found at ${LOGO}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Generating icons from ${LOGO}...`);
|
||||
|
||||
for (const [folder, size] of DENSITIES) {
|
||||
const dir = path.join(RES, folder);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const png = await sharp(LOGO)
|
||||
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// Write both legacy and adaptive icon names
|
||||
for (const name of ['ic_launcher.png', 'ic_launcher_foreground.png', 'ic_launcher_round.png']) {
|
||||
fs.writeFileSync(path.join(dir, name), png);
|
||||
}
|
||||
console.log(` ${folder}: ${size}x${size} OK`);
|
||||
}
|
||||
|
||||
// Also write the legacy icon to drawable for completeness
|
||||
const drawableDir = path.join(RES, 'drawable');
|
||||
if (!fs.existsSync(drawableDir)) fs.mkdirSync(drawableDir, { recursive: true });
|
||||
const refPng = await sharp(LOGO)
|
||||
.resize(144, 144, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
fs.writeFileSync(path.join(drawableDir, 'ic_launcher.png'), refPng);
|
||||
|
||||
console.log('Done. Android launcher icons generated.');
|
||||
}
|
||||
|
||||
generate().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# Patches the generated AndroidManifest.xml with:
|
||||
# 1. CAMERA permission
|
||||
# 2. Deep link intent filter (zeavisedu:// scheme) for Google OAuth return
|
||||
# Run after `tauri android init` to apply.
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="gen/android/app/src/main/AndroidManifest.xml"
|
||||
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "ERROR: $MANIFEST not found. Run 'tauri android init' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── CAMERA permission ──────────────────────────────────────────────────
|
||||
|
||||
if ! grep -q 'android.permission.CAMERA' "$MANIFEST"; then
|
||||
echo "Adding CAMERA permission to AndroidManifest.xml..."
|
||||
sed -i 's|<uses-permission android:name="android.permission.INTERNET" />|<uses-permission android:name="android.permission.INTERNET" />\n <uses-permission android:name="android.permission.CAMERA" />\n <uses-feature android:name="android.hardware.camera" android:required="false" />\n <uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />|' "$MANIFEST"
|
||||
else
|
||||
echo "CAMERA permission already present."
|
||||
fi
|
||||
|
||||
# ── Deep link intent filter ────────────────────────────────────────────
|
||||
# Allows the app to receive zeavisedu:// scheme URLs from the system browser
|
||||
# (used after Google OAuth completes in external browser on Android)
|
||||
|
||||
DEEP_LINK_FILTER='<!-- Deep link for Google OAuth return from system browser -->\
|
||||
<intent-filter android:autoVerify="true">\
|
||||
<action android:name="android.intent.action.VIEW" />\
|
||||
<category android:name="android.intent.category.DEFAULT" />\
|
||||
<category android:name="android.intent.category.BROWSABLE" />\
|
||||
<data android:scheme="zeavisedu" />\
|
||||
</intent-filter>'
|
||||
|
||||
if grep -q 'android:scheme="zeavisedu"' "$MANIFEST"; then
|
||||
echo "Deep link intent filter already present."
|
||||
else
|
||||
echo "Adding deep link intent filter to AndroidManifest.xml..."
|
||||
# Insert before the closing </activity> tag of MainActivity
|
||||
sed -i "s|</activity>|${DEEP_LINK_FILTER}\n </activity>|" "$MANIFEST"
|
||||
echo "Deep link intent filter added."
|
||||
fi
|
||||
|
||||
echo "AndroidManifest patched successfully."
|
||||
@@ -1,6 +1,8 @@
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"beforeDevCommand": "cd ../web && bun run dev"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "ZeaVis Edu",
|
||||
@@ -26,5 +26,13 @@
|
||||
"active": true,
|
||||
"targets": "all"
|
||||
},
|
||||
"plugins": {}
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"mobile": [
|
||||
{
|
||||
"scheme": ["zeavisedu"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>ZeaVis Edu</title>
|
||||
<script>
|
||||
var LIVE = 'https://zeavisedu.asepharyana.my.id';
|
||||
var T = window.__TAURI_INTERNALS__;
|
||||
|
||||
function navigate(path) {
|
||||
window.location.replace(LIVE + path);
|
||||
}
|
||||
|
||||
// On cold start, check if the app was opened via a deep link (Google OAuth)
|
||||
// before redirecting to the live web app.
|
||||
if (T && T.invoke) {
|
||||
T.invoke('plugin:deep-link|get_current')
|
||||
.then(function(urls) {
|
||||
if (urls && urls.length > 0 && urls[0]) {
|
||||
try {
|
||||
var u = new URL(urls[0]);
|
||||
var target = u.pathname + u.search + u.hash;
|
||||
if (target && target !== '/') {
|
||||
// Preserve full path + query (e.g. /login?token=xxx)
|
||||
navigate(target);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* malformed URL — fall through */ }
|
||||
}
|
||||
// No deep link — redirect to live app home
|
||||
navigate('/');
|
||||
})
|
||||
.catch(function() { navigate('/'); });
|
||||
} else {
|
||||
// Not in Tauri (dev mode or unknown) — redirect to live
|
||||
navigate('/');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body style="background:#f0fdf4;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">
|
||||
<p style="color:#16a34a">Memuat ZeaVis Edu...</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ server {
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://zeavis-api:3000/api/;
|
||||
proxy_pass http://zeavis-api: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;
|
||||
@@ -14,7 +14,7 @@ server {
|
||||
|
||||
# Expose API metrics through the web endpoint (Prometheus scrape target)
|
||||
location /metrics {
|
||||
proxy_pass http://zeavis-api:3000/metrics;
|
||||
proxy_pass http://zeavis-api: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;
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tauri-apps/plugin-deep-link": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@zeavis/shared": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
+138
-97
@@ -3,6 +3,8 @@ import {
|
||||
createBrowserRouter,
|
||||
RouterProvider,
|
||||
Navigate,
|
||||
Outlet,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
import { AuthInitializer } from "@/components/auth-initializer";
|
||||
import { AuthGuard } from "@/components/auth-guard";
|
||||
@@ -18,9 +20,10 @@ import { TelemetryPage } from "@/pages/telemetry-page";
|
||||
import { LoginPage } from "@/pages/login-page";
|
||||
import { RegisterPage } from "@/pages/register-page";
|
||||
import { MainLayout } from "@/components/layout/main-layout";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { setupDeepLinkHandler, consumeDeepLinkTarget } from "@/lib/tauri";
|
||||
|
||||
function LogoutProses() {
|
||||
const setUser = useAuthStore((state) => state.setUser);
|
||||
@@ -31,7 +34,7 @@ function LogoutProses() {
|
||||
|
||||
}).catch((error) => {
|
||||
console.error("Oops, gagal logout dari server:", error);
|
||||
setUser(null);
|
||||
setUser(null);
|
||||
});
|
||||
}, [setUser]);
|
||||
|
||||
@@ -42,105 +45,138 @@ import { trackPageView, trackError } from "./lib/telemetry";
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/", element: <Navigate to="/login" replace /> },
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/register", element: <RegisterPage /> },
|
||||
{
|
||||
path: "/dashboard",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DashboardPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/scan",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<ScanPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/library",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<LibraryPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/diagnoses",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosesPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/diagnoses/:id",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosisDetailPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/expert/reviews",
|
||||
element: (
|
||||
<AuthGuard requireExpert={true}>
|
||||
<MainLayout>
|
||||
<ExpertReviewsPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/catalog",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<CatalogPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/catalog/:slug",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiseaseDetailPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/logout",
|
||||
element: (
|
||||
<LogoutProses />
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/telemetry",
|
||||
element: (
|
||||
<MainLayout>
|
||||
<TelemetryPage />
|
||||
</MainLayout>
|
||||
),
|
||||
element: <DeepLinkRouterHandler />,
|
||||
children: [
|
||||
{ path: "/", element: <Navigate to="/login" replace /> },
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/register", element: <RegisterPage /> },
|
||||
{
|
||||
path: "/dashboard",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DashboardPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/scan",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<ScanPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/library",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<LibraryPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/diagnoses",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosesPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/diagnoses/:id",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosisDetailPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/expert/reviews",
|
||||
element: (
|
||||
<AuthGuard requireExpert={true}>
|
||||
<MainLayout>
|
||||
<ExpertReviewsPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/catalog",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<CatalogPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/catalog/:slug",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiseaseDetailPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/logout",
|
||||
element: <LogoutProses />,
|
||||
},
|
||||
{
|
||||
path: "/telemetry",
|
||||
element: (
|
||||
<MainLayout>
|
||||
<TelemetryPage />
|
||||
</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() {
|
||||
const location = window.location;
|
||||
useEffect(() => {
|
||||
@@ -161,6 +197,11 @@ function GlobalErrorTracker() {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
// Register deep link handler for Android OAuth return
|
||||
useEffect(() => {
|
||||
setupDeepLinkHandler();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthInitializer />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB |
@@ -1,23 +1,42 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FormEvent, useState, useCallback } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
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 = {
|
||||
mode: 'login' | 'register';
|
||||
mode: "login" | "register";
|
||||
isSubmitting: boolean;
|
||||
error: string | null;
|
||||
googleOAuthEnabled: boolean;
|
||||
onSubmit: (payload: { name?: string; email: string; password: string }) => Promise<unknown>;
|
||||
onSubmit: (payload: {
|
||||
name?: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}) => Promise<unknown>;
|
||||
onFieldChange?: () => void;
|
||||
};
|
||||
|
||||
export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubmit, onFieldChange }: AuthFormProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
export function AuthForm({
|
||||
mode,
|
||||
isSubmitting,
|
||||
error,
|
||||
googleOAuthEnabled,
|
||||
onSubmit,
|
||||
onFieldChange,
|
||||
}: AuthFormProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -25,58 +44,124 @@ export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubm
|
||||
await onSubmit({ name, email, password });
|
||||
}
|
||||
|
||||
const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const platform = isTauri() ? "tauri" : "web";
|
||||
const googleUrl = `${apiBaseUrl}/api/v1/auth/google?platform=${platform}`;
|
||||
await openUrl(googleUrl);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="mx-auto w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{mode === 'login' ? 'Masuk ke ZeaVis Edu' : 'Buat akun ZeaVis Edu'}</CardTitle>
|
||||
<CardDescription>
|
||||
{mode === 'login'
|
||||
? 'Masuk untuk melihat riwayat diagnosis daun jagung Anda.'
|
||||
: 'Daftar untuk menyimpan diagnosis dan mengikuti review pakar.'}
|
||||
<Card className="mx-auto w-full max-w-md bg-transparent border-none shadow-none">
|
||||
<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>
|
||||
<CardDescription className="text-sm text-emerald-800/80">
|
||||
{mode === "login"
|
||||
? "Masuk untuk menyimpan diagnosis dan mengikuti review pakar."
|
||||
: "Daftar untuk menyimpan diagnosis dan mengikuti review pakar."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{mode === 'register' && (
|
||||
{mode === "register" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nama</Label>
|
||||
<Input id="name" value={name} onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
onFieldChange?.();
|
||||
}} required />
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Masukkan nama Anda"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
onFieldChange?.();
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" value={email} onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
onFieldChange?.();
|
||||
}} required />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Masukkan email Anda"
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
onFieldChange?.();
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Input id="password" type={showPassword ? "text" : "password"} minLength={8} value={password} onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
onFieldChange?.();
|
||||
}} required />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Password minimal 8 karakter"
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
onFieldChange?.();
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground focus:outline-none"
|
||||
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>
|
||||
</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}>
|
||||
{isSubmitting ? 'Memproses...' : mode === 'login' ? 'Masuk' : 'Daftar'}
|
||||
{isSubmitting
|
||||
? "Memproses..."
|
||||
: mode === "login"
|
||||
? "Masuk"
|
||||
: "Daftar"}
|
||||
</Button>
|
||||
</form>
|
||||
{googleOAuthEnabled && (
|
||||
<Button className="mt-3 w-full" variant="outline" asChild>
|
||||
<a href="/api/v1/auth/google">Masuk dengan Google</a>
|
||||
<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">
|
||||
<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
|
||||
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" />
|
||||
</svg>
|
||||
Masuk dengan Google
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -10,27 +10,32 @@ type AuthGuardProps = {
|
||||
};
|
||||
|
||||
export function AuthGuard({ children, requireExpert = false }: AuthGuardProps) {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const setUser = useAuthStore((state) => state.setUser);
|
||||
const query = useQuery({
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: () => apiClient.getMe(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
if (query.data?.user) {
|
||||
setUser(query.data.user);
|
||||
}
|
||||
}, [query.data, setUser]);
|
||||
|
||||
if (query.isLoading) {
|
||||
// Tunjukkan loading hanya jika belum ada user di store
|
||||
if (query.isLoading && !user) {
|
||||
return <main className="min-h-screen p-8 text-center text-muted-foreground">Memeriksa sesi...</main>;
|
||||
}
|
||||
|
||||
if (!query.data?.user) {
|
||||
// Cek store dulu, baru query — mencegah redirect saat refetch background
|
||||
const currentUser = query.data?.user ?? user;
|
||||
if (!currentUser) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (requireExpert && query.data.user.role !== 'expert') {
|
||||
if (requireExpert && currentUser.role !== 'expert') {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@ export function AuthInitializer() {
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: () => apiClient.getMe(),
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
if (query.data?.user) {
|
||||
setUser(query.data.user);
|
||||
}
|
||||
}, [query.data, setUser]);
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { SwitchCamera, CameraOff, Aperture } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface CameraCaptureProps {
|
||||
onCapture: (file: File) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type FacingMode = "environment" | "user";
|
||||
|
||||
export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [facingMode, setFacingMode] = useState<FacingMode>("environment");
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error" | "denied">("loading");
|
||||
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCamera = useCallback(
|
||||
async (mode: FacingMode) => {
|
||||
stopStream();
|
||||
setStatus("loading");
|
||||
setErrorMsg("");
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: mode,
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
},
|
||||
audio: false,
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
}
|
||||
setStatus("ready");
|
||||
} catch (err: unknown) {
|
||||
const e = err as DOMException;
|
||||
if (e.name === "NotAllowedError" || e.name === "PermissionDeniedError") {
|
||||
setStatus("denied");
|
||||
setErrorMsg("Izin kamera ditolak. Buka pengaturan untuk mengizinkan akses kamera.");
|
||||
} else if (e.name === "NotFoundError") {
|
||||
setStatus("error");
|
||||
setErrorMsg("Kamera tidak ditemukan pada perangkat ini.");
|
||||
} else if (e.name === "NotReadableError") {
|
||||
setStatus("error");
|
||||
setErrorMsg("Kamera sedang digunakan oleh aplikasi lain.");
|
||||
} else {
|
||||
setStatus("error");
|
||||
setErrorMsg(`Gagal mengakses kamera: ${e.message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
[stopStream],
|
||||
);
|
||||
|
||||
// Start camera on mount
|
||||
useEffect(() => {
|
||||
startCamera(facingMode);
|
||||
return () => stopStream();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const toggleFacing = () => {
|
||||
const next = facingMode === "environment" ? "user" : "environment";
|
||||
setFacingMode(next);
|
||||
startCamera(next);
|
||||
};
|
||||
|
||||
const handleCapture = () => {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!video || !canvas) return;
|
||||
|
||||
const vw = video.videoWidth;
|
||||
const vh = video.videoHeight;
|
||||
canvas.width = vw;
|
||||
canvas.height = vh;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.drawImage(video, 0, 0, vw, vh);
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return;
|
||||
const file = new File([blob], `camera-${Date.now()}.jpg`, {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
stopStream();
|
||||
onCapture(file);
|
||||
},
|
||||
"image/jpeg",
|
||||
0.92,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 w-full">
|
||||
{/* Viewfinder */}
|
||||
<div className="relative w-full rounded-xl overflow-hidden bg-black aspect-[4/3] max-h-[420px]">
|
||||
{status === "loading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80 text-white">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-8 w-8 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm">Membuka kamera...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === "error" || status === "denied") && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/90 text-white p-6">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<CameraOff className="text-red-400" size={40} />
|
||||
<p className="text-sm text-red-300">{errorMsg}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 px-3 text-sm text-white border-white/30 hover:bg-white/10"
|
||||
onClick={() => startCamera(facingMode)}
|
||||
>
|
||||
Coba Lagi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className={`w-full h-full object-cover ${status === "ready" ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
{/* Scan area overlay */}
|
||||
{status === "ready" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
<div
|
||||
className="relative flex items-center justify-center"
|
||||
style={{ width: "70%", height: "75%" }}
|
||||
>
|
||||
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-lime-300" />
|
||||
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-lime-300" />
|
||||
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-lime-300" />
|
||||
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-lime-300" />
|
||||
<div className="text-white text-center flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold tracking-widest">
|
||||
AREA SCAN
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-center gap-4 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full h-12 w-12 p-0"
|
||||
onClick={onClose}
|
||||
title="Tutup kamera"
|
||||
>
|
||||
<CameraOff size={20} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="rounded-full h-16 w-16 p-0 bg-white border-4 border-green-500 hover:bg-green-50"
|
||||
onClick={handleCapture}
|
||||
disabled={status !== "ready"}
|
||||
title="Ambil foto"
|
||||
>
|
||||
<Aperture className="text-green-600" size={32} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-full h-12 w-12 p-0"
|
||||
onClick={toggleFacing}
|
||||
title="Ganti kamera"
|
||||
>
|
||||
<SwitchCamera size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Hidden canvas for capture */}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { RiskBadge } from "@/components/risk-badge";
|
||||
|
||||
type Props = {
|
||||
imageUrl: string;
|
||||
confidence: number; // contoh: 0.95
|
||||
confidence: number;
|
||||
diseaseName: string;
|
||||
scientificName: string;
|
||||
riskLevel: string;
|
||||
@@ -42,8 +42,8 @@ export function DiagnosisResultView({
|
||||
|
||||
const getRiskLevelKey = (level: string): "low" | "medium" | "high" => {
|
||||
const normalized = level.toLowerCase();
|
||||
if (normalized.includes("rendah")) return "low";
|
||||
if (normalized.includes("tinggi")) return "high";
|
||||
if (normalized.includes("rendah") || normalized === "low") return "low";
|
||||
if (normalized.includes("tinggi") || normalized === "high") return "high";
|
||||
return "medium";
|
||||
};
|
||||
|
||||
@@ -85,18 +85,35 @@ export function DiagnosisResultView({
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-sm font-bold text-slate-700">
|
||||
<span>Tingkat Keyakinan AI</span>
|
||||
<span className="text-emerald-600">{confidencePercent}%</span>
|
||||
<span
|
||||
className={
|
||||
confidencePercent >= 75
|
||||
? "text-emerald-600"
|
||||
: "text-amber-500"
|
||||
}
|
||||
>
|
||||
{confidencePercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-2.5 overflow-hidden">
|
||||
<div
|
||||
className="bg-emerald-500 h-2.5 rounded-full transition-all duration-1000"
|
||||
className={`h-2.5 rounded-full transition-all duration-1000 ${
|
||||
confidencePercent >= 75 ? "bg-emerald-500" : "bg-amber-500"
|
||||
}`}
|
||||
style={{ width: `${confidencePercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-[11px] text-emerald-600 flex items-center gap-1 font-medium">
|
||||
<CheckCircle2 className="w-3 h-3" /> Di atas ambang batas minimum
|
||||
(75%)
|
||||
</p>
|
||||
{confidencePercent >= 75 ? (
|
||||
<p className="text-[11px] text-emerald-600 flex items-center gap-1 font-medium">
|
||||
<CheckCircle2 className="w-3 h-3" /> Di atas ambang batas
|
||||
minimum (75%)
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-amber-600 flex items-center gap-1 font-medium">
|
||||
<AlertTriangle className="w-3 h-3" /> Di bawah ambang batas
|
||||
minimum (75%)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-amber-200/50">
|
||||
|
||||
@@ -14,7 +14,29 @@ import type {
|
||||
} from '@zeavis/shared';
|
||||
import { recordApiCall } from './telemetry';
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id';
|
||||
|
||||
const AUTH_TOKEN_KEY = 'zeavis_auth_token';
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
try {
|
||||
if (token) {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage may throw in private browsing
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiError extends Error {
|
||||
status: number;
|
||||
@@ -24,10 +46,21 @@ export interface ApiError extends Error {
|
||||
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const start = performance.now();
|
||||
const url = `${apiBaseUrl}${endpoint}`;
|
||||
const token = getAuthToken();
|
||||
const headers = new Headers(options?.headers);
|
||||
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
if (options?.body && !options.method) {
|
||||
// auto-set Content-Type for JSON bodies
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: options?.headers,
|
||||
headers,
|
||||
});
|
||||
|
||||
const duration = performance.now() - start;
|
||||
@@ -93,23 +126,29 @@ export const apiClient = {
|
||||
},
|
||||
|
||||
async register(payload: RegisterRequest): Promise<AuthResponse> {
|
||||
return fetchApi('/api/v1/auth/register', {
|
||||
const result = await fetchApi<AuthResponse>('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (result.token) setAuthToken(result.token);
|
||||
return result;
|
||||
},
|
||||
|
||||
async login(payload: AuthRequest): Promise<AuthResponse> {
|
||||
return fetchApi('/api/v1/auth/login', {
|
||||
const result = await fetchApi<AuthResponse>('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (result.token) setAuthToken(result.token);
|
||||
return result;
|
||||
},
|
||||
|
||||
async logout(): Promise<{ ok: boolean }> {
|
||||
return fetchApi('/api/v1/auth/logout', { method: 'POST' });
|
||||
const result = await fetchApi<{ ok: boolean }>('/api/v1/auth/logout', { method: 'POST' });
|
||||
setAuthToken(null);
|
||||
return result;
|
||||
},
|
||||
|
||||
// Disease catalog methods
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Lightweight Tauri environment detection and utilities.
|
||||
* 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;
|
||||
|
||||
export function isTauri(): boolean {
|
||||
if (_isTauri !== null) return _isTauri;
|
||||
_isTauri =
|
||||
typeof window !== 'undefined' &&
|
||||
'__TAURI_INTERNALS__' in window;
|
||||
return _isTauri;
|
||||
}
|
||||
|
||||
/** Get the Tauri IPC invoke function directly from the global internals. */
|
||||
function tauriInvoke(): (cmd: string, args?: Record<string, unknown>) => Promise<unknown> {
|
||||
const T = (window as any).__TAURI_INTERNALS__;
|
||||
if (!T?.invoke) throw new Error('Tauri IPC not available');
|
||||
return T.invoke.bind(T);
|
||||
}
|
||||
|
||||
export async function openUrl(url: string): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const invoke = tauriInvoke();
|
||||
await invoke('plugin:opener|open_url', { url });
|
||||
} catch (err) {
|
||||
console.error('Tauri openUrl failed, trying fallback:', err);
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deep link handling (no full reload) ─────────────────────────────────
|
||||
|
||||
const DEEP_LINK_KEY = 'zeavis_pending_deeplink';
|
||||
const DEEP_LINK_EVENT = 'zeavis:deeplink';
|
||||
|
||||
/** 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> {
|
||||
if (!isTauri()) return;
|
||||
|
||||
try {
|
||||
const invoke = tauriInvoke();
|
||||
|
||||
// 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')
|
||||
.then((urls: any) => {
|
||||
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(() => {});
|
||||
|
||||
// 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');
|
||||
listen('deep-link://new-url', (event: any) => {
|
||||
const urls = event.payload as string[];
|
||||
for (const url of urls) {
|
||||
const target = extractDeepLinkTarget(url);
|
||||
if (target) {
|
||||
storeDeepLinkTarget(target);
|
||||
window.dispatchEvent(new CustomEvent(DEEP_LINK_EVENT, { detail: target }));
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (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] ?? '';
|
||||
}
|
||||
}
|
||||
@@ -54,12 +54,12 @@ export function CatalogPage() {
|
||||
<div className="space-y-6 max-w-5xl mx-auto pb-10">
|
||||
{/* Page Title */}
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-[#214B11]">
|
||||
<h1 className="text-2xl font-bold text-emerald-800">
|
||||
Pustaka Penyakit
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground text-sm md:text-base">
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Referensi lengkap penyakit dan kondisi daun jagung yang dapat
|
||||
dideteksi oleh sistem AI ZeaVis Edu.
|
||||
dideteksi oleh sistem AI ZeaVis Edu
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useUiStore } from "@/store/ui-store";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import bg from "@/assets/images/dashboard-bg.webp";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { dashboardCompact } = useUiStore();
|
||||
@@ -23,16 +22,17 @@ export function DashboardPage() {
|
||||
const summary = summaryQuery.data;
|
||||
const diseases = diseasesQuery.data ?? [];
|
||||
|
||||
const diseasesQuick = useMemo(() =>
|
||||
diseases
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
.map((d) => ({
|
||||
name: d.commonName,
|
||||
sci: d.label,
|
||||
color: d.accentColor,
|
||||
slug: d.slug,
|
||||
})),
|
||||
[diseases]
|
||||
const diseasesQuick = useMemo(
|
||||
() =>
|
||||
diseases
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
.map((d) => ({
|
||||
name: d.commonName,
|
||||
sci: d.label,
|
||||
color: d.accentColor,
|
||||
slug: d.slug,
|
||||
})),
|
||||
[diseases],
|
||||
);
|
||||
|
||||
const missionCards = [
|
||||
@@ -74,7 +74,11 @@ export function DashboardPage() {
|
||||
{/* Hero header */}
|
||||
<header
|
||||
className="relative overflow-hidden rounded-3xl bg-cover bg-center bg-no-repeat shadow-sm"
|
||||
style={{ backgroundImage: `url(${bg})` }}
|
||||
style={{
|
||||
backgroundImage: `url(https://cdn.pixabay.com/photo/2014/09/09/19/07/corn-field-440338_1280.jpg)`,
|
||||
backgroundPosition: "bottom",
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[#2F6E1A]/60 to-black/30" />
|
||||
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6 p-6 md:p-10">
|
||||
@@ -82,7 +86,9 @@ export function DashboardPage() {
|
||||
<span className="inline-block rounded-full bg-[#1E8A2A]/80 px-4 py-2 text-xs font-semibold">
|
||||
AI FOR SMART EDUCATION
|
||||
</span>
|
||||
<h1 className="text-2xl md:text-4xl font-extrabold">Selamat Datang di</h1>
|
||||
<h1 className="text-2xl md:text-4xl font-extrabold">
|
||||
Selamat Datang di
|
||||
</h1>
|
||||
<h2 className="text-2xl md:text-4xl font-extrabold tracking-tight text-[#9AD872]">
|
||||
ZeaVis Edu
|
||||
</h2>
|
||||
@@ -107,10 +113,7 @@ export function DashboardPage() {
|
||||
variant="outline"
|
||||
className="px-4 md:px-6 py-3 md:py-6 text-sm md:text-lg font-semibold text-white hover:bg-[#1E8A2A]"
|
||||
>
|
||||
<Link
|
||||
to="/catalog"
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<Link to="/catalog" className="inline-flex items-center gap-2">
|
||||
Pustaka Penyakit
|
||||
<ChevronRight className="h-5 w-6" />
|
||||
</Link>
|
||||
@@ -169,7 +172,10 @@ export function DashboardPage() {
|
||||
<div className="text-3xl font-bold">
|
||||
{summary.imageClassificationCount}
|
||||
</div>
|
||||
<Link to="/diagnoses" className="text-emerald-600 ml-auto hover:underline">
|
||||
<Link
|
||||
to="/diagnoses"
|
||||
className="text-emerald-600 ml-auto hover:underline"
|
||||
>
|
||||
Lihat daftar
|
||||
</Link>
|
||||
</CardContent>
|
||||
@@ -186,7 +192,10 @@ export function DashboardPage() {
|
||||
<div className="text-3xl font-bold text-amber-600">
|
||||
{summary.needsReviewCount}
|
||||
</div>
|
||||
<Link to="/diagnoses?status=needs_review" className="text-amber-600 ml-auto hover:underline">
|
||||
<Link
|
||||
to="/diagnoses?status=needs_review"
|
||||
className="text-amber-600 ml-auto hover:underline"
|
||||
>
|
||||
Lihat daftar
|
||||
</Link>
|
||||
</CardContent>
|
||||
@@ -200,10 +209,11 @@ export function DashboardPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-full flex flex-col justify-start pt-2">
|
||||
<div className="text-3xl font-bold text-red-600">
|
||||
—
|
||||
</div>
|
||||
<Link to="/diagnoses?status=failed" className="text-red-600 ml-auto hover:underline">
|
||||
<div className="text-3xl font-bold text-red-600">—</div>
|
||||
<Link
|
||||
to="/diagnoses?status=failed"
|
||||
className="text-red-600 ml-auto hover:underline"
|
||||
>
|
||||
Lihat daftar
|
||||
</Link>
|
||||
</CardContent>
|
||||
@@ -220,7 +230,10 @@ export function DashboardPage() {
|
||||
<div className="text-3xl font-bold text-red-600">
|
||||
{summary.riskDistribution.high}
|
||||
</div>
|
||||
<Link to="/catalog?risk=high" className="text-red-600 ml-auto hover:underline">
|
||||
<Link
|
||||
to="/catalog?risk=high"
|
||||
className="text-red-600 ml-auto hover:underline"
|
||||
>
|
||||
Lihat pustaka
|
||||
</Link>
|
||||
</CardContent>
|
||||
@@ -276,7 +289,8 @@ export function DashboardPage() {
|
||||
Penyakit yang Dapat Dideteksi
|
||||
</h3>
|
||||
<p className="text-[15px] font-normal text-muted-foreground">
|
||||
{diseases.length} kelas penyakit dan kondisi daun jagung dalam sistem kami
|
||||
{diseases.length} kelas penyakit dan kondisi daun jagung dalam
|
||||
sistem kami
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
@@ -295,9 +309,7 @@ export function DashboardPage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{diseasesQuick.map((d) => (
|
||||
<Link key={d.slug} to={`/catalog/${d.slug}`}>
|
||||
<Card
|
||||
className="rounded-2xl bg-white p-4 shadow-sm h-full transition-transform hover:scale-[1.03] hover:shadow-md cursor-pointer"
|
||||
>
|
||||
<Card className="rounded-2xl bg-white p-4 shadow-sm h-full transition-transform hover:scale-[1.03] hover:shadow-md cursor-pointer">
|
||||
<CardContent className="h-full p-4 flex flex-col justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
@@ -324,7 +336,9 @@ export function DashboardPage() {
|
||||
{/* Scan quick access */}
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center gap-4 sm:gap-6 bg-[#1E8A2A] rounded-3xl p-5 sm:p-6">
|
||||
<div className="flex-1 text-white">
|
||||
<h3 className="text-2xl font-bold">Siap Mendeteksi Penyakit Daun?</h3>
|
||||
<h3 className="text-2xl font-bold">
|
||||
Siap Mendeteksi Penyakit Daun?
|
||||
</h3>
|
||||
<p className="text-sm text-[#9AD872] font-normal mt-2">
|
||||
Unggah foto daun jagung Anda dan dapatkan hasil analisis AI
|
||||
dalam hitungan detik.
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DiagnosesPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Diagnosa Tanaman</h1>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Diagnosa Penyakit</h1>
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Lihat hasil diagnosa dari scan yang telah dilakukan
|
||||
</p>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function ExpertReviewsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Review Pakar</h1>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Tinjauan Pakar</h1>
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Tinjau hasil diagnosa dari scan yang telah dilakukan dan berikan
|
||||
feedback untuk meningkatkan akurasi sistem AI ZeaVis Edu
|
||||
|
||||
@@ -1,20 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { apiClient, setAuthToken } from "@/lib/api-client";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { isTauri } from "@/lib/tauri";
|
||||
|
||||
function getUrlParam(name: string): string | null {
|
||||
return new URLSearchParams(window.location.search).get(name);
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const setUser = useAuthStore((state) => state.setUser);
|
||||
const location = useLocation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const oauthTokenConsumed = useRef(false);
|
||||
const [oauthProcessing, setOauthProcessing] = useState(false);
|
||||
|
||||
// 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(() => {
|
||||
const token = getUrlParam("token");
|
||||
if (!token || oauthTokenConsumed.current) return;
|
||||
oauthTokenConsumed.current = true;
|
||||
setOauthProcessing(true);
|
||||
|
||||
// Store token for future API calls and fetch user
|
||||
setAuthToken(token);
|
||||
|
||||
apiClient
|
||||
.getMe()
|
||||
.then((data) => {
|
||||
setUser(data.user);
|
||||
queryClient.setQueryData(["auth", "me"], data);
|
||||
navigate("/dashboard", { replace: true });
|
||||
})
|
||||
.catch((err) => {
|
||||
setAuthToken(null);
|
||||
setOauthProcessing(false);
|
||||
setError(err instanceof Error ? err.message : "Google login gagal");
|
||||
});
|
||||
}, [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
|
||||
const oauthError = getUrlParam("error");
|
||||
const meQuery = useQuery({
|
||||
queryKey: ["auth", "me"],
|
||||
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({
|
||||
mutationFn: apiClient.login,
|
||||
onSuccess: (response) => {
|
||||
@@ -26,13 +98,39 @@ export function LoginPage() {
|
||||
setError(err instanceof Error ? err.message : "Login gagal"),
|
||||
});
|
||||
|
||||
// Show loading spinner while OAuth token is being processed
|
||||
if (oauthProcessing) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-6 py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-10 w-10 border-4 border-green-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-gray-500 text-sm">
|
||||
Menyelesaikan login dengan Google...
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-6 py-12">
|
||||
<div className="w-full max-w-sm md:max-w-md space-y-4">
|
||||
<main className="relative flex min-h-screen items-center justify-center px-6 py-12">
|
||||
{/* Background Image with Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)), url(https://cdn.pixabay.com/photo/2014/09/09/19/07/corn-field-440338_1280.jpg)`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Glassmorphism Container */}
|
||||
<div className="relative z-10 w-full max-w-sm md:max-w-md space-y-6 bg-white/60 backdrop-blur-md p-8 md:p-10 rounded-3xl shadow-2xl border border-white/50">
|
||||
<AuthForm
|
||||
mode="login"
|
||||
isSubmitting={mutation.isPending}
|
||||
error={error}
|
||||
error={oauthError || error}
|
||||
googleOAuthEnabled={Boolean(
|
||||
meQuery.data?.features.googleOAuthEnabled,
|
||||
)}
|
||||
@@ -42,9 +140,13 @@ export function LoginPage() {
|
||||
}}
|
||||
onFieldChange={() => setError(null)}
|
||||
/>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
|
||||
<p className="text-center text-sm text-slate-700">
|
||||
Belum punya akun?{" "}
|
||||
<Link className="text-primary" to="/register">
|
||||
<Link
|
||||
className="text-emerald-700 font-bold hover:underline"
|
||||
to="/register"
|
||||
>
|
||||
Daftar
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -28,7 +28,18 @@ export function RegisterPage() {
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-6 py-12">
|
||||
<div className="w-full max-w-sm md:max-w-md space-y-4">
|
||||
{/* Background Image with Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)), url(https://cdn.pixabay.com/photo/2014/09/09/19/07/corn-field-440338_1280.jpg)`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
/>
|
||||
{/* Glassmorphism Container */}
|
||||
<div className="relative z-10 w-full max-w-sm md:max-w-md space-y-6 bg-white/60 backdrop-blur-md p-8 md:p-10 rounded-3xl shadow-2xl border border-white/50">
|
||||
<AuthForm
|
||||
mode="register"
|
||||
isSubmitting={mutation.isPending}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -16,9 +16,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import type { DiagnosisRecord } from "@zeavis/shared";
|
||||
import { diseaseCatalogSeed } from "@zeavis/shared"; // Import data seed lokal ditambahkan
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { trackScan, trackDiagnosisResult } from "@/lib/telemetry";
|
||||
import { DiagnosisResultView } from "../components/diagnose-result-view";
|
||||
import { CameraCapture } from "../components/camera-capture";
|
||||
|
||||
export function ScanPage() {
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
@@ -31,6 +33,22 @@ export function ScanPage() {
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Camera mode state
|
||||
const [useCamera, setUseCamera] = useState(false);
|
||||
const handleCameraCapture = useCallback((file: File) => {
|
||||
setFileName(file.name);
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageDimensions({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = url;
|
||||
setUseCamera(false);
|
||||
}, []);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (file: File) => apiClient.createDiagnosis(file),
|
||||
@@ -76,7 +94,7 @@ export function ScanPage() {
|
||||
const [diagnosisPreview, setDiagnosisPreview] =
|
||||
useState<DiagnosisRecord | null>(null);
|
||||
|
||||
const diagnosesQuery = useQuery({
|
||||
useQuery({
|
||||
queryKey: ["diagnoses"],
|
||||
queryFn: () => apiClient.getDiagnoses(),
|
||||
enabled: previewOpen,
|
||||
@@ -87,10 +105,10 @@ export function ScanPage() {
|
||||
{/* Main Header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Scan Tanaman</h1>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Pindai Daun</h1>
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Unggah foto daun jagung untuk dianalisis oleh sistem AI kami secara
|
||||
real-time.
|
||||
real-time
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
@@ -102,8 +120,8 @@ export function ScanPage() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column: Upload Area */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Card className="w-full lg:h-117 py-3">
|
||||
<CardContent className="px-6 py-4 h-full flex flex-col">
|
||||
<Card className="w-full h-fit py-3">
|
||||
<CardContent className="px-6 py-4 flex flex-col">
|
||||
<div className="text-black flex items-center gap-2 mb-3 text-lg font-semibold">
|
||||
<Camera className="text-green-500" size={25} />
|
||||
Area Unggah Gambar
|
||||
@@ -111,33 +129,91 @@ export function ScanPage() {
|
||||
|
||||
{!previewUrl ? (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="w-full border-2 border-dashed border-green-300 rounded-md p-10 h-60 text-center cursor-pointer"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload className="mx-auto text-green-500 mb-3" size={48} />
|
||||
<h3 className="font-semibold text-base text-gray-800 mb-1">
|
||||
Seret & Lepas Foto Daun
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
atau klik untuk memilih file berkas dari perangkat Anda
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
|
||||
<Check size={14} /> PNG, JPG, JPEG, WEBP
|
||||
</div>
|
||||
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
|
||||
<Check size={14} /> Maks. 5 MB
|
||||
</div>
|
||||
</div>
|
||||
{/* Mode toggle */}
|
||||
<div className="flex rounded-lg bg-gray-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseCamera(false)}
|
||||
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
!useCamera
|
||||
? "bg-white text-green-700 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<Upload size={16} className="inline mr-1.5" />
|
||||
Unggah
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseCamera(true)}
|
||||
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
useCamera
|
||||
? "bg-white text-green-700 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<Camera size={16} className="inline mr-1.5" />
|
||||
Kamera
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Upload size={18} /> Pilih Berkas
|
||||
</button>
|
||||
|
||||
{useCamera ? (
|
||||
<CameraCapture
|
||||
onCapture={handleCameraCapture}
|
||||
onClose={() => setUseCamera(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
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()}
|
||||
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
|
||||
className="mx-auto text-green-500 mb-3"
|
||||
size={48}
|
||||
/>
|
||||
<h3 className="font-semibold text-base text-gray-800 mb-1">
|
||||
Seret & Lepas Foto Daun
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
atau klik untuk memilih file berkas dari perangkat
|
||||
Anda
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
|
||||
<Check size={14} /> PNG, JPG, JPEG, WEBP
|
||||
</div>
|
||||
<div className="bg-green-100 text-green-700 px-3 py-1 rounded-full inline-flex items-center gap-1 text-xs font-medium">
|
||||
<Check size={14} /> Maks. 5 MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Upload size={18} /> Pilih Berkas
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -373,29 +449,46 @@ export function ScanPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DiagnosisResultView
|
||||
imageUrl={
|
||||
previewUrl ||
|
||||
diagnosisPreview.imageUrl ||
|
||||
"https://placehold.co/600x400?text=Foto+Daun"
|
||||
}
|
||||
confidence={diagnosisPreview.confidence ?? 0}
|
||||
diseaseName={
|
||||
diagnosisPreview.disease?.commonName ?? "Tidak Diketahui"
|
||||
}
|
||||
scientificName={diagnosisPreview.disease?.label ?? ""}
|
||||
riskLevel={diagnosisPreview.disease?.riskLevel ?? "Sedang"}
|
||||
description={
|
||||
diagnosisPreview.disease?.description ??
|
||||
diagnosisPreview.disease?.summary ??
|
||||
"Deskripsi tidak tersedia."
|
||||
}
|
||||
symptoms={diagnosisPreview.disease?.symptoms ?? []}
|
||||
preventions={diagnosisPreview.disease?.recommendations ?? []}
|
||||
medicines={
|
||||
(diagnosisPreview.disease as any)?.medicineRecommendations ?? []
|
||||
}
|
||||
/>
|
||||
{/* Render DiagnosisResultView dengan Fallback Obat */}
|
||||
{(() => {
|
||||
// Fallback logic for scientific name and medicine recommendations
|
||||
const seedData = diagnosisPreview.disease
|
||||
? diseaseCatalogSeed.find(
|
||||
(seed) =>
|
||||
seed.commonName === diagnosisPreview.disease?.commonName,
|
||||
)
|
||||
: null;
|
||||
|
||||
// If the API doesn't return medicine recommendations, use the seed data as a fallback
|
||||
const finalMedicines =
|
||||
(diagnosisPreview.disease as any)?.medicineRecommendations ||
|
||||
seedData?.medicineRecommendations ||
|
||||
[];
|
||||
|
||||
return (
|
||||
<DiagnosisResultView
|
||||
imageUrl={
|
||||
previewUrl ||
|
||||
diagnosisPreview.imageUrl ||
|
||||
"https://placehold.co/600x400?text=Foto+Daun"
|
||||
}
|
||||
confidence={diagnosisPreview.confidence ?? 0}
|
||||
diseaseName={
|
||||
diagnosisPreview.disease?.commonName ?? "Tidak Diketahui"
|
||||
}
|
||||
scientificName={diagnosisPreview.disease?.label ?? ""}
|
||||
riskLevel={diagnosisPreview.disease?.riskLevel ?? "Sedang"}
|
||||
description={
|
||||
diagnosisPreview.disease?.description ??
|
||||
diagnosisPreview.disease?.summary ??
|
||||
"Deskripsi tidak tersedia."
|
||||
}
|
||||
symptoms={diagnosisPreview.disease?.symptoms ?? []}
|
||||
preventions={diagnosisPreview.disease?.recommendations ?? []}
|
||||
medicines={finalMedicines} // Datanya terhubung ke sini!
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* All Model Predictions */}
|
||||
{diagnosisPreview.predictions &&
|
||||
|
||||
@@ -225,25 +225,25 @@ export function TelemetryPage() {
|
||||
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])`),
|
||||
// API
|
||||
queryInstant(`zeavis_api_http_requests_total{instance="${INST}:3000"}`),
|
||||
queryInstant(`zeavis_api_http_requests_active{instance="${INST}:3000"}`),
|
||||
queryInstant(`zeavis_api_http_request_duration_seconds_sum{instance="${INST}:3000"} / zeavis_api_http_request_duration_seconds_count{instance="${INST}:3000"}`),
|
||||
queryRange(`zeavis_api_http_requests_total{instance="${INST}:3000"}`, 60),
|
||||
queryRange(`zeavis_api_http_request_duration_seconds_sum{instance="${INST}:3000"} / zeavis_api_http_request_duration_seconds_count{instance="${INST}:3000"}`, 60),
|
||||
queryInstant(`zeavis_api_http_requests_total{instance="${INST}:4006"}`),
|
||||
queryInstant(`zeavis_api_http_requests_active{instance="${INST}:4006"}`),
|
||||
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}:4006"}`, 60),
|
||||
queryRange(`zeavis_api_http_request_duration_seconds_sum{instance="${INST}:4006"} / zeavis_api_http_request_duration_seconds_count{instance="${INST}:4006"}`, 60),
|
||||
// ML
|
||||
queryInstant(`zeavis_ml_zeavis_ml_model_load_status{instance="${INST}:8000"}`),
|
||||
queryInstant(`zeavis_ml_zeavis_ml_model_load_status{instance="${INST}:4012"}`),
|
||||
// NodeJS
|
||||
queryInstant(`nodejs_heap_size_used_bytes{instance="${INST}:3000"}`),
|
||||
queryInstant(`nodejs_heap_size_total_bytes{instance="${INST}:3000"}`),
|
||||
queryInstant(`nodejs_eventloop_lag_seconds{instance="${INST}:3000"}`),
|
||||
queryInstant(`nodejs_active_handles_total{instance="${INST}:3000"}`),
|
||||
queryInstant(`nodejs_active_requests_total{instance="${INST}:3000"}`),
|
||||
queryRange(`nodejs_heap_size_used_bytes{instance="${INST}:3000"}`, 60),
|
||||
queryRange(`nodejs_eventloop_lag_seconds{instance="${INST}:3000"}`, 60),
|
||||
queryInstant(`nodejs_heap_size_used_bytes{instance="${INST}:4006"}`),
|
||||
queryInstant(`nodejs_heap_size_total_bytes{instance="${INST}:4006"}`),
|
||||
queryInstant(`nodejs_eventloop_lag_seconds{instance="${INST}:4006"}`),
|
||||
queryInstant(`nodejs_active_handles_total{instance="${INST}:4006"}`),
|
||||
queryInstant(`nodejs_active_requests_total{instance="${INST}:4006"}`),
|
||||
queryRange(`nodejs_heap_size_used_bytes{instance="${INST}:4006"}`, 60),
|
||||
queryRange(`nodejs_eventloop_lag_seconds{instance="${INST}:4006"}`, 60),
|
||||
// Process
|
||||
queryInstant(`rate(process_cpu_seconds_total{instance="${INST}:3000"}[5m])`),
|
||||
queryInstant(`process_resident_memory_bytes{instance="${INST}:3000"}`),
|
||||
queryInstant(`process_open_fds{instance="${INST}:3000"}`),
|
||||
queryInstant(`rate(process_cpu_seconds_total{instance="${INST}:4006"}[5m])`),
|
||||
queryInstant(`process_resident_memory_bytes{instance="${INST}:4006"}`),
|
||||
queryInstant(`process_open_fds{instance="${INST}:4006"}`),
|
||||
]);
|
||||
|
||||
setCpuData(cpuR); setMemData(memR); setDiskData(diskR);
|
||||
@@ -286,12 +286,11 @@ export function TelemetryPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-[28px] font-extrabold text-[#214B11] flex items-center gap-3">
|
||||
<Activity className="h-7 w-7 text-[#48A111]" />
|
||||
<h1 className="text-2xl font-bold text-emerald-800">
|
||||
Telemetry Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Real-time metrics from Prometheus
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Real-time monitoring dari performa sistem dan aplikasi ZeaVis Edu
|
||||
{error && <span className="text-amber-600 ml-2">(partial — {error})</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+17
-2
@@ -6,10 +6,25 @@ import { metricsPlugin } from './vite-plugin-metrics';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
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 {
|
||||
plugins: [react(), tsconfigPaths(), metricsPlugin()],
|
||||
plugins: [
|
||||
react(),
|
||||
tsconfigPaths(),
|
||||
metricsPlugin(),
|
||||
{
|
||||
name: 'cloudflare-rocket-loader-fix',
|
||||
transformIndexHtml(html) {
|
||||
// Prevent Cloudflare Rocket Loader from mangling <script type="module">
|
||||
// which breaks the entire JS bundle (blank page)
|
||||
return html.replace(
|
||||
/<script type="module"/g,
|
||||
'<script data-cfasync="false" type="module"',
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': apiProxyTarget,
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "zeavis-edu",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.11.0",
|
||||
"@tauri-apps/plugin-deep-link": "2.4.9",
|
||||
"@tauri-apps/plugin-opener": "2.5.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@moonrepo/cli": "^2.2.5",
|
||||
"typescript": "^6.0.3",
|
||||
@@ -36,6 +41,9 @@
|
||||
"apps/tauri": {
|
||||
"name": "@zeavis/tauri",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"sharp": "0.35.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
},
|
||||
@@ -46,6 +54,8 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tauri-apps/plugin-deep-link": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@zeavis/shared": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -89,7 +99,7 @@
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
@@ -153,6 +163,60 @@
|
||||
|
||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.1", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-T15JRWOubQ3f5+GxnWeIvo47u5qV0M9HBgJhT+f2gE1e9e6OhR6K73Re52Hm80qWcu1DNb3GweKmpr/MnuP2Ow=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.1", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.0" }, "os": "darwin", "cpu": "x64" }, "sha512-t1CPD0cr7XCHjwUj6tQ5MC0pCi866I+gUW6zbUX4aFPnKd1DFBtk0M+gWcjX8VeEzgfCNiSiNTVFZ6b7kvdbnQ=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.1", "", { "dependencies": { "@img/sharp-wasm32": "0.35.1" }, "os": "freebsd" }, "sha512-MBSQXqNPThW9EcZ905H6N4sEdX5EwZEYzGx5EBq9ncDCGJALMiY1xPFJxNdzuB1iBjLOpIfxajM6YxdvwmQSLA=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.0", "", { "os": "linux", "cpu": "none" }, "sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.0" }, "os": "linux", "cpu": "arm" }, "sha512-jygmR02PpCYypt7xB7nst1vqjZp/BpRA/Kf9nK7qRponJ/KrLPaZWEG4G15z1d2FZ6XqI+T0350ha3RSnKx24A=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.0" }, "os": "linux", "cpu": "arm64" }, "sha512-ErCRyGU7LeoaFBZ0xW8hhLlXzhAg80sc4vxePB86qvtEvW1jEhhmbiNBP4oEzZfPMnu6HwHXfzD2W2kBU+RnCw=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.0" }, "os": "linux", "cpu": "ppc64" }, "sha512-LUWZ2+r2UoLCd8j0RLCwQ4gL6w47+Y7igxtVnPIDXOOEjV86LpBkAHq5VpJeg+GHbw0KN/JWlPJOdZjyZnFqFQ=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.0" }, "os": "linux", "cpu": "none" }, "sha512-i7x6J3mwF4JgT0sM4V4WlAWdJ0bucPtA9rzO1bTji1n5qgBq/W5nn87RvOQPleuuxahNoLdTngByD8/vDDLArw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.0" }, "os": "linux", "cpu": "s390x" }, "sha512-0zSaTUjTF0kIWTSYxD4EG/nvCU4jez53+3RdURtoY3HvbXtIQ98W90JnrGz/oLRFuEnfIy9+7xeq883euc0ZWw=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.0" }, "os": "linux", "cpu": "x64" }, "sha512-NbJD4mWdeyrNQKluO/tR/wBDOelcowSVGNBWxI0e3ZtlXc6F/UOVKDj1MLD4zl3oHTuvKW3s+MA9N54YTldAYw=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.1", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.0" }, "os": "linux", "cpu": "arm64" }, "sha512-VoW2sQCWI+0YIKQEmWJ8vzaQjTg9wIyfkFpvEfAS2h43X6iHu7GTk1hhOgB4IpSzCHe8UwQZIcx7b81VTaOrJA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.1", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.0" }, "os": "linux", "cpu": "x64" }, "sha512-LjBoSd/c5JU0/K5MwzDMlgsSRP2bPn98JQGFFQAOLQ0bU/1z4ekxUdSKY9BmlwSh/cA+OrvpgsWqfZyYfVHBRw=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.1", "", { "dependencies": { "@emnapi/runtime": "^1.11.0" } }, "sha512-PCQUoQdZyE8tp3HpbevuihfUmgSP4qWI0FGEPWoeXqaS+cUrFfemabHQiebUmUmlUhCuNnQMxGrQ+CPqK4hnxg=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.1", "", { "dependencies": { "@img/sharp-wasm32": "0.35.1" }, "cpu": "none" }, "sha512-xU2ml2bU2OPxYVvW2A6ae4M1g5QKyhKG06P4FAt+YEaFQQO0919Qx+XxIZEUuWTMoDViLpMws2/dQwoe/VcA6A=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-IkmHwuFhYpd3bTsN5SAahjwhiAcyXPooBt8vEUgxY3T0IP70sSJ0nU1xiPzZY8AH/OB1XpV3j8aZSVSOSfTbdA=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-wQahqCi9MD8Yxzg4gVM4fNrZxh+r6vD55PyIg+WJPaM5ZRUyF35iQpwJCuma3r6viU9/8Pxlc+XHV+woVa6nCQ=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.1", "", { "os": "win32", "cpu": "x64" }, "sha512-WzBtkYtZHATLPe8XRharxZXxQ9cdLrQWHiwxt+BJ5rBsisQrKeeV86ErxPSVhcG6xCEuNhs0SqLpWr7XDa2k6w=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -341,6 +405,8 @@
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w=="],
|
||||
@@ -365,6 +431,10 @@
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
|
||||
|
||||
"@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.9", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA=="],
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
|
||||
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
@@ -605,8 +675,12 @@
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
"sharp": ["sharp@0.35.1", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.1", "@img/sharp-darwin-x64": "0.35.1", "@img/sharp-freebsd-wasm32": "0.35.1", "@img/sharp-libvips-darwin-arm64": "1.3.0", "@img/sharp-libvips-darwin-x64": "1.3.0", "@img/sharp-libvips-linux-arm": "1.3.0", "@img/sharp-libvips-linux-arm64": "1.3.0", "@img/sharp-libvips-linux-ppc64": "1.3.0", "@img/sharp-libvips-linux-riscv64": "1.3.0", "@img/sharp-libvips-linux-s390x": "1.3.0", "@img/sharp-libvips-linux-x64": "1.3.0", "@img/sharp-libvips-linuxmusl-arm64": "1.3.0", "@img/sharp-libvips-linuxmusl-x64": "1.3.0", "@img/sharp-linux-arm": "0.35.1", "@img/sharp-linux-arm64": "0.35.1", "@img/sharp-linux-ppc64": "0.35.1", "@img/sharp-linux-riscv64": "0.35.1", "@img/sharp-linux-s390x": "0.35.1", "@img/sharp-linux-x64": "0.35.1", "@img/sharp-linuxmusl-arm64": "0.35.1", "@img/sharp-linuxmusl-x64": "0.35.1", "@img/sharp-webcontainers-wasm32": "0.35.1", "@img/sharp-win32-arm64": "0.35.1", "@img/sharp-win32-ia32": "0.35.1", "@img/sharp-win32-x64": "0.35.1" } }, "sha512-lW979AMi+ESidzMv/Lnv+F9bknzLyxLqFI05Sm433vOeRcltgxQmXpnfOOFIAlKtwXU/ksupm2srQoFCkR214g=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -671,6 +745,8 @@
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
+9
-9
@@ -31,22 +31,22 @@ services:
|
||||
networks:
|
||||
- app-shared-net
|
||||
- telemetry-net
|
||||
ports:
|
||||
- "${TS_IP:-0.0.0.0}:4006:4006"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
API_PORT: "3000"
|
||||
API_PORT: "4006"
|
||||
WEB_APP_URL: https://zeavisedu.asepharyana.my.id
|
||||
ML_SERVICE_URL: ${ML_SERVICE_URL:-http://zeavis-ml:8000}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
ML_SERVICE_URL: ${ML_SERVICE_URL:-http://zeavis-ml:4012}
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.zeavis-api.rule: Host(`api-zeavisedu.asepharyana.my.id`)
|
||||
traefik.http.routers.zeavis-api.entrypoints: websecure
|
||||
traefik.http.routers.zeavis-api.tls: "true"
|
||||
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:
|
||||
@@ -54,7 +54,7 @@ services:
|
||||
container_name: zeavis-node-exporter
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9100:9100"
|
||||
- "${TS_IP:-0.0.0.0}:9100:9100"
|
||||
command:
|
||||
- "--path.rootfs=/host"
|
||||
- "--web.listen-address=:9100"
|
||||
@@ -72,17 +72,17 @@ services:
|
||||
networks:
|
||||
- app-shared-net
|
||||
- telemetry-net
|
||||
ports:
|
||||
- "${TS_IP:-0.0.0.0}:4012:4012"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
MODEL_PATH: /app/model/model.onnx
|
||||
MODEL_INPUT_SIZE: "224"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.zeavis-ml.rule: Host(`ml-zeavisedu.asepharyana.my.id`)
|
||||
traefik.http.routers.zeavis-ml.entrypoints: websecure
|
||||
traefik.http.routers.zeavis-ml.tls: "true"
|
||||
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>
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
> 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
|
||||
|
||||
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||
|
||||
**Files:**
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
> 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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
> 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`.
|
||||
- Placeholder scan: No TODO/TBD placeholders remain.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
> 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.
|
||||
|
||||
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
|
||||
|
||||
> 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:
|
||||
|
||||
- `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
|
||||
|
||||
> Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.
|
||||
|
||||
**Files:**
|
||||
- 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.
|
||||
|
||||
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`
|
||||
|
||||
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
|
||||
|
||||
> 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 compose file will define:
|
||||
|
||||
@@ -68,3 +68,7 @@ The implementation should pass:
|
||||
- `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.
|
||||
|
||||
---
|
||||
|
||||
> 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.
|
||||
- Database migrations for domain entities.
|
||||
- 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
|
||||
- UI redesigns or feature additions
|
||||
- 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.
|
||||
|
||||
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.
|
||||
3. Access expert review as a non-expert and confirm forbidden response.
|
||||
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
|
||||
|
||||
> 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:
|
||||
|
||||
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 ];
|
||||
};
|
||||
});
|
||||
}
|
||||
+100
-54
@@ -1,16 +1,37 @@
|
||||
# Infra — ZeaVis Edu Multi-VPS Deployment
|
||||
# Infrastruktur — ZeaVis Edu
|
||||
|
||||
## Arsitektur
|
||||
> 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)
|
||||
|
||||
---
|
||||
|
||||
## Daftar Isi
|
||||
|
||||
1. [Arsitektur](#1-arsitektur)
|
||||
2. [Prasyarat GitHub Secrets](#2-prasyarat-github-secrets)
|
||||
3. [Setup VPS](#3-setup-vps)
|
||||
4. [Port yang Dibuka](#4-port-yang-dibuka)
|
||||
5. [Metrics Flow](#5-metrics-flow)
|
||||
6. [Perintah Penting](#6-perintah-penting)
|
||||
|
||||
---
|
||||
|
||||
## 1. Arsitektur
|
||||
|
||||
ZeaVis Edu berjalan di **dua VPS terpisah** yang terhubung melalui **Tailscale** mesh VPN:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
|
||||
│ 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 │
|
||||
│ │ │ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ ┌──────────┐ ┌──────────────┐ │
|
||||
│ │ Web │ │ API │ │ ML │ │ │ │Prometheus│ │Metric │ │
|
||||
│ │:80 │ │:3000 │ │:8000 │ │ │ │:9090 │ │Ingester │ │
|
||||
│ │:4011 │ │:4006 │ │:4012 │ │ │ │:9090 │ │Ingester │ │
|
||||
│ │/metrics │ │/metrics │ │/metrics │ │ │ │ │ │:9091 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │ │ └────┬─────┘ └──────┬───────┘ │
|
||||
│ ┌──────────────────────────────────────┐ │ │ │ │ │
|
||||
@@ -19,8 +40,8 @@
|
||||
│ └──────────────────────────────────────┘ │ │ ┌──────────────────────────────────────┐ │
|
||||
│ │ │ │ Vector │ │
|
||||
│ ┌──────────────┐ │ │ │ :9001 │ │
|
||||
│ │ Traefik │ │ │ └────────────────┬─────────────────────┘ │
|
||||
│ │ (Coolify) │ │ │ │ │
|
||||
│ │ Caddy │ │ │ └────────────────┬─────────────────────┘ │
|
||||
│ │ 2.11.4 │ │ │ │ │
|
||||
│ └──────────────┘ │ │ ▼ │
|
||||
│ │ │ ┌──────────────────────────────────────┐ │
|
||||
│ ZeaVis Edu Apps via │ │ │ ClickHouse │ │
|
||||
@@ -39,74 +60,82 @@
|
||||
│ │ │ │ :8181 │ │
|
||||
│ │ │ └──────────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ │ Coolify + Traefik handles: │
|
||||
│ │ │ telemetry.zeavisedu.asepharyana.my.id │
|
||||
│ │ │ Caddy handles: │
|
||||
│ │ │ telemetry.zeavisedu.asepharyana.my.id │
|
||||
└─────────────────────────────────────────────┘ └──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
| VPS | Hostname | OS | Peran |
|
||||
|---|---|---|---|
|
||||
| **App VPS** | `imrnes` | Arch Linux | Web nginx (:4011), API (:4006), ML Service (:4012) |
|
||||
| **Telemetry VPS** | `orange` | Ubuntu | Prometheus, ClickHouse, Telemetry UI |
|
||||
|
||||
### GitHub Secrets (untuk CI/CD)
|
||||
---
|
||||
|
||||
**App VPS deploy (`.github/workflows/deploy.yml`):**
|
||||
| Secret | Value |
|
||||
|--------|-------|
|
||||
| `VPS_HOST` | `100.108.1.124` (imrnes) |
|
||||
## 2. Prasyarat GitHub Secrets
|
||||
|
||||
### App VPS — `.github/workflows/deploy.yml`
|
||||
|
||||
| Secret | Keterangan |
|
||||
|---|---|
|
||||
| `VPS_HOST` | `100.121.180.82` (imrnes) |
|
||||
| `VPS_USER` | `mytheclipse` |
|
||||
| `VPS_SSH_KEY` | Private SSH key for imrnes |
|
||||
| `VPS_SSH_KEY` | Private SSH key untuk imrnes |
|
||||
| `VPS_PORT` | `22` |
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `SESSION_SECRET` | Random session secret |
|
||||
|
||||
**Telemetry VPS deploy (`.github/workflows/telemetry-ci-cd.yml`):**
|
||||
| Secret | Value |
|
||||
|--------|-------|
|
||||
### Telemetry VPS — `.github/workflows/telemetry-ci-cd.yml`
|
||||
|
||||
| Secret | Keterangan |
|
||||
|---|---|
|
||||
| `TELEMETRY_VPS_HOST` | `100.96.248.86` (orange) |
|
||||
| `TELEMETRY_VPS_USER` | SSH username for orange |
|
||||
| `TELEMETRY_VPS_SSH_KEY` | Private SSH key for orange |
|
||||
| `TELEMETRY_VPS_USER` | SSH username |
|
||||
| `TELEMETRY_VPS_SSH_KEY` | Private SSH key |
|
||||
| `TELEMETRY_VPS_PORT` | `22` |
|
||||
| `GHCR_PAT` | GitHub PAT with `write:packages` + `read:packages` |
|
||||
| `GHCR_PAT` | GitHub PAT dengan `write:packages` + `read:packages` |
|
||||
|
||||
### VPS Setup
|
||||
---
|
||||
|
||||
#### 1. App VPS (imrnes — 100.108.1.124)
|
||||
## 3. Setup VPS
|
||||
|
||||
### App VPS (imrnes — 100.121.180.82)
|
||||
|
||||
```bash
|
||||
# Create Docker network
|
||||
docker network create app-shared-net
|
||||
docker network create telemetry-net
|
||||
|
||||
# ZeaVis Edu apps deploy automatically via GitHub Actions
|
||||
# Semua service dikelola Nix + systemd — deploy otomatis via GitHub Actions:
|
||||
# nix build .#<service> → nix copy ssh://imrnes → systemctl restart zeavis-<service>
|
||||
# Reverse proxy: Caddy 2.11.4 (systemd caddy.service, /etc/caddy/Caddyfile, auto-TLS LE)
|
||||
```
|
||||
|
||||
#### 2. Telemetry VPS (orange — 100.96.248.86)
|
||||
### Telemetry VPS (orange — 100.96.248.86)
|
||||
|
||||
Deploy via GitHub Actions workflow `.github/workflows/telemetry-ci-cd.yml`.
|
||||
Deploy via GitHub Actions atau manual:
|
||||
|
||||
Atau manual:
|
||||
```bash
|
||||
ssh mytheclipse@100.96.248.86
|
||||
mkdir -p /opt/telemetry
|
||||
# ... sync files from telemetry/ directory ...
|
||||
cd /opt/telemetry
|
||||
docker compose up -d
|
||||
bash clickhouse/init.sh
|
||||
# Telemetry stack juga Nix + systemd (Docker dihapus dari produksi 2026-08-02)
|
||||
# Deploy otomatis via GitHub Actions → nix build → nix copy ssh:// → systemctl restart
|
||||
```
|
||||
|
||||
## Port yang dibuka
|
||||
---
|
||||
|
||||
## 4. Port yang Dibuka
|
||||
|
||||
### App VPS (imrnes)
|
||||
|
||||
| Port | Service | Akses |
|
||||
|------|---------|-------|
|
||||
| 80/443 | Web (via Traefik/Coolify) | Public |
|
||||
| 3000 | API metrics | Tailscale-only |
|
||||
| 8000 | ML service metrics | Tailscale-only |
|
||||
|---|---|---|
|
||||
| 80/443 | Web entry (Caddy, auto-TLS LE) | Public |
|
||||
| 4011 | Web nginx (`zeavisedu.asepharyana.my.id`) | Public (via Caddy) |
|
||||
| 4006 | API metrics (zeavis-api) | via Caddy / Tailscale-only |
|
||||
| 4012 | ML service metrics (zeavis-ml) | Tailscale-only |
|
||||
| 9100 | Node Exporter | Tailscale-only |
|
||||
|
||||
### Telemetry VPS (orange)
|
||||
|
||||
| Port | Service | Akses |
|
||||
|------|---------|-------|
|
||||
| 80/443 | Telemetry UI (via Coolify Traefik) | Public |
|
||||
|---|---|---|
|
||||
| 80/443 | Telemetry UI (via Caddy) | Public |
|
||||
| 8181 | Telemetry UI (direct) | Tailscale-only |
|
||||
| 9090 | Prometheus | Tailscale-only |
|
||||
| 9091 | Metric Ingester | Tailscale-only |
|
||||
@@ -114,27 +143,44 @@ bash clickhouse/init.sh
|
||||
| 8123 | ClickHouse HTTP | Tailscale-only |
|
||||
| 9000 | ClickHouse Native | Tailscale-only |
|
||||
|
||||
## Metrics Flow
|
||||
---
|
||||
|
||||
1. **App services** expose `/metrics` pada port masing-masing
|
||||
2. **Prometheus** di orange VPS scrape via Tailscale IP (`100.108.1.124:PORT`)
|
||||
3. **Prometheus** forward ke **Metric Ingester** via `remote_write`
|
||||
4. **Metric Ingester** enrich → filter → forward ke **Vector**
|
||||
5. **Vector** buffer → write ke **ClickHouse**
|
||||
## 5. Metrics Flow
|
||||
|
||||
1. **App services** mengekspos `GET /metrics` di port masing-masing
|
||||
2. **Prometheus** di orange VPS scrape via Tailscale IP (`100.121.180.82:PORT`)
|
||||
3. Prometheus forward ke **Metric Ingester** via `remote_write`
|
||||
4. Metric Ingester enrich → filter → forward ke **Vector**
|
||||
5. Vector buffer → write ke **ClickHouse**
|
||||
6. **Telemetry UI** query via **Query Proxy** → **ClickHouse**
|
||||
|
||||
## Useful Commands
|
||||
```
|
||||
App Services (/metrics)
|
||||
│
|
||||
▼ (scrape via Tailscale)
|
||||
Prometheus ──(remote_write)──► Metric Ingester ──► Vector ──► ClickHouse
|
||||
│
|
||||
Query Proxy ◄── Telemetry UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Perintah Penting
|
||||
|
||||
```bash
|
||||
# Telemetry stack status
|
||||
# Status telemetry stack
|
||||
make telemetry-status
|
||||
|
||||
# View telemetry logs
|
||||
# Lihat log service tertentu
|
||||
make telemetry-logs s=prometheus
|
||||
|
||||
# Send test metric
|
||||
# Kirim test metric
|
||||
make telemetry-test-metric
|
||||
|
||||
# Restart a service
|
||||
# Restart service
|
||||
make telemetry-restart s=vector
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
← [Kembali ke README utama](../README.md) • [ML Service →](../apps/ml-service/README.md) • [Pipeline ML →](../Machine_Learning/README.md)
|
||||
|
||||
+6
-1
@@ -13,5 +13,10 @@
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.11.0",
|
||||
"@tauri-apps/plugin-deep-link": "2.4.9",
|
||||
"@tauri-apps/plugin-opener": "2.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export type RegisterRequest = AuthRequest & {
|
||||
export type AuthResponse = {
|
||||
user: AuthUser;
|
||||
features: AuthFeatures;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export type DiagnosisPrediction = {
|
||||
|
||||
+1
-1
Submodule telemetry updated: 2582a53592...89d560baf8
Reference in New Issue
Block a user