Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1c014d9b3 | ||
|
|
1db8eee8ea | ||
|
|
74e17386ee | ||
|
|
a9ef795c90 | ||
|
|
153abf4352 | ||
|
|
da5c7c1cfa | ||
|
|
58d4cc0164 | ||
|
|
a3105200e3 | ||
|
|
c75cba214e |
@@ -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.x.x.a:3000`), 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.
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
+133
-98
@@ -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);
|
||||
@@ -32,7 +34,7 @@ function LogoutProses() {
|
||||
|
||||
}).catch((error) => {
|
||||
console.error("Oops, gagal logout dari server:", error);
|
||||
setUser(null);
|
||||
setUser(null);
|
||||
});
|
||||
}, [setUser]);
|
||||
|
||||
@@ -43,105 +45,138 @@ import { trackPageView, trackError } from "./lib/telemetry";
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/", element: <Navigate to="/login" replace /> },
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/register", element: <RegisterPage /> },
|
||||
{
|
||||
path: "/dashboard",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DashboardPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/scan",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<ScanPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/library",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<LibraryPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/diagnoses",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosesPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/diagnoses/:id",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosisDetailPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/expert/reviews",
|
||||
element: (
|
||||
<AuthGuard requireExpert={true}>
|
||||
<MainLayout>
|
||||
<ExpertReviewsPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/catalog",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<CatalogPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/catalog/:slug",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiseaseDetailPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/logout",
|
||||
element: (
|
||||
<LogoutProses />
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/telemetry",
|
||||
element: (
|
||||
<MainLayout>
|
||||
<TelemetryPage />
|
||||
</MainLayout>
|
||||
),
|
||||
element: <DeepLinkRouterHandler />,
|
||||
children: [
|
||||
{ path: "/", element: <Navigate to="/login" replace /> },
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/register", element: <RegisterPage /> },
|
||||
{
|
||||
path: "/dashboard",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DashboardPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/scan",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<ScanPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/library",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<LibraryPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/diagnoses",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosesPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/diagnoses/:id",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiagnosisDetailPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/expert/reviews",
|
||||
element: (
|
||||
<AuthGuard requireExpert={true}>
|
||||
<MainLayout>
|
||||
<ExpertReviewsPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/catalog",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<CatalogPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/catalog/:slug",
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<MainLayout>
|
||||
<DiseaseDetailPage />
|
||||
</MainLayout>
|
||||
</AuthGuard>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/logout",
|
||||
element: <LogoutProses />,
|
||||
},
|
||||
{
|
||||
path: "/telemetry",
|
||||
element: (
|
||||
<MainLayout>
|
||||
<TelemetryPage />
|
||||
</MainLayout>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
/**
|
||||
* Listens for deep-link custom events and routes via React Router's navigate(),
|
||||
* avoiding full page reloads that break the Tauri IPC bridge.
|
||||
*/
|
||||
function DeepLinkRouterHandler() {
|
||||
const navigate = useNavigate();
|
||||
const handled = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
// Check for cold-start pending deep link
|
||||
const pending = consumeDeepLinkTarget();
|
||||
if (pending && !handled.current.has(pending)) {
|
||||
handled.current.add(pending);
|
||||
navigate(pending, { replace: true });
|
||||
}
|
||||
|
||||
// Listen for warm-start deep links
|
||||
const handler = (e: CustomEvent<string>) => {
|
||||
const target = e.detail;
|
||||
if (handled.current.has(target)) return;
|
||||
handled.current.add(target);
|
||||
navigate(target, { replace: true });
|
||||
};
|
||||
window.addEventListener('zeavis:deeplink', handler as EventListener);
|
||||
return () => window.removeEventListener('zeavis:deeplink', handler as EventListener);
|
||||
}, [navigate]);
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function PageViewTracker() {
|
||||
const location = window.location;
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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) => {
|
||||
setName(event.target.value);
|
||||
onFieldChange?.();
|
||||
}} required />
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Masukkan nama Anda"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
onFieldChange?.();
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" value={email} onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
onFieldChange?.();
|
||||
}} required />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Masukkan email Anda"
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
onFieldChange?.();
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Input id="password" type={showPassword ? "text" : "password"} minLength={8} value={password} onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
onFieldChange?.();
|
||||
}} required />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Password minimal 8 karakter"
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
onFieldChange?.();
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground focus:outline-none"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600" role="alert">{error}</p>}
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Memproses...' : mode === 'login' ? 'Masuk' : 'Daftar'}
|
||||
{isSubmitting
|
||||
? "Memproses..."
|
||||
: mode === "login"
|
||||
? "Masuk"
|
||||
: "Daftar"}
|
||||
</Button>
|
||||
</form>
|
||||
{googleOAuthEnabled && (
|
||||
<Button className="mt-3 w-full flex items-center justify-center gap-2.5" variant="outline" onClick={handleGoogleLogin} type="button">
|
||||
<Button
|
||||
className="mt-3 w-full flex items-center justify-center gap-2.5"
|
||||
variant="outline"
|
||||
onClick={handleGoogleLogin}
|
||||
type="button"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden="true">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
<path fill="none" d="M1 1h22v22H1z" />
|
||||
</svg>
|
||||
Masuk dengan Google
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+55
-20
@@ -1,6 +1,13 @@
|
||||
/**
|
||||
* Lightweight Tauri environment detection and utilities.
|
||||
* Uses raw __TAURI_INTERNALS__ IPC to avoid bundling/import issues on Android.
|
||||
*
|
||||
* Deep-link flow (no full page reloads — uses React Router navigate()):
|
||||
* 1. Tauri deep-link plugin receives URL via intent/custom-scheme.
|
||||
* 2. processDeepLinkUrl stores the target path in sessionStorage +
|
||||
* dispatches a custom DOM event.
|
||||
* 3. <DeepLinkRouterHandler /> inside <RouterProvider> picks it up and
|
||||
* calls navigate(), keeping the React app alive.
|
||||
*/
|
||||
|
||||
let _isTauri: boolean | null = null;
|
||||
@@ -22,7 +29,6 @@ function tauriInvoke(): (cmd: string, args?: Record<string, unknown>) => Promise
|
||||
|
||||
export async function openUrl(url: string): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
// Not in Tauri — normal browser navigation
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
@@ -31,47 +37,76 @@ export async function openUrl(url: string): Promise<void> {
|
||||
await invoke('plugin:opener|open_url', { url });
|
||||
} catch (err) {
|
||||
console.error('Tauri openUrl failed, trying fallback:', err);
|
||||
// Fallback: navigate the WebView (Google will block, but best effort)
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
|
||||
function processDeepLinkUrl(url: string): void {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const target = u.pathname + u.search + u.hash;
|
||||
if (target && target !== '/') {
|
||||
window.location.href = target;
|
||||
return;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
// ── Deep link handling (no full reload) ─────────────────────────────────
|
||||
|
||||
// Fallback: handle both :// and :/ custom schemes
|
||||
let match = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/);
|
||||
if (!match) match = url.match(/^[^:]+:\/(\/.*)?$/);
|
||||
if (match?.[1]) window.location.href = match[1];
|
||||
const DEEP_LINK_KEY = 'zeavis_pending_deeplink';
|
||||
const DEEP_LINK_EVENT = 'zeavis:deeplink';
|
||||
|
||||
/** Store a target path for the React Router to pick up without page reload. */
|
||||
function storeDeepLinkTarget(target: string): void {
|
||||
try { sessionStorage.setItem(DEEP_LINK_KEY, target); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Read and clear the stored deep link target. */
|
||||
export function consumeDeepLinkTarget(): string | null {
|
||||
try {
|
||||
const v = sessionStorage.getItem(DEEP_LINK_KEY);
|
||||
if (v) sessionStorage.removeItem(DEEP_LINK_KEY);
|
||||
return v;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
|
||||
export async function setupDeepLinkHandler(): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
|
||||
try {
|
||||
const invoke = tauriInvoke();
|
||||
|
||||
// Cold-start: app just opened via intent:// or custom scheme
|
||||
// Cold-start: app opened via intent:// (e.g. from Google OAuth callback).
|
||||
// Use window.location.href for this (full page reload) — at cold start there
|
||||
// is no SPA state to lose, so redirecting via location.href avoids orphaned
|
||||
// IPC promises that cause "Cannot read properties of undefined (reading 'runCallback')".
|
||||
invoke('plugin:deep-link|get_current')
|
||||
.then((urls: any) => {
|
||||
if (urls?.[0]) processDeepLinkUrl(urls[0]);
|
||||
if (!urls?.[0]) return;
|
||||
const target = extractDeepLinkTarget(urls[0]);
|
||||
if (target && target !== window.location.pathname + window.location.search + window.location.hash) {
|
||||
window.location.href = target;
|
||||
}
|
||||
})
|
||||
.catch(() => { /* plugin may not be registered yet */ });
|
||||
.catch(() => {});
|
||||
|
||||
// Warm-start: listen for new URLs while app is running
|
||||
// Warm-start: listen for new URLs (already running app).
|
||||
// Use React Router navigate() here since we have SPA state.
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
listen('deep-link://new-url', (event: any) => {
|
||||
const urls = event.payload as string[];
|
||||
for (const url of urls) processDeepLinkUrl(url);
|
||||
for (const url of urls) {
|
||||
const target = extractDeepLinkTarget(url);
|
||||
if (target) {
|
||||
storeDeepLinkTarget(target);
|
||||
window.dispatchEvent(new CustomEvent(DEEP_LINK_EVENT, { detail: target }));
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Tauri deep-link setup failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract path+query+hash from a deep-link URL. */
|
||||
function extractDeepLinkTarget(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.pathname + u.search + u.hash;
|
||||
} catch {
|
||||
let m = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/);
|
||||
if (!m) m = url.match(/^[^:]+:\/(\/.*)?$/);
|
||||
return m?.[1] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { 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,6 +33,7 @@ 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);
|
||||
@@ -164,8 +165,25 @@ 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"
|
||||
|
||||
+1
-1
Submodule telemetry updated: 2582a53592...89d560baf8
Reference in New Issue
Block a user