- 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
178 lines
6.4 KiB
TypeScript
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>
|
|
);
|
|
}
|