Update frontend dashboard and health service
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
);
|
||||
1546
app/frontend/package-lock.json
generated
1546
app/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
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: {},
|
||||
},
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
# Placeholder to ensure public assets directory exists for builds.
|
||||
@@ -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"
|
||||
|
||||
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