Update frontend dashboard and health service

This commit is contained in:
2025-11-28 02:23:41 +01:00
parent 6b73ae043d
commit e26b426c2e
15 changed files with 2309 additions and 662 deletions

View File

@@ -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"]

View File

@@ -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;
}

View File

@@ -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 (
<html lang="en">
<body>{children}</body>
<body className="bg-sand font-sans text-ink">{children}</body>
</html>
);
}

View File

@@ -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<string, { status: string; output?: string }>;
};
checks?:
| Record<string, { status: string; output?: string; details?: string }>
| {
name?: string;
key?: string;
check?: string;
status: string;
output?: string;
details?: string;
}[];
}
export default function Home() {
const [health, setHealth] = useState<HealthSummary | null>(null);
const [ready, setReady] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [readyError, setReadyError] = useState<string | null>(null);
const [attemptedHealthUrl, setAttemptedHealthUrl] = useState<string | null>(
null
);
const [attemptedReadyUrl, setAttemptedReadyUrl] = useState<string | null>(
null
);
const [lastUpdated, setLastUpdated] = useState<string | null>(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<string>("");
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<number[]>(() =>
Array.from({ length: WAVE_SAMPLES }, () => BASELINE)
);
const queueRef = useRef<number[]>([]);
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<T>(opts: {
baseUrl: string;
path: string;
intervalMs: number;
parser: (res: Response) => Promise<T>;
onPoll?: () => void;
}): { state: PollState<T>; pollNow: () => Promise<void> } {
const { baseUrl, path, intervalMs, parser, onPoll } = opts;
const [state, setState] = useState<PollState<T>>({
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<HealthSummary>({
baseUrl: apiBase,
path: "/health",
intervalMs: POLL_INTERVALS.health,
parser: parseHealth,
onPoll: pulseHealth,
});
const { state: readyState, pollNow: pollReady } = usePoller<string>({
baseUrl: apiBase,
path: "/health/ready",
intervalMs: POLL_INTERVALS.ready,
parser: parseText,
onPoll: pulseReady,
});
const { state: liveState, pollNow: pollLive } = usePoller<string>({
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 (
<main className="page">
<div className={`starfall ${starsActive ? "active" : ""}`} aria-hidden="true" />
<div className="nebula" aria-hidden="true" />
<header className="topbar">
<div className="brand">
<div className="logo-wrap">
<Image
src="/logo.png"
alt="avaaz.ai logo"
width={28}
height={28}
className="logo"
priority
/>
</div>
avaaz.ai status
</div>
<div className={`pill subtle ${isPolling ? "pulse" : ""}`}>
Polling every 10s
</div>
</header>
<main className="relative min-h-screen overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_20%_10%,rgba(148,163,184,0.18),transparent_45%),radial-gradient(circle_at_80%_0%,rgba(203,213,225,0.18),transparent_35%),radial-gradient(circle_at_40%_90%,rgba(226,232,240,0.22),transparent_40%)]" />
<div className="absolute inset-0 bg-gradient-to-b from-white/80 via-[#f3f4f6cc] to-[#e5e7eb80] backdrop-blur-[1px]" />
<section className="hero">
<div>
<p className="eyebrow">System overview</p>
<h1>Minimal health & readiness monitor</h1>
<p className="lede">
Inspired by gemini.google.com lightweight cards, soft gradients,
and live probes for backend health.
</p>
<div className="chip-row">
<div className={badgeClass}>
Health: {health && !error ? health.status : "unhealthy"}
<div className="relative mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 pb-16 pt-10 sm:px-6 lg:px-8">
<header className="flex flex-col gap-4 rounded-3xl border border-black/5 bg-white/80 px-5 py-4 shadow-card backdrop-blur md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-black/5 bg-gradient-to-br from-white via-white to-slate-50 shadow-sm">
<Image
src="/logo.png"
alt="avaaz.ai logo"
width={40}
height={40}
priority
className="h-9 w-9 rounded-xl object-contain"
/>
</div>
<div className={readyClass}>
Ready: {ready && !readyError ? ready : "not ready"}
<div>
<p className="text-lg font-semibold text-ink">avaaz.ai</p>
<p className="text-sm text-inkMuted">
Unified ECG monitoring for live, ready, and health probes.
</p>
</div>
{lastUpdated && (
<div className="pill subtle">Updated {lastUpdated}</div>
)}
</div>
</div>
</section>
<section className="grid">
<article className="card glass">
<div className="card-header">
<span className="pill subtle">/health</span>
<span className={badgeClass}>
{health && !error ? health.status : "unhealthy"}
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-semibold shadow-sm ${
overallOk
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-rose-200 bg-rose-50 text-rose-800"
}`}
>
<span
className={`h-2.5 w-2.5 rounded-full ${
overallOk ? "bg-success" : "bg-danger"
}`}
/>
{overallLabel}
</span>
<span className="inline-flex items-center gap-2 rounded-full border border-black/5 bg-black/5 px-3 py-2 text-xs font-semibold uppercase tracking-[0.1em] text-inkMuted">
Live clock
<span className="font-mono text-sm">{now || "— — : — —"}</span>
</span>
</div>
<p className="muted">
{attemptedHealthUrl || `${apiBase}/health`}
</p>
{health ? (
<div className="meta">
<div>
<p className="meta-label">Status</p>
<p className="meta-value">{health.status}</p>
</header>
<section className="grid gap-5 md:grid-cols-[1.1fr,1.4fr]">
<div className="rounded-3xl border border-black/5 bg-white/80 p-6 shadow-card backdrop-blur">
<p className="text-xs uppercase tracking-[0.2em] text-inkMuted">
Live dashboard
</p>
<h1 className="mt-2 text-3xl font-semibold leading-tight text-ink md:text-4xl">
ECG-style observability for avaaz.ai
</h1>
<p className="mt-3 text-base text-inkMuted">
A continuous, scrolling signal shows every poll with sharp blips
whenever a probe fires. Color shifts between healthy (green),
unhealthy (red), and active polling (amber).
</p>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm text-ink">
<div className="flex items-center justify-between rounded-2xl border border-black/5 bg-white/70 px-4 py-3">
<span className="text-inkMuted">/health/live</span>
<span className="font-semibold">
{liveOk ? "Live" : "Down"} · 10s cadence
</span>
</div>
{Object.entries(health.checks || {}).map(([key, val]) => (
<div key={key}>
<p className="meta-label">{key}</p>
<p className="meta-value">{val.status}</p>
<div className="flex items-center justify-between rounded-2xl border border-black/5 bg-white/70 px-4 py-3">
<span className="text-inkMuted">/health/ready</span>
<span className="font-semibold">
{readyOk ? "Ready" : "Not ready"} · 30s cadence
</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-black/5 bg-white/70 px-4 py-3">
<span className="text-inkMuted">/health</span>
<span className="font-semibold">
{healthOk ? "Healthy" : "Degraded"} · 60s cadence
</span>
</div>
</div>
</div>
<div className="relative overflow-hidden rounded-3xl border border-black/5 bg-gradient-to-br from-white via-[#f3f4f6] to-white p-6 shadow-card">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_20%_10%,rgba(148,163,184,0.18),transparent_35%),radial-gradient(circle_at_80%_10%,rgba(203,213,225,0.14),transparent_30%),radial-gradient(circle_at_40%_90%,rgba(226,232,240,0.14),transparent_28%)]" />
<div className="flex items-center justify-between text-sm text-inkMuted">
<span className="font-semibold uppercase tracking-[0.12em]">
Unified ECG strip
</span>
<span className="inline-flex items-center gap-2 rounded-full border border-black/5 bg-white/70 px-3 py-1 font-semibold text-ink">
<span className="h-2 w-2 rounded-full bg-success" />
Healthy
<span className="h-2 w-2 rounded-full bg-danger" />
Unhealthy
<span className="h-2 w-2 rounded-full bg-pulse" />
Polling
</span>
</div>
<div className="mt-3 rounded-2xl border border-black/5 bg-white/80 p-4 shadow-inner">
<svg
viewBox="0 0 360 120"
preserveAspectRatio="none"
className="h-32 w-full"
>
<polyline
points={unifiedWave.wavePoints}
fill="none"
stroke={overallStrokeColor}
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="360"
cy={unifiedWave.waveHeight}
r="7"
className="stroke-white/50"
style={{ fill: overallStrokeColor }}
/>
</svg>
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-ink">
<div className="rounded-2xl border border-black/5 bg-white/70 px-3 py-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Last health update
</p>
<p className="text-base font-semibold text-ink">
{formatTime(healthState.lastUpdated)}
</p>
</div>
<div className="rounded-2xl border border-black/5 bg-white/70 px-3 py-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Next pulse cadence
</p>
<p className="text-base font-semibold text-ink">
10s / 30s / 60s
</p>
</div>
</div>
</div>
</section>
<section className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
<ECGMonitorCard
title="Live probe"
endpoint="/health/live"
intervalLabel="10 seconds"
tone="blue"
now={now}
wavePoints={liveWave.wavePoints}
waveHeight={liveWave.waveHeight}
healthy={liveOk}
loading={liveState.loading}
statusLabel={
liveState.loading
? "Polling"
: liveOk
? "Operational"
: "Attention"
}
statusDetail={liveState.error ?? liveState.data ?? "Awaiting data"}
lastUpdatedLabel={formatTime(liveState.lastUpdated)}
state={liveState}
onManualTrigger={pollLive}
>
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2 text-sm">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Signal
</p>
<p className="font-semibold text-ink">
{liveState.data ?? liveState.error ?? "No response yet"}
</p>
</div>
</ECGMonitorCard>
<ECGMonitorCard
title="Readiness"
endpoint="/health/ready"
intervalLabel="30 seconds"
tone="mint"
now={now}
wavePoints={readyWave.wavePoints}
waveHeight={readyWave.waveHeight}
healthy={readyOk}
loading={readyState.loading}
statusLabel={
readyState.loading
? "Polling"
: readyOk
? "Operational"
: "Attention"
}
statusDetail={
readyState.error ??
readyState.data ??
"Waiting for first readiness signal"
}
lastUpdatedLabel={formatTime(readyState.lastUpdated)}
state={readyState}
onManualTrigger={pollReady}
>
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2 text-sm">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Response
</p>
<p className="font-semibold text-ink">
{readyState.data ?? readyState.error ?? "No response yet"}
</p>
</div>
</ECGMonitorCard>
<ECGMonitorCard
title="Deep health"
endpoint="/health"
intervalLabel="1 minute"
tone="coral"
now={now}
wavePoints={healthWave.wavePoints}
waveHeight={healthWave.waveHeight}
healthy={healthOk}
loading={healthState.loading}
statusLabel={
healthState.loading
? "Polling"
: healthOk
? "Operational"
: "Attention"
}
statusDetail={
healthState.error ??
healthState.data?.status ??
"Waiting for first health payload"
}
lastUpdatedLabel={formatTime(healthState.lastUpdated)}
state={healthState}
onManualTrigger={pollHealth}
>
<div className="grid grid-cols-2 gap-3 text-sm">
{healthState.data?.version && (
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Version
</p>
<p className="font-semibold text-ink">{healthState.data.version}</p>
</div>
))}
)}
{healthState.data?.serviceId && (
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Service
</p>
<p className="font-semibold text-ink">
{healthState.data.serviceId}
</p>
</div>
)}
{healthState.data?.description && (
<div className="col-span-2 rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Notes
</p>
<p className="text-ink">{healthState.data.description}</p>
</div>
)}
</div>
) : (
<p className="muted">{error || "Waiting for response..."}</p>
)}
</article>
<article className="card glass">
<div className="card-header">
<span className="pill subtle">/health/ready</span>
<span className={readyClass}>
{ready && !readyError ? ready : "not ready"}
</span>
</div>
<p className="muted">
{attemptedReadyUrl || `${apiBase}/health/ready`}
</p>
{ready ? (
<div className="meta">
<div>
<p className="meta-label">Status</p>
<p className="meta-value">{ready}</p>
</div>
<div className="mt-3 space-y-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Checks
</p>
{checks.length > 0 ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{checks.map((check) => (
<div
key={check.label}
className="rounded-2xl border border-black/5 bg-white/70 px-3 py-2"
>
<div className="flex items-center justify-between text-xs text-inkMuted">
<span>{check.label}</span>
<span
className={`h-2 w-2 rounded-full ${
isStatusHealthy(check.status) ? "bg-success" : "bg-danger"
}`}
/>
</div>
<p className="text-sm font-semibold text-ink">{check.status}</p>
{check.output && (
<p className="text-xs text-inkMuted">{check.output}</p>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-inkMuted">Awaiting check details.</p>
)}
</div>
) : (
<p className="muted">{readyError || "Waiting for response..."}</p>
)}
</article>
</section>
</ECGMonitorCard>
</section>
</div>
</main>
);
}

View File

@@ -0,0 +1,162 @@
import type { ReactNode } from "react";
import type { PollState } from "@/types/monitor";
type Tone = "mint" | "blue" | "coral";
const toneStyles: Record<Tone, string> = {
mint: "from-white via-white to-emerald-50 ring-emerald-100",
blue: "from-white via-white to-sky-50 ring-sky-100",
coral: "from-white via-white to-rose-50 ring-rose-100",
};
interface ECGMonitorCardProps<T> {
title: string;
endpoint: string;
intervalLabel: string;
tone?: Tone;
now: string;
wavePoints: string;
waveHeight: number;
healthy: boolean;
loading: boolean;
statusLabel: string;
statusDetail: string;
lastUpdatedLabel: string;
state: PollState<T>;
children?: ReactNode;
onManualTrigger: () => void;
}
/**
* Presentational card that renders endpoint status and the shared ECG waveform with
* consistent tone, status badges, and contextual metadata.
*/
export function ECGMonitorCard<T>({
title,
endpoint,
intervalLabel,
tone = "mint",
now,
wavePoints,
waveHeight,
healthy,
loading,
statusLabel,
statusDetail,
lastUpdatedLabel,
state,
children,
onManualTrigger,
}: ECGMonitorCardProps<T>) {
const signalId = `${endpoint.replace(/[^a-z0-9]/gi, "-")}-stroke`;
const badgeColor = loading
? "border-amber-200 bg-amber-50 text-amber-800"
: healthy
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-rose-200 bg-rose-50 text-rose-800";
const dotColor = loading ? "bg-pulse" : healthy ? "bg-success" : "bg-danger";
const strokeColor = loading ? "#f59e0b" : healthy ? "#22c55e" : "#ef4444";
return (
<article
className={`relative overflow-hidden rounded-3xl border border-black/5 bg-gradient-to-br ${toneStyles[tone]} p-5 shadow-card ring-4 ring-transparent transition hover:-translate-y-1 hover:shadow-[0_24px_60px_rgba(15,23,42,0.16)]`}
>
<div className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_20%_10%,rgba(96,165,250,0.08),transparent_30%),radial-gradient(circle_at_80%_0%,rgba(52,211,153,0.1),transparent_30%)]" />
<div className="flex items-start justify-between gap-3">
<div className="space-y-2 min-w-0">
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-black/5 bg-black/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] text-inkMuted">
<span className="break-words">{endpoint}</span>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-gradient-to-r from-accent-blue to-accent-mint" />
</div>
<h3 className="text-lg font-semibold text-ink">{title}</h3>
<p className="text-sm text-inkMuted">every {intervalLabel}</p>
</div>
<div className="flex flex-col items-end gap-2 text-right">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-semibold shadow-sm ${badgeColor}`}
>
<span className={`h-2.5 w-2.5 rounded-full ${dotColor}`} />
{statusLabel}
</span>
<button
type="button"
onClick={onManualTrigger}
className="inline-flex items-center gap-2 rounded-full bg-ink text-white px-3 py-2 text-xs font-semibold shadow-card transition hover:bg-inkSoft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
>
<span className="h-2 w-2 rounded-full bg-white" aria-hidden />
Poll now
</button>
</div>
<span className="text-xs font-semibold uppercase tracking-[0.08em] text-inkMuted">
{now}
</span>
</div>
</div>
<div className="mt-4 flex flex-col gap-3">
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Last updated
</p>
<p className="text-base font-semibold text-ink">{lastUpdatedLabel}</p>
</div>
<div className="relative overflow-hidden rounded-2xl border border-black/5 bg-gradient-to-br from-white via-white to-slate-50 p-4">
<div className="pointer-events-none absolute inset-0 opacity-60 mix-blend-multiply">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_10%_20%,rgba(96,165,250,0.12),transparent_35%),radial-gradient(circle_at_80%_0%,rgba(52,211,153,0.12),transparent_30%)]" />
</div>
<div className="flex items-center justify-between text-xs text-inkMuted">
<span>ECG signal</span>
<span className="inline-flex items-center gap-2 rounded-full bg-black/5 px-2 py-1 font-semibold">
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
{loading ? "Polling" : healthy ? "Healthy" : "Unhealthy"}
</span>
</div>
<svg
viewBox="0 0 360 120"
preserveAspectRatio="none"
className="mt-2 h-28 w-full text-success drop-shadow-sm"
>
<defs>
<linearGradient id={signalId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.9" />
<stop offset="50%" stopColor={strokeColor} stopOpacity="0.75" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.95" />
</linearGradient>
</defs>
<polyline
points={wavePoints}
fill="none"
stroke={`url(#${signalId})`}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="360"
cy={waveHeight}
r="6"
className={`stroke-white/40 ${dotColor}`}
/>
</svg>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">Status</p>
<p className="text-base font-semibold text-ink">{statusDetail}</p>
</div>
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">Endpoint</p>
<p className="text-xs font-mono text-ink">{state.attemptedUrl}</p>
</div>
</div>
{children}
</div>
</article>
);
}

View File

@@ -0,0 +1,44 @@
import nextPlugin from "@next/eslint-plugin-next";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"**/.next/**",
"node_modules/**",
"dist/**",
"tailwind.config.js",
"postcss.config.mjs",
],
},
{
files: ["**/*.{js,jsx,ts,tsx}"],
extends: [
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
nextPlugin.configs.recommended,
nextPlugin.configs["core-web-vitals"],
],
languageOptions: {
parserOptions: {
project: "./tsconfig.json",
},
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports", fixStyle: "separate-type-imports" },
],
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: false },
],
"@typescript-eslint/no-floating-promises": "error",
},
}
);

File diff suppressed because it is too large Load Diff

View File

@@ -4,21 +4,28 @@
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build --webpack",
"start": "next start",
"lint": "next lint"
"lint": "eslint ."
},
"type": "module",
"dependencies": {
"next": "15.5.6",
"react": "18.3.1",
"react-dom": "18.3.1"
"@tailwindcss/postcss": "^4.1.17",
"next": "^16.0.5",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/node": "22.9.4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.1",
"eslint": "9.39.1",
"eslint-config-next": "15.5.6",
"typescript": "5.7.3"
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.5",
"globals": "^16.5.0",
"postcss": "^8.4.49",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};

View File

@@ -1 +0,0 @@
# Placeholder to ensure public assets directory exists for builds.

View File

@@ -1,21 +1,21 @@
{
"compilerOptions": {
"target": "es2017",
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
"ES2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"types": [
"node"
@@ -24,13 +24,19 @@
{
"name": "next"
}
]
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"

View File

@@ -0,0 +1,7 @@
export interface PollState<T> {
data: T | null;
error: string | null;
loading: boolean;
lastUpdated: string | null;
attemptedUrl: string;
}