mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -6454,6 +6454,25 @@
|
||||
"saved": "KI-Server-Schlüssel gespeichert",
|
||||
"error": "Speichern des KI-Server-Schlüssels fehlgeschlagen"
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Zustand der KI-Provider",
|
||||
"description": "Zustellstatus jedes KI-Providers, zusammengefasst aus dem Wiederholungsregister pro Nutzer.",
|
||||
"loading": "Provider-Zustand wird geladen…",
|
||||
"loadError": "Der Provider-Zustand konnte nicht geladen werden.",
|
||||
"empty": "Es wurden noch keine KI-Aufrufe aufgezeichnet.",
|
||||
"trackedUsers": "{count} Nutzer erfasst",
|
||||
"lastOk": "letzter Erfolg {at}",
|
||||
"lastFailure": "letzter Fehlschlag {at}",
|
||||
"statusHealthy": "In Ordnung",
|
||||
"statusFailing": "{failing} von {tracked} scheitern, {run} in Folge",
|
||||
"typeAdminOpenai": "Server-Schlüssel (Betreiber)",
|
||||
"typeAdminCodex": "Zentrales ChatGPT (Betreiber)",
|
||||
"typeOpenai": "OpenAI-Schlüssel (persönlich)",
|
||||
"typeAnthropic": "Anthropic-Schlüssel (persönlich)",
|
||||
"typeCodex": "ChatGPT-Anmeldung (persönlich)",
|
||||
"typeLocal": "Lokales Modell (persönlich)",
|
||||
"typeGateway": "OpenAI-kompatibles Gateway (persönlich)"
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Auswertungs-Qualität",
|
||||
"loading": "Feedback-Zusammenfassung wird geladen…",
|
||||
|
||||
@@ -6454,6 +6454,25 @@
|
||||
"saved": "AI server key settings saved",
|
||||
"error": "Saving the AI server key failed"
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "AI provider health",
|
||||
"description": "Delivery state of every AI provider, folded from the per-user retry ledger.",
|
||||
"loading": "Loading provider health…",
|
||||
"loadError": "Provider health could not be loaded.",
|
||||
"empty": "No AI calls have been recorded yet.",
|
||||
"trackedUsers": "{count} users tracked",
|
||||
"lastOk": "last success {at}",
|
||||
"lastFailure": "last failure {at}",
|
||||
"statusHealthy": "Healthy",
|
||||
"statusFailing": "{failing} of {tracked} failing, {run} in a row",
|
||||
"typeAdminOpenai": "Server key (operator)",
|
||||
"typeAdminCodex": "Central ChatGPT (operator)",
|
||||
"typeOpenai": "OpenAI key (personal)",
|
||||
"typeAnthropic": "Anthropic key (personal)",
|
||||
"typeCodex": "ChatGPT sign-in (personal)",
|
||||
"typeLocal": "Local model (personal)",
|
||||
"typeGateway": "OpenAI-compatible gateway (personal)"
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Insights Quality",
|
||||
"loading": "Loading feedback summary…",
|
||||
|
||||
@@ -6454,6 +6454,25 @@
|
||||
"saved": "Ajustes de la clave de IA del servidor guardados",
|
||||
"error": "No se pudo guardar la clave de IA del servidor"
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Estado de los proveedores de IA",
|
||||
"description": "Estado de entrega de cada proveedor de IA, agregado del registro de reintentos por usuario.",
|
||||
"loading": "Cargando el estado de los proveedores…",
|
||||
"loadError": "No se pudo cargar el estado de los proveedores.",
|
||||
"empty": "Aún no se han registrado llamadas de IA.",
|
||||
"trackedUsers": "{count} usuarios registrados",
|
||||
"lastOk": "último éxito {at}",
|
||||
"lastFailure": "último fallo {at}",
|
||||
"statusHealthy": "Correcto",
|
||||
"statusFailing": "{failing} de {tracked} fallan, {run} seguidos",
|
||||
"typeAdminOpenai": "Clave del servidor (operador)",
|
||||
"typeAdminCodex": "ChatGPT central (operador)",
|
||||
"typeOpenai": "Clave de OpenAI (personal)",
|
||||
"typeAnthropic": "Clave de Anthropic (personal)",
|
||||
"typeCodex": "Inicio de sesión de ChatGPT (personal)",
|
||||
"typeLocal": "Modelo local (personal)",
|
||||
"typeGateway": "Pasarela compatible con OpenAI (personal)"
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Calidad de los análisis",
|
||||
"loading": "Cargando resumen de comentarios...",
|
||||
|
||||
@@ -6454,6 +6454,25 @@
|
||||
"saved": "Paramètres de la clé IA du serveur enregistrés",
|
||||
"error": "Échec de l'enregistrement de la clé IA du serveur"
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "État des fournisseurs d'IA",
|
||||
"description": "État de livraison de chaque fournisseur d'IA, agrégé depuis le registre de reprise par utilisateur.",
|
||||
"loading": "Chargement de l'état des fournisseurs…",
|
||||
"loadError": "Impossible de charger l'état des fournisseurs.",
|
||||
"empty": "Aucun appel d'IA n'a encore été enregistré.",
|
||||
"trackedUsers": "{count} utilisateurs suivis",
|
||||
"lastOk": "dernier succès {at}",
|
||||
"lastFailure": "dernier échec {at}",
|
||||
"statusHealthy": "En bon état",
|
||||
"statusFailing": "{failing} sur {tracked} en échec, {run} d'affilée",
|
||||
"typeAdminOpenai": "Clé du serveur (opérateur)",
|
||||
"typeAdminCodex": "ChatGPT central (opérateur)",
|
||||
"typeOpenai": "Clé OpenAI (personnelle)",
|
||||
"typeAnthropic": "Clé Anthropic (personnelle)",
|
||||
"typeCodex": "Connexion ChatGPT (personnelle)",
|
||||
"typeLocal": "Modèle local (personnel)",
|
||||
"typeGateway": "Passerelle compatible OpenAI (personnelle)"
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Qualité des analyses",
|
||||
"loading": "Chargement du résumé des retours...",
|
||||
|
||||
@@ -6454,6 +6454,25 @@
|
||||
"saved": "Impostazioni della chiave IA del server salvate",
|
||||
"error": "Salvataggio della chiave IA del server non riuscito"
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Stato dei provider IA",
|
||||
"description": "Stato di consegna di ogni provider IA, aggregato dal registro dei tentativi per utente.",
|
||||
"loading": "Caricamento dello stato dei provider…",
|
||||
"loadError": "Impossibile caricare lo stato dei provider.",
|
||||
"empty": "Non è stata ancora registrata alcuna chiamata IA.",
|
||||
"trackedUsers": "{count} utenti tracciati",
|
||||
"lastOk": "ultimo successo {at}",
|
||||
"lastFailure": "ultimo errore {at}",
|
||||
"statusHealthy": "Regolare",
|
||||
"statusFailing": "{failing} su {tracked} in errore, {run} di fila",
|
||||
"typeAdminOpenai": "Chiave del server (operatore)",
|
||||
"typeAdminCodex": "ChatGPT centrale (operatore)",
|
||||
"typeOpenai": "Chiave OpenAI (personale)",
|
||||
"typeAnthropic": "Chiave Anthropic (personale)",
|
||||
"typeCodex": "Accesso ChatGPT (personale)",
|
||||
"typeLocal": "Modello locale (personale)",
|
||||
"typeGateway": "Gateway compatibile OpenAI (personale)"
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Qualità delle analisi",
|
||||
"loading": "Caricamento riepilogo feedback...",
|
||||
|
||||
@@ -6454,6 +6454,25 @@
|
||||
"saved": "Zapisano ustawienia serwerowego klucza AI",
|
||||
"error": "Nie udało się zapisać serwerowego klucza AI"
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Stan dostawców AI",
|
||||
"description": "Stan dostarczania każdego dostawcy AI, zebrany z rejestru ponowień na użytkownika.",
|
||||
"loading": "Ładowanie stanu dostawców…",
|
||||
"loadError": "Nie udało się załadować stanu dostawców.",
|
||||
"empty": "Nie zarejestrowano jeszcze żadnych wywołań AI.",
|
||||
"trackedUsers": "Śledzeni użytkownicy: {count}",
|
||||
"lastOk": "ostatni sukces {at}",
|
||||
"lastFailure": "ostatnia awaria {at}",
|
||||
"statusHealthy": "Sprawny",
|
||||
"statusFailing": "{failing} z {tracked} zawodzi, {run} z rzędu",
|
||||
"typeAdminOpenai": "Klucz serwera (operator)",
|
||||
"typeAdminCodex": "Centralny ChatGPT (operator)",
|
||||
"typeOpenai": "Klucz OpenAI (osobisty)",
|
||||
"typeAnthropic": "Klucz Anthropic (osobisty)",
|
||||
"typeCodex": "Logowanie ChatGPT (osobiste)",
|
||||
"typeLocal": "Model lokalny (osobisty)",
|
||||
"typeGateway": "Brama zgodna z OpenAI (osobista)"
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Jakość analiz",
|
||||
"loading": "Ładowanie podsumowania opinii...",
|
||||
|
||||
@@ -258,6 +258,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/provider-health": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/notifications/reminder-check": {
|
||||
kind: "adminConsole",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { AboutSection } from "@/components/settings/about-section";
|
||||
import { AiQualitySection } from "@/components/admin/ai-quality-section";
|
||||
import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
|
||||
import { ProviderHealthSection } from "@/components/admin/provider-health-section";
|
||||
import { CentralCodexSection } from "@/components/admin/central-codex-section";
|
||||
import { AssistantSection } from "@/components/admin/assistant-section";
|
||||
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
|
||||
@@ -89,6 +90,7 @@ export function AdminSectionRenderer({
|
||||
<AssistantSection />
|
||||
<AiServerKeySection />
|
||||
<CentralCodexSection />
|
||||
<ProviderHealthSection />
|
||||
<CoachFeedbackSection />
|
||||
<AiQualitySection />
|
||||
</SectionFrame>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* v1.37.31 — contract for the operator provider-health readout
|
||||
* (`/api/admin/provider-health`). The per-user retry ledger existed for a
|
||||
* long time with no operator surface; this pins the fold: one row per
|
||||
* provider type, failing counted from the LAST result only, the worst
|
||||
* uninterrupted failure run taken across failing users, central types
|
||||
* sorted first, and no per-user data in the response.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
providerHealth: {
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/api-handler";
|
||||
|
||||
const groupBy = vi.mocked(prisma.providerHealth.groupBy);
|
||||
|
||||
function group(
|
||||
providerType: string,
|
||||
lastResult: string,
|
||||
count: number,
|
||||
max: {
|
||||
consecutiveFailures?: number;
|
||||
lastFailureAt?: Date | null;
|
||||
lastOkAt?: Date | null;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
providerType,
|
||||
lastResult,
|
||||
_count: { _all: count },
|
||||
_max: {
|
||||
consecutiveFailures: max.consecutiveFailures ?? 0,
|
||||
lastFailureAt: max.lastFailureAt ?? null,
|
||||
lastOkAt: max.lastOkAt ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("GET /api/admin/provider-health", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue({
|
||||
user: { id: "admin1" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("is admin-gated before it touches the ledger", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(new Error("forbidden"));
|
||||
|
||||
await expect(GET()).rejects.toThrow("forbidden");
|
||||
expect(groupBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("folds ok and failing groups of one type into a single row", async () => {
|
||||
groupBy.mockResolvedValue([
|
||||
group("admin-openai", "ok", 3, {
|
||||
lastOkAt: new Date("2026-08-27T07:00:00Z"),
|
||||
}),
|
||||
group("admin-openai", "hard_failed", 2, {
|
||||
consecutiveFailures: 3334,
|
||||
lastFailureAt: new Date("2026-08-27T06:00:00Z"),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.providers).toEqual([
|
||||
{
|
||||
providerType: "admin-openai",
|
||||
tracked: 5,
|
||||
failing: 2,
|
||||
maxConsecutiveFailures: 3334,
|
||||
lastOkAt: "2026-08-27T07:00:00.000Z",
|
||||
lastFailureAt: "2026-08-27T06:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts auth_failed as failing and keeps an all-ok type at zero", async () => {
|
||||
groupBy.mockResolvedValue([
|
||||
group("codex", "auth_failed", 1, { consecutiveFailures: 7 }),
|
||||
group("local", "ok", 4),
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
const byType = Object.fromEntries(
|
||||
body.data.providers.map(
|
||||
(p: { providerType: string; failing: number }) => [
|
||||
p.providerType,
|
||||
p.failing,
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(byType).toEqual({ codex: 1, local: 0 });
|
||||
});
|
||||
|
||||
it("sorts the operator-managed types first, the rest alphabetically", async () => {
|
||||
groupBy.mockResolvedValue([
|
||||
group("local", "ok", 1),
|
||||
group("anthropic", "ok", 1),
|
||||
group("admin-codex", "ok", 1),
|
||||
group("admin-openai", "ok", 1),
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(
|
||||
body.data.providers.map((p: { providerType: string }) => p.providerType),
|
||||
).toEqual(["admin-openai", "admin-codex", "anthropic", "local"]);
|
||||
});
|
||||
|
||||
it("answers an empty ledger with an empty list, not an error", async () => {
|
||||
groupBy.mockResolvedValue([] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.providers).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* v1.37.31 — per-provider delivery health for the operator.
|
||||
*
|
||||
* The per-user retry ledger (`provider_health`) has recorded every AI
|
||||
* delivery outcome for a long time, but no surface ever read it for the
|
||||
* operator: a central provider (`admin-openai` / `admin-codex`) could fail
|
||||
* on every call for weeks and the admin console said nothing. This endpoint
|
||||
* folds the ledger into one row per provider type so the console can show
|
||||
* it. Read-only, admin-cookie-only, and it exposes counts and timestamps
|
||||
* only — never which user a row belongs to.
|
||||
*/
|
||||
|
||||
interface ProviderHealthSummary {
|
||||
providerType: string;
|
||||
/** Users whose chain has recorded at least one outcome for this type. */
|
||||
tracked: number;
|
||||
/** Users whose LAST outcome for this type was a failure. */
|
||||
failing: number;
|
||||
/** Highest uninterrupted failure run across those users. */
|
||||
maxConsecutiveFailures: number;
|
||||
lastOkAt: string | null;
|
||||
lastFailureAt: string | null;
|
||||
}
|
||||
|
||||
/** The two operator-managed tags sort first — they affect every user on the chain. */
|
||||
const CENTRAL_TYPES = ["admin-openai", "admin-codex"];
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.provider-health.get" } });
|
||||
|
||||
const groups = await prisma.providerHealth.groupBy({
|
||||
by: ["providerType", "lastResult"],
|
||||
_count: { _all: true },
|
||||
_max: {
|
||||
consecutiveFailures: true,
|
||||
lastFailureAt: true,
|
||||
lastOkAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const byType = new Map<string, ProviderHealthSummary>();
|
||||
for (const g of groups) {
|
||||
const row = byType.get(g.providerType) ?? {
|
||||
providerType: g.providerType,
|
||||
tracked: 0,
|
||||
failing: 0,
|
||||
maxConsecutiveFailures: 0,
|
||||
lastOkAt: null,
|
||||
lastFailureAt: null,
|
||||
};
|
||||
row.tracked += g._count._all;
|
||||
if (g.lastResult !== "ok") {
|
||||
row.failing += g._count._all;
|
||||
row.maxConsecutiveFailures = Math.max(
|
||||
row.maxConsecutiveFailures,
|
||||
g._max.consecutiveFailures ?? 0,
|
||||
);
|
||||
}
|
||||
const ok = g._max.lastOkAt?.toISOString() ?? null;
|
||||
if (ok && (!row.lastOkAt || ok > row.lastOkAt)) row.lastOkAt = ok;
|
||||
const fail = g._max.lastFailureAt?.toISOString() ?? null;
|
||||
if (fail && (!row.lastFailureAt || fail > row.lastFailureAt)) {
|
||||
row.lastFailureAt = fail;
|
||||
}
|
||||
byType.set(g.providerType, row);
|
||||
}
|
||||
|
||||
const providers = [...byType.values()].sort((a, b) => {
|
||||
const ca = CENTRAL_TYPES.indexOf(a.providerType);
|
||||
const cb = CENTRAL_TYPES.indexOf(b.providerType);
|
||||
if (ca !== -1 || cb !== -1) {
|
||||
return (
|
||||
(ca === -1 ? CENTRAL_TYPES.length : ca) -
|
||||
(cb === -1 ? CENTRAL_TYPES.length : cb)
|
||||
);
|
||||
}
|
||||
return a.providerType.localeCompare(b.providerType);
|
||||
});
|
||||
|
||||
return apiSuccess({ providers });
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,11 @@ export const adminKeys = {
|
||||
* `insightsSettings.hasAdminKey` flag mirrors the configured state.
|
||||
*/
|
||||
adminAiServerKey: () => ["admin", "ai-server-key"] as const,
|
||||
/**
|
||||
* v1.37.31 — per-provider delivery health folded from the per-user
|
||||
* retry ledger. Read via `/api/admin/provider-health`.
|
||||
*/
|
||||
adminProviderHealth: () => ["admin", "provider-health"] as const,
|
||||
/**
|
||||
* Operator-shared central Codex (ChatGPT subscription) connection status.
|
||||
* Read/write via `/api/admin/central-codex`; the user-facing
|
||||
|
||||
Reference in new issue
Block a user