Compare commits

...
49 Commits
Author SHA1 Message Date
asepharyana fe60ae71e6 ci: add Nix GC cleanup job on VPS after deploy 2026-08-04 13:57:56 +07:00
aseph ec6c1da94c ci: use free GHA Nix cache (disable FlakeHub cache, not subscribed) 2026-08-03 16:44:16 +07:00
asepharyana 6d37cd8eb9 ci: enable FlakeHub Cache (id-token: write + use-flakehub) 2026-08-03 16:20:56 +07:00
asepharyana 18927bbc86 ci: add test gate before Nix deploy (API unit tests) 2026-08-03 13:37:39 +07:00
asepharyana a4b546058a docs: sync remaining .md to 4000s infra 2026-08-02 16:49:11 +07:00
asepharyana 0010b023f6 chore: update imrnes tailscale IP 100.121.180.82 2026-08-02 16:21:45 +07:00
asepharyana e18ccab15f chore: ml-service port 8000 to 4012 2026-08-02 16:14:27 +07:00
asepharyana 46d98a3544 chore: sync ports to 4000s infra (4006/4011/4012) and DB pool 6432 2026-08-02 16:14:12 +07:00
asepharyana 2eb4e47585 fix(nix): restrict flake to x86_64-linux (nixpkgs 26.11 dropped darwin) 2026-08-01 18:03:48 +07:00
asepharyana ffccd31bbc ci: publish flake to FlakeHub (rolling) 2026-08-01 17:58:40 +07:00
asepharyana 4c79a1f09d ci: migrate CI to GitHub Actions (deploy nix + mirror ke Gitea backup)
Build & Deploy (Nix) / build-and-deploy (api) (push) Canceled after 0s
Build & Deploy (Nix) / build-and-deploy (ml-service) (push) Canceled after 0s
Build & Deploy (Nix) / build-and-deploy (web) (push) Canceled after 0s
Mirror to Gitea / mirror (push) Canceled after 0s
2026-08-01 16:42:37 +07:00
MythEclipse a07c26c55b ci: remove GitHub-only workflows (deploy via .gitea nix workflow)
Build & Deploy (Nix) / build-and-deploy (api) (push) Successful in 1m47s
Build & Deploy (Nix) / build-and-deploy (ml-service) (push) Successful in 3m18s
Build & Deploy (Nix) / build-and-deploy (web) (push) Successful in 1m12s
2026-07-31 12:56:03 +07:00
MythEclipse af0ab508f6 ci: add Nix flake (api/ml-service/web) + Gitea Actions deploy workflow + model.onnx
Build & Deploy (Nix) / build-and-deploy (api) (push) Successful in 1m50s
Build & Deploy (Nix) / build-and-deploy (ml-service) (push) Successful in 3m18s
Build & Deploy (Nix) / build-and-deploy (web) (push) Successful in 1m14s
2026-07-31 12:52:33 +07:00
Taufik PathurrohmanandGitHub bd5aa81988 Update validate_onnx_parity.py
Build and Deploy / build (map[dockerfile:apps/api/Dockerfile name:api]) (push) Failing after 3m54s
Build and Deploy / build (map[dockerfile:apps/ml-service/Dockerfile name:ml]) (push) Failing after 41s
Build and Deploy / build (map[dockerfile:apps/web/Dockerfile name:web]) (push) Failing after 21s
Build and Deploy / deploy (push) Skipped
update code & comment
2026-06-18 21:31:03 +07:00
Luhung Pandyaska SuyiandGitHub 7877892f9b Add multiple dataset sources to README 2026-06-18 21:27:14 +07:00
Taufik PathurrohmanandGitHub f8f36bcdb8 Update README.md
Penambahan penjelasan lengkap mengenai Sumber dataset
2026-06-18 21:15:40 +07:00
Selly SupriyatinandGitHub d1c014d9b3 Merge pull request #45 from ATLAS-PJK-GM007/selly/frontend
feat(auth): add placeholders and helper text to improve form UX
2026-06-16 22:55:34 +07:00
seriouselly 1db8eee8ea feat(auth): add placeholders and helper text to improve form UX
- Add descriptive placeholders to name, email, and password input fields.
- Display a helper text in register mode to guide users on password length requirements.
- Adjust password `minLength` validation in the frontend.
2026-06-16 22:46:06 +07:00
Selly SupriyatinandGitHub 74e17386ee Merge pull request #44 from ATLAS-PJK-GM007/selly/frontend
feat(ui): add green leaf favicon using SVG data URI
2026-06-16 22:00:36 +07:00
seriouselly a9ef795c90 feat(ui): add green leaf favicon using SVG data URI
- Update index.html to include a Lucide leaf icon as the tab favicon.
- Use URL-encoded SVG data URI to apply the ZeaVis Edu green brand color (#22C55E) directly without needing external image files.
2026-06-16 21:59:43 +07:00
Selly SupriyatinandGitHub 153abf4352 Merge pull request #43 from ATLAS-PJK-GM007/selly/frontend
feat(scan): implement drag and drop functionality for image upload
2026-06-16 19:20:20 +07:00
seriouselly da5c7c1cfa feat(scan): implement drag and drop functionality for image upload
- Add `onDragOver`, `onDragLeave`, and `onDrop` event handlers to capture dragged files.
- Introduce `isDragging` state to provide visual UI feedback when a file is hovered over the drop zone.
- Wire the dropped file data to the existing `handleFile` processing logic.
2026-06-16 19:12:44 +07:00
MythEclipseandClaude 58d4cc0164 chore: update telemetry submodule and fix Makefile comment
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 16:40:23 +07:00
MythEclipseandClaude a3105200e3 docs(claude): document Android Google OAuth fixes and design rules
Record the three bugs found during Android OAuth debugging,
their root causes, and the fix patterns to follow for future
Tauri deep-link handlers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 16:37:58 +07:00
MythEclipseandClaude c75cba214e fix(android): resolve Google OAuth login flow on Tauri Android
Three interrelated fixes for the Android Google sign-in flow:

1. API base URL mismatch (404 error):
   - auth-form.tsx used 'window.location.origin || VITE_API_BASE_URL',
     which fell back to 'http://tauri.localhost' in Android WebView
     instead of the actual API server.
   - Fix: import shared 'apiBaseUrl' from api-client.ts (already had
     the correct fallback: 'https://zeavisedu.asepharyana.my.id').
   - Added .env with VITE_API_BASE_URL for dev mode resilience.

2. Deep-link caused IPC callback errors:
   - 'processDeepLinkUrl()' used window.location.href = target,
     triggering a full page reload that orphaned pending Tauri IPC
     promises, causing 'Cannot read properties of undefined (reading
     'runCallback')' errors.
   - Cold-start: keep get_current but use window.location.href (safe
     at boot — no SPA state to lose).
   - Warm-start: use sessionStorage + custom DOM event + React Router
     navigate() via new <DeepLinkRouterHandler /> layout route,
     avoiding any page reload.

3. SPA navigation did not trigger OAuth token handler:
   - LoginPage's useEffect for ?token=xxx depended only on
     [setUser, queryClient, navigate] — location.search changes
     from a SPA navigate() call were ignored.
   - Fix: added location.search and location to deps.
   - Added visibilitychange + focus listeners so returning from the
     Google auth browser always re-checks URL params.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 16:36:31 +07:00
MythEclipse c010d16aaa style(web): adjust card height and layout on scan page 2026-06-16 14:18:04 +07:00
MythEclipseandClaude 94ed062026 fix(infra): add compose port bindings for Prometheus scraping via Tailscale
Add ports mapping (api:3000, ml:8000, node_exporter:9100) bound to
TS_IP env var (default 0.0.0.0). Inject TS_IP via deploy GH Action.

Why: containers had no host port mapping → Prometheus on telemetry
VPS (imrnes) could not scrape metrics via Tailscale IP.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 14:15:18 +07:00
MythEclipse b4bd114eed fix(tauri): configure deep-link plugin for mobile custom scheme 2026-06-16 05:14:08 +07:00
MythEclipseandClaude 7423e4d83b fix(tauri): use raw __TAURI_INTERNALS__ invoke, fix dynamic import on Android
Root cause: dynamic import('@tauri-apps/plugin-opener') silently fails on
Android Tauri WebView because the module resolution path for @tauri-apps/api
(plugin dependency) differs from npm expectations in the bundled context.

Rewrote tauri.ts to use window.__TAURI_INTERNALS__.invoke() directly:
- openUrl() → invoke('plugin:opener|open_url', {url})
- setupDeepLinkHandler() → invoke('plugin:deep-link|get_current')
- Warm-start listener still uses import('@tauri-apps/api/event') for
  deep-link://new-url events (bundled as separate chunk by Vite)
- Added @tauri-apps/api as direct dependency

Also kept withGlobalTauri: true (needed for __TAURI_INTERNALS__ injection)
but reverted APK frontendDist back to bundled React app (../web/dist)
since redirect-to-live-web approach was unreliable.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 04:34:13 +07:00
MythEclipseandClaude 4b252a1d5d fix(tauri): enable withGlobalTauri so Tauri APIs work on live web
Without this, the redirect page loads zeavisedu.asepharyana.my.id but
__TAURI_INTERNALS__ is not injected, so isTauri() returns false,
handleGoogleLogin uses window.location.href (WebView navigation), and
Google blocks embedded WebView since 2016.

With withGlobalTauri=true, the live web page detects Tauri, uses tauri-
plugin-opener to open system browser, and Google OAuth works.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 04:01:38 +07:00
MythEclipseandClaude 7148e1026e feat(android): APK loads live web, fix cold-start deep links for Google OAuth
APK architecture change: replaces bundled React SPA with minimal redirect
page that always loads live web content. No more APK rebuilds for web changes.

Root cause of OAuth failure on Android:
1. Cold-start deep links lost — APK's old bundled JS called onOpenUrl()
   (warm-start listener only) but NOT getCurrent() which is required for
   cold-start deep links. Fix: redirect page + setupDeepLinkHandler both
   call getCurrent() before redirecting/navigating.
2. Session cookie was dropped — renderTauriDeepLinkPage returned a raw
   new Response() which overwrote the Set-Cookie header set by the
   callback handler. Fix: inject Set-Cookie into the Response.
3. tauri.conf.json frontendDist → "./web/dist-tauri" (redirect page)
4. Added @tauri-apps/plugin-deep-link and @tauri-apps/plugin-opener as
   web app deps so live-web imports work in Tauri WebView.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 03:47:22 +07:00
MythEclipseandClaude 96f337ef46 docs: add Tauri 2 Android and Rust ONNX Runtime sections
- New 'Platform' section detailing Tauri 2 Android app (WebView, Deep Link OAuth, Camera)
- Add Android CI/CD info, build commands, and GitHub Actions workflow reference
- Tagline updated to include 'Rust ONNX Runtime' and 'Tauri 2 Android'
- Architecture tree now shows apps/tauri/
- Components table: Android App row with Tauri 2, Rust, WebView, Deep Link OAuth
- Tech Stack: separate 'Inference Engine' section with Rust/Axum/ONNX Runtime bolded
- Prerequisites: Java 21 + Android SDK added
- Dev commands: tauri dev and tauri android dev included
- Cakupan updated: Web + Android (Tauri 2) now in-scope

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 02:58:52 +07:00
Selly SupriyatinandGitHub 3b5839ebd5 Merge pull request #42 from ATLAS-PJK-GM007/selly/frontend
style(sidebar): standardize capitalization of feature titles
2026-06-16 02:48:40 +07:00
MythEclipseandClaude e2742a7a60 fix(auth): fix Android deep link intent URL format for Google OAuth
The intent:// URL in renderTauriDeepLinkPage was intent:/path (single slash),
producing zeavisedu:/login?token=xxx — a non-hierarchical URL that new URL()
cannot parse. Fixed to intent://login/path which produces a proper
hierarchical URI (zeavisedu://login/login?token=xxx).

Also hardened setupDeepLinkHandler to handle both double-slash (://) and
single-slash (:/) custom-scheme URLs as fallback.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 02:44:33 +07:00
seriouselly ae22adee40 style(sidebar): standardize capitalization of feature titles
- Update navigation labels in the sidebar for consistent casing and better visual uniformity.
2026-06-16 02:38:01 +07:00
Selly SupriyatinandGitHub ee02bd4f9a Merge pull request #41 from ATLAS-PJK-GM007/selly/frontend
Selly/frontend
2026-06-16 02:14:36 +07:00
seriouselly 310d8adc8d Merge branch 'main' of https://github.com/ATLAS-PJK-GM007/ZeaVis-Edu into selly/frontend 2026-06-16 02:13:32 +07:00
seriouselly ccbcd99053 refactor(ui): remove local image assets and use external URLs
- Delete the `assets/images` directory to reduce repository size and footprint.
- Update `dashboard-page.tsx` to fetch background images from external URLs instead of local file imports.
2026-06-16 02:13:17 +07:00
seriouselly 52e60b99dd Revert "refactor(ui): remove local image assets and use external URLs"
This reverts commit e19321a2b7.
2026-06-16 02:10:32 +07:00
seriouselly e19321a2b7 refactor(ui): remove local image assets and use external URLs
- Delete the `assets/images` directory to reduce repository size and footprint.
- Update `dashboard-page.tsx` to fetch background images from external URLs instead of local file imports.
2026-06-16 02:09:36 +07:00
MythEclipse d1275b5b65 fix(tauri): remove unused setup closure entirely 2026-06-16 02:04:07 +07:00
MythEclipse 12162fd6f7 fix(tauri): remove setup block that caused E0599 compile error
app.get_webview_window() needs tauri::Manager trait imported.
Removed the live URL redirect in setup for now — the bundled
frontend works fine. Can re-add later with proper imports.
2026-06-16 02:03:38 +07:00
MythEclipseandClaude 9ab8d41adf feat(android): generate launcher icons from zeavis-logo.svg during CI build
- Add scripts/generate-icons.js using sharp to convert SVG to PNG
  at all Android density buckets (mdpi 48→xxxhdpi 192)
- Add sharp as devDependency
- Update CI workflow to run icon generation after tauri android init
- Replaces Tauri's default icons with ZeaVis Edu logo

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 01:53:59 +07:00
Selly SupriyatinandGitHub 6f07fc05ac Merge pull request #40 from ATLAS-PJK-GM007/selly/frontend
Selly/frontend
2026-06-16 01:53:39 +07:00
seriouselly cbcc7da33d Merge branch 'main' of https://github.com/ATLAS-PJK-GM007/ZeaVis-Edu into selly/frontend 2026-06-16 01:52:02 +07:00
seriouselly d1282364e7 refactor(auth): replace local background images with external URLs
- Remove local image imports for the background in login and register pages.
- Update background-image styles to use external URL links for easier asset management.
2026-06-16 01:51:22 +07:00
MythEclipseandClaude 254fe19bb6 feat(android): always load live web URL so APK never needs rebuild for web changes
On Android, the app navigates to the live production URL immediately
after setup. The APK becomes a thin shell — bundled frontend is only
a placeholder for the ~1 second before redirect. All web updates
(deploy web) take effect instantly on all installed APKs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 01:49:03 +07:00
MythEclipseandClaude 99acc43ff0 fix(android): use intent:// protocol for deep link return from system browser
Replace custom zeavisedu:// scheme in callback HTML with Chrome's
native intent:// protocol which directly opens the target Android app
by package name. Includes browser_fallback_url for non-app scenarios.

Also updates the HTML page with better UX: auto-redirect via JS,
fallback button, and copyable URL for manual paste.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 01:25:02 +07:00
MythEclipseandClaude 1afd2e013c fix(android): add deep link handler to intercept zeavisedu:// URLs and navigate WebView
Login-page won't auto-process token on deep link return because
the WebView stays on the page it was on. Added setupDeepLinkHandler()
which listens for zeavisedu:// scheme URLs and navigates the WebView
to the correct path+query.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 01:03:45 +07:00
69 changed files with 2012 additions and 721 deletions
+2 -2
View File
@@ -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.
-199
View File
@@ -1,199 +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: Patch AndroidManifest (CAMERA permission)
working-directory: apps/tauri
run: bash scripts/patch-android-manifest.sh
- name: Build Tauri Android APK
working-directory: apps/tauri
env:
JAVA_HOME: ${{ env.JAVA_HOME_21_X64 }}
ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }}
NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
run: bun tauri android build --apk
- name: Decode keystore
if: github.event_name != 'pull_request'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/tauri/zeavis.keystore
- name: Sign APK
if: github.event_name != 'pull_request'
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
APK_UNSIGNED=$(find apps/tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*-signed*' | head -1)
APK_SIGNED="apps/tauri/gen/android/app/build/outputs/apk/universal/release/zeavis-edu-v${{ steps.version.outputs.version }}.apk"
$ANDROID_SDK_ROOT/build-tools/36.0.0/apksigner sign \
--ks apps/tauri/zeavis.keystore \
--ks-pass "pass:${ANDROID_KEYSTORE_PASSWORD}" \
--ks-key-alias "${ANDROID_KEY_ALIAS}" \
--key-pass "pass:${ANDROID_KEY_PASSWORD}" \
--out "$APK_SIGNED" \
"$APK_UNSIGNED"
echo "signed_apk=${APK_SIGNED}" >> $GITHUB_ENV
echo "Signed APK: $APK_SIGNED"
ls -lh "$APK_SIGNED"
- name: Create Release and upload APK
if: github.event_name != 'pull_request'
id: release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.version }}
name: v${{ steps.version.outputs.version }}
body: |
ZeaVis Edu Android APK v${{ steps.version.outputs.version }}
📦 Built from ${{ github.sha }}
🔗 Triggered by ${{ github.actor }}
### Install
Download the APK below and install on your Android device.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
files: |
${{ env.signed_apk }}
apps/tauri/gen/android/app/build/outputs/bundle/**/*.aab
draft: false
prerelease: false
+107 -182
View File
@@ -1,208 +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
GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
GOOGLE_REDIRECT_URI=https://zeavisedu.asepharyana.my.id/api/v1/auth/google/callback
ENVEOF
} > .env
docker login ghcr.io -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }}
docker network create app-shared-net 2>/dev/null || true
docker network create telemetry-net 2>/dev/null || true
docker compose down --remove-orphans || true
docker rm -f zeavis-web zeavis-api zeavis-ml zeavis-node-exporter 2>/dev/null || true
docker compose pull
docker compose up -d
# Wait for containers to be healthy (up to 60s)
wait_container() {
local name=$1
for i in $(seq 1 30); do
docker compose ps | grep -q "${name}.*Up" && return 0
sleep 2
done
return 1
}
wait_container zeavis-web || exit 1
wait_container zeavis-api || exit 1
wait_container zeavis-ml || exit 1
wait_container zeavis-node-exporter || echo "⚠️ node_exporter not running (non-fatal)"
docker compose ps
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
+26
View File
@@ -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)"
+45 -5
View File
@@ -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
View File
@@ -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 autodiscovery 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 15second scrape interval —
+3 -2
View File
@@ -92,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 |
|---|---|
@@ -104,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 |
@@ -116,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 |
|---|---|
Binary file not shown.
+18 -2
View File
@@ -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:
+1 -1
View File
@@ -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/.
# =============================================================================
+79 -22
View File
@@ -4,7 +4,7 @@
<h1 align="center">ZeaVis Edu</h1>
<p align="center">
<strong>Asisten Edukasi Interaktif untuk Deteksi Penyakit Daun Jagung</strong><br>
<em>Computer Vision &bull; EfficientNetV2B0 &bull; Transfer Learning</em>
<em>Computer Vision &bull; EfficientNetV2B0 &bull; Rust ONNX Runtime &bull; Tauri 2 Android</em>
</p>
</p>
@@ -16,6 +16,7 @@
<a href="#-jadwal"><b>Jadwal</b></a> &bull;
<a href="#-tech-stack"><b>Tech Stack</b></a> &bull;
<a href="#-memulai"><b>Memulai</b></a> &bull;
<a href="#-platform"><b>Platform</b></a> &bull;
<a href="#-dokumentasi"><b>Dokumentasi</b></a>
</p>
@@ -35,7 +36,7 @@ Proyek ini merupakan **Capstone Project** dalam program **Pijak × IBM SkillsBui
| NPM | Nama | Learning Path | Peran |
|---|---|---|---|
| APC246D6Y0028 | **Asep Haryana Saputra** | Back-End | Arsitektur sistem, RESTful API, deployment Docker/Cloud, keamanan upload stream |
| APC246D6Y0028 | **Asep Haryana Saputra** | Back-End | Arsitektur sistem, RESTful API, deployment Nix/Cloud, keamanan upload stream |
| APC013D6X0081 | **Selly Supriyatin** | Front-End | UI/UX responsif, mekanisme unggah gambar, modul edukasi (rekomendasi obat & penanganan) |
| APC013D6Y0091 | **Taufik Pathurrohman** | Machine Learning | Data Engineering — ekstraksi dataset, cleaning, augmentasi gambar |
| APC414D6Y0138 | **Luhung Pandyaska Suyi** | Machine Learning | Model Architecture & Training — CNN, hyperparameter tuning |
@@ -65,8 +66,11 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
|---|---|
| Arsitektur Model | **EfficientNetV2B0** — keseimbangan optimal antara akurasi dan efisiensi parameter |
| Metode Pelatihan | **Transfer Learning** pada Google Colab (GPU T4) |
| Sumber Dataset | Kaggle — [Corn Leaf Disease](https://www.kaggle.com/datasets/ndisan/corn-leaf-disease) |
| Deployment | VPS dengan Docker, ONNX Runtime untuk inferensi real-time |
| Sumber Dataset 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) |
| Deployment | VPS dengan Nix + systemd + Caddy, ONNX Runtime untuk inferensi real-time |
---
@@ -79,7 +83,7 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
| 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 edukatif | Aplikasi mobile native |
| Aplikasi Web + Android (Tauri 2) | Aplikasi iOS |
### 4 Kelas yang Diklasifikasikan
@@ -97,8 +101,8 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
| 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 Docker |
| 5 | **Prototipe Akhir** | Aplikasi web final dengan klasifikasi + modul edukasi (rekomendasi obat & penanganan) |
| 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) |
---
@@ -119,7 +123,7 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
| 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 + container Docker isolasi resource |
| **Server downtime / latensi tinggi** | Batasan upload ≤ 5 MB + kompresi server-side + rate limiting + isolasi resource per-service (systemd) |
| **Foto blur / objek bukan daun jagung** | Panduan visual (overlay) pada UI + validasi anomali + disclaimer "alat bantu edukasi, bukan pengganti POPT" |
| **Bottleneck integrasi ML ↔ API ↔ UI** | API Contract ketat di minggu ke-1 + integrasi bertahap (CI) mulai minggu ke-3 |
@@ -131,7 +135,8 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
.
├── apps/
│ ├── api/ # Backend Elysia/Bun + Drizzle ORM + PostgreSQL
│ ├── ml-service/ # Rust/Axum + ONNX Runtime inference service
│ ├── 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
@@ -139,7 +144,7 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
│ └── README.md # ⤷ Panduan deployment multi-VPS
├── packages/shared/ # Tipe & utilitas TypeScript bersama
├── telemetry/ # Submodule — Prometheus → ClickHouse pipeline
├── docker-compose.yml # Konfigurasi deployment container
├── flake.nix # Konfigurasi deployment Nix (systemd services)
├── package.json # Root workspace Bun + Moon
└── README.md # ⤷ Anda di sini
```
@@ -147,28 +152,32 @@ ZeaVis Edu menggunakan **Computer Vision** sebagai asisten edukasi interaktif:
| 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 | Rust, Axum, ONNX Runtime | [`apps/ml-service/README.md`](apps/ml-service/README.md) |
| ML Inference Engine | Rust, Axum, ONNX Runtime | [`apps/ml-service/README.md`](apps/ml-service/README.md) |
| ML Pipeline | Python, TensorFlow/Keras, EfficientNetV2B0 | [`Machine_Learning/README.md`](Machine_Learning/README.md) |
| Infrastruktur | Docker, Coolify, Traefik, Tailscale | [`infra/README.md`](infra/README.md) |
| Infrastruktur | Nix, systemd, Caddy, Tailscale | [`infra/README.md`](infra/README.md) |
| Telemetry | Prometheus, ClickHouse, Vector, Vue 3 | `telemetry/` |
---
## 🛠️ Tech Stack
### Frontend
### Frontend & Mobile
React &bull; Vite &bull; TypeScript &bull; React Router &bull; TanStack Query &bull; Zustand &bull; Tailwind CSS
**Tauri 2** (Android) &bull; Rust &bull; WebView &bull; Deep Link OAuth
### Backend API
Bun &bull; Elysia &bull; Drizzle ORM &bull; PostgreSQL &bull; prom-client
### Machine Learning
Python &bull; TensorFlow/Keras &bull; EfficientNetV2B0 &bull; Google Colab (GPU T4)
Rust &bull; Axum &bull; ONNX Runtime &bull; TFLite &bull; TensorFlow.js
### Inference Engine
**Rust** &bull; **Axum** &bull; **ONNX Runtime** &bull; TFLite &bull; TensorFlow.js
### DevOps & Infrastruktur
Docker &bull; Docker Compose &bull; Coolify &bull; Traefik &bull; Tailscale &bull; GitHub Actions (CI/CD)
Nix &bull; systemd &bull; Caddy &bull; Tailscale &bull; GitHub Actions (CI/CD)
### Observabilitas
Prometheus &bull; Metric Ingester (Go) &bull; Vector &bull; ClickHouse &bull; Query Proxy (Go) &bull; Telemetry UI (Vue 3)
@@ -181,9 +190,10 @@ Prometheus &bull; Metric Ingester (Go) &bull; Vector &bull; ClickHouse &bull; Qu
- **Bun** — runtime & package manager
- **Python 3.93.11** — pipeline ML
- **Rust & Cargo** — `apps/ml-service`
- **Docker & Docker Compose** — deployment & telemetry
- **PostgreSQL** — backend API
- **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`)
### Instalasi
@@ -199,7 +209,9 @@ bun install
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 # Hanya ML service (port 8000)
cd apps/ml-service && cargo run # ML inference engine (port 4012)
cd apps/tauri && bun run tauri dev # Tauri desktop dev
cd apps/tauri && bun run tauri android dev # Tauri Android dev
```
### Environment Variables
@@ -227,15 +239,60 @@ Salin `.env.example` ke `.env` dan isi:
### Deployment
Produksi: **Nix + systemd + Caddy** (Docker sudah dihapus dari produksi 2026-08-02).
Deploy via GitHub Actions → `nix build .#<service>``nix copy ssh://imrnes``systemctl restart zeavis-<service>`.
Reverse proxy: Caddy 2.11.4 (`systemd caddy.service`, auto-TLS Let's Encrypt, HTTP/3).
```bash
docker compose up -d # App services
make telemetry-up # Telemetry stack
# 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 |
|---|---|---|
| **Web** | React + Vite → Static SPA | `bun run build` |
| **Android** | Tauri 2 + Rust → WebView APK | `cd apps/tauri && bun run tauri android build --apk` |
### Tauri 2 Android
Aplikasi Android membungkus frontend web yang sama dalam **WebView native** menggunakan **Tauri 2**, memberikan akses ke API native Android tanpa menulis ulang UI.
**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
# 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
```
> Konfigurasi: `apps/tauri/tauri.conf.json` &bull; `apps/tauri/gen/android/`
---
## 📚 Dokumentasi
| Dokumen | Isi |
@@ -255,7 +312,7 @@ make telemetry-up # Telemetry stack
| `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 |
| Docker Compose gagal | `docker network create app-shared-net` |
| Service tidak restart setelah deploy | `systemctl restart zeavis-api zeavis-web zeavis-ml` (Nix+systemd, bukan Docker) |
| Konversi TFJS gagal | `export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python` |
---
+2 -2
View File
@@ -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"]
+1 -1
View File
@@ -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',
},
});
+1 -1
View File
@@ -18,7 +18,7 @@ const allowedOrigins = [
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',
+35 -12
View File
@@ -64,23 +64,40 @@ function decodeGoogleIdToken(idToken: string): GoogleIdPayload {
}
/**
* Render a page for the Android system browser that redirects back to the
* Tauri app via a custom scheme (zeavisedu://). The app's AndroidManifest
* must register an intent filter for this scheme.
* 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 {
// Rewrite https://... to zeavisedu://... for the custom scheme
const deepLink = targetUrl.replace(/^https?:\/\//, 'zeavisedu://');
// 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, '&quot;');
const escapedPath = pathAndQuery.replace(/"/g, '&quot;');
// 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">
<p style="color:#166534;font-size:1.1rem;margin-bottom:1.5rem">Login berhasil!<br>Kembali ke aplikasi...</p>
<a href="${deepLink.replace(/"/g, '&quot;')}" 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">Buka ZeaVis Edu</a>
<p style="color:#6b7280;font-size:0.8rem;margin-top:1rem">Jika tombol tidak berfungsi, salin URL ini:<br><code style="word-break:break-all;font-size:0.75rem">${deepLink.replace(/</g, '&lt;')}</code></p>
<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, '&quot;')}" 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, '&lt;').replace(/>/g, '&gt;')}</code>
</div>
<script>window.location.href=${JSON.stringify(deepLink)};</script>
<script>
// Auto-open the intent
window.location.href = ${JSON.stringify(intentUrl)};
</script>
</body></html>`;
return new Response(html, {
status: 200,
@@ -297,12 +314,18 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
}
const token = await createSession(user.id);
set.headers['Set-Cookie'] = createSessionCookie(token, request.headers);
const sessionCookie = createSessionCookie(token, request.headers);
authCounter.labels('login', 'true').inc();
const successUrl = `${env.webAppUrl}/login?token=${encodeURIComponent(token)}`;
if (platform === 'tauri') return renderTauriDeepLinkPage(successUrl);
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) {
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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"]
+12 -12
View File
@@ -48,7 +48,7 @@ Output build lokal berada di `target/` dan direktori tersebut diabaikan oleh Git
Semua perintah di bawah dijalankan dari direktori `apps/ml-service`.
### Opsi 1: Default (Port 8000)
### Opsi 1: Default (Port 4012)
```bash
cargo run
@@ -59,7 +59,7 @@ Service akan mencari model di path default:
../../Machine_Learning/model/model.onnx
```
### Opsi 2: Local Development dengan .env.example (Port 8001)
### Opsi 2: Local Development dengan .env.example (Port 4012)
```bash
source .env.example
@@ -79,7 +79,7 @@ ML_SERVICE_PORT=9000 MODEL_PATH=/path/to/model.onnx cargo run
| Variable | Default | Keterangan |
|---|---|---|
| `ML_SERVICE_HOST` | `0.0.0.0` | Bind address |
| `ML_SERVICE_PORT` | `8000` | Bind port |
| `ML_SERVICE_PORT` | `4012` | Bind port |
| `MODEL_PATH` | `../../Machine_Learning/model/model.onnx` | Path ke file model ONNX |
| `MODEL_INPUT_SIZE` | `224` | Ukuran input gambar (224×224 untuk EfficientNetV2B0) |
| `RUST_LOG` | `info` | Level logging (debug, info, warn, error) |
@@ -91,7 +91,7 @@ ML_SERVICE_PORT=9000 MODEL_PATH=/path/to/model.onnx cargo run
### Health Check
```bash
curl http://localhost:8000/health
curl http://localhost:4012/health
```
```json
@@ -104,7 +104,7 @@ curl http://localhost:8000/health
### Metadata
```bash
curl http://localhost:8000/metadata
curl http://localhost:4012/metadata
```
```json
@@ -123,7 +123,7 @@ curl http://localhost:8000/metadata
Upload gambar daun jagung untuk klasifikasi:
```bash
curl -X POST http://localhost:8000/predict \
curl -X POST http://localhost:4012/predict \
-F "file=@/path/to/corn-leaf.jpg"
```
@@ -157,20 +157,20 @@ cargo build --release
cargo test
```
### Verifikasi Manual (default port 8000)
### Verifikasi Manual (default port 4012)
```bash
# 1. Start service
cargo run
# 2. Health check
curl http://localhost:8000/health
curl http://localhost:4012/health
# 3. Metadata
curl http://localhost:8000/metadata
curl http://localhost:4012/metadata
# 4. Prediksi
curl -X POST http://localhost:8000/predict \
curl -X POST http://localhost:4012/predict \
-F "file=@../../Machine_Learning/dataset/Daun\ Sehat/sample.jpg"
```
@@ -182,7 +182,7 @@ Service dapat di-deploy via Docker. Build dari root repository karena Dockerfile
```bash
docker build -f apps/ml-service/Dockerfile -t zeavis-ml-service .
docker run -p 8000:8000 zeavis-ml-service
docker run -p 4012:4012 zeavis-ml-service
```
Pastikan `Machine_Learning/model/model.onnx` sudah dibuat sebelum build image.
@@ -210,7 +210,7 @@ MODEL_PATH=/absolute/path/to/model.onnx cargo run
```bash
ML_SERVICE_PORT=9000 cargo run
# Cek port yang digunakan:
lsof -i :8000
lsof -i :4012
```
### ONNX Runtime tidak kompatibel
+2 -2
View File
@@ -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,
+616
View File
@@ -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",
]
+3
View File
@@ -12,5 +12,8 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2"
},
"dependencies": {
"sharp": "0.35.1"
}
}
+65
View File
@@ -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);
});
+10 -2
View File
@@ -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"]
}
]
}
}
}
+44
View File
@@ -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>
+5
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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

+113 -46
View File
@@ -1,24 +1,42 @@
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 { isTauri, openUrl } from '@/lib/tauri';
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>) {
@@ -28,70 +46,119 @@ export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubm
const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const platform = isTauri() ? 'tauri' : 'web';
// Use API base URL, not window.location.origin — on Tauri Android
// the origin is http://tauri.localhost which is not the API server.
const apiBase = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const googleUrl = `${apiBase}/api/v1/auth/google?platform=${platform}`;
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 flex items-center justify-center gap-2.5" variant="outline" onClick={handleGoogleLogin} type="button">
<Button
className="mt-3 w-full flex items-center justify-center gap-2.5"
variant="outline"
onClick={handleGoogleLogin}
type="button"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden="true">
<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="#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
+1 -1
View File
@@ -14,7 +14,7 @@ import type {
} from '@zeavis/shared';
import { recordApiCall } from './telemetry';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id';
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id';
const AUTH_TOKEN_KEY = 'zeavis_auth_token';
+92 -5
View File
@@ -1,7 +1,13 @@
/**
* Lightweight Tauri environment detection and utilities.
* Avoids importing @tauri-apps/api at module level so the web build
* doesn't bundle Tauri internals.
* 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;
@@ -14,12 +20,93 @@ export function isTauri(): boolean {
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;
}
// Lazy-import Tauri opener only in Tauri context
const { openUrl: tauriOpenUrl } = await import('@tauri-apps/plugin-opener');
await tauriOpenUrl(url);
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] ?? '';
}
}
+3 -3
View File
@@ -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>
+43 -29
View File
@@ -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.
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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
+64 -7
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useRef } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Link, useNavigate, useLocation } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AuthForm } from "@/components/auth-form";
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);
@@ -13,11 +14,13 @@ 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;
@@ -39,7 +42,36 @@ export function LoginPage() {
setOauthProcessing(false);
setError(err instanceof Error ? err.message : "Google login gagal");
});
}, [setUser, queryClient, navigate]);
}, [setUser, queryClient, navigate, location.search]);
// Backup: when app returns from background (e.g. after Google OAuth browser)
// re-check URL params — the deep-link event may have been missed.
useEffect(() => {
if (!isTauri()) return;
if (getUrlParam("token") || oauthTokenConsumed.current) return;
const onVisibility = () => {
if (document.visibilityState !== "visible") return;
const token = getUrlParam("token");
if (token && !oauthTokenConsumed.current) {
setOauthProcessing(true);
}
};
const onFocus = () => {
const token = getUrlParam("token");
if (token && !oauthTokenConsumed.current) {
setOauthProcessing(true);
}
};
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("focus", onFocus);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("focus", onFocus);
};
}, []);
// Show OAuth error from query param
const oauthError = getUrlParam("error");
@@ -48,6 +80,13 @@ export function LoginPage() {
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) => {
@@ -65,15 +104,29 @@ export function LoginPage() {
<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>
<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}
@@ -87,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>
+12 -1
View File
@@ -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}
+40 -21
View File
@@ -33,24 +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 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 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),
@@ -107,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">
@@ -122,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
@@ -167,15 +165,36 @@ export function ScanPage() {
) : (
<>
<div
className="w-full border-2 border-dashed border-green-300 rounded-md p-10 h-60 text-center cursor-pointer"
className={`w-full border-2 border-dashed rounded-md p-10 h-60 text-center cursor-pointer transition-colors duration-200 ${
isDragging
? "border-blue-500 bg-blue-100"
: "border-green-300"
}`}
onClick={() => inputRef.current?.click()}
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} />
<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
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">
+19 -20
View File
@@ -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>
+1 -1
View File
@@ -6,7 +6,7 @@ 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: [
+67 -1
View File
@@ -5,6 +5,7 @@
"": {
"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",
},
@@ -40,6 +41,9 @@
"apps/tauri": {
"name": "@zeavis/tauri",
"version": "0.1.0",
"dependencies": {
"sharp": "0.35.1",
},
"devDependencies": {
"@tauri-apps/cli": "^2",
},
@@ -50,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",
@@ -93,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=="],
@@ -157,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=="],
@@ -615,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=="],
@@ -681,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=="],
+10 -4
View File
@@ -31,26 +31,30 @@ 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}
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:
image: prom/node-exporter:v1.8.2
container_name: zeavis-node-exporter
restart: unless-stopped
ports:
- "${TS_IP:-0.0.0.0}:9100:9100"
command:
- "--path.rootfs=/host"
- "--web.listen-address=:9100"
@@ -68,6 +72,8 @@ services:
networks:
- app-shared-net
- telemetry-net
ports:
- "${TS_IP:-0.0.0.0}:4012:4012"
env_file:
- .env
environment:
@@ -79,4 +85,4 @@ services:
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
View File
@@ -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
}
+145
View File
@@ -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 ];
};
});
}
+22 -23
View File
@@ -1,6 +1,8 @@
# Infrastruktur — ZeaVis Edu
> 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)
@@ -24,12 +26,12 @@ ZeaVis Edu berjalan di **dua VPS terpisah** yang terhubung melalui **Tailscale**
```
┌─────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ 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 │ │
│ └──────────┘ └──────────┘ └──────────┘ │ │ └────┬─────┘ └──────┬───────┘ │
│ ┌──────────────────────────────────────┐ │ │ │ │ │
@@ -38,8 +40,8 @@ ZeaVis Edu berjalan di **dua VPS terpisah** yang terhubung melalui **Tailscale**
│ └──────────────────────────────────────┘ │ │ ┌──────────────────────────────────────┐ │
│ │ │ │ Vector │ │
│ ┌──────────────┐ │ │ │ :9001 │ │
│ │ Traefik │ │ │ └────────────────┬─────────────────────┘ │
│ │ (Coolify) │ │ │ │ │
│ │ Caddy │ │ │ └────────────────┬─────────────────────┘ │
│ │ 2.11.4 │ │ │ │ │
│ └──────────────┘ │ │ ▼ │
│ │ │ ┌──────────────────────────────────────┐ │
│ ZeaVis Edu Apps via │ │ │ ClickHouse │ │
@@ -58,14 +60,14 @@ ZeaVis Edu berjalan di **dua VPS terpisah** yang terhubung melalui **Tailscale**
│ │ │ │ :8181 │ │
│ │ │ └──────────────────────────────────────┘ │
│ │ │ │
│ │ │ Coolify + Traefik handles:
│ │ │ telemetry.zeavisedu.asepharyana.my.id
│ │ │ Caddy handles:
│ │ │ telemetry.zeavisedu.asepharyana.my.id │
└─────────────────────────────────────────────┘ └──────────────────────────────────────────────┘
```
| VPS | Hostname | OS | Peran |
|---|---|---|---|
| **App VPS** | `imrnes` | Arch Linux | Web (:80), API (:3000), ML Service (:8000) |
| **App VPS** | `imrnes` | Arch Linux | Web nginx (:4011), API (:4006), ML Service (:4012) |
| **Telemetry VPS** | `orange` | Ubuntu | Prometheus, ClickHouse, Telemetry UI |
---
@@ -76,7 +78,7 @@ ZeaVis Edu berjalan di **dua VPS terpisah** yang terhubung melalui **Tailscale**
| Secret | Keterangan |
|---|---|
| `VPS_HOST` | `100.108.1.124` (imrnes) |
| `VPS_HOST` | `100.121.180.82` (imrnes) |
| `VPS_USER` | `mytheclipse` |
| `VPS_SSH_KEY` | Private SSH key untuk imrnes |
| `VPS_PORT` | `22` |
@@ -97,14 +99,12 @@ ZeaVis Edu berjalan di **dua VPS terpisah** yang terhubung melalui **Tailscale**
## 3. Setup VPS
### App VPS (imrnes — 100.108.1.124)
### 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)
```
### Telemetry VPS (orange — 100.96.248.86)
@@ -113,10 +113,8 @@ Deploy via GitHub Actions atau manual:
```bash
ssh mytheclipse@100.96.248.86
mkdir -p /opt/telemetry
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
```
---
@@ -127,16 +125,17 @@ bash clickhouse/init.sh
| 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 |
@@ -149,7 +148,7 @@ bash clickhouse/init.sh
## 5. Metrics Flow
1. **App services** mengekspos `GET /metrics` di port masing-masing
2. **Prometheus** di orange VPS scrape via Tailscale IP (`100.108.1.124:PORT`)
2. **Prometheus** di orange VPS scrape via Tailscale IP (`100.121.180.82:PORT`)
3. Prometheus forward ke **Metric Ingester** via `remote_write`
4. Metric Ingester enrich → filter → forward ke **Vector**
5. Vector buffer → write ke **ClickHouse**
+1
View File
@@ -15,6 +15,7 @@
"packages/*"
],
"dependencies": {
"@tauri-apps/api": "2.11.0",
"@tauri-apps/plugin-deep-link": "2.4.9",
"@tauri-apps/plugin-opener": "2.5.4"
}