Files
hammer-queue/frontend/src/components/AppHealthWidget.tsx
Hammer 8b8d56370e
Some checks failed
CI/CD / test (push) Has been cancelled
CI/CD / deploy (push) Has been cancelled
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
2026-01-30 14:19:55 +00:00

178 lines
6.4 KiB
TypeScript

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>
);
}