Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11bf89c758 | ||
|
|
bc039bbbeb | ||
|
|
14e4995a7d | ||
|
|
53b96eb80c | ||
|
|
515a084dfd | ||
|
|
901f0bd41b | ||
|
|
ee08b9f097 | ||
|
|
17a2c892f8 | ||
|
|
dfa18ea0fd | ||
|
|
0818fa49fb | ||
|
|
ae1585e216 | ||
|
|
fe60ae71e6 | ||
|
|
ec6c1da94c | ||
|
|
6d37cd8eb9 | ||
|
|
18927bbc86 | ||
|
|
a4b546058a | ||
|
|
0010b023f6 | ||
|
|
e18ccab15f | ||
|
|
46d98a3544 | ||
|
|
2eb4e47585 | ||
|
|
ffccd31bbc | ||
|
|
4c79a1f09d | ||
|
|
a07c26c55b | ||
|
|
af0ab508f6 | ||
|
|
bd5aa81988 | ||
|
|
7877892f9b | ||
|
|
f8f36bcdb8 | ||
|
|
d1c014d9b3 | ||
|
|
1db8eee8ea | ||
|
|
74e17386ee | ||
|
|
a9ef795c90 | ||
|
|
153abf4352 | ||
|
|
da5c7c1cfa | ||
|
|
58d4cc0164 | ||
|
|
a3105200e3 | ||
|
|
c75cba214e | ||
|
|
c010d16aaa | ||
|
|
94ed062026 | ||
|
|
b4bd114eed | ||
|
|
7423e4d83b | ||
|
|
4b252a1d5d | ||
|
|
7148e1026e | ||
|
|
96f337ef46 | ||
|
|
3b5839ebd5 | ||
|
|
e2742a7a60 | ||
|
|
ae22adee40 |
+2
-2
@@ -1,6 +1,6 @@
|
||||
WEB_PORT=5173
|
||||
API_PORT=3000
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/zeavis_edu
|
||||
API_PORT=4006
|
||||
DATABASE_URL=postgres://asephs:***@100.121.180.82:6432/zeavis_edu
|
||||
|
||||
# ── Telemetry / ClickHouse ──────────────────────────────────────────
|
||||
# These credentials are used by the telemetry Docker Compose stack.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: dependabot-auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
# Auto-merge Dependabot PRs with passing CI
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependabot:
|
||||
runs-on: ubuntu-latest
|
||||
# Only for Dependabot PRs
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Auto-merge
|
||||
run: gh pr merge --auto --merge "$PR_NUMBER"
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,31 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
day: "monday"
|
||||
time: "05:00"
|
||||
timezone: "Asia/Makassar"
|
||||
open-pull-requests-limit: 10
|
||||
assignees:
|
||||
- "asepharyana"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
prefix-development: "chore"
|
||||
include: "scope"
|
||||
reviewers:
|
||||
- "asepharyana"
|
||||
labels:
|
||||
- "dependencies"
|
||||
groups:
|
||||
production:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
development:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
@@ -1,203 +0,0 @@
|
||||
name: Build Android APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'apps/web/**'
|
||||
- 'apps/tauri/**'
|
||||
- 'packages/shared/**'
|
||||
- '.github/workflows/android.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/web/**'
|
||||
- 'apps/tauri/**'
|
||||
- 'packages/shared/**'
|
||||
- '.github/workflows/android.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL || 'https://zeavisedu.asepharyana.my.id' }}
|
||||
|
||||
jobs:
|
||||
build-apk:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
apps/*/node_modules
|
||||
packages/*/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', '**/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Setup Java 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: 'platforms;android-36 build-tools;36.0.0'
|
||||
- name: Pin NDK version
|
||||
run: echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/$(ls ${ANDROID_SDK_ROOT}/ndk | sort -V | head -1)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup Rust with Android targets
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: >-
|
||||
aarch64-linux-android,
|
||||
armv7-linux-androideabi,
|
||||
i686-linux-android,
|
||||
x86_64-linux-android
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
apps/tauri/target
|
||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('apps/tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-android-
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-android-${{ hashFiles('apps/tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-android-
|
||||
|
||||
- name: Install Tauri CLI
|
||||
run: cd apps/tauri && bun install
|
||||
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
# Get latest git tag, default to v0.1.0
|
||||
LATEST_TAG=$(git tag --list 'v*' --sort=-v:refname | head -1)
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
NEW_VERSION="0.1.0"
|
||||
else
|
||||
# Strip 'v' prefix, bump patch
|
||||
BASE="${LATEST_TAG#v}"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
|
||||
PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
fi
|
||||
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "New version: ${NEW_VERSION}"
|
||||
|
||||
# Update tauri.conf.json
|
||||
jq --arg v "${NEW_VERSION}" '.version = $v' apps/tauri/tauri.conf.json > /tmp/tauri.conf.json && mv /tmp/tauri.conf.json apps/tauri/tauri.conf.json
|
||||
|
||||
# Update Cargo.toml
|
||||
sed -i "s/^version = \".*\"/version = \"${NEW_VERSION}\"/" apps/tauri/Cargo.toml
|
||||
|
||||
echo "Updated tauri.conf.json and Cargo.toml to ${NEW_VERSION}"
|
||||
|
||||
- name: Init Tauri Android project
|
||||
working-directory: apps/tauri
|
||||
env:
|
||||
JAVA_HOME: ${{ env.JAVA_HOME_21_X64 }}
|
||||
ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }}
|
||||
NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
|
||||
run: |
|
||||
rm -rf gen/android
|
||||
bun tauri android init
|
||||
|
||||
- name: Generate Android launcher icons from SVG logo
|
||||
working-directory: apps/tauri
|
||||
run: bun run scripts/generate-icons.js
|
||||
|
||||
- name: Patch AndroidManifest (CAMERA permission + deep link)
|
||||
working-directory: apps/tauri
|
||||
run: bash scripts/patch-android-manifest.sh
|
||||
|
||||
- name: Build Tauri Android APK
|
||||
working-directory: apps/tauri
|
||||
env:
|
||||
JAVA_HOME: ${{ env.JAVA_HOME_21_X64 }}
|
||||
ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }}
|
||||
NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
|
||||
run: bun tauri android build --apk
|
||||
|
||||
- name: Decode keystore
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > apps/tauri/zeavis.keystore
|
||||
|
||||
- name: Sign APK
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
APK_UNSIGNED=$(find apps/tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*-signed*' | head -1)
|
||||
APK_SIGNED="apps/tauri/gen/android/app/build/outputs/apk/universal/release/zeavis-edu-v${{ steps.version.outputs.version }}.apk"
|
||||
$ANDROID_SDK_ROOT/build-tools/36.0.0/apksigner sign \
|
||||
--ks apps/tauri/zeavis.keystore \
|
||||
--ks-pass "pass:${ANDROID_KEYSTORE_PASSWORD}" \
|
||||
--ks-key-alias "${ANDROID_KEY_ALIAS}" \
|
||||
--key-pass "pass:${ANDROID_KEY_PASSWORD}" \
|
||||
--out "$APK_SIGNED" \
|
||||
"$APK_UNSIGNED"
|
||||
echo "signed_apk=${APK_SIGNED}" >> $GITHUB_ENV
|
||||
echo "Signed APK: $APK_SIGNED"
|
||||
ls -lh "$APK_SIGNED"
|
||||
|
||||
- name: Create Release and upload APK
|
||||
if: github.event_name != 'pull_request'
|
||||
id: release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ steps.version.outputs.version }}
|
||||
name: v${{ steps.version.outputs.version }}
|
||||
body: |
|
||||
ZeaVis Edu Android APK v${{ steps.version.outputs.version }}
|
||||
|
||||
📦 Built from ${{ github.sha }}
|
||||
🔗 Triggered by ${{ github.actor }}
|
||||
|
||||
### Install
|
||||
Download the APK below and install on your Android device.
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
files: |
|
||||
${{ env.signed_apk }}
|
||||
apps/tauri/gen/android/app/build/outputs/bundle/**/*.aab
|
||||
draft: false
|
||||
prerelease: false
|
||||
+110
-185
@@ -1,208 +1,133 @@
|
||||
name: Build and Deploy
|
||||
name: Build & Deploy (Nix)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL || '' }}
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
concurrency:
|
||||
group: zeavis-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: false
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install deps (root workspace)
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Test API
|
||||
working-directory: apps/api
|
||||
run: bun test
|
||||
|
||||
build-and-deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
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: |
|
||||
STORE_PATH=$(nix build .#${{ matrix.service }} --impure --option sandbox false --no-link --print-out-paths | tail -1)
|
||||
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
||||
echo "Build OK zeavis-${{ matrix.service }}: $STORE_PATH"
|
||||
|
||||
- name: Setup SSH key
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
sed -i 's/\r$//' ~/.ssh/id_ed25519
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Deploy zeavis-${{ matrix.service }} to VPS
|
||||
run: |
|
||||
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
||||
echo "=== Copying zeavis-${{ matrix.service }}: $STORE_PATH ==="
|
||||
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
||||
|
||||
echo "=== Updating profile + restarting ==="
|
||||
ssh "$VPS_USER@$VPS_HOST" "
|
||||
set -eu
|
||||
echo "::group::Install huggingface_hub"
|
||||
python -m pip install --upgrade pip -q
|
||||
python -m pip install huggingface_hub -q
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Check HF_TOKEN"
|
||||
if [ -z "${HF_TOKEN:-}" ]; then
|
||||
echo "ERROR: HF_TOKEN secret is not set."
|
||||
echo "Add it: https://github.com/ATLAS-PJK-GM007/ZeaVis-Edu/settings/secrets/actions"
|
||||
exit 1
|
||||
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
|
||||
echo "HF_TOKEN is set (length: ${#HF_TOKEN})"
|
||||
echo "::endgroup::"
|
||||
|
||||
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('.')
|
||||
|
||||
# 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')
|
||||
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
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Mirror to Gitea
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Mirror to Gitea
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
git remote add gitea "https://oauth2:${GITEA_TOKEN}@git.imrnes.team/MythEclipse/zeavis-edu.git"
|
||||
git push --mirror gitea
|
||||
echo "✅ Mirrored to Gitea (MythEclipse/zeavis-edu)"
|
||||
@@ -142,19 +142,31 @@ make telemetry-down
|
||||
|
||||
## Telemetry architecture
|
||||
|
||||
The repository includes a full Prometheus → ClickHouse metric pipeline as a git submodule at `telemetry/`. Each ZeaVis Edu service exposes a `GET /metrics` endpoint:
|
||||
The telemetry stack lives as a git submodule at `telemetry/` (repo `MythEclipse/Telemetry`). Architecture:
|
||||
|
||||
| Layer | Service | Role |
|
||||
|-------|---------|------|
|
||||
| Collector & Storage | **Prometheus** | Metric scraping & TSDB storage |
|
||||
| System metrics | **Node Exporter** | CPU, memory, disk per host |
|
||||
| Query | **Query Proxy** | REST API over Prometheus HTTP API |
|
||||
| Visualization | **Grafana** | OSS dashboard & PromQL |
|
||||
| Entry point | **Telemetry UI** | nginx + Vue 3 SPA |
|
||||
|
||||
Data flow: Node Exporter → Prometheus scrape (every 15s) → Grafana (PromQL) / Query Proxy (/api/metrics).
|
||||
|
||||
Each ZeaVis Edu service exposes a `GET /metrics` endpoint:
|
||||
|
||||
- **Web app** (`apps/web`): In dev mode, a Vite plugin serves client-side session metrics (page views, Web Vitals). In production, nginx proxies `/metrics` to the API service. Source: `apps/web/src/lib/telemetry.ts`, `apps/web/vite-plugin-metrics.ts`.
|
||||
- **API** (`apps/api`): Uses `prom-client` for Node.js default metrics plus custom HTTP, auth, classification, and diagnosis counters/histograms. Source: `apps/api/src/lib/telemetry.ts`, exposed via `apps/api/src/routes/metrics.ts`.
|
||||
- **ML service** (`apps/ml-service`): Uses the `prometheus` Rust crate for HTTP metrics, prediction counts, and model load status. Source: `apps/ml-service/src/telemetry.rs`.
|
||||
|
||||
All three share the `zeavis_` metric prefix and are scraped by the Telemetry Prometheus instance via `file_sd_configs` (see `telemetry/prometheus/targets/zeavis-edu.json`).
|
||||
All three share the `zeavis_` metric prefix and are scraped by Prometheus via `file_sd_configs` (see `telemetry/prometheus/targets/zeavis-edu.json`).
|
||||
|
||||
**IMPORTANT — Production architecture:** ZeaVis Edu apps and the Telemetry stack run on **separate VPS instances** connected via **Tailscale** (mesh VPN). Prometheus scrapes the API and ML service through their **Tailscale IPs** (e.g. `100.x.x.a:3000`), not via Docker hostnames. The target file `telemetry/prometheus/targets/zeavis-edu.json` has `__CHANGE_ME__` placeholders — before deploying, replace with the actual Tailscale IPs of the app VPS.
|
||||
**IMPORTANT — Production architecture:** ZeaVis Edu apps and the Telemetry stack run on **separate VPS instances** connected via **Tailscale** (mesh VPN). Prometheus scrapes the API and ML service through their **Tailscale IPs** (e.g. `100.121.180.82:4006`), not via Docker hostnames. The target file has `__CHANGE_ME__` placeholders — replace with actual Tailscale IPs before deploying.
|
||||
|
||||
The telemetry stack is managed from the project root via `make telemetry-*` targets (see `Makefile`). The Docker Compose files in `telemetry/deploy/` define 6 services (Prometheus, Metric Ingester, Vector, ClickHouse, Query Proxy, Telemetry UI).
|
||||
The telemetry stack is managed from the project root via `make telemetry-*` targets (see `Makefile`). Docker Compose defines 5 services (Prometheus, Node Exporter, Query Proxy, Grafana, Telemetry UI).
|
||||
|
||||
For **local single-host dev**, Prometheus can reach app services via a shared Docker network (`app-shared-net`). Use `make telemetry-up-local` for this mode — it includes the `docker-compose.telemetry.yml` override.
|
||||
For **local single-host dev**, Prometheus can reach app services via a shared Docker network (`app-shared-net`). Use `make telemetry-up-local` for this mode.
|
||||
|
||||
## Fullstack application architecture
|
||||
|
||||
@@ -191,6 +203,34 @@ The following files/directories are generated or externally supplied during the
|
||||
- `Machine_Learning/best_model/best_model.keras` — trained model downloaded from Colab/Google Drive.
|
||||
- `Machine_Learning/model/saved_model/`, `model/model.tflite`, `model/model.onnx`, and `model/tfjs_model/` — production exports.
|
||||
|
||||
## Android Google OAuth (Tauri) — known issues & fixes
|
||||
|
||||
The Tauri Android app uses Chrome's `intent://` protocol to bounce back from Google's OAuth browser page. Three bugs were found and fixed in commit `c75cba2`:
|
||||
|
||||
### 1. API base URL falls back to `http://tauri.localhost`
|
||||
|
||||
**Symptom:** Google login button navigates to `http://tauri.localhost/api/v1/auth/google` → 404.
|
||||
**Root cause:** `auth-form.tsx` used `import.meta.env.VITE_API_BASE_URL || window.location.origin`. In Android WebView the origin is `http://tauri.localhost` (Vite dev server), not the API server.
|
||||
**Fix:** Import shared `apiBaseUrl` from `api-client.ts` which already has the correct fallback: `import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id'`.
|
||||
|
||||
### 2. `deep-link:get_current` IPC promise orphaned on SPA navigation
|
||||
|
||||
**Symptom:** `Cannot read properties of undefined (reading 'runCallback')` floods log; OAuth never completes.
|
||||
**Root cause:** `plugin:deep-link|get_current` returns a JS promise that stays pending. When React Router's `navigate()` changes the URL (SPA, no page reload), the Tauri IPC bridge invalidates the pending callback reference — but the promise never resolves or rejects cleanly, so `.runCallback` is undefined.
|
||||
**Fix (cold start):** `get_current` resolves via `window.location.href = target` (full reload). At boot there is no SPA state to lose, so a hard redirect is safe.
|
||||
**Fix (warm start / `deep-link://new-url` event):** Store target in `sessionStorage` + dispatch a custom DOM event. A `<DeepLinkRouterHandler>` root layout route listens for the event and calls React Router's `navigate()`, keeping SPA state alive.
|
||||
|
||||
### 3. LoginPage `?token=` effect does not re-run on SPA navigation
|
||||
|
||||
**Symptom:** App navigates to `/login?token=xxx` but stays on the login form.
|
||||
**Root cause:** The `useEffect` that reads `?token` and exchanges it for a session only listed `[setUser, queryClient, navigate]` as deps. React Router SPA navigation changes `location.search` but does not remount the component — so the effect never re-runs.
|
||||
**Fix:** Added `location.search` to the effect's dependency array. Also added `visibilitychange` and `focus` event listeners as a backup — when the user returns from the Google OAuth browser tab, the app picks up the token from the URL even if the deep-link plugin's event was missed.
|
||||
|
||||
### Design rule for Tauri deep-link handlers
|
||||
|
||||
- **Cold start** (app was not running) → safe to use `window.location.href` (full reload). The React app has just booted, no state to lose.
|
||||
- **Warm start** (app was running, user returns from system browser) → use React Router `navigate()` via custom events / sessionStorage. Do NOT use `window.location.href` — it triggers a full page unload which orphan Tauri IPC promises.
|
||||
|
||||
## Notes for future changes
|
||||
|
||||
- Keep README command examples and this file in sync when changing the ML pipeline.
|
||||
|
||||
+7
-7
@@ -10,15 +10,15 @@ application stack and the payload each service provides.
|
||||
| Service | Host (prod) | Metrics Endpoint | Port (local) |
|
||||
|-----------------------|-----------------------------------|----------------------------|--------------|
|
||||
| Web (Vite dev) | `zeavisedu.asepharyana.my.id` | `GET /metrics` | 5173 |
|
||||
| API (Elysia) | `api-zeavisedu.asepharyana.my.id` | `GET /metrics` | 3000 |
|
||||
| ML Service (Axum) | `ml-zeavisedu.asepharyana.my.id` | `GET /metrics` | 8000 |
|
||||
| API (Elysia) | `api-zeavisedu.asepharyana.my.id` | `GET /metrics` | 4006 |
|
||||
| ML Service (Axum) | `ml-zeavisedu.asepharyana.my.id` | `GET /metrics` | 4012 |
|
||||
| Prometheus Collector | — | `GET /metrics` (self) | 9090 |
|
||||
|
||||
> In production all metrics are scraped by the Prometheus collector running in the
|
||||
> Telemetry stack on a **separate VPS** connected via **Tailscale**.
|
||||
> See [`telemetry/prometheus/targets/`](./telemetry/prometheus/targets/)
|
||||
> for the auto‑discovery configuration. Target files must use **Tailscale IPs**
|
||||
> (e.g. `100.x.x.a:3000`), not Docker hostnames, because the services are on
|
||||
> (e.g. `100.121.180.82:4006`), not Docker hostnames, because the services are on
|
||||
> different hosts.
|
||||
>
|
||||
> In production (nginx), the web app proxies `/metrics` to the API service:
|
||||
@@ -101,11 +101,11 @@ The Telemetry submodule includes a Prometheus instance that uses
|
||||
```json
|
||||
[
|
||||
{
|
||||
"targets": ["100.x.x.a:3000"],
|
||||
"targets": ["100.121.180.82:4006"],
|
||||
"labels": { "service": "zeavis-api", "component": "backend", "env": "production" }
|
||||
},
|
||||
{
|
||||
"targets": ["100.x.x.b:8000"],
|
||||
"targets": ["100.121.180.82:4012"],
|
||||
"labels": { "service": "zeavis-ml", "component": "inference", "env": "production" }
|
||||
}
|
||||
]
|
||||
@@ -113,8 +113,8 @@ The Telemetry submodule includes a Prometheus instance that uses
|
||||
|
||||
> ⚠️ **Cross-VPS:** Gunakan **IP Tailscale** (bukan Docker hostname) karena
|
||||
> Prometheus dan ZeaVis Edu berjalan di VPS berbeda. Pastikan port service
|
||||
> (`:3000`, `:8000`) terekspos di `0.0.0.0` atau diizinkan oleh aturan
|
||||
> `iptables`/`ufw` untuk interface Tailscale (`tailscale0`/`100.x.x.x/10`).
|
||||
> (`:4006`, `:4012`) terekspos di `0.0.0.0` atau diizinkan oleh aturan
|
||||
> `iptables`/`ufw` untuk interface Tailscale (`tailscale0`/`100.121.180.82`).
|
||||
|
||||
The Prometheus config (in `telemetry/prometheus/prometheus.yml`) will
|
||||
automatically pick up new files within its 15‑second scrape interval —
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -10,10 +10,10 @@ import onnxruntime as ort
|
||||
import tensorflow as tf
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
|
||||
# Definisi label kelas sesuai urutan output model klasifikasi
|
||||
LABELS = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]
|
||||
|
||||
|
||||
# Kelas eksepsi kustom untuk menangani ketidaksesuaian akurasi prediksi
|
||||
class ParityError(RuntimeError):
|
||||
"""Raised when Keras and ONNX predictions do not match."""
|
||||
pass
|
||||
@@ -33,16 +33,19 @@ def preprocess_image(image_path, input_size):
|
||||
Raises:
|
||||
ParityError: If image cannot be loaded or processed.
|
||||
"""
|
||||
# Penanganan error secara aman saat memuat gambar ke format RGB
|
||||
try:
|
||||
img = Image.open(image_path).convert("RGB")
|
||||
except (FileNotFoundError, UnidentifiedImageError, OSError) as e:
|
||||
raise ParityError(f"Failed to load image {image_path}: {e}")
|
||||
|
||||
# Penyesuaian resolusi gambar menggunakan metode interpolasi Bilinear
|
||||
try:
|
||||
img = img.resize((input_size, input_size), Image.Resampling.BILINEAR)
|
||||
except Exception as e:
|
||||
raise ParityError(f"Failed to resize image {image_path}: {e}")
|
||||
|
||||
# Konversi ke matriks float32 dan penambahan dimensi batch (1, H, W, C)
|
||||
img_array = np.array(img, dtype=np.float32)
|
||||
img_batch = np.expand_dims(img_array, axis=0)
|
||||
|
||||
@@ -60,6 +63,7 @@ def predict_keras(model, image_batch):
|
||||
Returns:
|
||||
Predictions array (1, num_classes).
|
||||
"""
|
||||
# Eksekusi inferensi pada model TensorFlow/Keras tanpa log proses
|
||||
predictions = model.predict(image_batch, verbose=0)
|
||||
return predictions
|
||||
|
||||
@@ -75,6 +79,7 @@ def predict_onnx(session, image_batch):
|
||||
Returns:
|
||||
Predictions array (1, num_classes).
|
||||
"""
|
||||
# Eksekusi inferensi secara dinamis pada model ONNX menggunakan sesi runtime
|
||||
input_name = session.get_inputs()[0].name
|
||||
predictions = session.run(None, {input_name: image_batch})
|
||||
return predictions[0]
|
||||
@@ -94,14 +99,18 @@ def validate_image(image_path, keras_model, onnx_session, input_size, atol):
|
||||
Raises:
|
||||
ParityError: If predictions do not match or image cannot be processed.
|
||||
"""
|
||||
# Menyiapkan tensor gambar untuk pengujian
|
||||
img_batch = preprocess_image(image_path, input_size)
|
||||
|
||||
# Mengekstrak matriks probabilitas dari kedua format model
|
||||
keras_pred = predict_keras(keras_model, img_batch)
|
||||
onnx_pred = predict_onnx(onnx_session, img_batch)
|
||||
|
||||
# Mendapatkan indeks kelas dengan probabilitas tertinggi (Top-1)
|
||||
keras_label_idx = np.argmax(keras_pred[0])
|
||||
onnx_label_idx = np.argmax(onnx_pred[0])
|
||||
|
||||
# Validasi keselarasan keputusan klasifikasi utama
|
||||
if keras_label_idx != onnx_label_idx:
|
||||
keras_label = LABELS[keras_label_idx]
|
||||
onnx_label = LABELS[onnx_label_idx]
|
||||
@@ -110,6 +119,7 @@ def validate_image(image_path, keras_model, onnx_session, input_size, atol):
|
||||
f"Keras={keras_label}, ONNX={onnx_label}"
|
||||
)
|
||||
|
||||
# Validasi selisih nilai desimal probabilitas menggunakan toleransi absolut
|
||||
if not np.allclose(keras_pred, onnx_pred, atol=atol):
|
||||
max_diff = np.max(np.abs(keras_pred - onnx_pred))
|
||||
raise ParityError(
|
||||
@@ -117,6 +127,7 @@ def validate_image(image_path, keras_model, onnx_session, input_size, atol):
|
||||
f"max difference={max_diff:.6e} (atol={atol})"
|
||||
)
|
||||
|
||||
# Pencatatan log sistem jika kedua model presisi 100%
|
||||
label = LABELS[keras_label_idx]
|
||||
logging.info(f"PASS: {image_path} -> {label}")
|
||||
|
||||
@@ -125,6 +136,7 @@ def main():
|
||||
"""Validate parity between Keras and ONNX models."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
|
||||
# Inisialisasi parser argumen untuk antarmuka CLI (Command Line Interface)
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate parity between Keras and ONNX models"
|
||||
)
|
||||
@@ -161,6 +173,7 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Pengecekan eksistensi berkas model sebelum memuat memori
|
||||
if not args.keras_model.exists():
|
||||
msg = f"Keras model not found at {args.keras_model}"
|
||||
logging.error(msg)
|
||||
@@ -171,15 +184,18 @@ def main():
|
||||
logging.error(msg)
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
# Memuat model Keras (tanpa kompilasi agar lebih hemat beban komputasi)
|
||||
logging.info(f"Loading Keras model from {args.keras_model}...")
|
||||
keras_model = tf.keras.models.load_model(args.keras_model, compile=False)
|
||||
|
||||
# Memuat sesi ONNX dengan penyedia eksekusi CPU murni
|
||||
logging.info(f"Loading ONNX model from {args.onnx_model}...")
|
||||
onnx_session = ort.InferenceSession(
|
||||
str(args.onnx_model),
|
||||
providers=["CPUExecutionProvider"],
|
||||
)
|
||||
|
||||
# Iterasi pengujian paritas (kesetaraan performa) untuk setiap gambar
|
||||
logging.info(f"Validating {len(args.images)} image(s)...")
|
||||
for image_path in args.images:
|
||||
try:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# ZeaVis Edu — Root Makefile
|
||||
#
|
||||
# Orchestrates the application stack (web, api, ml) and the telemetry
|
||||
# metric pipeline (Prometheus → Ingester → Vector → ClickHouse).
|
||||
# metric pipeline (Prometheus → Grafana).
|
||||
#
|
||||
# Telemetry commands operate on the submodule at telemetry/.
|
||||
# =============================================================================
|
||||
|
||||
@@ -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 • EfficientNetV2B0 • Transfer Learning</em>
|
||||
<em>Computer Vision • EfficientNetV2B0 • Rust ONNX Runtime • Tauri 2 Android</em>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="#-jadwal"><b>Jadwal</b></a> •
|
||||
<a href="#-tech-stack"><b>Tech Stack</b></a> •
|
||||
<a href="#-memulai"><b>Memulai</b></a> •
|
||||
<a href="#-platform"><b>Platform</b></a> •
|
||||
<a href="#-dokumentasi"><b>Dokumentasi</b></a>
|
||||
</p>
|
||||
|
||||
@@ -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 • Vite • TypeScript • React Router • TanStack Query • Zustand • Tailwind CSS
|
||||
**Tauri 2** (Android) • Rust • WebView • Deep Link OAuth
|
||||
|
||||
### Backend API
|
||||
Bun • Elysia • Drizzle ORM • PostgreSQL • prom-client
|
||||
|
||||
### Machine Learning
|
||||
Python • TensorFlow/Keras • EfficientNetV2B0 • Google Colab (GPU T4)
|
||||
Rust • Axum • ONNX Runtime • TFLite • TensorFlow.js
|
||||
|
||||
### Inference Engine
|
||||
**Rust** • **Axum** • **ONNX Runtime** • TFLite • TensorFlow.js
|
||||
|
||||
### DevOps & Infrastruktur
|
||||
Docker • Docker Compose • Coolify • Traefik • Tailscale • GitHub Actions (CI/CD)
|
||||
Nix • systemd • Caddy • Tailscale • GitHub Actions (CI/CD)
|
||||
|
||||
### Observabilitas
|
||||
Prometheus • Metric Ingester (Go) • Vector • ClickHouse • Query Proxy (Go) • Telemetry UI (Vue 3)
|
||||
@@ -181,9 +190,10 @@ Prometheus • Metric Ingester (Go) • Vector • ClickHouse • Qu
|
||||
|
||||
- **Bun** — runtime & package manager
|
||||
- **Python 3.9–3.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` • `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
@@ -11,7 +11,7 @@ RUN bun install --production
|
||||
FROM oven/bun:1.3.14 AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV API_PORT=3000
|
||||
ENV API_PORT=4006
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/api/node_modules apps/api/node_modules
|
||||
@@ -20,5 +20,5 @@ COPY package.json bunfig.toml tsconfig.base.json ./
|
||||
COPY apps/api apps/api
|
||||
COPY packages/shared packages/shared
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 4006
|
||||
CMD ["bun", "apps/api/src/index.ts"]
|
||||
|
||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? 'postgres://postgres:postgres@localhost:5432/zeavis_edu',
|
||||
url: process.env.DATABASE_URL ?? 'postgres://asephs:***@100.121.180.82:6432/zeavis_edu',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "1.4.2",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"@opentelemetry/exporter-prometheus": "0.218.0",
|
||||
"@opentelemetry/instrumentation-http": "0.218.0",
|
||||
"@opentelemetry/sdk-node": "0.218.0",
|
||||
"@opentelemetry/semantic-conventions": "1.41.1",
|
||||
"@opentelemetry/exporter-prometheus": "0.221.0",
|
||||
"@opentelemetry/instrumentation-http": "0.221.0",
|
||||
"@opentelemetry/sdk-node": "0.221.0",
|
||||
"@opentelemetry/semantic-conventions": "1.43.0",
|
||||
"@zeavis/shared": "workspace:*",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
@@ -29,6 +29,6 @@
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -80,7 +80,9 @@ function renderTauriDeepLinkPage(targetUrl: string): Response {
|
||||
const escapedPath = pathAndQuery.replace(/"/g, '"');
|
||||
// intent:// scheme: Chrome on Android opens the target app by package name
|
||||
// browser_fallback_url: shown if the app isn't installed
|
||||
const intentUrl = `intent:${escapedPath}#Intent;scheme=zeavisedu;package=com.zeavis.edu;S.browser_fallback_url=${encodeURIComponent(targetUrl)};end`;
|
||||
// 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">
|
||||
@@ -312,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,4 +1,4 @@
|
||||
MODEL_PATH=../../Machine_Learning/model/model.onnx
|
||||
MODEL_INPUT_SIZE=224
|
||||
ML_SERVICE_HOST=0.0.0.0
|
||||
ML_SERVICE_PORT=8001
|
||||
ML_SERVICE_PORT=4012
|
||||
|
||||
@@ -11,7 +11,7 @@ WORKDIR /app
|
||||
ENV MODEL_PATH=/app/model/model.onnx
|
||||
ENV MODEL_INPUT_SIZE=224
|
||||
ENV ML_SERVICE_HOST=0.0.0.0
|
||||
ENV ML_SERVICE_PORT=8000
|
||||
ENV ML_SERVICE_PORT=4012
|
||||
ENV RUST_LOG=info
|
||||
|
||||
RUN pacman -Syu --noconfirm ca-certificates 2>/dev/null
|
||||
@@ -19,5 +19,5 @@ RUN pacman -Syu --noconfirm ca-certificates 2>/dev/null
|
||||
COPY --from=builder /app/target/release/zeavis-ml-service /usr/local/bin/zeavis-ml-service
|
||||
COPY Machine_Learning/model/model.onnx /app/model/model.onnx
|
||||
|
||||
EXPOSE 8000
|
||||
EXPOSE 4012
|
||||
CMD ["zeavis-ml-service"]
|
||||
|
||||
+12
-12
@@ -48,7 +48,7 @@ Output build lokal berada di `target/` dan direktori tersebut diabaikan oleh Git
|
||||
|
||||
Semua perintah di bawah dijalankan dari direktori `apps/ml-service`.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -30,7 +30,7 @@ impl Config {
|
||||
|
||||
pub fn from_env_with_base_dir(base_dir: &Path) -> Result<Self> {
|
||||
let host = env::var("ML_SERVICE_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
|
||||
let port = parse_env_u16("ML_SERVICE_PORT", 8000)?;
|
||||
let port = parse_env_u16("ML_SERVICE_PORT", 4012)?;
|
||||
let input_size = parse_env_u32("MODEL_INPUT_SIZE", DEFAULT_INPUT_SIZE)?;
|
||||
let model_path = env::var("MODEL_PATH").unwrap_or_else(|_| DEFAULT_MODEL_PATH.to_string());
|
||||
let temperature = parse_env_f32("MODEL_TEMPERATURE", DEFAULT_TEMPERATURE)?;
|
||||
@@ -116,7 +116,7 @@ mod tests {
|
||||
let config = Config::from_env_with_base_dir(Path::new("/repo/apps/ml-service")).unwrap();
|
||||
|
||||
assert_eq!(config.host, "0.0.0.0");
|
||||
assert_eq!(config.port, 8000);
|
||||
assert_eq!(config.port, 4012);
|
||||
assert_eq!(config.input_size, 224);
|
||||
assert_eq!(
|
||||
config.model_path,
|
||||
|
||||
Generated
+616
@@ -32,6 +32,137 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -127,6 +258,19 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
@@ -295,6 +439,35 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -378,6 +551,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -579,6 +758,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@@ -665,6 +853,33 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -682,6 +897,37 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -809,6 +1055,19 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
@@ -1147,6 +1406,12 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1174,6 +1439,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -1459,6 +1730,25 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1640,6 +1930,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
@@ -2003,12 +2299,44 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2034,6 +2362,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2057,6 +2391,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -2122,6 +2462,17 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
@@ -2167,6 +2518,20 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -2399,6 +2764,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -2414,6 +2789,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -2690,6 +3078,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -3042,6 +3440,65 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"open",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"windows",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.2"
|
||||
@@ -3141,6 +3598,19 @@ dependencies = [
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.0"
|
||||
@@ -3221,6 +3691,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -3445,9 +3924,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -3497,6 +3988,17 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
@@ -4033,6 +4535,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -4457,6 +4970,67 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.3",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeavis-edu-tauri"
|
||||
version = "0.1.0"
|
||||
@@ -4465,6 +5039,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-opener",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4526,3 +5102,43 @@ name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.117",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
"@tauri-apps/cli": "^2"
|
||||
},
|
||||
"dependencies": {
|
||||
"sharp": "0.35.1"
|
||||
"sharp": "0.35.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"beforeDevCommand": "cd ../web && bun run dev"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "ZeaVis Edu",
|
||||
@@ -26,5 +26,13 @@
|
||||
"active": true,
|
||||
"targets": "all"
|
||||
},
|
||||
"plugins": {}
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"mobile": [
|
||||
{
|
||||
"scheme": ["zeavisedu"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>ZeaVis Edu</title>
|
||||
<script>
|
||||
var LIVE = 'https://zeavisedu.asepharyana.my.id';
|
||||
var T = window.__TAURI_INTERNALS__;
|
||||
|
||||
function navigate(path) {
|
||||
window.location.replace(LIVE + path);
|
||||
}
|
||||
|
||||
// On cold start, check if the app was opened via a deep link (Google OAuth)
|
||||
// before redirecting to the live web app.
|
||||
if (T && T.invoke) {
|
||||
T.invoke('plugin:deep-link|get_current')
|
||||
.then(function(urls) {
|
||||
if (urls && urls.length > 0 && urls[0]) {
|
||||
try {
|
||||
var u = new URL(urls[0]);
|
||||
var target = u.pathname + u.search + u.hash;
|
||||
if (target && target !== '/') {
|
||||
// Preserve full path + query (e.g. /login?token=xxx)
|
||||
navigate(target);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* malformed URL — fall through */ }
|
||||
}
|
||||
// No deep link — redirect to live app home
|
||||
navigate('/');
|
||||
})
|
||||
.catch(function() { navigate('/'); });
|
||||
} else {
|
||||
// Not in Tauri (dev mode or unknown) — redirect to live
|
||||
navigate('/');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body style="background:#f0fdf4;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0">
|
||||
<p style="color:#16a34a">Memuat ZeaVis Edu...</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ZeaVis Edu</title>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2322C55E' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z'/%3E%3Cpath d='M2 22l10-10'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ server {
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://zeavis-api:3000/api/;
|
||||
proxy_pass http://zeavis-api:4006/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -14,7 +14,7 @@ server {
|
||||
|
||||
# Expose API metrics through the web endpoint (Prometheus scrape target)
|
||||
location /metrics {
|
||||
proxy_pass http://zeavis-api:3000/metrics;
|
||||
proxy_pass http://zeavis-api:4006/metrics;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tauri-apps/plugin-deep-link": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@zeavis/shared": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -20,10 +22,10 @@
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"recharts": "3.8.1",
|
||||
"recharts": "3.10.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"web-vitals": "5.3.0",
|
||||
"web-vitals": "6.1.1",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -31,7 +33,7 @@
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"postcss": "^8.5.15",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.0.14",
|
||||
"vite-tsconfig-paths": "6.1.1"
|
||||
}
|
||||
|
||||
+40
-5
@@ -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,10 +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 } from "@/lib/tauri";
|
||||
import { setupDeepLinkHandler, consumeDeepLinkTarget } from "@/lib/tauri";
|
||||
|
||||
function LogoutProses() {
|
||||
const setUser = useAuthStore((state) => state.setUser);
|
||||
@@ -43,6 +45,9 @@ import { trackPageView, trackError } from "./lib/telemetry";
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <DeepLinkRouterHandler />,
|
||||
children: [
|
||||
{ path: "/", element: <Navigate to="/login" replace /> },
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/register", element: <RegisterPage /> },
|
||||
@@ -128,9 +133,7 @@ const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: "/logout",
|
||||
element: (
|
||||
<LogoutProses />
|
||||
),
|
||||
element: <LogoutProses />,
|
||||
},
|
||||
{
|
||||
path: "/telemetry",
|
||||
@@ -140,8 +143,40 @@ const router = createBrowserRouter([
|
||||
</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(() => {
|
||||
|
||||
@@ -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 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>
|
||||
<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.'}
|
||||
{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) => {
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Masukkan nama Anda"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
onFieldChange?.();
|
||||
}} required />
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" value={email} onChange={(event) => {
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Masukkan email Anda"
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
onFieldChange?.();
|
||||
}} required />
|
||||
}}
|
||||
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) => {
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Password minimal 8 karakter"
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
onFieldChange?.();
|
||||
}} required />
|
||||
}}
|
||||
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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+81
-24
@@ -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,42 +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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for deep link URLs when the app is opened from an external link.
|
||||
* On Android, after Google OAuth completes in the system browser, the
|
||||
* callback page redirects to zeavisedu://... which triggers this listener.
|
||||
* We extract the path + query and navigate the WebView there.
|
||||
*/
|
||||
// ── 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;
|
||||
|
||||
const { onOpenUrl } = await import('@tauri-apps/plugin-deep-link');
|
||||
onOpenUrl((urls) => {
|
||||
for (const url of urls) {
|
||||
// url looks like: zeavisedu://zeavisedu.asepharyana.my.id/login?token=xxx
|
||||
// Extract path + query after the host
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const target = u.pathname + u.search + u.hash;
|
||||
if (target && target !== '/') {
|
||||
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 {
|
||||
// If URL parsing fails, try to extract everything after the scheme
|
||||
const match = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/);
|
||||
if (match?.[1]) {
|
||||
window.location.href = match[1];
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Warm-start: listen for new URLs (already running app).
|
||||
// Use React Router navigate() here since we have SPA state.
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
listen('deep-link://new-url', (event: any) => {
|
||||
const urls = event.payload as string[];
|
||||
for (const url of urls) {
|
||||
const target = extractDeepLinkTarget(url);
|
||||
if (target) {
|
||||
storeDeepLinkTarget(target);
|
||||
window.dispatchEvent(new CustomEvent(DEEP_LINK_EVENT, { detail: target }));
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Tauri deep-link setup failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract path+query+hash from a deep-link URL. */
|
||||
function extractDeepLinkTarget(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.pathname + u.search + u.hash;
|
||||
} catch {
|
||||
let m = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/);
|
||||
if (!m) m = url.match(/^[^:]+:\/(\/.*)?$/);
|
||||
return m?.[1] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +54,12 @@ export function CatalogPage() {
|
||||
<div className="space-y-6 max-w-5xl mx-auto pb-10">
|
||||
{/* Page Title */}
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-[#214B11]">
|
||||
<h1 className="text-2xl font-bold text-emerald-800">
|
||||
Pustaka Penyakit
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground text-sm md:text-base">
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Referensi lengkap penyakit dan kondisi daun jagung yang dapat
|
||||
dideteksi oleh sistem AI ZeaVis Edu.
|
||||
dideteksi oleh sistem AI ZeaVis Edu
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DiagnosesPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Diagnosa Tanaman</h1>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Diagnosa Penyakit</h1>
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Lihat hasil diagnosa dari scan yang telah dilakukan
|
||||
</p>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function ExpertReviewsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Review Pakar</h1>
|
||||
<h1 className="text-2xl font-bold text-emerald-800">Tinjauan Pakar</h1>
|
||||
<p className="text-gray-500 mt-1 text-md">
|
||||
Tinjau hasil diagnosa dari scan yang telah dilakukan dan berikan
|
||||
feedback untuk meningkatkan akurasi sistem AI ZeaVis Edu
|
||||
|
||||
@@ -1,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) => {
|
||||
|
||||
@@ -33,11 +33,11 @@ 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) => {
|
||||
const handleCameraCapture = useCallback((file: File) => {
|
||||
setFileName(file.name);
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
@@ -48,9 +48,7 @@ export function ScanPage() {
|
||||
};
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+10
-4
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user