Improve ops monitoring and chart loading

This commit is contained in:
2569718930@qq.com
2026-05-31 03:21:58 +08:00
parent ca72072da0
commit 2a6d8748f4
38 changed files with 3110 additions and 637 deletions
@@ -1,29 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import { RefreshCcw, ShieldCheck, Database, Cpu, HardDrive } from "lucide-react";
import { AlertTriangle, RefreshCcw, ShieldCheck, Database, Cpu, HardDrive, RadioTower } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api";
import type { SystemStatusPayload, HealthPayload } from "@/types/ops";
import type { SourceHealthPayload, SystemStatusPayload, HealthPayload } from "@/types/ops";
function sourceStatusTone(status?: string) {
if (status === "fresh") return "text-emerald-500";
if (status === "expected_wait") return "text-blue-500";
if (status === "delayed") return "text-amber-500";
if (status === "stale" || status === "missing") return "text-red-500";
return "text-slate-500";
}
function sourceStatusLabel(status?: string) {
if (status === "fresh") return "正常";
if (status === "expected_wait") return "等待更新";
if (status === "delayed") return "延迟";
if (status === "stale") return "断线";
if (status === "missing") return "缺失";
return "未知";
}
function formatAge(ageMin?: number | null) {
if (ageMin == null) return "—";
if (ageMin < 60) return `${Math.round(ageMin)}m`;
return `${(ageMin / 60).toFixed(1)}h`;
}
export function SystemPageClient() {
const [loading, setLoading] = useState(true);
const [health, setHealth] = useState<HealthPayload | null>(null);
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
const [sourceHealth, setSourceHealth] = useState<SourceHealthPayload | null>(null);
const [error, setError] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const [h, s] = await Promise.all([
const [h, s, sh] = await Promise.all([
opsApi.health(),
opsApi.systemStatus() as Promise<SystemStatusPayload>,
opsApi.sourceHealth(80) as Promise<SourceHealthPayload>,
]);
setHealth(h);
setStatus(s);
setSourceHealth(sh);
} catch (e) {
setError(String(e).slice(0, 200));
} finally {
@@ -45,6 +71,13 @@ export function SystemPageClient() {
const dbOk = status?.db?.ok ?? health?.db?.ok;
const cacheAnalysis = status?.cache?.analysis;
const sourceIssues = (sourceHealth?.cities || [])
.flatMap((city) =>
(city.sources || [])
.filter((source) => ["delayed", "stale", "missing", "unknown"].includes(String(source.status || "")))
.map((source) => ({ city: city.city, source })),
)
.slice(0, 10);
return (
<div className="space-y-6">
@@ -165,6 +198,73 @@ export function SystemPageClient() {
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RadioTower className="h-4 w-4 text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-5">
{["fresh", "expected_wait", "delayed", "stale", "missing"].map((key) => (
<div key={key} className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<div className="text-[11px] text-slate-500">{sourceStatusLabel(key)}</div>
<div className={`text-lg font-black ${sourceStatusTone(key)}`}>
{sourceHealth?.status_counts?.[key] ?? 0}
</div>
</div>
))}
</div>
{sourceIssues.length ? (
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="w-full min-w-[760px] text-left text-xs">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{sourceIssues.map(({ city, source }, index) => (
<tr key={`${city}-${source.role}-${source.source_code}-${index}`} className="border-t border-slate-100">
<td className="px-3 py-2 font-mono font-bold text-slate-800">{city}</td>
<td className="px-3 py-2">
<div className="font-semibold text-slate-800">{source.source_label || source.source_code}</div>
<div className="text-[11px] text-slate-500">{source.role}</div>
</td>
<td className={`px-3 py-2 font-bold ${sourceStatusTone(source.status)}`}>
{sourceStatusLabel(source.status)}
</td>
<td className="px-3 py-2 font-mono text-slate-600">{formatAge(source.age_min)}</td>
<td className="px-3 py-2 font-mono text-slate-600">{source.observed_at || "—"}</td>
<td className="px-3 py-2 text-slate-500">{source.reason || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
<ShieldCheck className="h-4 w-4" />
MGMKNMIIMS 线/
</div>
)}
{(sourceHealth?.cities || []).some((city) => !city.cache_exists) ? (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold text-amber-700">
<AlertTriangle className="h-4 w-4" />
full/panel
</div>
) : null}
</CardContent>
</Card>
{/* DB Path */}
{status?.db?.db_path ? (
<Card>