Give the operator a provider-health readout

The per-user retry ledger has recorded every AI delivery outcome for a
long time, but no surface ever read it for the operator: a central
provider could fail on every call for weeks and the admin console said
nothing. The AI settings section now carries a health card, backed by a
new admin-only endpoint that folds the ledger into one row per provider
type: users tracked, users currently failing, the worst uninterrupted
failure run, and the last success and failure instants. Operator-managed
types sort first because their failure takes the shared fallback down
for everyone whose chain relies on it. Counts and timestamps only; the
response never names a user.
This commit is contained in:
Marc-André Bombeck committed 2026-08-27 11:26:40 +02:00
1 parent 1d7bd82ca8
commit 6c4682acfe
12 files changed
+488

No files matched your search

@@ -0,0 +1,130 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Activity } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
/**
* v1.37.31 — the delivery-health readout the `provider_health` ledger never
* had. The ledger records every AI call outcome per user and provider tag;
* before this card, a failing central provider was invisible until someone
* read the database. The card shows one row per provider type: how many
* users' chains touch it, how many of them are currently failing, the worst
* uninterrupted failure run, and the last success / failure instants.
*/
interface ProviderHealthRow {
providerType: string;
tracked: number;
failing: number;
maxConsecutiveFailures: number;
lastOkAt: string | null;
lastFailureAt: string | null;
}
export function ProviderHealthSection() {
const { t } = useTranslations();
const { data, isPending, isError } = useQuery({
queryKey: queryKeys.adminProviderHealth(),
queryFn: () =>
apiGet<{ providers: ProviderHealthRow[] }>("/api/admin/provider-health"),
});
const typeLabel = (type: string): string => {
switch (type) {
case "admin-openai":
return t("admin.providerHealth.typeAdminOpenai");
case "admin-codex":
return t("admin.providerHealth.typeAdminCodex");
case "openai":
return t("admin.providerHealth.typeOpenai");
case "anthropic":
return t("admin.providerHealth.typeAnthropic");
case "codex":
return t("admin.providerHealth.typeCodex");
case "local":
return t("admin.providerHealth.typeLocal");
case "openai-compatible":
return t("admin.providerHealth.typeGateway");
default:
return type;
}
};
const providers = data?.providers ?? [];
return (
<SettingsCard>
<SettingsCardHeader
icon={Activity}
title={t("admin.providerHealth.title")}
description={t("admin.providerHealth.description")}
/>
{isPending ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.loading")}
</p>
) : isError ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.loadError")}
</p>
) : providers.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.empty")}
</p>
) : (
<ul className="flex flex-col gap-3" data-testid="provider-health-list">
{providers.map((p) => (
<li
key={p.providerType}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border p-3"
data-provider-type={p.providerType}
>
<div className="flex min-w-0 flex-col gap-1">
<span className="text-sm font-medium">
{typeLabel(p.providerType)}
</span>
<span className="text-muted-foreground text-xs">
{t("admin.providerHealth.trackedUsers", {
count: p.tracked,
})}
{p.lastOkAt
? ` · ${t("admin.providerHealth.lastOk", {
at: formatDateTime(p.lastOkAt),
})}`
: ""}
{p.failing > 0 && p.lastFailureAt
? ` · ${t("admin.providerHealth.lastFailure", {
at: formatDateTime(p.lastFailureAt),
})}`
: ""}
</span>
</div>
{p.failing === 0 ? (
<Badge variant="secondary">
{t("admin.providerHealth.statusHealthy")}
</Badge>
) : (
<Badge variant="destructive">
{t("admin.providerHealth.statusFailing", {
failing: p.failing,
tracked: p.tracked,
run: p.maxConsecutiveFailures,
})}
</Badge>
)}
</li>
))}
</ul>
)}
</SettingsCard>
);
}