Update frontend dashboard and health service

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

View File

@@ -1,13 +1,13 @@
# 1. Run Applicaiton # 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 ```bash
uv tool install cleanpy uv tool install cleanpy
pip cache purge && cleanpy . && uv cache clean && npm cache clean --force 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 ```bash
cd backend cd backend
@@ -21,16 +21,15 @@
uv run ruff check --fix && uv run pytest 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 ```bash
uv run uvicorn src.main:app --reload --port 8000 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 ```bash
cd ..
cd frontend cd frontend
npm audit fix --force 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. 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 ```bash
cd frontend
npm install && npm update && npm run lint && npm run build 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 ```bash
cd frontend npm run dev
npm run start
``` ```

View File

@@ -66,9 +66,9 @@ async def check_database_status() -> ComponentCheck:
""" """
async def db_logic(): async def db_logic():
# IMPORTANT: Replace this sleep simulation with the actual async DB client call (e.g., await database.ping()) # 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: async def check_media_server_status() -> ComponentCheck:
""" """

View File

@@ -13,14 +13,16 @@ FROM node:22-slim AS base
WORKDIR /app WORKDIR /app
ENV NPM_CONFIG_LOGLEVEL=warn \ ENV NPM_CONFIG_LOGLEVEL=warn \
NODE_OPTIONS="--enable-source-maps" NODE_OPTIONS="--enable-source-maps" \
NEXT_TELEMETRY_DISABLED=1
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Dependencies cache # Dependencies cache
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
FROM base AS deps FROM base AS deps
COPY package*.json ./ 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) # 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 --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build 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/.next ./.next
COPY --from=builder --chown=node:node /app/public ./public 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.json ./package.json
COPY --from=builder --chown=node:node /app/package-lock.json ./package-lock.json
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "start"] CMD ["npm", "run", "start"]

View File

@@ -1,305 +1,35 @@
:root { @import "tailwindcss";
color-scheme: dark;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", @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; sans-serif;
line-height: 1.5;
background: #04060d;
color: #f6f7fb;
} }
* { :root {
box-sizing: border-box; color-scheme: light;
background-color: #f7f7fb;
} }
body { body {
margin: 0; @apply min-h-screen bg-gradient-to-br from-[#f9fafb] via-[#f2f4f6] to-[#e7eaee] text-ink antialiased;
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;
} }
.page { * {
position: relative; @apply selection:bg-blue-200 selection:text-ink;
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;
}
} }

View File

@@ -1,10 +1,10 @@
import "./globals.css"; import "./globals.css";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ReactNode } from "react"; import type { ReactNode } from "react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "avaaz.ai", title: "avaaz.ai | Live Health Console",
description: "Health check frontend for avaaz.ai", description: "Live ECG-style monitoring for avaaz.ai health endpoints.",
icons: { icons: {
icon: [{ url: "/favicon.png", type: "image/png" }], icon: [{ url: "/favicon.png", type: "image/png" }],
shortcut: ["/favicon.png"], shortcut: ["/favicon.png"],
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body className="bg-sand font-sans text-ink">{children}</body>
</html> </html>
); );
} }

View File

@@ -1,194 +1,596 @@
"use client"; "use client";
import Image from "next/image"; 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; status: string;
version?: string; version?: string;
serviceId?: string; serviceId?: string;
description?: 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 POLL_INTERVALS = {
const [health, setHealth] = useState<HealthSummary | null>(null); live: 10_000,
const [ready, setReady] = useState<string | null>(null); ready: 30_000,
const [error, setError] = useState<string | null>(null); health: 60_000,
const [readyError, setReadyError] = useState<string | null>(null); } as const;
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 apiBase = useMemo( const WAVE_SAMPLES = 140;
() => process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000", 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(() => { useEffect(() => {
async function fetchHealth() { const tick = () =>
try { setNow(
const url = `${apiBase}/health`; new Date().toLocaleTimeString([], {
setAttemptedHealthUrl(url); hour: "2-digit",
const res = await fetch(url); minute: "2-digit",
if (!res.ok) throw new Error(`Health check failed: ${res.status}`); second: "2-digit",
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 run = async () => { tick();
setIsPolling(true); const id = setInterval(tick, 1_000);
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);
return () => clearInterval(id); return () => clearInterval(id);
}, [apiBase]); }, []);
const badgeClass = return now;
health && health.status === "ok" && !error }
? "status-pill ok"
: "status-pill bad";
const readyClass = function hasStatusField(value: unknown): value is { status?: unknown } {
ready && !readyError return typeof value === "object" && value !== null && "status" in value;
? "status-pill ok" }
: "status-pill bad";
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 ( return (
<main className="page"> <main className="relative min-h-screen overflow-hidden">
<div className={`starfall ${starsActive ? "active" : ""}`} aria-hidden="true" /> <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="nebula" aria-hidden="true" /> <div className="absolute inset-0 bg-gradient-to-b from-white/80 via-[#f3f4f6cc] to-[#e5e7eb80] backdrop-blur-[1px]" />
<header className="topbar">
<div className="brand"> <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">
<div className="logo-wrap"> <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 <Image
src="/logo.png" src="/logo.png"
alt="avaaz.ai logo" alt="avaaz.ai logo"
width={28} width={40}
height={28} height={40}
className="logo"
priority priority
className="h-9 w-9 rounded-xl object-contain"
/> />
</div> </div>
avaaz.ai status <div>
<p className="text-lg font-semibold text-ink">avaaz.ai</p>
<p className="text-sm text-inkMuted">
Unified ECG monitoring for live, ready, and health probes.
</p>
</div> </div>
<div className={`pill subtle ${isPolling ? "pulse" : ""}`}> </div>
Polling every 10s <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> </div>
</header> </header>
<section className="hero"> <section className="grid gap-5 md:grid-cols-[1.1fr,1.4fr]">
<div> <div className="rounded-3xl border border-black/5 bg-white/80 p-6 shadow-card backdrop-blur">
<p className="eyebrow">System overview</p> <p className="text-xs uppercase tracking-[0.2em] text-inkMuted">
<h1>Minimal health & readiness monitor</h1> Live dashboard
<p className="lede">
Inspired by gemini.google.com lightweight cards, soft gradients,
and live probes for backend health.
</p> </p>
<div className="chip-row"> <h1 className="mt-2 text-3xl font-semibold leading-tight text-ink md:text-4xl">
<div className={badgeClass}> ECG-style observability for avaaz.ai
Health: {health && !error ? health.status : "unhealthy"} </h1>
<p className="mt-3 text-base text-inkMuted">
A continuous, scrolling signal shows every poll with sharp blips
whenever a probe fires. Color shifts between healthy (green),
unhealthy (red), and active polling (amber).
</p>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm text-ink">
<div className="flex items-center justify-between rounded-2xl border border-black/5 bg-white/70 px-4 py-3">
<span className="text-inkMuted">/health/live</span>
<span className="font-semibold">
{liveOk ? "Live" : "Down"} · 10s cadence
</span>
</div> </div>
<div className={readyClass}> <div className="flex items-center justify-between rounded-2xl border border-black/5 bg-white/70 px-4 py-3">
Ready: {ready && !readyError ? ready : "not ready"} <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>
{lastUpdated && (
<div className="pill subtle">Updated {lastUpdated}</div>
)}
</div> </div>
</div> </div>
</section> </section>
<section className="grid"> <section className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
<article className="card glass"> <ECGMonitorCard
<div className="card-header"> title="Live probe"
<span className="pill subtle">/health</span> endpoint="/health/live"
<span className={badgeClass}> intervalLabel="10 seconds"
{health && !error ? health.status : "unhealthy"} tone="blue"
</span> now={now}
</div> wavePoints={liveWave.wavePoints}
<p className="muted"> waveHeight={liveWave.waveHeight}
{attemptedHealthUrl || `${apiBase}/health`} 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> </p>
{health ? (
<div className="meta">
<div>
<p className="meta-label">Status</p>
<p className="meta-value">{health.status}</p>
</div> </div>
{Object.entries(health.checks || {}).map(([key, val]) => ( </ECGMonitorCard>
<div key={key}>
<p className="meta-label">{key}</p> <ECGMonitorCard
<p className="meta-value">{val.status}</p> title="Readiness"
endpoint="/health/ready"
intervalLabel="30 seconds"
tone="mint"
now={now}
wavePoints={readyWave.wavePoints}
waveHeight={readyWave.waveHeight}
healthy={readyOk}
loading={readyState.loading}
statusLabel={
readyState.loading
? "Polling"
: readyOk
? "Operational"
: "Attention"
}
statusDetail={
readyState.error ??
readyState.data ??
"Waiting for first readiness signal"
}
lastUpdatedLabel={formatTime(readyState.lastUpdated)}
state={readyState}
onManualTrigger={pollReady}
>
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2 text-sm">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Response
</p>
<p className="font-semibold text-ink">
{readyState.data ?? readyState.error ?? "No response yet"}
</p>
</div>
</ECGMonitorCard>
<ECGMonitorCard
title="Deep health"
endpoint="/health"
intervalLabel="1 minute"
tone="coral"
now={now}
wavePoints={healthWave.wavePoints}
waveHeight={healthWave.waveHeight}
healthy={healthOk}
loading={healthState.loading}
statusLabel={
healthState.loading
? "Polling"
: healthOk
? "Operational"
: "Attention"
}
statusDetail={
healthState.error ??
healthState.data?.status ??
"Waiting for first health payload"
}
lastUpdatedLabel={formatTime(healthState.lastUpdated)}
state={healthState}
onManualTrigger={pollHealth}
>
<div className="grid grid-cols-2 gap-3 text-sm">
{healthState.data?.version && (
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Version
</p>
<p className="font-semibold text-ink">{healthState.data.version}</p>
</div>
)}
{healthState.data?.serviceId && (
<div className="rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Service
</p>
<p className="font-semibold text-ink">
{healthState.data.serviceId}
</p>
</div>
)}
{healthState.data?.description && (
<div className="col-span-2 rounded-2xl border border-black/5 bg-white/80 px-3 py-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Notes
</p>
<p className="text-ink">{healthState.data.description}</p>
</div>
)}
</div>
<div className="mt-3 space-y-2">
<p className="text-xs uppercase tracking-[0.08em] text-inkMuted">
Checks
</p>
{checks.length > 0 ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{checks.map((check) => (
<div
key={check.label}
className="rounded-2xl border border-black/5 bg-white/70 px-3 py-2"
>
<div className="flex items-center justify-between text-xs text-inkMuted">
<span>{check.label}</span>
<span
className={`h-2 w-2 rounded-full ${
isStatusHealthy(check.status) ? "bg-success" : "bg-danger"
}`}
/>
</div>
<p className="text-sm font-semibold text-ink">{check.status}</p>
{check.output && (
<p className="text-xs text-inkMuted">{check.output}</p>
)}
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="muted">{error || "Waiting for response..."}</p> <p className="text-sm text-inkMuted">Awaiting check details.</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> </div>
<p className="muted"> </ECGMonitorCard>
{attemptedReadyUrl || `${apiBase}/health/ready`}
</p>
{ready ? (
<div className="meta">
<div>
<p className="meta-label">Status</p>
<p className="meta-value">{ready}</p>
</div>
</div>
) : (
<p className="muted">{readyError || "Waiting for response..."}</p>
)}
</article>
</section> </section>
</div>
</main> </main>
); );
} }

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

71
tmp/Dockerfile Normal file
View File

@@ -0,0 +1,71 @@
#
# FRONTEND DOCKERFILE
#
# Multi-stage image for the Next.js SPA/SSR frontend.
# - runner: production server with minimal footprint
# - builder: compiles the Next.js app
# - dev: hot-reload friendly image
#
# COMPOSE_PROFILES decides which stage is used by docker-compose.yml.
#
FROM node:22-slim AS base
WORKDIR /app
ENV NPM_CONFIG_LOGLEVEL=warn \
NODE_OPTIONS="--enable-source-maps"
# ------------------------------------------------------------------------------
# Dependencies cache
# ------------------------------------------------------------------------------
FROM base AS deps
COPY package*.json ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
# ------------------------------------------------------------------------------
# Production dependencies only (pruned to omit dev tooling)
# ------------------------------------------------------------------------------
FROM base AS prod-deps
COPY package*.json ./
RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi
# ------------------------------------------------------------------------------
# Builder: compile the application for production
# ------------------------------------------------------------------------------
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
RUN npm run build
# ------------------------------------------------------------------------------
# Production runner: serve the built Next.js app
# ------------------------------------------------------------------------------
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1
USER node
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/.next ./.next
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "run", "start"]
# ------------------------------------------------------------------------------
# Development: keeps node_modules and sources mounted for hot reload
# ------------------------------------------------------------------------------
FROM deps AS dev
WORKDIR /app
ENV NODE_ENV=development \
NEXT_TELEMETRY_DISABLED=1
USER node
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0", "--port", "3000"]