Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74e17386ee | ||
|
|
a9ef795c90 | ||
|
|
153abf4352 | ||
|
|
da5c7c1cfa | ||
|
|
58d4cc0164 | ||
|
|
a3105200e3 | ||
|
|
c75cba214e |
@@ -142,19 +142,31 @@ make telemetry-down
|
|||||||
|
|
||||||
## Telemetry architecture
|
## Telemetry architecture
|
||||||
|
|
||||||
The repository includes a full Prometheus → ClickHouse metric pipeline as a git submodule at `telemetry/`. Each ZeaVis Edu service exposes a `GET /metrics` endpoint:
|
The telemetry stack lives as a git submodule at `telemetry/` (repo `MythEclipse/Telemetry`). Architecture:
|
||||||
|
|
||||||
|
| Layer | Service | Role |
|
||||||
|
|-------|---------|------|
|
||||||
|
| Collector & Storage | **Prometheus** | Metric scraping & TSDB storage |
|
||||||
|
| System metrics | **Node Exporter** | CPU, memory, disk per host |
|
||||||
|
| Query | **Query Proxy** | REST API over Prometheus HTTP API |
|
||||||
|
| Visualization | **Grafana** | OSS dashboard & PromQL |
|
||||||
|
| Entry point | **Telemetry UI** | nginx + Vue 3 SPA |
|
||||||
|
|
||||||
|
Data flow: Node Exporter → Prometheus scrape (every 15s) → Grafana (PromQL) / Query Proxy (/api/metrics).
|
||||||
|
|
||||||
|
Each ZeaVis Edu service exposes a `GET /metrics` endpoint:
|
||||||
|
|
||||||
- **Web app** (`apps/web`): In dev mode, a Vite plugin serves client-side session metrics (page views, Web Vitals). In production, nginx proxies `/metrics` to the API service. Source: `apps/web/src/lib/telemetry.ts`, `apps/web/vite-plugin-metrics.ts`.
|
- **Web app** (`apps/web`): In dev mode, a Vite plugin serves client-side session metrics (page views, Web Vitals). In production, nginx proxies `/metrics` to the API service. Source: `apps/web/src/lib/telemetry.ts`, `apps/web/vite-plugin-metrics.ts`.
|
||||||
- **API** (`apps/api`): Uses `prom-client` for Node.js default metrics plus custom HTTP, auth, classification, and diagnosis counters/histograms. Source: `apps/api/src/lib/telemetry.ts`, exposed via `apps/api/src/routes/metrics.ts`.
|
- **API** (`apps/api`): Uses `prom-client` for Node.js default metrics plus custom HTTP, auth, classification, and diagnosis counters/histograms. Source: `apps/api/src/lib/telemetry.ts`, exposed via `apps/api/src/routes/metrics.ts`.
|
||||||
- **ML service** (`apps/ml-service`): Uses the `prometheus` Rust crate for HTTP metrics, prediction counts, and model load status. Source: `apps/ml-service/src/telemetry.rs`.
|
- **ML service** (`apps/ml-service`): Uses the `prometheus` Rust crate for HTTP metrics, prediction counts, and model load status. Source: `apps/ml-service/src/telemetry.rs`.
|
||||||
|
|
||||||
All three share the `zeavis_` metric prefix and are scraped by the Telemetry Prometheus instance via `file_sd_configs` (see `telemetry/prometheus/targets/zeavis-edu.json`).
|
All three share the `zeavis_` metric prefix and are scraped by Prometheus via `file_sd_configs` (see `telemetry/prometheus/targets/zeavis-edu.json`).
|
||||||
|
|
||||||
**IMPORTANT — Production architecture:** ZeaVis Edu apps and the Telemetry stack run on **separate VPS instances** connected via **Tailscale** (mesh VPN). Prometheus scrapes the API and ML service through their **Tailscale IPs** (e.g. `100.x.x.a:3000`), not via Docker hostnames. The target file `telemetry/prometheus/targets/zeavis-edu.json` has `__CHANGE_ME__` placeholders — before deploying, replace with the actual Tailscale IPs of the app VPS.
|
**IMPORTANT — Production architecture:** ZeaVis Edu apps and the Telemetry stack run on **separate VPS instances** connected via **Tailscale** (mesh VPN). Prometheus scrapes the API and ML service through their **Tailscale IPs** (e.g. `100.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
|
## Fullstack application architecture
|
||||||
|
|
||||||
@@ -191,6 +203,34 @@ The following files/directories are generated or externally supplied during the
|
|||||||
- `Machine_Learning/best_model/best_model.keras` — trained model downloaded from Colab/Google Drive.
|
- `Machine_Learning/best_model/best_model.keras` — trained model downloaded from Colab/Google Drive.
|
||||||
- `Machine_Learning/model/saved_model/`, `model/model.tflite`, `model/model.onnx`, and `model/tfjs_model/` — production exports.
|
- `Machine_Learning/model/saved_model/`, `model/model.tflite`, `model/model.onnx`, and `model/tfjs_model/` — production exports.
|
||||||
|
|
||||||
|
## Android Google OAuth (Tauri) — known issues & fixes
|
||||||
|
|
||||||
|
The Tauri Android app uses Chrome's `intent://` protocol to bounce back from Google's OAuth browser page. Three bugs were found and fixed in commit `c75cba2`:
|
||||||
|
|
||||||
|
### 1. API base URL falls back to `http://tauri.localhost`
|
||||||
|
|
||||||
|
**Symptom:** Google login button navigates to `http://tauri.localhost/api/v1/auth/google` → 404.
|
||||||
|
**Root cause:** `auth-form.tsx` used `import.meta.env.VITE_API_BASE_URL || window.location.origin`. In Android WebView the origin is `http://tauri.localhost` (Vite dev server), not the API server.
|
||||||
|
**Fix:** Import shared `apiBaseUrl` from `api-client.ts` which already has the correct fallback: `import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id'`.
|
||||||
|
|
||||||
|
### 2. `deep-link:get_current` IPC promise orphaned on SPA navigation
|
||||||
|
|
||||||
|
**Symptom:** `Cannot read properties of undefined (reading 'runCallback')` floods log; OAuth never completes.
|
||||||
|
**Root cause:** `plugin:deep-link|get_current` returns a JS promise that stays pending. When React Router's `navigate()` changes the URL (SPA, no page reload), the Tauri IPC bridge invalidates the pending callback reference — but the promise never resolves or rejects cleanly, so `.runCallback` is undefined.
|
||||||
|
**Fix (cold start):** `get_current` resolves via `window.location.href = target` (full reload). At boot there is no SPA state to lose, so a hard redirect is safe.
|
||||||
|
**Fix (warm start / `deep-link://new-url` event):** Store target in `sessionStorage` + dispatch a custom DOM event. A `<DeepLinkRouterHandler>` root layout route listens for the event and calls React Router's `navigate()`, keeping SPA state alive.
|
||||||
|
|
||||||
|
### 3. LoginPage `?token=` effect does not re-run on SPA navigation
|
||||||
|
|
||||||
|
**Symptom:** App navigates to `/login?token=xxx` but stays on the login form.
|
||||||
|
**Root cause:** The `useEffect` that reads `?token` and exchanges it for a session only listed `[setUser, queryClient, navigate]` as deps. React Router SPA navigation changes `location.search` but does not remount the component — so the effect never re-runs.
|
||||||
|
**Fix:** Added `location.search` to the effect's dependency array. Also added `visibilitychange` and `focus` event listeners as a backup — when the user returns from the Google OAuth browser tab, the app picks up the token from the URL even if the deep-link plugin's event was missed.
|
||||||
|
|
||||||
|
### Design rule for Tauri deep-link handlers
|
||||||
|
|
||||||
|
- **Cold start** (app was not running) → safe to use `window.location.href` (full reload). The React app has just booted, no state to lose.
|
||||||
|
- **Warm start** (app was running, user returns from system browser) → use React Router `navigate()` via custom events / sessionStorage. Do NOT use `window.location.href` — it triggers a full page unload which orphan Tauri IPC promises.
|
||||||
|
|
||||||
## Notes for future changes
|
## Notes for future changes
|
||||||
|
|
||||||
- Keep README command examples and this file in sync when changing the ML pipeline.
|
- Keep README command examples and this file in sync when changing the ML pipeline.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# ZeaVis Edu — Root Makefile
|
# ZeaVis Edu — Root Makefile
|
||||||
#
|
#
|
||||||
# Orchestrates the application stack (web, api, ml) and the telemetry
|
# Orchestrates the application stack (web, api, ml) and the telemetry
|
||||||
# metric pipeline (Prometheus → Ingester → Vector → ClickHouse).
|
# metric pipeline (Prometheus → Grafana).
|
||||||
#
|
#
|
||||||
# Telemetry commands operate on the submodule at telemetry/.
|
# Telemetry commands operate on the submodule at telemetry/.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ZeaVis Edu</title>
|
<title>ZeaVis Edu</title>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2322C55E' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z'/%3E%3Cpath d='M2 22l10-10'/%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+133
-98
@@ -3,6 +3,8 @@ import {
|
|||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
Navigate,
|
Navigate,
|
||||||
|
Outlet,
|
||||||
|
useNavigate,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { AuthInitializer } from "@/components/auth-initializer";
|
import { AuthInitializer } from "@/components/auth-initializer";
|
||||||
import { AuthGuard } from "@/components/auth-guard";
|
import { AuthGuard } from "@/components/auth-guard";
|
||||||
@@ -18,10 +20,10 @@ import { TelemetryPage } from "@/pages/telemetry-page";
|
|||||||
import { LoginPage } from "@/pages/login-page";
|
import { LoginPage } from "@/pages/login-page";
|
||||||
import { RegisterPage } from "@/pages/register-page";
|
import { RegisterPage } from "@/pages/register-page";
|
||||||
import { MainLayout } from "@/components/layout/main-layout";
|
import { MainLayout } from "@/components/layout/main-layout";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useAuthStore } from "@/store/auth-store";
|
import { useAuthStore } from "@/store/auth-store";
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { setupDeepLinkHandler } from "@/lib/tauri";
|
import { setupDeepLinkHandler, consumeDeepLinkTarget } from "@/lib/tauri";
|
||||||
|
|
||||||
function LogoutProses() {
|
function LogoutProses() {
|
||||||
const setUser = useAuthStore((state) => state.setUser);
|
const setUser = useAuthStore((state) => state.setUser);
|
||||||
@@ -32,7 +34,7 @@ function LogoutProses() {
|
|||||||
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error("Oops, gagal logout dari server:", error);
|
console.error("Oops, gagal logout dari server:", error);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
});
|
});
|
||||||
}, [setUser]);
|
}, [setUser]);
|
||||||
|
|
||||||
@@ -43,105 +45,138 @@ import { trackPageView, trackError } from "./lib/telemetry";
|
|||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{ path: "/", element: <Navigate to="/login" replace /> },
|
|
||||||
{ path: "/login", element: <LoginPage /> },
|
|
||||||
{ path: "/register", element: <RegisterPage /> },
|
|
||||||
{
|
{
|
||||||
path: "/dashboard",
|
element: <DeepLinkRouterHandler />,
|
||||||
element: (
|
children: [
|
||||||
<AuthGuard>
|
{ path: "/", element: <Navigate to="/login" replace /> },
|
||||||
<MainLayout>
|
{ path: "/login", element: <LoginPage /> },
|
||||||
<DashboardPage />
|
{ path: "/register", element: <RegisterPage /> },
|
||||||
</MainLayout>
|
{
|
||||||
</AuthGuard>
|
path: "/dashboard",
|
||||||
),
|
element: (
|
||||||
},
|
<AuthGuard>
|
||||||
{
|
<MainLayout>
|
||||||
path: "/scan",
|
<DashboardPage />
|
||||||
element: (
|
</MainLayout>
|
||||||
<AuthGuard>
|
</AuthGuard>
|
||||||
<MainLayout>
|
),
|
||||||
<ScanPage />
|
},
|
||||||
</MainLayout>
|
{
|
||||||
</AuthGuard>
|
path: "/scan",
|
||||||
),
|
element: (
|
||||||
},
|
<AuthGuard>
|
||||||
{
|
<MainLayout>
|
||||||
path: "/library",
|
<ScanPage />
|
||||||
element: (
|
</MainLayout>
|
||||||
<AuthGuard>
|
</AuthGuard>
|
||||||
<MainLayout>
|
),
|
||||||
<LibraryPage />
|
},
|
||||||
</MainLayout>
|
{
|
||||||
</AuthGuard>
|
path: "/library",
|
||||||
),
|
element: (
|
||||||
},
|
<AuthGuard>
|
||||||
{
|
<MainLayout>
|
||||||
path: "/diagnoses",
|
<LibraryPage />
|
||||||
element: (
|
</MainLayout>
|
||||||
<AuthGuard>
|
</AuthGuard>
|
||||||
<MainLayout>
|
),
|
||||||
<DiagnosesPage />
|
},
|
||||||
</MainLayout>
|
{
|
||||||
</AuthGuard>
|
path: "/diagnoses",
|
||||||
),
|
element: (
|
||||||
},
|
<AuthGuard>
|
||||||
{
|
<MainLayout>
|
||||||
path: "/diagnoses/:id",
|
<DiagnosesPage />
|
||||||
element: (
|
</MainLayout>
|
||||||
<AuthGuard>
|
</AuthGuard>
|
||||||
<MainLayout>
|
),
|
||||||
<DiagnosisDetailPage />
|
},
|
||||||
</MainLayout>
|
{
|
||||||
</AuthGuard>
|
path: "/diagnoses/:id",
|
||||||
),
|
element: (
|
||||||
},
|
<AuthGuard>
|
||||||
{
|
<MainLayout>
|
||||||
path: "/expert/reviews",
|
<DiagnosisDetailPage />
|
||||||
element: (
|
</MainLayout>
|
||||||
<AuthGuard requireExpert={true}>
|
</AuthGuard>
|
||||||
<MainLayout>
|
),
|
||||||
<ExpertReviewsPage />
|
},
|
||||||
</MainLayout>
|
{
|
||||||
</AuthGuard>
|
path: "/expert/reviews",
|
||||||
),
|
element: (
|
||||||
},
|
<AuthGuard requireExpert={true}>
|
||||||
{
|
<MainLayout>
|
||||||
path: "/catalog",
|
<ExpertReviewsPage />
|
||||||
element: (
|
</MainLayout>
|
||||||
<AuthGuard>
|
</AuthGuard>
|
||||||
<MainLayout>
|
),
|
||||||
<CatalogPage />
|
},
|
||||||
</MainLayout>
|
{
|
||||||
</AuthGuard>
|
path: "/catalog",
|
||||||
),
|
element: (
|
||||||
},
|
<AuthGuard>
|
||||||
{
|
<MainLayout>
|
||||||
path: "/catalog/:slug",
|
<CatalogPage />
|
||||||
element: (
|
</MainLayout>
|
||||||
<AuthGuard>
|
</AuthGuard>
|
||||||
<MainLayout>
|
),
|
||||||
<DiseaseDetailPage />
|
},
|
||||||
</MainLayout>
|
{
|
||||||
</AuthGuard>
|
path: "/catalog/:slug",
|
||||||
),
|
element: (
|
||||||
},
|
<AuthGuard>
|
||||||
{
|
<MainLayout>
|
||||||
path: "/logout",
|
<DiseaseDetailPage />
|
||||||
element: (
|
</MainLayout>
|
||||||
<LogoutProses />
|
</AuthGuard>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/telemetry",
|
path: "/logout",
|
||||||
element: (
|
element: <LogoutProses />,
|
||||||
<MainLayout>
|
},
|
||||||
<TelemetryPage />
|
{
|
||||||
</MainLayout>
|
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() {
|
function PageViewTracker() {
|
||||||
const location = window.location;
|
const location = window.location;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { apiBaseUrl } from '@/lib/api-client';
|
||||||
import { isTauri, openUrl } from '@/lib/tauri';
|
import { isTauri, openUrl } from '@/lib/tauri';
|
||||||
|
|
||||||
type AuthFormProps = {
|
type AuthFormProps = {
|
||||||
@@ -29,10 +30,7 @@ export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubm
|
|||||||
const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => {
|
const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const platform = isTauri() ? 'tauri' : 'web';
|
const platform = isTauri() ? 'tauri' : 'web';
|
||||||
// Use API base URL, not window.location.origin — on Tauri Android
|
const googleUrl = `${apiBaseUrl}/api/v1/auth/google?platform=${platform}`;
|
||||||
// the origin is http://tauri.localhost which is not the API server.
|
|
||||||
const apiBase = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
|
||||||
const googleUrl = `${apiBase}/api/v1/auth/google?platform=${platform}`;
|
|
||||||
await openUrl(googleUrl);
|
await openUrl(googleUrl);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
} from '@zeavis/shared';
|
} from '@zeavis/shared';
|
||||||
import { recordApiCall } from './telemetry';
|
import { recordApiCall } from './telemetry';
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id';
|
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id';
|
||||||
|
|
||||||
const AUTH_TOKEN_KEY = 'zeavis_auth_token';
|
const AUTH_TOKEN_KEY = 'zeavis_auth_token';
|
||||||
|
|
||||||
|
|||||||
+55
-20
@@ -1,6 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Lightweight Tauri environment detection and utilities.
|
* Lightweight Tauri environment detection and utilities.
|
||||||
* Uses raw __TAURI_INTERNALS__ IPC to avoid bundling/import issues on Android.
|
* Uses raw __TAURI_INTERNALS__ IPC to avoid bundling/import issues on Android.
|
||||||
|
*
|
||||||
|
* Deep-link flow (no full page reloads — uses React Router navigate()):
|
||||||
|
* 1. Tauri deep-link plugin receives URL via intent/custom-scheme.
|
||||||
|
* 2. processDeepLinkUrl stores the target path in sessionStorage +
|
||||||
|
* dispatches a custom DOM event.
|
||||||
|
* 3. <DeepLinkRouterHandler /> inside <RouterProvider> picks it up and
|
||||||
|
* calls navigate(), keeping the React app alive.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let _isTauri: boolean | null = null;
|
let _isTauri: boolean | null = null;
|
||||||
@@ -22,7 +29,6 @@ function tauriInvoke(): (cmd: string, args?: Record<string, unknown>) => Promise
|
|||||||
|
|
||||||
export async function openUrl(url: string): Promise<void> {
|
export async function openUrl(url: string): Promise<void> {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
// Not in Tauri — normal browser navigation
|
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -31,47 +37,76 @@ export async function openUrl(url: string): Promise<void> {
|
|||||||
await invoke('plugin:opener|open_url', { url });
|
await invoke('plugin:opener|open_url', { url });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Tauri openUrl failed, trying fallback:', err);
|
console.error('Tauri openUrl failed, trying fallback:', err);
|
||||||
// Fallback: navigate the WebView (Google will block, but best effort)
|
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function processDeepLinkUrl(url: string): void {
|
// ── Deep link handling (no full reload) ─────────────────────────────────
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
const target = u.pathname + u.search + u.hash;
|
|
||||||
if (target && target !== '/') {
|
|
||||||
window.location.href = target;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch { /* fall through */ }
|
|
||||||
|
|
||||||
// Fallback: handle both :// and :/ custom schemes
|
const DEEP_LINK_KEY = 'zeavis_pending_deeplink';
|
||||||
let match = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/);
|
const DEEP_LINK_EVENT = 'zeavis:deeplink';
|
||||||
if (!match) match = url.match(/^[^:]+:\/(\/.*)?$/);
|
|
||||||
if (match?.[1]) window.location.href = match[1];
|
/** Store a target path for the React Router to pick up without page reload. */
|
||||||
|
function storeDeepLinkTarget(target: string): void {
|
||||||
|
try { sessionStorage.setItem(DEEP_LINK_KEY, target); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read and clear the stored deep link target. */
|
||||||
|
export function consumeDeepLinkTarget(): string | null {
|
||||||
|
try {
|
||||||
|
const v = sessionStorage.getItem(DEEP_LINK_KEY);
|
||||||
|
if (v) sessionStorage.removeItem(DEEP_LINK_KEY);
|
||||||
|
return v;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function setupDeepLinkHandler(): Promise<void> {
|
export async function setupDeepLinkHandler(): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const invoke = tauriInvoke();
|
const invoke = tauriInvoke();
|
||||||
|
|
||||||
// Cold-start: app just opened via intent:// or custom scheme
|
// Cold-start: app opened via intent:// (e.g. from Google OAuth callback).
|
||||||
|
// Use window.location.href for this (full page reload) — at cold start there
|
||||||
|
// is no SPA state to lose, so redirecting via location.href avoids orphaned
|
||||||
|
// IPC promises that cause "Cannot read properties of undefined (reading 'runCallback')".
|
||||||
invoke('plugin:deep-link|get_current')
|
invoke('plugin:deep-link|get_current')
|
||||||
.then((urls: any) => {
|
.then((urls: any) => {
|
||||||
if (urls?.[0]) processDeepLinkUrl(urls[0]);
|
if (!urls?.[0]) return;
|
||||||
|
const target = extractDeepLinkTarget(urls[0]);
|
||||||
|
if (target && target !== window.location.pathname + window.location.search + window.location.hash) {
|
||||||
|
window.location.href = target;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { /* plugin may not be registered yet */ });
|
.catch(() => {});
|
||||||
|
|
||||||
// Warm-start: listen for new URLs while app is running
|
// Warm-start: listen for new URLs (already running app).
|
||||||
|
// Use React Router navigate() here since we have SPA state.
|
||||||
const { listen } = await import('@tauri-apps/api/event');
|
const { listen } = await import('@tauri-apps/api/event');
|
||||||
listen('deep-link://new-url', (event: any) => {
|
listen('deep-link://new-url', (event: any) => {
|
||||||
const urls = event.payload as string[];
|
const urls = event.payload as string[];
|
||||||
for (const url of urls) processDeepLinkUrl(url);
|
for (const url of urls) {
|
||||||
|
const target = extractDeepLinkTarget(url);
|
||||||
|
if (target) {
|
||||||
|
storeDeepLinkTarget(target);
|
||||||
|
window.dispatchEvent(new CustomEvent(DEEP_LINK_EVENT, { detail: target }));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Tauri deep-link setup failed:', err);
|
console.error('Tauri deep-link setup failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extract path+query+hash from a deep-link URL. */
|
||||||
|
function extractDeepLinkTarget(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.pathname + u.search + u.hash;
|
||||||
|
} catch {
|
||||||
|
let m = url.match(/^[^:]+:\/\/(?:[^/]+)?(\/.*)?$/);
|
||||||
|
if (!m) m = url.match(/^[^:]+:\/(\/.*)?$/);
|
||||||
|
return m?.[1] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AuthForm } from "@/components/auth-form";
|
import { AuthForm } from "@/components/auth-form";
|
||||||
import { apiClient, setAuthToken } from "@/lib/api-client";
|
import { apiClient, setAuthToken } from "@/lib/api-client";
|
||||||
import { useAuthStore } from "@/store/auth-store";
|
import { useAuthStore } from "@/store/auth-store";
|
||||||
|
import { isTauri } from "@/lib/tauri";
|
||||||
|
|
||||||
function getUrlParam(name: string): string | null {
|
function getUrlParam(name: string): string | null {
|
||||||
return new URLSearchParams(window.location.search).get(name);
|
return new URLSearchParams(window.location.search).get(name);
|
||||||
@@ -13,11 +14,13 @@ export function LoginPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const setUser = useAuthStore((state) => state.setUser);
|
const setUser = useAuthStore((state) => state.setUser);
|
||||||
|
const location = useLocation();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const oauthTokenConsumed = useRef(false);
|
const oauthTokenConsumed = useRef(false);
|
||||||
const [oauthProcessing, setOauthProcessing] = useState(false);
|
const [oauthProcessing, setOauthProcessing] = useState(false);
|
||||||
|
|
||||||
// Handle OAuth callback: the API redirects to /login?token=<session_token>
|
// Handle OAuth callback: the API redirects to /login?token=<session_token>
|
||||||
|
// Must re-run on location.search change (SPA navigates to /login?token=xxx)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getUrlParam("token");
|
const token = getUrlParam("token");
|
||||||
if (!token || oauthTokenConsumed.current) return;
|
if (!token || oauthTokenConsumed.current) return;
|
||||||
@@ -39,7 +42,36 @@ export function LoginPage() {
|
|||||||
setOauthProcessing(false);
|
setOauthProcessing(false);
|
||||||
setError(err instanceof Error ? err.message : "Google login gagal");
|
setError(err instanceof Error ? err.message : "Google login gagal");
|
||||||
});
|
});
|
||||||
}, [setUser, queryClient, navigate]);
|
}, [setUser, queryClient, navigate, location.search]);
|
||||||
|
|
||||||
|
// Backup: when app returns from background (e.g. after Google OAuth browser)
|
||||||
|
// re-check URL params — the deep-link event may have been missed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return;
|
||||||
|
if (getUrlParam("token") || oauthTokenConsumed.current) return;
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
const token = getUrlParam("token");
|
||||||
|
if (token && !oauthTokenConsumed.current) {
|
||||||
|
setOauthProcessing(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
const token = getUrlParam("token");
|
||||||
|
if (token && !oauthTokenConsumed.current) {
|
||||||
|
setOauthProcessing(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
window.removeEventListener("focus", onFocus);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Show OAuth error from query param
|
// Show OAuth error from query param
|
||||||
const oauthError = getUrlParam("error");
|
const oauthError = getUrlParam("error");
|
||||||
@@ -48,6 +80,13 @@ export function LoginPage() {
|
|||||||
queryFn: () => apiClient.getMe(),
|
queryFn: () => apiClient.getMe(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Already authenticated — redirect to dashboard
|
||||||
|
useEffect(() => {
|
||||||
|
if (!meQuery.isLoading && meQuery.data?.user) {
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
}
|
||||||
|
}, [meQuery.data, meQuery.isLoading, navigate]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: apiClient.login,
|
mutationFn: apiClient.login,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function ScanPage() {
|
|||||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
// Camera mode state
|
// Camera mode state
|
||||||
const [useCamera, setUseCamera] = useState(false);
|
const [useCamera, setUseCamera] = useState(false);
|
||||||
@@ -164,8 +165,25 @@ export function ScanPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="w-full border-2 border-dashed border-green-300 rounded-md p-10 h-60 text-center cursor-pointer"
|
className={`w-full border-2 border-dashed rounded-md p-10 h-60 text-center cursor-pointer transition-colors duration-200 ${
|
||||||
|
isDragging
|
||||||
|
? "border-blue-500 bg-blue-100"
|
||||||
|
: "border-green-300"
|
||||||
|
}`}
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setIsDragging(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile) {
|
||||||
|
handleFile(droppedFile);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Upload
|
<Upload
|
||||||
className="mx-auto text-green-500 mb-3"
|
className="mx-auto text-green-500 mb-3"
|
||||||
|
|||||||
+1
-1
Submodule telemetry updated: 2582a53592...89d560baf8
Reference in New Issue
Block a user