165 lines
6.8 KiB
TypeScript
165 lines
6.8 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { AlertTriangle, CheckCircle2, Database, RefreshCw } from 'lucide-react';
|
|
import { RouteGuard } from '@/components/auth/Guard';
|
|
import {
|
|
type ConsistencyCheckResult,
|
|
type ConsistencyResult,
|
|
getConsistencyReport,
|
|
summarizeConsistencyResult,
|
|
} from '@/lib/consistency-api';
|
|
|
|
export default function ConsistencyPage() {
|
|
return (
|
|
<RouteGuard permission="consistency:view">
|
|
<ConsistencyPageContent />
|
|
</RouteGuard>
|
|
);
|
|
}
|
|
|
|
function ConsistencyPageContent() {
|
|
const [report, setReport] = useState<ConsistencyResult | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchReport = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
setReport(await getConsistencyReport());
|
|
} catch (e: any) {
|
|
setError(e?.message ?? '读取一致性报告失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { void fetchReport(); }, []);
|
|
|
|
const totals = useMemo(() => report ? summarizeConsistencyResult(report) : null, [report]);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-[var(--bg)]">
|
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
|
<div className="flex items-center gap-2.5">
|
|
<Database className="h-4 w-4 text-[var(--accent)]" strokeWidth={2} />
|
|
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">一致性校验</h1>
|
|
{report && <StatusPill status={report.status} />}
|
|
</div>
|
|
<button
|
|
onClick={() => void fetchReport()}
|
|
disabled={loading}
|
|
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:opacity-50"
|
|
>
|
|
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
重新校验
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
|
{error && (
|
|
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-700">{error}</div>
|
|
)}
|
|
|
|
{report && totals && (
|
|
<>
|
|
<section className="mb-4 grid gap-3 md:grid-cols-4">
|
|
<Metric label="错误" value={totals.error} tone="error" />
|
|
<Metric label="告警" value={totals.warn} tone="warn" />
|
|
<Metric label="通过" value={totals.ok} tone="ok" />
|
|
<Metric label="检查项" value={totals.total} />
|
|
</section>
|
|
|
|
<section className="mb-4 overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
|
<div className="border-b border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-2 text-[12px] font-semibold text-[var(--ink)]">
|
|
数据计数
|
|
</div>
|
|
<div className="grid gap-px bg-[var(--line)] sm:grid-cols-2 lg:grid-cols-4">
|
|
{Object.entries(report.counts).map(([key, value]) => (
|
|
<div key={key} className="flex items-center justify-between bg-[var(--bg-card)] px-3 py-2">
|
|
<span className="text-[12px] text-[var(--ink-soft)]">{key}</span>
|
|
<span className="font-mono text-[12px] font-semibold tabular-nums text-[var(--ink)]">{value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<CheckGroup title="分区键" checks={report.checks.partitionKeys} />
|
|
<CheckGroup title="孤儿引用" checks={report.checks.orphanReferences} />
|
|
<CheckGroup title="审计覆盖" checks={report.checks.auditCoverage} />
|
|
</>
|
|
)}
|
|
|
|
{!report && !error && (
|
|
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-12 text-center text-[13px] text-[var(--ink-muted)]">
|
|
{loading ? '校验中...' : '暂无校验结果'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Metric({ label, value, tone }: { label: string; value: number; tone?: 'ok' | 'warn' | 'error' }) {
|
|
const toneClass = tone === 'error'
|
|
? 'text-red-600'
|
|
: tone === 'warn'
|
|
? 'text-amber-600'
|
|
: tone === 'ok'
|
|
? 'text-emerald-600'
|
|
: 'text-[var(--ink)]';
|
|
return (
|
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3">
|
|
<div className={`font-mono text-[20px] font-semibold tabular-nums ${toneClass}`}>{value}</div>
|
|
<div className="mt-0.5 text-[12px] text-[var(--ink-muted)]">{label}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CheckGroup({ title, checks }: { title: string; checks: ConsistencyCheckResult[] }) {
|
|
return (
|
|
<section className="mb-4 overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
|
<div className="flex items-center justify-between border-b border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-2">
|
|
<h2 className="text-[12px] font-semibold text-[var(--ink)]">{title}</h2>
|
|
<span className="text-[11px] text-[var(--ink-muted)]">{checks.length}</span>
|
|
</div>
|
|
<div className="divide-y divide-[var(--line)]">
|
|
{checks.map((check) => (
|
|
<div key={check.id} className="grid grid-cols-[120px_1fr_80px] items-center gap-3 px-3 py-2 text-[12px]">
|
|
<SeverityBadge severity={check.severity} />
|
|
<div className="min-w-0">
|
|
<div className="truncate font-medium text-[var(--ink)]">{check.id}</div>
|
|
<div className="truncate text-[var(--ink-muted)]">{check.message}</div>
|
|
</div>
|
|
<div className="text-right font-mono text-[12px] tabular-nums text-[var(--ink-soft)]">{check.count}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function StatusPill({ status }: { status: ConsistencyResult['status'] }) {
|
|
const ok = status === 'pass';
|
|
return (
|
|
<span className={`inline-flex h-6 items-center gap-1 rounded-md px-2 text-[11px] font-semibold ${ok ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
|
|
{ok ? <CheckCircle2 className="h-3.5 w-3.5" /> : <AlertTriangle className="h-3.5 w-3.5" />}
|
|
{ok ? 'PASS' : 'FAIL'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function SeverityBadge({ severity }: { severity: ConsistencyCheckResult['severity'] }) {
|
|
const className = severity === 'error'
|
|
? 'bg-red-50 text-red-700 border-red-200'
|
|
: severity === 'warn'
|
|
? 'bg-amber-50 text-amber-700 border-amber-200'
|
|
: 'bg-emerald-50 text-emerald-700 border-emerald-200';
|
|
return (
|
|
<span className={`inline-flex h-6 w-20 items-center justify-center rounded-md border text-[11px] font-semibold uppercase ${className}`}>
|
|
{severity}
|
|
</span>
|
|
);
|
|
}
|