Add app scaffold and workflows
All checks were successful
Continuous Integration / Validate and test changes (push) Successful in 3s
All checks were successful
Continuous Integration / Validate and test changes (push) Successful in 3s
This commit is contained in:
75
app/frontend/Dockerfile
Normal file
75
app/frontend/Dockerfile
Normal file
@@ -0,0 +1,75 @@
|
||||
#
|
||||
# FRONTEND DOCKERFILE
|
||||
#
|
||||
# Multi-stage image for the Next.js SPA/SSR frontend.
|
||||
# - runner: production server with minimal footprint
|
||||
# - builder: compiles the Next.js app
|
||||
# - dev: hot-reload friendly image
|
||||
#
|
||||
# COMPOSE_PROFILES decides which stage is used by docker-compose.yml.
|
||||
#
|
||||
|
||||
FROM node:22-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
ENV NPM_CONFIG_LOGLEVEL=warn \
|
||||
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 \
|
||||
&& chown -R node:node /app
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Production dependencies only (pruned to omit dev tooling)
|
||||
# ------------------------------------------------------------------------------
|
||||
FROM base AS prod-deps
|
||||
COPY package*.json ./
|
||||
RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Builder: compile the application for production
|
||||
# ------------------------------------------------------------------------------
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Production runner: serve the built Next.js app
|
||||
# ------------------------------------------------------------------------------
|
||||
FROM node:22-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
USER node
|
||||
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"]
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Development: keeps node_modules and sources mounted for hot reload
|
||||
# ------------------------------------------------------------------------------
|
||||
FROM deps AS dev
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=development \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0", "--port", "3000"]
|
||||
35
app/frontend/app/globals.css
Normal file
35
app/frontend/app/globals.css
Normal file
@@ -0,0 +1,35 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
background-color: #f7f7fb;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-gradient-to-br from-[#f9fafb] via-[#f2f4f6] to-[#e7eaee] text-ink antialiased;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply selection:bg-blue-200 selection:text-ink;
|
||||
}
|
||||
20
app/frontend/app/layout.tsx
Normal file
20
app/frontend/app/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-sand font-sans text-ink">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
596
app/frontend/app/page.tsx
Normal file
596
app/frontend/app/page.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ECGMonitorCard } from "@/components/ECGMonitorCard";
|
||||
import type { PollState } from "@/types/monitor";
|
||||
|
||||
interface HealthSummary {
|
||||
status: string;
|
||||
version?: string;
|
||||
serviceId?: string;
|
||||
description?: string;
|
||||
checks?:
|
||||
| Record<string, { status: string; output?: string; details?: string }>
|
||||
| {
|
||||
name?: string;
|
||||
key?: string;
|
||||
check?: string;
|
||||
status: string;
|
||||
output?: string;
|
||||
details?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const POLL_INTERVALS = {
|
||||
live: 10_000,
|
||||
ready: 30_000,
|
||||
health: 60_000,
|
||||
} as const;
|
||||
|
||||
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(() => {
|
||||
const tick = () =>
|
||||
setNow(
|
||||
new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
);
|
||||
|
||||
tick();
|
||||
const id = setInterval(tick, 1_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return now;
|
||||
}
|
||||
|
||||
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="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]" />
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</ECGMonitorCard>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
162
app/frontend/components/ECGMonitorCard.tsx
Normal file
162
app/frontend/components/ECGMonitorCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
app/frontend/eslint.config.mjs
Normal file
44
app/frontend/eslint.config.mjs
Normal 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",
|
||||
},
|
||||
}
|
||||
);
|
||||
6
app/frontend/next.config.mjs
Normal file
6
app/frontend/next.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6600
app/frontend/package-lock.json
generated
Normal file
6600
app/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
app/frontend/package.json
Normal file
31
app/frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "avaaz-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"next": "^16.0.5",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
6
app/frontend/postcss.config.mjs
Normal file
6
app/frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
app/frontend/public/favicon.png
Normal file
BIN
app/frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 800 KiB |
BIN
app/frontend/public/logo.png
Normal file
BIN
app/frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 984 KiB |
44
app/frontend/tsconfig.json
Normal file
44
app/frontend/tsconfig.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
7
app/frontend/types/monitor.ts
Normal file
7
app/frontend/types/monitor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface PollState<T> {
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
lastUpdated: string | null;
|
||||
attemptedUrl: string;
|
||||
}
|
||||
Reference in New Issue
Block a user