Add ops dashboards for training data and model coverage

This commit is contained in:
2569718930@qq.com
2026-04-03 00:57:19 +08:00
parent 37cd8b8166
commit 781c247952
24 changed files with 3634 additions and 907 deletions
+431
View File
@@ -31,6 +31,96 @@ type SystemStatusPayload = {
};
};
integrations?: Record<string, unknown>;
training_data?: {
db_ok?: boolean;
db_path?: string;
error?: string;
truth_records?: {
row_count?: number;
cities_count?: number;
min_date?: string | null;
max_date?: string | null;
source_counts?: Record<string, number>;
};
truth_revisions?: {
row_count?: number;
last_updated_at?: number | null;
};
training_features?: {
row_count?: number;
cities_count?: number;
min_date?: string | null;
max_date?: string | null;
};
city_coverage?: {
total_cities?: number;
with_truth_rows?: number;
with_feature_rows?: number;
highlighted?: Array<{
city: string;
name?: string;
settlement_source?: string;
settlement_station_code?: string;
truth_rows?: number;
feature_rows?: number;
truth_min_date?: string | null;
truth_max_date?: string | null;
feature_min_date?: string | null;
feature_max_date?: string | null;
}>;
top_gaps?: Array<{
city: string;
name?: string;
settlement_source?: string;
settlement_station_code?: string;
truth_rows?: number;
feature_rows?: number;
truth_min_date?: string | null;
truth_max_date?: string | null;
feature_min_date?: string | null;
feature_max_date?: string | null;
}>;
};
model_city_coverage?: {
cities_with_emos_training?: number;
cities_with_lgbm_candidates?: number;
weakest?: Array<{
city: string;
name?: string;
settlement_source?: string;
truth_rows?: number;
feature_rows?: number;
emos_training_samples?: number;
emos_snapshot_samples?: number;
emos_evaluation_samples?: number;
lgbm_candidate_rows?: number;
}>;
strongest?: Array<{
city: string;
name?: string;
settlement_source?: string;
truth_rows?: number;
feature_rows?: number;
emos_training_samples?: number;
emos_snapshot_samples?: number;
emos_evaluation_samples?: number;
lgbm_candidate_rows?: number;
}>;
};
artifacts?: {
emos_training_samples?: number;
emos_snapshot_samples?: number;
emos_daily_record_samples?: number;
emos_evaluation_samples?: number;
emos_shadow_samples?: number;
emos_delta_crps?: number | null;
lgbm_sample_count?: number;
lgbm_train_count?: number;
lgbm_validation_count?: number;
lgbm_validation_mae?: number | null;
lgbm_validation_deb_mae?: number | null;
};
};
};
type PaymentRuntimePayload = {
@@ -140,6 +230,16 @@ function formatDateTime(value?: string | null) {
return date.toLocaleString("zh-CN", { hour12: false });
}
function formatUnixDateTime(value?: number | null) {
if (!value) return "-";
return formatDateTime(new Date(value * 1000).toISOString());
}
function formatMetric(value?: number | null, digits = 3) {
if (value === null || value === undefined || Number.isNaN(value)) return "-";
return Number(value).toFixed(digits);
}
function maskUrl(value?: string | null) {
if (!value) return "-";
if (value.length <= 40) return value;
@@ -313,6 +413,36 @@ export function OpsDashboard() {
}, [incidentReasonFilter, load, loadPaymentIncidents]);
const rolloutDecision = status?.probability?.rollout?.decision;
const trainingData = status?.training_data;
const truthRecords = trainingData?.truth_records;
const truthRevisions = trainingData?.truth_revisions;
const trainingFeatures = trainingData?.training_features;
const cityCoverage = trainingData?.city_coverage;
const modelCityCoverage = trainingData?.model_city_coverage;
const trainingArtifacts = trainingData?.artifacts;
const truthSources = Object.entries(truthRecords?.source_counts || {});
const cityCoverageRows = useMemo(() => {
const modelIndex = new Map(
[...(modelCityCoverage?.strongest || []), ...(modelCityCoverage?.weakest || [])].map((entry) => [entry.city, entry]),
);
const rows: Array<Record<string, unknown>> = [];
for (const entry of [...(cityCoverage?.top_gaps || []), ...(cityCoverage?.highlighted || [])]) {
if (!entry || rows.some((row) => row.city === entry.city)) continue;
const modelEntry = modelIndex.get(entry.city);
rows.push({
...entry,
emos_training_samples: modelEntry?.emos_training_samples ?? 0,
emos_evaluation_samples: modelEntry?.emos_evaluation_samples ?? 0,
lgbm_candidate_rows: modelEntry?.lgbm_candidate_rows ?? entry.feature_rows ?? 0,
});
}
rows.sort((a, b) => {
const aScore = Number(a.truth_rows || 0) + Number(a.feature_rows || 0) + Number(a.emos_training_samples || 0);
const bScore = Number(b.truth_rows || 0) + Number(b.feature_rows || 0) + Number(b.emos_training_samples || 0);
return aScore - bScore || String(a.city || "").localeCompare(String(b.city || ""));
});
return rows;
}, [cityCoverage?.highlighted, cityCoverage?.top_gaps, modelCityCoverage?.strongest, modelCityCoverage?.weakest]);
const funnelSteps = useMemo(() => {
const events = analyticsFunnel?.events || {};
@@ -682,6 +812,307 @@ export function OpsDashboard() {
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[1.2fr_1.2fr_1fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> 14 </CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-300">
<div className="grid gap-3 sm:grid-cols-2">
<MobileField label="truth rows" value={String(truthRecords?.row_count ?? 0)} mono />
<MobileField label="cities" value={String(truthRecords?.cities_count ?? 0)} mono />
<MobileField label="min date" value={truthRecords?.min_date || "-"} mono />
<MobileField label="max date" value={truthRecords?.max_date || "-"} mono />
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500"></div>
<div className="mt-3 flex flex-wrap gap-2">
{truthSources.length ? (
truthSources.map(([source, count]) => (
<Badge key={source} variant="secondary">
{source}: {count}
</Badge>
))
) : (
<span className="text-xs text-slate-500"></span>
)}
</div>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="flex justify-between gap-3">
<span>revision rows</span>
<span>{truthRevisions?.row_count ?? 0}</span>
</div>
<div className="mt-2 flex justify-between gap-3 text-xs text-slate-500">
<span>last revision</span>
<span>{formatUnixDateTime(truthRevisions?.last_updated_at)}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-300">
<div className="grid gap-3 sm:grid-cols-2">
<MobileField label="feature rows" value={String(trainingFeatures?.row_count ?? 0)} mono />
<MobileField label="cities" value={String(trainingFeatures?.cities_count ?? 0)} mono />
<MobileField label="min date" value={trainingFeatures?.min_date || "-"} mono />
<MobileField label="max date" value={trainingFeatures?.max_date || "-"} mono />
</div>
<div className="rounded-2xl border border-dashed border-slate-700 bg-slate-950/40 px-3 py-2 text-xs text-slate-500">
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3 text-xs text-slate-500">
DB: {trainingData?.db_ok ? "ok" : "error"} · {trainingData?.db_path || "-"}
{trainingData?.error ? <div className="mt-2 text-rose-300">{trainingData.error}</div> : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> EMOS / LGBM </CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">EMOS</div>
<div className="mt-2 space-y-2">
<div className="flex justify-between gap-3"><span>training samples</span><span>{trainingArtifacts?.emos_training_samples ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>evaluation samples</span><span>{trainingArtifacts?.emos_evaluation_samples ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>shadow samples</span><span>{trainingArtifacts?.emos_shadow_samples ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>delta CRPS</span><span>{formatMetric(trainingArtifacts?.emos_delta_crps)}</span></div>
</div>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">LGBM</div>
<div className="mt-2 space-y-2">
<div className="flex justify-between gap-3"><span>sample count</span><span>{trainingArtifacts?.lgbm_sample_count ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>train / val</span><span>{trainingArtifacts?.lgbm_train_count ?? 0} / {trainingArtifacts?.lgbm_validation_count ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>val mae</span><span>{formatMetric(trainingArtifacts?.lgbm_validation_mae)}</span></div>
<div className="flex justify-between gap-3"><span>DEB val mae</span><span>{formatMetric(trainingArtifacts?.lgbm_validation_deb_mae)}</span></div>
</div>
</div>
</CardContent>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">
<Card>
<CardHeader>
<CardTitle>Wunderground </CardTitle>
<CardDescription> WU </CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">truth cities {cityCoverage?.with_truth_rows ?? 0}/{cityCoverage?.total_cities ?? 0}</Badge>
<Badge variant="secondary">feature cities {cityCoverage?.with_feature_rows ?? 0}/{cityCoverage?.total_cities ?? 0}</Badge>
</div>
{(cityCoverage?.highlighted || []).map((entry) => (
<div key={entry.city} className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-slate-100">{entry.name || entry.city}</div>
<Badge variant="secondary">{entry.settlement_source || "-"}</Badge>
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<MobileField label="station" value={entry.settlement_station_code || "-"} mono />
<MobileField label="truth rows" value={String(entry.truth_rows ?? 0)} mono />
<MobileField label="truth max" value={entry.truth_max_date || "-"} mono />
<MobileField label="feature rows" value={String(entry.feature_rows ?? 0)} mono />
</div>
</div>
))}
{!(cityCoverage?.highlighted || []).length ? (
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2 text-slate-500"> Wunderground </div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> truth / feature </CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
{(cityCoverage?.top_gaps || []).length ? (
<div className="space-y-2">
{(cityCoverage?.top_gaps || []).map((entry) => (
<div key={entry.city} className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-slate-100">{entry.name || entry.city}</div>
<div className="mt-1 text-xs text-slate-500">
{entry.city} · {entry.settlement_source || "-"} · {entry.settlement_station_code || "-"}
</div>
</div>
<div className="flex gap-2 text-xs">
<Badge variant={entry.truth_rows ? "success" : "warning"}>truth {entry.truth_rows ?? 0}</Badge>
<Badge variant={entry.feature_rows ? "success" : "warning"}>feature {entry.feature_rows ?? 0}</Badge>
</div>
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-2 text-xs text-slate-500">
<div>truth range: {entry.truth_min_date || "-"} {entry.truth_max_date || "-"}</div>
<div>feature range: {entry.feature_min_date || "-"} {entry.feature_max_date || "-"}</div>
</div>
</div>
))}
</div>
) : (
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2 text-slate-500"></div>
)}
</CardContent>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> EMOS </CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">EMOS cities {modelCityCoverage?.cities_with_emos_training ?? 0}</Badge>
<Badge variant="secondary">LGBM candidate cities {modelCityCoverage?.cities_with_lgbm_candidates ?? 0}</Badge>
</div>
<div className="space-y-2">
{(modelCityCoverage?.strongest || []).map((entry) => (
<div key={entry.city} className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-slate-100">{entry.name || entry.city}</div>
<div className="mt-1 text-xs text-slate-500">{entry.city} · {entry.settlement_source || "-"}</div>
</div>
<div className="flex gap-2 text-xs">
<Badge variant="success">EMOS {entry.emos_training_samples ?? 0}</Badge>
<Badge variant="success">LGBM {entry.lgbm_candidate_rows ?? 0}</Badge>
</div>
</div>
</div>
))}
{!(modelCityCoverage?.strongest || []).length ? (
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2 text-slate-500"></div>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> EMOS/LGBM </CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
{(modelCityCoverage?.weakest || []).length ? (
<div className="space-y-2">
{(modelCityCoverage?.weakest || []).map((entry) => (
<div key={entry.city} className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-slate-100">{entry.name || entry.city}</div>
<div className="mt-1 text-xs text-slate-500">{entry.city} · {entry.settlement_source || "-"}</div>
</div>
<div className="flex gap-2 text-xs">
<Badge variant={entry.truth_rows ? "secondary" : "warning"}>truth {entry.truth_rows ?? 0}</Badge>
<Badge variant={entry.emos_training_samples ? "secondary" : "warning"}>EMOS {entry.emos_training_samples ?? 0}</Badge>
<Badge variant={entry.lgbm_candidate_rows ? "secondary" : "warning"}>LGBM {entry.lgbm_candidate_rows ?? 0}</Badge>
</div>
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-3 text-xs text-slate-500">
<div>feature rows: {entry.feature_rows ?? 0}</div>
<div>snapshot rows: {entry.emos_snapshot_samples ?? 0}</div>
<div>eval rows: {entry.emos_evaluation_samples ?? 0}</div>
</div>
</div>
))}
</div>
) : (
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2 text-slate-500"></div>
)}
</CardContent>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> truth / feature / EMOS / LGBM DEB</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 md:hidden">
{cityCoverageRows.map((entry) => (
<div key={String(entry.city)} className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="font-semibold text-slate-100">{String(entry.name || entry.city || "-")}</div>
<div className="mt-1 text-xs text-slate-500">
{String(entry.city || "-")} · {String(entry.settlement_source || "-")} · {String(entry.settlement_station_code || "-")}
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<MobileField label="truth" value={String(entry.truth_rows || 0)} mono />
<MobileField label="feature" value={String(entry.feature_rows || 0)} mono />
<MobileField label="EMOS" value={String(entry.emos_training_samples || 0)} mono />
<MobileField label="LGBM" value={String(entry.lgbm_candidate_rows || 0)} mono />
</div>
</div>
))}
</div>
<div className="hidden overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/70 md:block">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-900/80 text-xs uppercase tracking-[0.14em] text-slate-500">
<tr>
<th className="px-4 py-3">City</th>
<th className="px-4 py-3">Source</th>
<th className="px-4 py-3">Station</th>
<th className="px-4 py-3">Truth</th>
<th className="px-4 py-3">Feature</th>
<th className="px-4 py-3">EMOS</th>
<th className="px-4 py-3">LGBM</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{cityCoverageRows.map((entry) => (
<tr key={String(entry.city)}>
<td className="px-4 py-3">
<div className="font-semibold text-slate-100">{String(entry.name || entry.city || "-")}</div>
<div className="mt-1 text-xs text-slate-500">{String(entry.city || "-")}</div>
</td>
<td className="px-4 py-3">{String(entry.settlement_source || "-")}</td>
<td className="px-4 py-3">{String(entry.settlement_station_code || "-")}</td>
<td className="px-4 py-3">
<Badge variant={Number(entry.truth_rows || 0) > 0 ? "success" : "warning"}>{String(entry.truth_rows || 0)}</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={Number(entry.feature_rows || 0) > 0 ? "success" : "warning"}>{String(entry.feature_rows || 0)}</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={Number(entry.emos_training_samples || 0) > 0 ? "success" : "warning"}>{String(entry.emos_training_samples || 0)}</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={Number(entry.lgbm_candidate_rows || 0) > 0 ? "success" : "warning"}>{String(entry.lgbm_candidate_rows || 0)}</Badge>
</td>
</tr>
))}
{!cityCoverageRows.length ? (
<tr>
<td className="px-4 py-4 text-slate-500" colSpan={7}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">