mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user