Update frontend dashboard and health service
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
# 1. Run Applicaiton
|
||||
|
||||
1. Removes all cached Python packages stored by pip, removes local Python cache files, clears the cache used by uv, and forcibly clear the cache for Node.js.
|
||||
1. Remove all cached Python packages stored by pip, remove local Python cache files, clear the cache used by uv, and forcibly clear the cache for Node.js.
|
||||
|
||||
```bash
|
||||
uv tool install cleanpy
|
||||
pip cache purge && cleanpy . && uv cache clean && npm cache clean --force
|
||||
```
|
||||
|
||||
2. Resolve dependencies from your *pyproject.toml* and upgrade all packages. Synchronize the virtual environment with the dependencies specified in the *uv.lock* including packages needed for development.
|
||||
2. Resolve dependencies from *pyproject.toml* and upgrade all packages. Synchronize the virtual environment with the dependencies specified in the *uv.lock* including packages needed for **development**.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
@@ -21,16 +21,15 @@
|
||||
uv run ruff check --fix && uv run pytest
|
||||
```
|
||||
|
||||
4. Starts a local development API server, visible at port 8000, and automatically reloads the server as you make code changes.
|
||||
4. Start a local **development** API server, visible at port 8000, and automatically reloads the server when code changes are made.
|
||||
|
||||
```bash
|
||||
uv run uvicorn src.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
5. Scans dependencies for security vulnerabilities and attempts to automatically fix them by force-updating to the latest secure versions.
|
||||
5. Open a new terminal. Scan dependencies for security vulnerabilities and attempt to automatically fix them by force-updating to the latest secure versions.
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
cd frontend
|
||||
npm audit fix --force
|
||||
```
|
||||
@@ -38,13 +37,11 @@
|
||||
6. Install dependencies from *package.json*, then update those dependencies to the latest allowed versions based on version ranges. Next, check the source code for stylistic and syntax errors according to configured rules. Finally, compile or bundle the application for deployment or production use.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install && npm update && npm run lint && npm run build
|
||||
```
|
||||
|
||||
7. Execute start script in *package.json*, launch your Node.js application in production mode.
|
||||
7. Execute start script in *package.json*, launch Node.js application in **development** mode.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run start
|
||||
npm run dev
|
||||
```
|
||||
|
||||
@@ -66,9 +66,9 @@ async def check_database_status() -> ComponentCheck:
|
||||
"""
|
||||
async def db_logic():
|
||||
# IMPORTANT: Replace this sleep simulation with the actual async DB client call (e.g., await database.ping())
|
||||
await asyncio.sleep(0.042)
|
||||
await asyncio.sleep(0.045)
|
||||
|
||||
return await _run_check_with_timeout(db_logic(), name="postgres", timeout_ms=100)
|
||||
return await _run_check_with_timeout(db_logic(), name="postgres", timeout_ms=50)
|
||||
|
||||
async def check_media_server_status() -> ComponentCheck:
|
||||
"""
|
||||
|
||||
@@ -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