diff --git a/.github/workflows/telemetry-ci-cd.yml b/.github/workflows/telemetry-ci-cd.yml deleted file mode 100644 index c81b863..0000000 --- a/.github/workflows/telemetry-ci-cd.yml +++ /dev/null @@ -1,241 +0,0 @@ -name: Telemetry CI/CD — Build & Deploy to Orange VPS - -on: - push: - branches: [main, master] - paths: - - 'telemetry/**' - - '.github/workflows/telemetry-ci-cd.yml' - workflow_dispatch: - -env: - GHCR_REGISTRY: ghcr.io - METRIC_INGESTER_IMAGE: mytheclipse/telemetry-metric-ingester - QUERY_PROXY_IMAGE: mytheclipse/telemetry-query-proxy - -jobs: - ############################################################################## - # BUILD: Metric Ingester (Go service) - ############################################################################## - build-metric-ingester: - name: Build - Metric Ingester - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ${{ env.GHCR_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GHCR_PAT }} - - - name: Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.GHCR_REGISTRY }}/${{ env.METRIC_INGESTER_IMAGE }} - tags: | - type=sha,prefix=,suffix=,format=short - - - name: Build & push - uses: docker/build-push-action@v6 - with: - push: true - file: telemetry/deploy/Dockerfile.metric-ingester - context: telemetry - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - ############################################################################## - # BUILD: Query Proxy (Go service) - ############################################################################## - build-query-proxy: - name: Build - Query Proxy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ${{ env.GHCR_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GHCR_PAT }} - - - name: Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.GHCR_REGISTRY }}/${{ env.QUERY_PROXY_IMAGE }} - tags: | - type=sha,prefix=,suffix=,format=short - - - name: Build & push - uses: docker/build-push-action@v6 - with: - push: true - file: telemetry/deploy/Dockerfile.query-proxy - context: telemetry - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - ############################################################################## - # BUILD: Telemetry UI (Vue 3 SPA build verification) - ############################################################################## - build-telemetry-ui: - name: Build - Telemetry UI SPA - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - cache-dependency-path: telemetry/telemetry-ui/package-lock.json - - - run: cd telemetry/telemetry-ui && npm ci - - run: cd telemetry/telemetry-ui && npm run build - - run: echo "Telemetry UI SPA built and verified." - - ############################################################################## - # DEPLOY: to Orange VPS (100.96.248.86) - ############################################################################## - deploy: - name: Deploy to Orange VPS - needs: - - build-metric-ingester - - build-query-proxy - - build-telemetry-ui - runs-on: ubuntu-latest - if: | - always() - && !cancelled() - && !contains(needs.*.result, 'failure') - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: git short-sha - id: sha - run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> "$GITHUB_OUTPUT" - - - name: Build Telemetry UI SPA - working-directory: telemetry/telemetry-ui - run: | - npm ci - npm run build - - - name: Deploy via SSH - env: - VPS_HOST: ${{ secrets.TELEMETRY_VPS_HOST }} - VPS_PORT: ${{ secrets.TELEMETRY_VPS_PORT || '22' }} - VPS_USER: ${{ secrets.TELEMETRY_VPS_USER }} - GHCR_PAT: ${{ secrets.GHCR_PAT }} - SHA: ${{ steps.sha.outputs.sha }} - run: | - # Setup SSH key - mkdir -p ~/.ssh - echo "${{ secrets.TELEMETRY_VPS_SSH_KEY }}" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh-keyscan -p "$VPS_PORT" "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null - - # Prepare directories on VPS - ssh -p "$VPS_PORT" "$VPS_USER@$VPS_HOST" "mkdir -p /opt/telemetry" - - # Sync config files - rsync -avz --delete -e "ssh -p $VPS_PORT" \ - telemetry/prometheus/ \ - "$VPS_USER@$VPS_HOST:/opt/telemetry/prometheus/" - - rsync -avz --delete -e "ssh -p $VPS_PORT" \ - telemetry/metric-ingester/ \ - "$VPS_USER@$VPS_HOST:/opt/telemetry/metric-ingester/" - - rsync -avz --delete -e "ssh -p $VPS_PORT" \ - telemetry/vector/ \ - "$VPS_USER@$VPS_HOST:/opt/telemetry/vector/" - - rsync -avz --delete -e "ssh -p $VPS_PORT" \ - telemetry/clickhouse/ \ - "$VPS_USER@$VPS_HOST:/opt/telemetry/clickhouse/" - - rsync -avz --delete -e "ssh -p $VPS_PORT" \ - telemetry/query-proxy/ \ - "$VPS_USER@$VPS_HOST:/opt/telemetry/query-proxy/" - - rsync -avz --delete -e "ssh -p $VPS_PORT" \ - telemetry/telemetry-ui/dist/ \ - "$VPS_USER@$VPS_HOST:/opt/telemetry/telemetry-ui/dist/" - - rsync -avz --delete -e "ssh -p $VPS_PORT" \ - telemetry/deploy/nginx/ \ - "$VPS_USER@$VPS_HOST:/opt/telemetry/nginx/" - - # Copy and prepare docker-compose.yml - scp -P "$VPS_PORT" telemetry/deploy/docker-compose.yml \ - "$VPS_USER@$VPS_HOST:/opt/telemetry/docker-compose.yml" - - # Run deploy on VPS - ssh -p "$VPS_PORT" "$VPS_USER@$VPS_HOST" bash -s << 'DEPLOYEOF' - set -e - echo '=== Telemetry Deploy to Orange VPS ===' - - cd /opt/telemetry - - # Create .env with secrets (injected by GHA) - cat > .env << ENVEOF - CLICKHOUSE_USER=${CLICKHOUSE_USER:-telemetry} - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-telemetry} - ENVEOF - - # Login to GHCR - echo '${{ secrets.GHCR_PAT }}' | docker login ghcr.io -u '${{ github.actor }}' --password-stdin - - # Rewrite docker-compose to use SHA-tagged images - SHA='${{ steps.sha.outputs.sha }}' - TMP_FILE=$(mktemp /tmp/telemetry-compose-XXXXXX) - awk -v sha="$SHA" ' - /^[[:space:]]*image:[[:space:]]+ghcr.io\/[Mm]ythe?[Ee]clipse\/telemetry-/ { - sub(/:([^:]*)$/, ":" sha) - } - { print } - ' docker-compose.yml > "$TMP_FILE" - mv "$TMP_FILE" docker-compose.yml - - # Pull SHA-tagged images - docker pull "ghcr.io/mytheclipse/telemetry-metric-ingester:$SHA" || true - docker pull "ghcr.io/mytheclipse/telemetry-query-proxy:$SHA" || true - - # Tear down old stack (preserve volumes) - docker compose down --remove-orphans --volumes=false 2>/dev/null || true - - # Deploy fresh - docker compose up -d --remove-orphans --no-build - sleep 5 - - # Run ClickHouse schema migration - echo '=== Running ClickHouse schema migration ===' - bash clickhouse/init.sh 2>&1 || echo '⚠️ Schema migration had issues (non-fatal)' - echo '=== Schema migration complete ===' - - docker compose ps - echo '=== Deploy Complete ===' -DEPLOYEOF diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 02285f6..4d55f1b 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -14,7 +14,8 @@ export const env = { port: Number(Bun.env.API_PORT ?? 3000), databaseUrl: Bun.env.DATABASE_URL, sessionSecret: Bun.env.SESSION_SECRET, - uploaderBaseUrl: Bun.env.UPLOADER_BASE_URL ?? 'https://upload.asepharyana.tech', + uploaderBaseUrl: Bun.env.UPLOADER_BASE_URL ?? 'https://upload.asepharyana.my.id', + // Note: was 'https://upload.asepharyana.tech' — .tech domain is dead, changed to .my.id mlServiceUrl: Bun.env.ML_SERVICE_URL ?? 'http://127.0.0.1:8001', uploadMaxBytes: Number(Bun.env.UPLOAD_MAX_BYTES ?? 5 * 1024 * 1024), uploadAllowedMimeTypes, diff --git a/apps/api/src/lib/telemetry.ts b/apps/api/src/lib/telemetry.ts index cd55a90..95f5dea 100644 --- a/apps/api/src/lib/telemetry.ts +++ b/apps/api/src/lib/telemetry.ts @@ -44,6 +44,14 @@ export const diagnosisCounter = new Counter({ registers: [registry], }); +export const diagnosisUploadSize = new Histogram({ + name: 'zeavis_api_diagnosis_upload_bytes', + help: 'Distribution of uploaded diagnosis image sizes in bytes', + labelNames: ['status'] as const, + buckets: [1024, 51200, 102400, 204800, 512000, 1048576, 2097152, 5242880], + registers: [registry], +}); + export const authCounter = new Counter({ name: 'zeavis_api_auth_operations_total', help: 'Total authentication operations (login, register, refresh)', @@ -51,6 +59,43 @@ export const authCounter = new Counter({ registers: [registry], }); +export const diseaseCatalogRequests = new Counter({ + name: 'zeavis_api_disease_catalog_requests_total', + help: 'Total disease catalog page views', + labelNames: ['type'] as const, // 'list' | 'detail' + registers: [registry], +}); + +export const expertReviewCounter = new Counter({ + name: 'zeavis_api_expert_reviews_total', + help: 'Total expert reviews submitted', + labelNames: ['verdict'] as const, // 'verified' | 'corrected' + registers: [registry], +}); + +export const dbQueryDuration = new Histogram({ + name: 'zeavis_api_db_query_duration_seconds', + help: 'Histogram of database query durations', + labelNames: ['operation'] as const, + buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1], + registers: [registry], +}); + +export const imageModelInferenceDuration = new Histogram({ + name: 'zeavis_api_ml_inference_duration_seconds', + help: 'Histogram of ML model inference durations (via API->ML service)', + labelNames: ['result'] as const, // 'success' | 'failure' + buckets: [0.05, 0.1, 0.25, 0.5, 0.75, 1, 2, 5, 10], + registers: [registry], +}); + +export const mlServiceRequests = new Counter({ + name: 'zeavis_api_ml_service_requests_total', + help: 'Total requests forwarded to ML service', + labelNames: ['status'] as const, // 'success' | 'error' + registers: [registry], +}); + // ── Export ────────────────────────────────────────────── export function getMetricsContentType(): string { diff --git a/apps/ml-service/src/routes.rs b/apps/ml-service/src/routes.rs index edd9033..3d0cc09 100644 --- a/apps/ml-service/src/routes.rs +++ b/apps/ml-service/src/routes.rs @@ -130,18 +130,33 @@ pub async fn predict( }; // Preprocess the image + let preprocess_start = std::time::Instant::now(); let input = preprocess_image(&bytes, state.model.input_size())?; - // Run prediction - let prediction = state.model.predict(input)?; + // Record image size metric + telemetry::image_size_bytes().observe(bytes.len() as f64); - // Record business and request telemetry + // Run prediction with timing + let inference_start = std::time::Instant::now(); + let prediction = state.model.predict(input)?; + telemetry::inference_duration_seconds().observe(inference_start.elapsed().as_secs_f64()); + + // Record business telemetry telemetry::predictions_total().inc(); + telemetry::predictions_by_class() + .with_label_values(&[&prediction.label]) + .inc(); + telemetry::predictions_confidence().observe(prediction.confidence as f64); _guard.finish(); Ok(Json(prediction_response(prediction))) } +/// Helper to record errors from route handlers +pub fn record_error(kind: &str) { + telemetry::errors_total().with_label_values(&[kind]).inc(); +} + pub fn router(state: AppState) -> Router { Router::new() .route("/health", get(health)) diff --git a/apps/ml-service/src/telemetry.rs b/apps/ml-service/src/telemetry.rs index 6a08d15..44c36fc 100644 --- a/apps/ml-service/src/telemetry.rs +++ b/apps/ml-service/src/telemetry.rs @@ -1,4 +1,4 @@ -use prometheus::{Counter, Gauge, Histogram, HistogramOpts, Registry, TextEncoder}; +use prometheus::{Counter, CounterVec, Gauge, Histogram, HistogramOpts, HistogramVec, Opts, Registry, TextEncoder}; use std::sync::OnceLock; use std::time::Instant; @@ -78,6 +78,76 @@ define_metric!( .expect("create gauge") ); +/// Per-class prediction counter +define_metric!( + predictions_by_class, + CounterVec, + CounterVec::new( + Opts::new( + "zeavis_ml_predictions_by_class_total", + "Total predictions by predicted class label", + ), + &["label"], + ) + .expect("create counter_vec") +); + +/// Per-class ground-truth counter (for monitoring label distribution) +define_metric!( + predictions_confidence, + Histogram, + Histogram::with_opts( + HistogramOpts::new( + "zeavis_ml_prediction_confidence", + "Confidence values of predictions", + ) + .buckets(vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 0.99, 1.0]), + ) + .expect("create histogram") +); + +/// Latency of ONNX inference (model.predict call) +define_metric!( + inference_duration_seconds, + Histogram, + Histogram::with_opts( + HistogramOpts::new( + "zeavis_ml_inference_duration_seconds", + "ONNX model inference duration in seconds", + ) + .buckets(vec![0.01, 0.025, 0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0, 2.0]), + ) + .expect("create histogram") +); + +/// Image size processed by the ML service +define_metric!( + image_size_bytes, + Histogram, + Histogram::with_opts( + HistogramOpts::new( + "zeavis_ml_image_size_bytes", + "Size of images sent for prediction in bytes", + ) + .buckets(vec![1024.0, 10240.0, 51200.0, 102400.0, 204800.0, 512000.0, 1048576.0, 2097152.0]), + ) + .expect("create histogram") +); + +/// Error counter by error kind (e.g. bad_request, model_error, internal) +define_metric!( + errors_total, + CounterVec, + CounterVec::new( + Opts::new( + "zeavis_ml_errors_total", + "Total errors by kind", + ), + &["kind"], + ) + .expect("create counter_vec") +); + // ── Request Guard (Drop-based cleanup for active gauge) ─ pub struct RequestMetricsGuard { diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index a78644d..2a84fb8 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -37,7 +37,7 @@ function LogoutProses() { return ; } -import { trackPageView } from "./lib/telemetry"; +import { trackPageView, trackError } from "./lib/telemetry"; const queryClient = new QueryClient(); @@ -149,11 +149,23 @@ function PageViewTracker() { return null; } +function GlobalErrorTracker() { + useEffect(() => { + const handler = (event: ErrorEvent) => { + trackError(event.filename || "global"); + }; + window.addEventListener("error", handler); + return () => window.removeEventListener("error", handler); + }, []); + return null; +} + export function App() { return ( + ); diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index e24c6c7..fa1b7fa 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -12,6 +12,7 @@ import type { ReviewDiagnosisRequest, DashboardSummary, } from '@zeavis/shared'; +import { recordApiCall } from './telemetry'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''; @@ -21,6 +22,7 @@ export interface ApiError extends Error { } async function fetchApi(endpoint: string, options?: RequestInit): Promise { + const start = performance.now(); const url = `${apiBaseUrl}${endpoint}`; const response = await fetch(url, { credentials: 'include', @@ -28,6 +30,10 @@ async function fetchApi(endpoint: string, options?: RequestInit): Promise headers: options?.headers, }); + const duration = performance.now() - start; + const method = options?.method ?? 'GET'; + recordApiCall(method, endpoint, duration, response.status); + if (!response.ok) { let errorMessage = `HTTP ${response.status}`; let source: 'uploader' | 'model-service' | 'unknown' | undefined; diff --git a/apps/web/src/lib/telemetry.ts b/apps/web/src/lib/telemetry.ts index 46f3255..cdaf84d 100644 --- a/apps/web/src/lib/telemetry.ts +++ b/apps/web/src/lib/telemetry.ts @@ -2,8 +2,10 @@ * Client‑side telemetry for the ZeaVis Edu web app. * * In development, metrics are collected in‑memory and exposed at /metrics - * via a Vite plugin. In production they are sent as HTTP beacons to the - * Telemetry pipeline (see METRICS.md). + * via a Vite plugin. In production they are served through the same plugin + * (or proxied by nginx in production mode). + * + * Metric name prefix: zeavis_web_ */ // ── Web Vitals ────────────────────────────────────────── @@ -18,35 +20,99 @@ const vitalsBuffer: MetricEntry[] = []; export function reportWebVitals(metric: MetricEntry): void { vitalsBuffer.push(metric); - // Keep last 20 entries in memory for the /metrics endpoint - if (vitalsBuffer.length > 20) vitalsBuffer.shift(); - console.debug(`[telemetry] ${metric.name}: ${metric.value} (${metric.rating ?? 'n/a'})`); + if (vitalsBuffer.length > 30) vitalsBuffer.splice(0, vitalsBuffer.length - 30); } // ── Page‑view counter ─────────────────────────────────── let pageViewCount = 0; +const routeViews: Record = {}; export function trackPageView(path: string): void { pageViewCount++; - console.debug(`[telemetry] pageview: ${path} (total: ${pageViewCount})`); + routeViews[path] = (routeViews[path] || 0) + 1; } -// ── Metrics serialisation (consumed by vite‑plugin) ──── +// ── API call timing ───────────────────────────────────── +// Track how long API calls take from the browser side + +const apiLatencies: number[] = []; +const MAX_API_SAMPLES = 100; + +export function recordApiCall(method: string, path: string, durationMs: number, status: number): void { + apiLatencies.push(durationMs); + if (apiLatencies.length > MAX_API_SAMPLES) apiLatencies.shift(); + console.debug(`[telemetry] api ${method} ${path} → ${status} (${durationMs.toFixed(0)}ms)`); +} + +// ── Error tracking (client-side JS errors) ────────────── + +let errorCount = 0; + +export function trackError(source: string): void { + errorCount++; + console.debug(`[telemetry] error from ${source} (total: ${errorCount})`); +} + +// ── Diagnosis actions ─────────────────────────────────── + +let scanCount = 0; +let diagnosisSuccess = 0; +let diagnosisFailure = 0; + +export function trackScan(): void { + scanCount++; +} + +export function trackDiagnosisResult(success: boolean): void { + if (success) diagnosisSuccess++; + else diagnosisFailure++; +} + +// ── Metrics serialisation (consumed by vite-plugin) ──── export function collectMetrics(): string { const lines: string[] = []; - // ── Default process‑like metrics ────────────────────── lines.push('# HELP zeavis_web_page_views_total Total page views'); lines.push('# TYPE zeavis_web_page_views_total counter'); lines.push(`zeavis_web_page_views_total ${pageViewCount}`); - lines.push('# HELP zeavis_web_vital_bucket Web Vitals observed this session'); - lines.push('# TYPE zeavis_web_vital_bucket gauge'); - for (const v of vitalsBuffer) { - lines.push(`zeavis_web_vital_bucket{name="${v.name}",rating="${v.rating ?? 'unknown'}"} ${v.value}`); + lines.push('# HELP zeavis_web_route_views_total Page views per route'); + lines.push('# TYPE zeavis_web_route_views_total counter'); + for (const [route, count] of Object.entries(routeViews)) { + lines.push(`zeavis_web_route_views_total{route="${route}"} ${count}`); } + lines.push('# HELP zeavis_web_vital Web Vitals observed this session'); + lines.push('# TYPE zeavis_web_vital gauge'); + for (const v of vitalsBuffer) { + lines.push(`zeavis_web_vital{name="${v.name}",rating="${v.rating ?? 'unknown'}"} ${v.value}`); + } + + if (apiLatencies.length > 0) { + const avg = apiLatencies.reduce((a, b) => a + b, 0) / apiLatencies.length; + lines.push('# HELP zeavis_web_api_call_duration_ms Average API call duration from browser'); + lines.push('# TYPE zeavis_web_api_call_duration_ms gauge'); + lines.push(`zeavis_web_api_call_duration_ms ${avg.toFixed(2)}`); + } + + lines.push('# HELP zeavis_web_client_errors_total Client-side JS errors'); + lines.push('# TYPE zeavis_web_client_errors_total counter'); + lines.push(`zeavis_web_client_errors_total ${errorCount}`); + + lines.push('# HELP zeavis_web_scans_total Scan button clicks'); + lines.push('# TYPE zeavis_web_scans_total counter'); + lines.push(`zeavis_web_scans_total ${scanCount}`); + + lines.push('# HELP zeavis_web_diagnoses_total Diagnosis results from browser'); + lines.push('# TYPE zeavis_web_diagnoses_total counter'); + lines.push(`zeavis_web_diagnoses_total{result="success"} ${diagnosisSuccess}`); + lines.push(`zeavis_web_diagnoses_total{result="failure"} ${diagnosisFailure}`); + + lines.push('# HELP zeavis_web_active_users User activity (1 = active this session)'); + lines.push('# TYPE zeavis_web_active_users gauge'); + lines.push(`zeavis_web_active_users 1`); + return lines.join('\n') + '\n'; } diff --git a/apps/web/src/pages/scan-page.tsx b/apps/web/src/pages/scan-page.tsx index 4bb9e61..f94ba4d 100644 --- a/apps/web/src/pages/scan-page.tsx +++ b/apps/web/src/pages/scan-page.tsx @@ -18,6 +18,7 @@ import { Modal } from "@/components/ui/modal"; import { DiagnosisStatusBadge } from "@/components/diagnosis-status-badge"; import type { DiagnosisRecord } from "@zeavis/shared"; import { apiClient } from "@/lib/api-client"; +import { trackScan, trackDiagnosisResult } from "@/lib/telemetry"; export function ScanPage() { const [fileName, setFileName] = useState(null); @@ -31,6 +32,7 @@ export function ScanPage() { const mutation = useMutation({ mutationFn: (file: File) => apiClient.createDiagnosis(file), onSuccess: (diagnosis) => { + trackDiagnosisResult(diagnosis.status !== "failed"); queryClient.invalidateQueries({ queryKey: ["diagnoses"] }); setDiagnosisPreview(diagnosis); setPreviewOpen(true); @@ -38,6 +40,9 @@ export function ScanPage() { setPreviewUrl(null); if (inputRef.current) inputRef.current.value = ""; }, + onError: () => { + trackDiagnosisResult(false); + }, }); const handleFile = (f?: File) => { @@ -61,6 +66,7 @@ export function ScanPage() { const handleUpload = () => { const file = inputRef.current?.files?.[0]; if (!file) return; + trackScan(); mutation.mutate(file); }; diff --git a/apps/web/src/pages/telemetry-page.tsx b/apps/web/src/pages/telemetry-page.tsx index df6bf3b..ac45183 100644 --- a/apps/web/src/pages/telemetry-page.tsx +++ b/apps/web/src/pages/telemetry-page.tsx @@ -1,229 +1,119 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { - BarChart3, - Activity, - Cpu, - HardDrive, - Database, - Layers, - RefreshCw, - AlertTriangle, + BarChart3, Activity, Cpu, HardDrive, Database, + RefreshCw, Server, Wifi, Layers, } from "lucide-react"; import { - AreaChart, - Area, - XAxis, - YAxis, - CartesianGrid, - Tooltip, + AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, - BarChart, - Bar, } from "recharts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -// ─── Types ────────────────────────────────────────────────────────── -const API_BASE = "https://telemetry.imrnes.team/proxy/dashboard"; +// ─── Prometheus API ───────────────────────────────────────────────── +const PROM = "https://telemetry.imrnes.team/prometheus/api/v1"; +const INST = "100.96.248.86"; // orange VPS -interface DashboardStats { - cpu_usage: number; - disk_usage: number; - total_metrics: number; - active_services: number; - uptime_seconds: number; - health: { disk_readonly: boolean; errors: number }; -} - -interface DiscoveredMetric { - metric_name: string; - service: string; - sample_count: number; - latest_value: number; -} - -interface ChartPoint { +interface PromValue { time: string; value: number; } +async function queryRange(query: string, steps = 60): Promise { + const now = Math.floor(Date.now() / 1000); + const start = now - 3600; + const q = `query=${encodeURIComponent(query)}&start=${start}&end=${now}&step=${steps}`; + const res = await fetch(`${PROM}/query_range?${q}`); + if (!res.ok) throw new Error(`Prometheus: ${res.status}`); + const data = await res.json(); + const results = data?.data?.result ?? []; + if (results.length === 0) return []; + return results[0].values.map((v: [number, string]) => ({ + time: new Date(v[0] * 1000).toISOString(), + value: parseFloat(v[1]), + })); +} + +async function queryInstant(query: string): Promise { + const res = await fetch(`${PROM}/query?query=${encodeURIComponent(query)}`); + if (!res.ok) return null; + const data = await res.json(); + const results = data?.data?.result ?? []; + if (results.length === 0) return null; + return parseFloat(results[0].value[1]); +} + // ─── Helpers ───────────────────────────────────────────────────────── -function fmt(n: number): string { - if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"; - if (n >= 1_000) return (n / 1_000).toFixed(1) + "K"; - return n.toFixed(1); -} - -function fmtDuration(s: number): string { - const d = Math.floor(s / 86400); - const h = Math.floor((s % 86400) / 3600); - if (d > 0) return `${d}d ${h}h`; - const m = Math.floor((s % 3600) / 60); - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} - function fmtPct(v: number): string { - return (v * 100).toFixed(1) + "%"; + return v.toFixed(1) + "%"; } -// ─── API Client ────────────────────────────────────────────────────── -class TelemetryAPI { - private base: string; - constructor(base: string) { - this.base = base; - } - - async stats(): Promise { - const res = await fetch(`${this.base}/stats`); - if (!res.ok) throw new Error(`Stats API: ${res.status}`); - return res.json(); - } - - async discover(): Promise { - const res = await fetch(`${this.base}/discover`); - if (!res.ok) throw new Error(`Discover API: ${res.status}`); - const data = await res.json(); - return data.metrics ?? []; - } - - async charts( - panels: { key: string; metric: string; aggregation?: string }[], - ): Promise> { - const now = Math.floor(Date.now() / 1000); - const oneHourAgo = now - 3600; - const res = await fetch(`${this.base}/charts`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - panels: panels.map((p) => ({ - key: p.key, - metric: p.metric, - start: oneHourAgo, - end: now, - aggregation: p.aggregation ?? "avg", - })), - }), - }); - if (!res.ok) throw new Error(`Charts API: ${res.status}`); - const data = await res.json(); - const map = new Map(); - for (const r of data.results ?? []) { - map.set(r.key, r.data ?? []); - } - return map; - } +function fmtBytes(v: number): string { + if (v >= 1 << 30) return (v / (1 << 30)).toFixed(1) + " GiB"; + if (v >= 1 << 20) return (v / (1 << 20)).toFixed(1) + " MiB"; + if (v >= 1 << 10) return (v / (1 << 10)).toFixed(1) + " KiB"; + return v.toFixed(0) + " B"; } -const api = new TelemetryAPI(API_BASE); - -// ─── Stat Card ─────────────────────────────────────────────────────── -function StatCard({ - icon: Icon, - label, - value, - sub, - color, -}: { - icon: typeof BarChart3; - label: string; - value: string; - sub?: string; - color: string; +// ─── UI Components ────────────────────────────────────────────────── +function StatCard({ icon: Icon, label, value, sub, color }: { + icon: typeof BarChart3; label: string; value: string; sub?: string; color: string; }) { return ( - - - {label} - + + {label} - +
{value}
- {sub &&

{sub}

} + {sub &&

{sub}

}
); } -// ─── Metric Card ───────────────────────────────────────────────────── -function MetricChart({ - title, - data, - loading, - color, -}: { - title: string; - data: ChartPoint[]; - loading: boolean; - color: string; +function ChartCard({ title, data, color, unit = "", domain, valueFormatter }: { + title: string; data: PromValue[]; color: string; unit?: string; domain?: [number, number]; + valueFormatter?: (v: number) => string; }) { - if (loading) { - return ( - - - {title} - - -
- Loading... -
-
-
- ); - } - if (!data || data.length === 0) { return ( - + {title} - -
- No data available -
+ +
No data
); } - return ( - + {title} - - + + - + - { - const d = new Date(v); - return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`; - }} - /> - + + new Date(v).toLocaleTimeString()} - // eslint-disable-next-line @typescript-eslint/no-explicit-any - formatter={(val: any) => [typeof val === "number" ? val.toFixed(2) : String(val ?? ""), title]} - /> - { + const v = typeof val === "number" ? val : 0; + return [valueFormatter ? valueFormatter(v) : v.toFixed(2), title]; + }} /> + @@ -231,76 +121,141 @@ function MetricChart({ ); } +function GaugeCard({ label, value, max, unit, color }: { + label: string; value: number; max: number; unit: string; color: string; +}) { + const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0; + return ( + + + {label} + + +
+ {typeof value === "number" ? value.toFixed(1) : "?"} + {unit} +
+
+
+
+ + + ); +} + +// ─── Section Header ───────────────────────────────────────────────── +function SectionTitle({ icon: Icon, title, color }: { icon: typeof Layers; title: string; color?: string }) { + return ( +

+ {title} +

+ ); +} + // ─── Main Page ─────────────────────────────────────────────────────── export function TelemetryPage() { - const [stats, setStats] = useState(null); - const [metrics, setMetrics] = useState([]); - const [chartMap, setChartMap] = useState>(new Map()); - const [memChartData, setMemChartData] = useState(null); + // System + const [cpuData, setCpuData] = useState([]); + const [memData, setMemData] = useState([]); + const [diskData, setDiskData] = useState([]); + const [cpuNow, setCpuNow] = useState(null); + const [memNow, setMemNow] = useState(null); + const [diskNow, setDiskNow] = useState(null); + const [loadNow, setLoadNow] = useState(null); + const [netRx, setNetRx] = useState(null); + const [netTx, setNetTx] = useState(null); + const [netRxData, setNetRxData] = useState([]); + const [netTxData, setNetTxData] = useState([]); + + // ZeaVis API + const [apiReqsTotal, setApiReqsTotal] = useState(null); + const [apiReqsActive, setApiReqsActive] = useState(null); + const [apiLatency, setApiLatency] = useState(null); + const [apiReqsData, setApiReqsData] = useState([]); + const [apiLatencyData, setApiLatencyData] = useState([]); + + // ML + const [mlModelLoaded, setMlModelLoaded] = useState(null); + + // NodeJS + const [heapUsed, setHeapUsed] = useState(null); + const [heapTotal, setHeapTotal] = useState(null); + const [eventLoopLag, setEventLoopLag] = useState(null); + const [activeHandles, setActiveHandles] = useState(null); + const [activeRequests, setActiveRequests] = useState(null); + const [heapData, setHeapData] = useState([]); + const [elLagData, setElLagData] = useState([]); + + // Process + const [procCpu, setProcCpu] = useState(null); + const [procMem, setProcMem] = useState(null); + const [procFds, setProcFds] = useState(null); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); - const intervalRef = useRef(undefined); + const intRef = useRef(0); - const fetchData = useCallback(async (isRefresh = false) => { + const fetchAll = useCallback(async (isRefresh = false) => { try { - if (isRefresh) setRefreshing(true); - else setLoading(true); + if (isRefresh) setRefreshing(true); else setLoading(true); setError(null); - const [statsData, discoverData] = await Promise.all([ - api.stats(), - api.discover(), + const [ + cpuR, memR, diskR, + cpuN, memN, diskN, loadN, + netRxR, netTxR, netRxN, netTxN, + apiReqsN, apiActiveN, apiLatN, apiReqsR, apiLatR, + mlN, + heapN, heapTN, elN, ahN, arN, heapR, elR, + procCpuN, procMemN, procFdsN, + ] = await Promise.all([ + // Range queries + queryRange(`100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="${INST}:9100"}[5m])) * 100)`, 60), + queryRange(`(1 - node_memory_MemAvailable_bytes{instance="${INST}:9100"} / node_memory_MemTotal_bytes{instance="${INST}:9100"}) * 100`, 60), + queryRange(`(1 - node_filesystem_avail_bytes{instance="${INST}:9100",mountpoint="/"} / node_filesystem_size_bytes{instance="${INST}:9100",mountpoint="/"}) * 100`, 60), + // Instant queries - system + queryInstant(`100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="${INST}:9100"}[5m])) * 100)`), + queryInstant(`(1 - node_memory_MemAvailable_bytes{instance="${INST}:9100"} / node_memory_MemTotal_bytes{instance="${INST}:9100"}) * 100`), + queryInstant(`(1 - node_filesystem_avail_bytes{instance="${INST}:9100",mountpoint="/"} / node_filesystem_size_bytes{instance="${INST}:9100",mountpoint="/"}) * 100`), + queryInstant(`node_load15{instance="${INST}:9100"}`), + // Network + queryRange(`rate(node_network_receive_bytes_total{instance="${INST}:9100",device="eth0"}[5m])`, 60), + queryRange(`rate(node_network_transmit_bytes_total{instance="${INST}:9100",device="eth0"}[5m])`, 60), + 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), + // ML + queryInstant(`zeavis_ml_zeavis_ml_model_load_status{instance="${INST}:8000"}`), + // 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), + // 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"}`), ]); - setStats(statsData); - setMetrics(discoverData); - - // Fetch charts for top metrics (excluding prometheus noise) - const topMetrics = discoverData - .filter((m) => !m.metric_name.startsWith("prometheus_") && m.service !== "prometheus") - .slice(0, 4); - - const chartPanels = topMetrics.map((m) => ({ - key: m.metric_name, - metric: m.metric_name, - })); - - // Built-in computations + app metrics - chartPanels.unshift({ key: "_cpu_usage_pct", metric: "_cpu_usage_pct" }); - chartPanels.unshift({ key: "_disk_usage_pct", metric: "_disk_usage_pct" }); - - // Memory charts (raw values, % computed client-side) - chartPanels.push({ key: "node_memory_MemTotal_bytes", metric: "node_memory_MemTotal_bytes" }); - chartPanels.push({ key: "node_memory_MemAvailable_bytes", metric: "node_memory_MemAvailable_bytes" }); - - // App-specific metrics - for (const appMetric of ["zeavis_api_http_requests_total", "zeavis_api_http_requests_active"]) { - if (discoverData.find((m) => m.metric_name === appMetric)) { - chartPanels.push({ key: appMetric, metric: appMetric }); - } - } - - const charts = await api.charts(chartPanels); - setChartMap(charts); - - // Compute memory usage % = (total - available) / total - const memTotalData = charts.get("node_memory_MemTotal_bytes"); - const memAvailData = charts.get("node_memory_MemAvailable_bytes"); - if (memTotalData && memAvailData && memTotalData.length > 0 && memAvailData.length > 0) { - const merged: ChartPoint[] = []; - for (let i = 0; i < Math.min(memTotalData.length, memAvailData.length); i++) { - const total = memTotalData[i].value; - const avail = memAvailData[i].value; - if (total > 0) { - merged.push({ time: memTotalData[i].time, value: 1 - avail / total }); - } - } - setMemChartData(merged); - } else { - setMemChartData([]); - } - setChartMap(charts); + setCpuData(cpuR); setMemData(memR); setDiskData(diskR); + setCpuNow(cpuN); setMemNow(memN); setDiskNow(diskN); setLoadNow(loadN); + setNetRx(netRxN); setNetTx(netTxN); setNetRxData(netRxR); setNetTxData(netTxR); + setApiReqsTotal(apiReqsN); setApiReqsActive(apiActiveN); setApiLatency(apiLatN); + setApiReqsData(apiReqsR); setApiLatencyData(apiLatR); + setMlModelLoaded(mlN); + setHeapUsed(heapN); setHeapTotal(heapTN); setEventLoopLag(elN); + setActiveHandles(ahN); setActiveRequests(arN); + setHeapData(heapR); setElLagData(elR); + setProcCpu(procCpuN); setProcMem(procMemN); setProcFds(procFdsN); } catch (e) { setError(e instanceof Error ? e.message : "Unknown error"); } finally { @@ -310,57 +265,17 @@ export function TelemetryPage() { }, []); useEffect(() => { - fetchData(); - intervalRef.current = window.setInterval(() => fetchData(true), 30_000); - return () => { - if (intervalRef.current) clearInterval(intervalRef.current); - }; - }, [fetchData]); + fetchAll(); + intRef.current = window.setInterval(() => fetchAll(true), 30_000); + return () => clearInterval(intRef.current); + }, [fetchAll]); - const serviceCounts = useMemo(() => { - const counts = new Map(); - for (const m of metrics) { - counts.set(m.service, (counts.get(m.service) ?? 0) + 1); - } - return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]); - }, [metrics]); - - const topMetrics = useMemo(() => { - return metrics.slice(0, 10); - }, [metrics]); - - if (loading && !stats) { + if (loading && cpuNow === null) { return ( -
-
-
-
-

- Loading telemetry data... -

-
-
-
- ); - } - - if (error && !stats) { - return ( -
-
-
- -

- Failed to load telemetry data -

-

{error}

- -
+
+
+
+

Loading telemetry data...

); @@ -372,199 +287,82 @@ export function TelemetryPage() {

- + Telemetry Dashboard

-

- System metrics from Prometheus pipeline via ClickHouse. - {error && ( - - (partial data — {error}) - - )} +

+ Real-time metrics from Prometheus + {error && (partial — {error})}

-
- {/* Stat Cards */} - {stats && ( -
- - - - 0 - ? `${stats.health.errors} errors` - : "All healthy" - } - color={stats.health.errors > 0 ? "text-red-600" : "text-green-600"} - /> -
- )} - - {/* CPU, Memory, Disk charts */} -
- - - + {/* ===== SYSTEM ===== */} + +
+ + + + + + +
+
+ + + + +
+
+ +
- {/* Application Metrics */} - {(chartMap.has("zeavis_api_http_requests_total") || chartMap.has("zeavis_api_http_requests_active")) && ( -
-

Application Metrics

-
- {chartMap.has("zeavis_api_http_requests_total") && ( - - )} - {chartMap.has("zeavis_api_http_requests_active") && ( - - )} -
-
- )} + {/* ===== APPLICATION ===== */} + +
+ + + + + +
+
+ + + +
- {/* Top Metrics */} - - - - - Top Metrics - - - - {topMetrics.length === 0 ? ( -
- No metrics discovered yet -
- ) : ( -
- - - - - - - - - - - {topMetrics.map((m) => ( - - - - - - - ))} - -
- Metric - - Service - - Samples - - Latest Value -
- {m.metric_name} - - - {m.service} - - - {fmt(m.sample_count)} - - {typeof m.latest_value === "number" - ? m.latest_value.toFixed(4) - : String(m.latest_value)} -
-
- )} -
-
+ {/* ===== ML SERVICE ===== */} + +
+ + + + +
- {/* Service Distribution */} - - - - - Services - - - - {serviceCounts.length === 0 ? ( -
- No services discovered -
- ) : ( - - ({ name, count }))}> - - - - - - - - )} -
-
+ {/* ===== NODEJS DETAIL ===== */} + +
+ + + + + + +
+
+ (v / 1024 / 1024).toFixed(1) + " MiB"} /> + (v * 1000).toFixed(2) + " ms"} /> +
); }