feat: add app health monitoring section
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / deploy (push) Has been cancelled

- Backend: GET /api/health/apps and POST /api/health/check endpoints
- Checks 8 apps (dashboard, network, todo, nkode, gitea)
- 30s caching to avoid hammering endpoints
- Frontend: Health widget on dashboard page
- Dedicated /health page with detailed status cards
- Sidebar nav with colored status dot indicator
- Dark mode support throughout
This commit is contained in:
2026-01-30 14:19:55 +00:00
parent 30d1892a7d
commit 8b8d56370e
9 changed files with 647 additions and 2 deletions

View File

@@ -8,6 +8,7 @@ import { activityRoutes } from "./routes/activity";
import { summaryRoutes } from "./routes/summaries";
import { securityRoutes } from "./routes/security";
import { todoRoutes } from "./routes/todos";
import { healthRoutes } from "./routes/health";
import { auth } from "./lib/auth";
import { db } from "./db";
import { tasks, users } from "./db/schema";
@@ -126,6 +127,7 @@ const app = new Elysia()
.use(securityRoutes)
.use(summaryRoutes)
.use(todoRoutes)
.use(healthRoutes)
// Current user info (role, etc.)
.get("/api/me", async ({ request }) => {

View File

@@ -0,0 +1,154 @@
import { Elysia } from "elysia";
import { auth } from "../lib/auth";
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
async function requireSessionOrBearer(request: Request, headers: Record<string, string | undefined>) {
const authHeader = headers["authorization"];
if (authHeader === `Bearer ${BEARER_TOKEN}`) {
return { userId: "bearer" };
}
try {
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user) return { userId: session.user.id };
} catch {}
throw new Error("Unauthorized");
}
// Apps to monitor
const APPS = [
{ name: "Hammer Dashboard", url: "https://dash.donovankelly.xyz", type: "web" as const },
{ name: "Network App API", url: "https://api.thenetwork.donovankelly.xyz", type: "api" as const },
{ name: "Network App Web", url: "https://app.thenetwork.donovankelly.xyz", type: "web" as const },
{ name: "Todo App API", url: "https://api.todo.donovankelly.xyz", type: "api" as const },
{ name: "Todo App Web", url: "https://app.todo.donovankelly.xyz", type: "web" as const },
{ name: "nKode Frontend", url: "https://app.nkode.donovankelly.xyz", type: "web" as const },
{ name: "nKode Backend", url: "https://api.nkode.donovankelly.xyz", type: "api" as const },
{ name: "Gitea", url: "https://git.infra.donovankelly.xyz", type: "web" as const },
];
interface AppHealthResult {
name: string;
url: string;
type: "web" | "api";
status: "healthy" | "degraded" | "unhealthy";
responseTime: number;
httpStatus: number | null;
lastChecked: string;
error?: string;
}
// Cache
let cachedResults: AppHealthResult[] | null = null;
let cacheTimestamp = 0;
const CACHE_TTL = 30_000; // 30 seconds
async function checkApp(app: typeof APPS[number]): Promise<AppHealthResult> {
const start = Date.now();
const checkUrl = app.type === "api"
? (() => {
// Try common API health endpoints
if (app.url.includes("api.thenetwork")) return `${app.url}/api/auth/session`;
if (app.url.includes("api.todo")) return `${app.url}/api/auth/session`;
if (app.url.includes("api.nkode")) return `${app.url}/api/auth/session`;
return `${app.url}/`;
})()
: app.url;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
const res = await fetch(checkUrl, {
signal: controller.signal,
redirect: "follow",
headers: { "User-Agent": "HammerHealthCheck/1.0" },
});
clearTimeout(timeout);
const responseTime = Date.now() - start;
const httpStatus = res.status;
let status: AppHealthResult["status"];
if (httpStatus >= 200 && httpStatus < 300) {
status = responseTime > 5000 ? "degraded" : "healthy";
} else if (httpStatus >= 300 && httpStatus < 500) {
status = "degraded";
} else {
status = "unhealthy";
}
return {
name: app.name,
url: app.url,
type: app.type,
status,
responseTime,
httpStatus,
lastChecked: new Date().toISOString(),
};
} catch (err: any) {
const responseTime = Date.now() - start;
return {
name: app.name,
url: app.url,
type: app.type,
status: "unhealthy",
responseTime,
httpStatus: null,
lastChecked: new Date().toISOString(),
error: err.name === "AbortError" ? "Timeout (10s)" : (err.message || "Connection failed"),
};
}
}
async function checkAllApps(): Promise<AppHealthResult[]> {
const results = await Promise.all(APPS.map(checkApp));
cachedResults = results;
cacheTimestamp = Date.now();
return results;
}
export const healthRoutes = new Elysia({ prefix: "/api/health" })
.onError(({ error, set }) => {
const msg = (error as any)?.message || String(error);
if (msg === "Unauthorized") {
set.status = 401;
return { error: "Unauthorized" };
}
console.error("Health route error:", msg);
set.status = 500;
return { error: "Internal server error" };
})
// GET cached health status (or fresh if cache expired)
.get("/apps", async ({ request, headers }) => {
await requireSessionOrBearer(request, headers);
if (cachedResults && Date.now() - cacheTimestamp < CACHE_TTL) {
return {
apps: cachedResults,
cached: true,
cacheAge: Date.now() - cacheTimestamp,
};
}
const results = await checkAllApps();
return {
apps: results,
cached: false,
cacheAge: 0,
};
})
// POST force fresh check
.post("/check", async ({ request, headers }) => {
await requireSessionOrBearer(request, headers);
const results = await checkAllApps();
return {
apps: results,
cached: false,
cacheAge: 0,
};
});

View File

@@ -16,6 +16,7 @@ const SummariesPage = lazy(() => import("./pages/SummariesPage").then(m => ({ de
const AdminPage = lazy(() => import("./components/AdminPage").then(m => ({ default: m.AdminPage })));
const SecurityPage = lazy(() => import("./pages/SecurityPage").then(m => ({ default: m.SecurityPage })));
const TodosPage = lazy(() => import("./pages/TodosPage").then(m => ({ default: m.TodosPage })));
const HealthPage = lazy(() => import("./pages/HealthPage").then(m => ({ default: m.HealthPage })));
function PageLoader() {
return (
@@ -42,6 +43,7 @@ function AuthenticatedApp() {
<Route path="/summaries" element={<Suspense fallback={<PageLoader />}><SummariesPage /></Suspense>} />
<Route path="/security" element={<Suspense fallback={<PageLoader />}><SecurityPage /></Suspense>} />
<Route path="/todos" element={<Suspense fallback={<PageLoader />}><TodosPage /></Suspense>} />
<Route path="/health" element={<Suspense fallback={<PageLoader />}><HealthPage /></Suspense>} />
<Route path="/admin" element={<Suspense fallback={<PageLoader />}><AdminPage /></Suspense>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>

View File

@@ -0,0 +1,177 @@
import { useState, useEffect, useMemo } from "react";
import { Link } from "react-router-dom";
import { fetchAppHealth, forceHealthCheck } from "../lib/api";
import type { AppHealth, AppHealthResponse } from "../lib/types";
function StatusDot({ status, size = "sm" }: { status: AppHealth["status"]; size?: "sm" | "xs" }) {
const dotSize = size === "xs" ? "h-2 w-2" : "h-2.5 w-2.5";
const color =
status === "healthy" ? "bg-green-500" : status === "degraded" ? "bg-yellow-500" : "bg-red-500";
return <span className={`inline-flex rounded-full ${dotSize} ${color}`} />;
}
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
export function AppHealthWidget() {
const [data, setData] = useState<AppHealthResponse | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadHealth = async () => {
try {
const result = await fetchAppHealth();
setData(result);
} catch (err) {
console.error("Failed to load health:", err);
} finally {
setLoading(false);
}
};
const handleRefresh = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setRefreshing(true);
try {
const result = await forceHealthCheck();
setData(result);
} catch (err) {
console.error("Failed to refresh:", err);
} finally {
setRefreshing(false);
}
};
useEffect(() => {
loadHealth();
const interval = setInterval(loadHealth, 30_000);
return () => clearInterval(interval);
}, []);
const overallStatus = useMemo(() => {
if (!data) return null;
if (data.apps.some((a) => a.status === "unhealthy")) return "unhealthy";
if (data.apps.some((a) => a.status === "degraded")) return "degraded";
return "healthy";
}, [data]);
const counts = useMemo(() => {
if (!data) return { healthy: 0, degraded: 0, unhealthy: 0 };
return {
healthy: data.apps.filter((a) => a.status === "healthy").length,
degraded: data.apps.filter((a) => a.status === "degraded").length,
unhealthy: data.apps.filter((a) => a.status === "unhealthy").length,
};
}, [data]);
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm">
<div className="px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-gray-900 dark:text-gray-100">🏥 App Health</h2>
{overallStatus && (
<StatusDot status={overallStatus} />
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
disabled={refreshing}
className="text-xs text-gray-400 hover:text-amber-500 dark:hover:text-amber-400 transition disabled:opacity-50"
title="Force refresh"
>
<svg
className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<Link
to="/health"
className="text-xs text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium"
>
Details
</Link>
</div>
</div>
<div className="p-5">
{loading ? (
<div className="text-sm text-gray-400 dark:text-gray-500 italic py-4 text-center">
Checking services...
</div>
) : !data ? (
<div className="text-sm text-gray-400 dark:text-gray-500 italic py-4 text-center">
Failed to load health data
</div>
) : (
<>
{/* Summary bar */}
<div className="flex items-center gap-3 mb-4 text-xs text-gray-500 dark:text-gray-400">
{counts.healthy > 0 && (
<span className="flex items-center gap-1">
<StatusDot status="healthy" size="xs" /> {counts.healthy} healthy
</span>
)}
{counts.degraded > 0 && (
<span className="flex items-center gap-1">
<StatusDot status="degraded" size="xs" /> {counts.degraded} degraded
</span>
)}
{counts.unhealthy > 0 && (
<span className="flex items-center gap-1">
<StatusDot status="unhealthy" size="xs" /> {counts.unhealthy} unhealthy
</span>
)}
</div>
{/* Compact app list */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{data.apps.map((app) => (
<a
key={app.name}
href={app.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition group"
>
<StatusDot status={app.status} size="xs" />
<span className="text-sm text-gray-700 dark:text-gray-300 truncate flex-1 group-hover:text-amber-600 dark:group-hover:text-amber-400 transition">
{app.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono shrink-0">
{app.responseTime}ms
</span>
</a>
))}
</div>
{/* Last checked */}
{data.apps[0] && (
<p className="text-[10px] text-gray-400 dark:text-gray-600 text-center mt-3">
Last checked {timeAgo(data.apps[0].lastChecked)}
{data.cached && " (cached)"}
</p>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { useCurrentUser } from "../hooks/useCurrentUser";
import { useTasks } from "../hooks/useTasks";
@@ -6,6 +6,8 @@ import { useTheme } from "../hooks/useTheme";
import { KeyboardShortcutsModal } from "./KeyboardShortcutsModal";
import { CommandPalette } from "./CommandPalette";
import { signOut } from "../lib/auth-client";
import { fetchAppHealth } from "../lib/api";
import type { AppHealthStatus } from "../lib/types";
const navItems = [
{ to: "/", label: "Dashboard", icon: "🔨", badgeKey: null },
@@ -15,6 +17,7 @@ const navItems = [
{ to: "/activity", label: "Activity", icon: "📝", badgeKey: null },
{ to: "/summaries", label: "Summaries", icon: "📅", badgeKey: null },
{ to: "/security", label: "Security", icon: "🛡️", badgeKey: null },
{ to: "/health", label: "Health", icon: "🏥", badgeKey: "health" },
] as const;
export function DashboardLayout() {
@@ -22,10 +25,28 @@ export function DashboardLayout() {
const { tasks } = useTasks(15000);
const { theme, setTheme } = useTheme();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [healthStatus, setHealthStatus] = useState<AppHealthStatus | null>(null);
const activeTasks = useMemo(() => tasks.filter((t) => t.status === "active").length, [tasks]);
const blockedTasks = useMemo(() => tasks.filter((t) => t.status === "blocked").length, [tasks]);
// Fetch health status for sidebar indicator
useEffect(() => {
const checkHealth = async () => {
try {
const data = await fetchAppHealth();
if (data.apps.some((a) => a.status === "unhealthy")) setHealthStatus("unhealthy");
else if (data.apps.some((a) => a.status === "degraded")) setHealthStatus("degraded");
else setHealthStatus("healthy");
} catch {
setHealthStatus(null);
}
};
checkHealth();
const interval = setInterval(checkHealth, 60_000);
return () => clearInterval(interval);
}, []);
const cycleTheme = () => {
const next = theme === "light" ? "dark" : theme === "dark" ? "system" : "light";
setTheme(next);
@@ -107,6 +128,10 @@ export function DashboardLayout() {
<nav className="flex-1 px-3 py-4 space-y-1">
{navItems.map((item) => {
const badge = item.badgeKey === "queue" && activeTasks > 0 ? activeTasks : 0;
const healthDotColor =
healthStatus === "healthy" ? "bg-green-500" :
healthStatus === "degraded" ? "bg-yellow-500" :
healthStatus === "unhealthy" ? "bg-red-500" : null;
return (
<NavLink
key={item.to}
@@ -123,6 +148,9 @@ export function DashboardLayout() {
>
<span className="text-lg">{item.icon}</span>
<span className="flex-1">{item.label}</span>
{item.badgeKey === "health" && healthDotColor && (
<span className={`inline-flex rounded-full h-2 w-2 ${healthDotColor}`} />
)}
{badge > 0 && (
<span className="text-[10px] font-bold bg-amber-500 text-white px-1.5 py-0.5 rounded-full min-w-[18px] text-center leading-none">
{badge}

View File

@@ -1,4 +1,4 @@
import type { Task, Project, ProjectWithTasks, VelocityStats, Recurrence, Todo, TodoPriority } from "./types";
import type { Task, Project, ProjectWithTasks, VelocityStats, Recurrence, Todo, TodoPriority, AppHealthResponse } from "./types";
const BASE = "/api/tasks";
@@ -300,3 +300,20 @@ export async function deleteTodo(id: string): Promise<void> {
});
if (!res.ok) throw new Error("Failed to delete todo");
}
// ─── App Health API ───
export async function fetchAppHealth(): Promise<AppHealthResponse> {
const res = await fetch("/api/health/apps", { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch app health");
return res.json();
}
export async function forceHealthCheck(): Promise<AppHealthResponse> {
const res = await fetch("/api/health/check", {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error("Failed to force health check");
return res.json();
}

View File

@@ -70,6 +70,27 @@ export interface Todo {
updatedAt: string;
}
// ─── App Health ───
export type AppHealthStatus = "healthy" | "degraded" | "unhealthy";
export interface AppHealth {
name: string;
url: string;
type: "web" | "api";
status: AppHealthStatus;
responseTime: number;
httpStatus: number | null;
lastChecked: string;
error?: string;
}
export interface AppHealthResponse {
apps: AppHealth[];
cached: boolean;
cacheAge: number;
}
// ─── Tasks ───
export interface Task {

View File

@@ -2,6 +2,7 @@ import { useMemo, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTasks } from "../hooks/useTasks";
import { fetchProjects, fetchVelocityStats } from "../lib/api";
import { AppHealthWidget } from "../components/AppHealthWidget";
import type { Task, ProgressNote, Project, VelocityStats } from "../lib/types";
function StatCard({ label, value, icon, color }: { label: string; value: number; icon: string; color: string }) {
@@ -223,6 +224,9 @@ export function DashboardPage() {
{/* Velocity Chart */}
<VelocityChart stats={velocityStats} />
{/* App Health Widget */}
<AppHealthWidget />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Currently Working On */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm">

View File

@@ -0,0 +1,240 @@
import { useState, useEffect, useMemo } from "react";
import { fetchAppHealth, forceHealthCheck } from "../lib/api";
import type { AppHealth, AppHealthResponse } from "../lib/types";
function statusColor(status: AppHealth["status"]) {
switch (status) {
case "healthy":
return {
dot: "bg-green-500",
bg: "bg-green-50 dark:bg-green-900/20",
border: "border-green-200 dark:border-green-800",
text: "text-green-700 dark:text-green-400",
badge: "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400",
};
case "degraded":
return {
dot: "bg-yellow-500",
bg: "bg-yellow-50 dark:bg-yellow-900/20",
border: "border-yellow-200 dark:border-yellow-800",
text: "text-yellow-700 dark:text-yellow-400",
badge: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400",
};
case "unhealthy":
return {
dot: "bg-red-500",
bg: "bg-red-50 dark:bg-red-900/20",
border: "border-red-200 dark:border-red-800",
text: "text-red-700 dark:text-red-400",
badge: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400",
};
}
}
function StatusDot({ status }: { status: AppHealth["status"] }) {
const colors = statusColor(status);
return (
<span className="relative flex h-3 w-3">
{status === "healthy" && (
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full ${colors.dot} opacity-75`} />
)}
<span className={`relative inline-flex rounded-full h-3 w-3 ${colors.dot}`} />
</span>
);
}
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
function HealthCard({ app, large }: { app: AppHealth; large?: boolean }) {
const colors = statusColor(app.status);
return (
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
className={`block rounded-xl border ${colors.border} ${colors.bg} ${large ? "p-5" : "p-4"} hover:shadow-md transition group`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<StatusDot status={app.status} />
<h3 className={`font-semibold ${large ? "text-base" : "text-sm"} text-gray-900 dark:text-gray-100`}>
{app.name}
</h3>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium capitalize ${colors.badge}`}>
{app.status}
</span>
</div>
<div className="space-y-1.5">
<p className="text-xs text-gray-500 dark:text-gray-400 truncate group-hover:text-amber-600 dark:group-hover:text-amber-400 transition">
{app.url}
</p>
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span className={`font-mono ${app.responseTime > 5000 ? "text-yellow-600 dark:text-yellow-400" : app.responseTime > 2000 ? "text-amber-600 dark:text-amber-400" : ""}`}>
{app.responseTime}ms
</span>
{app.httpStatus && (
<span className="font-mono">
HTTP {app.httpStatus}
</span>
)}
<span className="text-gray-400 dark:text-gray-500">
{app.type === "api" ? "🔌 API" : "🌐 Web"}
</span>
</div>
{app.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{app.error}
</p>
)}
{large && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
Last checked: {timeAgo(app.lastChecked)}
</p>
)}
</div>
</a>
);
}
export function HealthPage() {
const [data, setData] = useState<AppHealthResponse | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadHealth = async () => {
try {
const result = await fetchAppHealth();
setData(result);
} catch (err) {
console.error("Failed to load health:", err);
} finally {
setLoading(false);
}
};
const handleRefresh = async () => {
setRefreshing(true);
try {
const result = await forceHealthCheck();
setData(result);
} catch (err) {
console.error("Failed to refresh health:", err);
} finally {
setRefreshing(false);
}
};
useEffect(() => {
loadHealth();
const interval = setInterval(loadHealth, 30_000);
return () => clearInterval(interval);
}, []);
const overallStatus = useMemo(() => {
if (!data) return null;
if (data.apps.some((a) => a.status === "unhealthy")) return "unhealthy";
if (data.apps.some((a) => a.status === "degraded")) return "degraded";
return "healthy";
}, [data]);
const counts = useMemo(() => {
if (!data) return { healthy: 0, degraded: 0, unhealthy: 0 };
return {
healthy: data.apps.filter((a) => a.status === "healthy").length,
degraded: data.apps.filter((a) => a.status === "degraded").length,
unhealthy: data.apps.filter((a) => a.status === "unhealthy").length,
};
}, [data]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500">
Checking services...
</div>
);
}
const bannerColors = overallStatus === "healthy"
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-800 dark:text-green-300"
: overallStatus === "degraded"
? "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-300"
: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-300";
const bannerIcon = overallStatus === "healthy" ? "✅" : overallStatus === "degraded" ? "⚠️" : "🔴";
const bannerText = overallStatus === "healthy"
? "All Systems Operational"
: overallStatus === "degraded"
? "Some Systems Degraded"
: "System Issues Detected";
return (
<div className="min-h-screen">
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-14 md:top-0 z-30">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">🏥 App Health</h1>
<p className="text-sm text-gray-400 dark:text-gray-500">Monitor all deployed services</p>
</div>
<button
onClick={handleRefresh}
disabled={refreshing}
className="px-3 py-2 text-sm font-medium rounded-lg bg-amber-500 hover:bg-amber-600 text-white transition disabled:opacity-50 flex items-center gap-2"
>
<svg
className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{refreshing ? "Checking..." : "Refresh"}
</button>
</div>
</header>
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6 space-y-6">
{/* Overall Status Banner */}
<div className={`rounded-xl border p-4 ${bannerColors} flex items-center justify-between`}>
<div className="flex items-center gap-3">
<span className="text-2xl">{bannerIcon}</span>
<div>
<h2 className="font-semibold text-lg">{bannerText}</h2>
<p className="text-sm opacity-80">
{counts.healthy} healthy · {counts.degraded} degraded · {counts.unhealthy} unhealthy
</p>
</div>
</div>
{data && (
<span className="text-xs opacity-60">
{data.cached ? `Cached (${Math.round(data.cacheAge / 1000)}s ago)` : "Fresh check"}
</span>
)}
</div>
{/* App Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{data?.apps.map((app) => (
<HealthCard key={app.name} app={app} large />
))}
</div>
</div>
</div>
);
}