feat: add app health monitoring section
- 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:
@@ -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 }) => {
|
||||
|
||||
154
backend/src/routes/health.ts
Normal file
154
backend/src/routes/health.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
177
frontend/src/components/AppHealthWidget.tsx
Normal file
177
frontend/src/components/AppHealthWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
240
frontend/src/pages/HealthPage.tsx
Normal file
240
frontend/src/pages/HealthPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user