From e26b426c2e66bffebdaf8b930f588642ab365d32 Mon Sep 17 00:00:00 2001 From: Madava Date: Fri, 28 Nov 2025 02:23:41 +0100 Subject: [PATCH] Update frontend dashboard and health service --- app/README.md | 15 +- app/backend/operations/health/service.py | 4 +- app/frontend/Dockerfile | 8 +- app/frontend/app/globals.css | 322 +--- app/frontend/app/layout.tsx | 8 +- app/frontend/app/page.tsx | 728 ++++++--- app/frontend/components/ECGMonitorCard.tsx | 162 ++ app/frontend/eslint.config.mjs | 44 + app/frontend/package-lock.json | 1546 +++++++++++++++++--- app/frontend/package.json | 29 +- app/frontend/postcss.config.mjs | 6 + app/frontend/public/.gitkeep | 1 - app/frontend/tsconfig.json | 20 +- app/frontend/types/monitor.ts | 7 + tmp/Dockerfile | 71 + 15 files changed, 2309 insertions(+), 662 deletions(-) create mode 100644 app/frontend/components/ECGMonitorCard.tsx create mode 100644 app/frontend/eslint.config.mjs create mode 100644 app/frontend/postcss.config.mjs delete mode 100644 app/frontend/public/.gitkeep create mode 100644 app/frontend/types/monitor.ts create mode 100644 tmp/Dockerfile diff --git a/app/README.md b/app/README.md index fc8f3e0..2d81ed7 100644 --- a/app/README.md +++ b/app/README.md @@ -1,13 +1,13 @@ # 1. Run Applicaiton -1. Removes all cached Python packages stored by pip, removes local Python cache files, clears the cache used by uv, and forcibly clear the cache for Node.js. +1. Remove all cached Python packages stored by pip, remove local Python cache files, clear the cache used by uv, and forcibly clear the cache for Node.js. ```bash uv tool install cleanpy pip cache purge && cleanpy . && uv cache clean && npm cache clean --force ``` -2. Resolve dependencies from your *pyproject.toml* and upgrade all packages. Synchronize the virtual environment with the dependencies specified in the *uv.lock* including packages needed for development. +2. Resolve dependencies from *pyproject.toml* and upgrade all packages. Synchronize the virtual environment with the dependencies specified in the *uv.lock* including packages needed for **development**. ```bash cd backend @@ -21,16 +21,15 @@ uv run ruff check --fix && uv run pytest ``` -4. Starts a local development API server, visible at port 8000, and automatically reloads the server as you make code changes. +4. Start a local **development** API server, visible at port 8000, and automatically reloads the server when code changes are made. ```bash uv run uvicorn src.main:app --reload --port 8000 ``` -5. Scans dependencies for security vulnerabilities and attempts to automatically fix them by force-updating to the latest secure versions. +5. Open a new terminal. Scan dependencies for security vulnerabilities and attempt to automatically fix them by force-updating to the latest secure versions. ```bash - cd .. cd frontend npm audit fix --force ``` @@ -38,13 +37,11 @@ 6. Install dependencies from *package.json*, then update those dependencies to the latest allowed versions based on version ranges. Next, check the source code for stylistic and syntax errors according to configured rules. Finally, compile or bundle the application for deployment or production use. ```bash - cd frontend npm install && npm update && npm run lint && npm run build ``` -7. Execute start script in *package.json*, launch your Node.js application in production mode. +7. Execute start script in *package.json*, launch Node.js application in **development** mode. ```bash - cd frontend - npm run start + npm run dev ``` diff --git a/app/backend/operations/health/service.py b/app/backend/operations/health/service.py index f7e56e3..4c36589 100644 --- a/app/backend/operations/health/service.py +++ b/app/backend/operations/health/service.py @@ -66,9 +66,9 @@ async def check_database_status() -> ComponentCheck: """ async def db_logic(): # IMPORTANT: Replace this sleep simulation with the actual async DB client call (e.g., await database.ping()) - await asyncio.sleep(0.042) + await asyncio.sleep(0.045) - return await _run_check_with_timeout(db_logic(), name="postgres", timeout_ms=100) + return await _run_check_with_timeout(db_logic(), name="postgres", timeout_ms=50) async def check_media_server_status() -> ComponentCheck: """ diff --git a/app/frontend/Dockerfile b/app/frontend/Dockerfile index ca0d5a5..df6a594 100644 --- a/app/frontend/Dockerfile +++ b/app/frontend/Dockerfile @@ -13,14 +13,16 @@ FROM node:22-slim AS base WORKDIR /app ENV NPM_CONFIG_LOGLEVEL=warn \ - NODE_OPTIONS="--enable-source-maps" + NODE_OPTIONS="--enable-source-maps" \ + NEXT_TELEMETRY_DISABLED=1 # ------------------------------------------------------------------------------ # Dependencies cache # ------------------------------------------------------------------------------ FROM base AS deps COPY package*.json ./ -RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi \ + && chown -R node:node /app # ------------------------------------------------------------------------------ # Production dependencies only (pruned to omit dev tooling) @@ -37,6 +39,7 @@ FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build # ------------------------------------------------------------------------------ @@ -53,6 +56,7 @@ COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules COPY --from=builder --chown=node:node /app/.next ./.next COPY --from=builder --chown=node:node /app/public ./public COPY --from=builder --chown=node:node /app/package.json ./package.json +COPY --from=builder --chown=node:node /app/package-lock.json ./package-lock.json EXPOSE 3000 CMD ["npm", "run", "start"] diff --git a/app/frontend/app/globals.css b/app/frontend/app/globals.css index 142a74a..03a45b9 100644 --- a/app/frontend/app/globals.css +++ b/app/frontend/app/globals.css @@ -1,305 +1,35 @@ -:root { - color-scheme: dark; - font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", +@import "tailwindcss"; + +@source "./app/**/*.{js,ts,jsx,tsx,mdx}"; +@source "./components/**/*.{js,ts,jsx,tsx,mdx}"; + +@theme { + --color-ink: #0f172a; + --color-inkSoft: #1e293b; + --color-inkMuted: #64748b; + --color-sand: #f7f7fb; + --color-card: #ffffff; + --color-success: #22c55e; + --color-danger: #ef4444; + --color-pulse: #f59e0b; + --color-accent-blue: #60a5fa; + --color-accent-mint: #34d399; + --color-accent-coral: #fb7185; + --font-sans: "Plus Jakarta Sans", "Inter", system-ui, -apple-system, "Segoe UI", + sans-serif; + --font-display: "Plus Jakarta Sans", "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; - line-height: 1.5; - background: #04060d; - color: #f6f7fb; } -* { - box-sizing: border-box; +:root { + color-scheme: light; + background-color: #f7f7fb; } body { - margin: 0; - min-height: 100vh; - background: radial-gradient(circle at 20% 20%, #182147 0%, transparent 30%), - radial-gradient(circle at 80% 10%, #1d3754 0%, transparent 25%), - radial-gradient(circle at 40% 80%, #1b2744 0%, transparent 30%), - #04060d; + @apply min-h-screen bg-gradient-to-br from-[#f9fafb] via-[#f2f4f6] to-[#e7eaee] text-ink antialiased; } -.page { - position: relative; - max-width: 1200px; - margin: 0 auto; - padding: 40px 24px 80px; -} - -.starfall { - position: fixed; - inset: 0; - pointer-events: none; - opacity: 0; - background-image: radial-gradient(2px 2px at 10% 20%, #d6e9ff, transparent), - radial-gradient(2px 2px at 30% 0%, #c7ddff, transparent), - radial-gradient(2px 2px at 60% 10%, #c0f2ff, transparent), - radial-gradient(2px 2px at 80% 25%, #f3d5ff, transparent), - radial-gradient(2px 2px at 50% 5%, #bfe2ff, transparent), - radial-gradient(2px 2px at 15% 5%, #d6e9ff, transparent); - background-size: 200px 200px, 260px 260px, 220px 220px, 240px 240px, - 210px 210px, 230px 230px; - z-index: 3; -} - -.starfall.active { - opacity: 1; - animation: starfall-move 1.4s ease-out forwards; -} - -@keyframes starfall-move { - 0% { - transform: translateY(-30px); - opacity: 0.9; - } - 100% { - transform: translateY(80px); - opacity: 0; - } -} - -.nebula { - position: absolute; - inset: 0; - pointer-events: none; - background: radial-gradient( - circle at 20% 30%, - rgba(121, 160, 255, 0.22), - transparent 32% - ), - radial-gradient( - circle at 80% 20%, - rgba(255, 120, 200, 0.12), - transparent 28% - ), - radial-gradient( - circle at 50% 80%, - rgba(80, 200, 255, 0.14), - transparent 30% - ); - filter: blur(60px); - z-index: 0; -} - -.topbar { - position: sticky; - top: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px 16px; - margin-bottom: 32px; - border-radius: 14px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.06); - backdrop-filter: blur(16px); - z-index: 2; -} - -.brand { - display: inline-flex; - align-items: center; - gap: 10px; - font-weight: 700; - letter-spacing: 0.02em; -} - -.logo-wrap { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 10px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.08); -} - -.logo { - border-radius: 6px; -} - -.hero { - position: relative; - z-index: 1; - padding: 12px; - color: #f6f7fb; -} - -.eyebrow { - margin: 0 0 8px; - text-transform: uppercase; - letter-spacing: 0.16em; - font-size: 0.78rem; - color: #9fb3ff; -} - -.hero h1 { - margin: 0 0 12px; - font-size: 2.4rem; - letter-spacing: -0.02em; -} - -.lede { - margin: 0 0 18px; - max-width: 720px; - color: #c7cee7; -} - -.chip-row { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; -} - -.pill { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 14px; - border-radius: 999px; - font-weight: 600; - border: 1px solid rgba(255, 255, 255, 0.12); -} - -.pill.subtle { - background: rgba(255, 255, 255, 0.04); - color: #cdd5f5; -} - -.pulse { - position: relative; - overflow: hidden; -} - -.pulse::after { - content: ""; - position: absolute; - inset: -2px; - border-radius: 999px; - background: radial-gradient( - circle at 20% 20%, - rgba(124, 211, 255, 0.35), - transparent 50% - ); - animation: ping 0.9s ease-out; - pointer-events: none; -} - -@keyframes ping { - 0% { - opacity: 0.75; - transform: scale(0.98); - } - 100% { - opacity: 0; - transform: scale(1.25); - } -} - -.status-pill { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 14px; - border-radius: 999px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - border: 1px solid transparent; -} - -.status-pill.ok { - color: #92ffba; - background: rgba(28, 187, 124, 0.14); - border-color: rgba(28, 187, 124, 0.4); -} - -.status-pill.bad { - color: #ffc7c7; - background: rgba(255, 99, 99, 0.12); - border-color: rgba(255, 99, 99, 0.3); -} - -.grid { - position: relative; - z-index: 1; - margin-top: 28px; - display: grid; - gap: 18px; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); -} - -.card { - position: relative; - border-radius: 18px; - padding: 20px; - overflow: hidden; -} - -.glass { - background: linear-gradient( - 135deg, - rgba(255, 255, 255, 0.05), - rgba(255, 255, 255, 0.02) - ), - rgba(255, 255, 255, 0.02); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); - backdrop-filter: blur(20px); -} - -.card-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; - margin-bottom: 10px; -} - -.muted { - color: #a5acc7; - font-size: 0.95rem; -} - -.meta { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 12px; - margin-top: 14px; -} - -.meta-label { - color: #7f88a8; - margin: 0 0 4px; - font-size: 0.85rem; - letter-spacing: 0.01em; -} - -.meta-value { - margin: 0; - color: #f6f7fb; - font-weight: 700; -} - -code { - padding: 4px 8px; - border-radius: 8px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.06); - font-size: 0.9rem; -} - -@media (max-width: 640px) { - .topbar { - flex-direction: column; - align-items: flex-start; - } - - .hero h1 { - font-size: 1.8rem; - } +* { + @apply selection:bg-blue-200 selection:text-ink; } diff --git a/app/frontend/app/layout.tsx b/app/frontend/app/layout.tsx index 99916a9..f67fcb7 100644 --- a/app/frontend/app/layout.tsx +++ b/app/frontend/app/layout.tsx @@ -1,10 +1,10 @@ import "./globals.css"; import type { Metadata } from "next"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; export const metadata: Metadata = { - title: "avaaz.ai", - description: "Health check frontend for avaaz.ai", + title: "avaaz.ai | Live Health Console", + description: "Live ECG-style monitoring for avaaz.ai health endpoints.", icons: { icon: [{ url: "/favicon.png", type: "image/png" }], shortcut: ["/favicon.png"], @@ -14,7 +14,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: ReactNode }) { return ( - {children} + {children} ); } diff --git a/app/frontend/app/page.tsx b/app/frontend/app/page.tsx index 71f0570..2028d9c 100644 --- a/app/frontend/app/page.tsx +++ b/app/frontend/app/page.tsx @@ -1,194 +1,596 @@ "use client"; import Image from "next/image"; -import { useEffect, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { ECGMonitorCard } from "@/components/ECGMonitorCard"; +import type { PollState } from "@/types/monitor"; -type HealthSummary = { +interface HealthSummary { status: string; version?: string; serviceId?: string; description?: string; - checks: Record; -}; + checks?: + | Record + | { + name?: string; + key?: string; + check?: string; + status: string; + output?: string; + details?: string; + }[]; +} -export default function Home() { - const [health, setHealth] = useState(null); - const [ready, setReady] = useState(null); - const [error, setError] = useState(null); - const [readyError, setReadyError] = useState(null); - const [attemptedHealthUrl, setAttemptedHealthUrl] = useState( - null - ); - const [attemptedReadyUrl, setAttemptedReadyUrl] = useState( - null - ); - const [lastUpdated, setLastUpdated] = useState(null); - const [isPolling, setIsPolling] = useState(false); - const [starsActive, setStarsActive] = useState(false); +const POLL_INTERVALS = { + live: 10_000, + ready: 30_000, + health: 60_000, +} as const; - const apiBase = useMemo( - () => process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000", - [] - ); +const WAVE_SAMPLES = 140; +const WAVE_WIDTH = 360; +const BASELINE = 68; +const MIN_Y = 20; +const MAX_Y = 110; +const NOISE = 1.4; + +function formatTime(timestamp: string | null) { + if (!timestamp) return "Pending"; + return new Date(timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function useNowString() { + const [now, setNow] = useState(""); useEffect(() => { - async function fetchHealth() { - try { - const url = `${apiBase}/health`; - setAttemptedHealthUrl(url); - const res = await fetch(url); - if (!res.ok) throw new Error(`Health check failed: ${res.status}`); - const payload = (await res.json()) as HealthSummary; - setHealth(payload); - setError(null); - return true; - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - setError(message); - setHealth(null); - return false; - } - } - async function fetchReady() { - try { - const url = `${apiBase}/health/ready`; - setAttemptedReadyUrl(url); - const res = await fetch(url); - if (!res.ok) throw new Error(`Ready check failed: ${res.status}`); - const payload = await res.text(); - setReady(payload); - setReadyError(null); - return true; - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - setReadyError(message); - setReady(null); - return false; - } - } + const tick = () => + setNow( + new Date().toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + ); - const run = async () => { - setIsPolling(true); - const [healthOk, readyOk] = await Promise.all([fetchHealth(), fetchReady()]); - setLastUpdated(new Date().toLocaleTimeString()); - if (healthOk && readyOk) { - setStarsActive(true); - setTimeout(() => setStarsActive(false), 1400); - } - setTimeout(() => setIsPolling(false), 900); - }; - - run(); - const id = setInterval(run, 10_000); + tick(); + const id = setInterval(tick, 1_000); return () => clearInterval(id); - }, [apiBase]); + }, []); - const badgeClass = - health && health.status === "ok" && !error - ? "status-pill ok" - : "status-pill bad"; + return now; +} - const readyClass = - ready && !readyError - ? "status-pill ok" - : "status-pill bad"; +function hasStatusField(value: unknown): value is { status?: unknown } { + return typeof value === "object" && value !== null && "status" in value; +} + +function isStatusHealthy(value: unknown): boolean { + if (!value) return false; + if (typeof value === "string") { + return ["ok", "pass", "healthy", "up", "ready", "live"].includes( + value.toLowerCase() + ); + } + if (hasStatusField(value) && typeof value.status === "string") { + return ["ok", "pass", "healthy", "up", "ready", "live"].includes( + value.status.toLowerCase() + ); + } + return false; +} + +/** + * Drives the shared ECG waveform: keeps the line scrolling and injects pulse spikes + * whenever a poll occurs so the right edge always shows the freshest activity. + */ +function useWaveform() { + const [wave, setWave] = useState(() => + Array.from({ length: WAVE_SAMPLES }, () => BASELINE) + ); + const queueRef = useRef([]); + const tickRef = useRef(0); + + // Queue sharp spikes that get blended into the scrolling ECG line. + const triggerPulse = useCallback((strength = 18) => { + queueRef.current.push( + BASELINE - 6, + BASELINE + strength, + BASELINE - 10, + BASELINE + strength * 0.6 + ); + }, []); + + useEffect(() => { + const id = setInterval(() => { + setWave((prev) => { + const queue = queueRef.current; + const drift = + Math.sin(tickRef.current / 6) * NOISE + + Math.cos(tickRef.current / 9) * (NOISE / 1.6); + tickRef.current += 1; + const nextVal = queue.length + ? queue.shift() ?? BASELINE + : BASELINE + drift; + const clamped = Math.max(MIN_Y, Math.min(MAX_Y, nextVal)); + const next = prev.slice(1); + next.push(clamped); + return next; + }); + }, 110); + return () => clearInterval(id); + }, []); + + const wavePoints = useMemo( + () => + wave + .map((value, idx) => { + const x = (idx / (wave.length - 1)) * WAVE_WIDTH; + return `${x.toFixed(1)},${value.toFixed(1)}`; + }) + .join(" "), + [wave] + ); + + return { wavePoints, waveHeight: wave[wave.length - 1], triggerPulse }; +} + +/** + * Polls an endpoint on a cadence, parsing the response and surfacing status metadata. + */ +function usePoller(opts: { + baseUrl: string; + path: string; + intervalMs: number; + parser: (res: Response) => Promise; + onPoll?: () => void; +}): { state: PollState; pollNow: () => Promise } { + const { baseUrl, path, intervalMs, parser, onPoll } = opts; + const [state, setState] = useState>({ + data: null, + error: null, + loading: false, + lastUpdated: null, + attemptedUrl: `${baseUrl}${path}`, + }); + + const poll = useCallback(async () => { + const url = `${baseUrl}${path}`; + onPoll?.(); + setState((prev) => ({ + ...prev, + loading: true, + attemptedUrl: url, + })); + + try { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`${path} responded with ${res.status}`); + const parsed = await parser(res); + setState((prev) => ({ + ...prev, + data: parsed, + error: null, + lastUpdated: new Date().toISOString(), + loading: false, + })); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + setState((prev) => ({ + ...prev, + data: null, + error: message, + lastUpdated: new Date().toISOString(), + loading: false, + })); + } + }, [baseUrl, path, parser, onPoll]); + + useEffect(() => { + void poll(); + const id = setInterval(() => void poll(), intervalMs); + return () => clearInterval(id); + }, [poll, intervalMs]); + + return { state, pollNow: poll }; +} + +export default function Home() { + const apiBase = useMemo( + () => process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000", + [] + ); + const now = useNowString(); + const unifiedWave = useWaveform(); + const liveWave = useWaveform(); + const readyWave = useWaveform(); + const healthWave = useWaveform(); + + const parseHealth = useCallback(async (res: Response) => { + return (await res.json()) as HealthSummary; + }, []); + const parseText = useCallback(async (res: Response) => res.text(), []); + + const pulseHealth = useCallback(() => { + unifiedWave.triggerPulse(22); + healthWave.triggerPulse(22); + }, [unifiedWave.triggerPulse, healthWave.triggerPulse]); + const pulseReady = useCallback(() => { + unifiedWave.triggerPulse(16); + readyWave.triggerPulse(16); + }, [unifiedWave.triggerPulse, readyWave.triggerPulse]); + const pulseLive = useCallback(() => { + unifiedWave.triggerPulse(12); + liveWave.triggerPulse(12); + }, [unifiedWave.triggerPulse, liveWave.triggerPulse]); + + const { state: healthState, pollNow: pollHealth } = usePoller({ + baseUrl: apiBase, + path: "/health", + intervalMs: POLL_INTERVALS.health, + parser: parseHealth, + onPoll: pulseHealth, + }); + + const { state: readyState, pollNow: pollReady } = usePoller({ + baseUrl: apiBase, + path: "/health/ready", + intervalMs: POLL_INTERVALS.ready, + parser: parseText, + onPoll: pulseReady, + }); + + const { state: liveState, pollNow: pollLive } = usePoller({ + baseUrl: apiBase, + path: "/health/live", + intervalMs: POLL_INTERVALS.live, + parser: parseText, + onPoll: pulseLive, + }); + + const healthOk = isStatusHealthy(healthState.data?.status) && !healthState.error; + const readyOk = isStatusHealthy(readyState.data) && !readyState.error; + const liveOk = isStatusHealthy(liveState.data) && !liveState.error; + const overallOk = healthOk && readyOk && liveOk; + + const checks = useMemo(() => { + const source = healthState.data?.checks; + if (!source) return []; + if (Array.isArray(source)) { + return source.map((item, idx) => ({ + label: item.name ?? item.key ?? item.check ?? `Check ${idx + 1}`, + status: item.status ?? "unknown", + output: item.output ?? item.details ?? "", + })); + } + return Object.entries(source).map(([label, val]) => ({ + label, + status: (val as { status?: string }).status ?? "unknown", + output: + (val as { output?: string; details?: string }).output ?? + (val as { details?: string }).details ?? + "", + })); + }, [healthState.data?.checks]); + + const overallLabel = overallOk + ? "All probes healthy" + : "Attention needed"; + const overallLoading = + healthState.loading || readyState.loading || liveState.loading; + const overallStrokeColor = overallLoading + ? "var(--color-pulse)" + : overallOk + ? "var(--color-success)" + : "var(--color-danger)"; return ( -
-