refactor(ui): move alerts page text onto i18n (#50271)

This commit is contained in:
yuneng-jiang 2026-11-05 15:54:56 -08:00 • committed by GitHub
parent 588c79618c
commit 05e4190918
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 324 additions and 114 deletions

View file

@ -1,14 +1,16 @@
"use client";
import { useTranslation } from "react-i18next";
import { Page, PageTabs, PageTabsList, PageTabsTrigger, PageTabsContent } from "@/components/shared/Page";
import ModelDeprecationsPanel from "@/components/ModelDeprecationAlerts/ModelDeprecationsPanel";
export default function AlertsPage() {
const { t } = useTranslation();
return (
<Page className="h-full gap-4">
<PageTabs defaultValue="model-deprecations" className="gap-4">
<PageTabsList>
<PageTabsTrigger value="model-deprecations">Model deprecations</PageTabsTrigger>
<PageTabsTrigger value="model-deprecations">{t("alerts.page.tabs.modelDeprecations")}</PageTabsTrigger>
</PageTabsList>
<PageTabsContent value="model-deprecations">
<ModelDeprecationsPanel />

View file

@ -1,19 +1,27 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable, DataTableToolbar } from "@/components/shared/DataTable";
import { PageTabs, PageTabsList, PageTabsTrigger, PageTabsContent } from "@/components/shared/Page";
import i18n from "@/i18n/i18n";
import type { DeprecationAlert } from "./deprecationAlerts";
import { AlertStatus, effectiveAlertStatus } from "./migrationState";
import { ALERT_STATUS_LABEL_KEYS, AlertStatus, effectiveAlertStatus } from "./migrationState";
import RecommendedReplacement from "./RecommendedReplacement";
import { DeprecationModel, DeprecationTiming } from "./DeprecationModel";
import { migrationHref } from "./migrationRoute";
const EMPTY_MESSAGE_KEYS = {
[AlertStatus.Open]: "alerts.inbox.empty.open",
[AlertStatus.Resolved]: "alerts.inbox.empty.resolved",
[AlertStatus.Dismissed]: "alerts.inbox.empty.dismissed",
} as const;
const columns: ColumnDef<DeprecationAlert>[] = [
{
id: "model",
header: "Model",
header: () => i18n.t("alerts.inbox.columns.model"),
cell: ({ row }) => (
<div className="py-1">
<DeprecationModel alert={row.original} />
@ -23,7 +31,7 @@ const columns: ColumnDef<DeprecationAlert>[] = [
},
{
id: "deprecation",
header: "Deprecation",
header: () => i18n.t("alerts.inbox.columns.deprecation"),
cell: ({ row }) => (
<div className="flex flex-col gap-1">
<DeprecationTiming alert={row.original} />
@ -31,14 +39,18 @@ const columns: ColumnDef<DeprecationAlert>[] = [
</div>
),
},
{ accessorKey: "requests", header: "Requests · 30d", cell: ({ row }) => row.original.requests.toLocaleString() },
{ accessorKey: "lastUsedDate", header: "Last seen (UTC)" },
{
accessorKey: "requests",
header: () => i18n.t("alerts.inbox.columns.requests"),
cell: ({ row }) => row.original.requests.toLocaleString(),
},
{ accessorKey: "lastUsedDate", header: () => i18n.t("alerts.inbox.columns.lastSeen") },
{
id: "review",
header: "",
cell: ({ row }) => (
<a href={migrationHref(row.original.usageModel)} className="whitespace-nowrap text-primary hover:underline">
Review impact →
{i18n.t("alerts.inbox.reviewImpact")}
</a>
),
},
@ -59,6 +71,7 @@ export default function AlertsInbox({
onRefresh: () => void;
refreshing: boolean;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const counts = { open: 0, resolved: 0, dismissed: 0 };
for (const alert of alerts) counts[effectiveAlertStatus(alert)]++;
@ -75,7 +88,7 @@ export default function AlertsInbox({
<PageTabsList>
{Object.values(AlertStatus).map((value) => (
<PageTabsTrigger key={value} value={value} className="capitalize">
{value}
{t(ALERT_STATUS_LABEL_KEYS[value])}
<span className="ml-1.5 text-xs text-muted-foreground">{counts[value]}</span>
</PageTabsTrigger>
))}
@ -88,14 +101,14 @@ export default function AlertsInbox({
fillHeight
size="compact"
isLoading={pending}
loadingMessage="Loading model deprecations…"
noDataMessage={`No ${status} model deprecations in the last 30 days.`}
loadingMessage={t("alerts.inbox.loading")}
noDataMessage={t(EMPTY_MESSAGE_KEYS[status])}
toolbar={(table) => (
<DataTableToolbar
table={table}
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="Search models…"
searchPlaceholder={t("alerts.inbox.searchPlaceholder")}
onRefresh={onRefresh}
isRefreshing={refreshing}
showViewOptions={false}

View file

@ -1,6 +1,7 @@
"use client";
import { ArrowUpRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DeprecationModel, DeprecationTiming } from "./DeprecationModel";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
@ -9,10 +10,11 @@ import RecommendedReplacement from "./RecommendedReplacement";
import type { DeprecationAlert } from "./deprecationAlerts";
export default function DeprecationAlertItem({ alert }: { alert: DeprecationAlert }) {
const { t } = useTranslation();
const retired = alert.model.status === "deprecated";
const title = retired
? `${alert.usageModel} is deprecated`
: `${alert.usageModel} retires in ${alert.model.days_until_deprecation} days`;
? t("alerts.alertItem.deprecatedTitle", { model: alert.usageModel })
: t("alerts.alertItem.retiresTitle", { model: alert.usageModel, days: alert.model.days_until_deprecation });
return (
<article aria-label={title} className="flex flex-col gap-3">
<div className="flex items-start gap-2.5">
@ -22,13 +24,13 @@ export default function DeprecationAlertItem({ alert }: { alert: DeprecationAler
<DeprecationTiming alert={alert} />
</p>
<p className="mt-1 text-xs text-muted-foreground">
{alert.requests.toLocaleString()} requests in the last 30 days · Last seen {alert.lastUsedDate} UTC
{t("alerts.alertItem.usage", { requests: alert.requests.toLocaleString(), date: alert.lastUsedDate })}
</p>
<RecommendedReplacement recommendation={alert.model} />
</div>
</div>
<a className={cn(buttonVariants({ size: "sm" }), "self-start")} href={migrationHref(alert.usageModel)}>
Review migration <ArrowUpRight className="size-3.5" />
{t("alerts.alertItem.reviewMigration")} <ArrowUpRight className="size-3.5" />
</a>
</article>
);

View file

@ -1,6 +1,8 @@
"use client";
import type { ParseKeys } from "i18next";
import { Mail } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Dialog,
@ -14,44 +16,40 @@ import {
import { uiHref } from "@/utils/uiHref";
const emailSettingsHref = `${uiHref("logging-and-alerts")}?tab=email-alerts`;
const setupSteps = [
const setupSteps: readonly { titleKey: ParseKeys; descriptionKey: ParseKeys }[] = [
{
title: "Connect email delivery",
description:
"Add your SMTP settings and public gateway URL in Email Settings. Send a test email to verify delivery.",
titleKey: "alerts.emailSetup.steps.connect.title",
descriptionKey: "alerts.emailSetup.steps.connect.description",
},
{
title: "Enable model deprecation emails",
description: "In Email events, turn on Model Deprecations and save. This is off by default.",
titleKey: "alerts.emailSetup.steps.enable.title",
descriptionKey: "alerts.emailSetup.steps.enable.description",
},
{
title: "Owners get two reminders",
description:
"Affected key owners and team admins receive a reminder 30 days before retirement, and one final reminder 1 day before. Recipients are based on actual usage in the last 30 days. Dates are checked in UTC.",
titleKey: "alerts.emailSetup.steps.reminders.title",
descriptionKey: "alerts.emailSetup.steps.reminders.description",
},
];
export default function DeprecationEmailSetup() {
const { t } = useTranslation();
return (
<Dialog>
<div className="flex flex-wrap items-center justify-between gap-2 border-b pb-3 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
<Mail className="size-3.5 shrink-0" aria-hidden />
Give affected owners time to migrate. Email reminders at 30 days and 1 day.
{t("alerts.emailSetup.summary")}
</span>
<DialogTrigger render={<Button variant="link" size="sm" />}>Set up email reminders</DialogTrigger>
<DialogTrigger render={<Button variant="link" size="sm" />}>{t("alerts.emailSetup.trigger")}</DialogTrigger>
</div>
<DialogContent>
<DialogHeader>
<DialogTitle>Set up model deprecation emails</DialogTitle>
<DialogDescription>
Help key owners and team admins act before a provider retires a model. Only their affected usage is
included.
</DialogDescription>
<DialogTitle>{t("alerts.emailSetup.title")}</DialogTitle>
<DialogDescription>{t("alerts.emailSetup.description")}</DialogDescription>
</DialogHeader>
<ol className="my-3 space-y-5">
{setupSteps.map((step, index) => (
<li key={step.title} className="flex gap-3">
<li key={step.titleKey} className="flex gap-3">
<span
className="flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-medium"
aria-hidden
@ -59,15 +57,15 @@ export default function DeprecationEmailSetup() {
{index + 1}
</span>
<div>
<h3 className="text-sm font-medium">{step.title}</h3>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{step.description}</p>
<h3 className="text-sm font-medium">{t(step.titleKey)}</h3>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{t(step.descriptionKey)}</p>
</div>
</li>
))}
</ol>
<DialogFooter>
<a className={buttonVariants()} href={emailSettingsHref}>
Open Email Settings
{t("alerts.emailSetup.openSettings")}
</a>
</DialogFooter>
</DialogContent>

View file

@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import type { DeprecationAlert } from "./deprecationAlerts";
import { migrationHref } from "./migrationRoute";
@ -17,10 +18,11 @@ export function DeprecationModel({ alert }: { alert: DeprecationAlert }) {
}
export function DeprecationTiming({ alert }: { alert: DeprecationAlert }) {
const { t } = useTranslation();
const days = alert.model.days_until_deprecation;
let label = `Deprecates in ${days} ${days === 1 ? "day" : "days"}`;
if (days === 0) label = "Deprecates today";
if (days < 0) label = `Deprecated ${Math.abs(days)} ${days === -1 ? "day" : "days"} ago`;
let label = t("alerts.timing.deprecatesIn", { count: days });
if (days === 0) label = t("alerts.timing.today");
if (days < 0) label = t("alerts.timing.deprecatedAgo", { count: Math.abs(days) });
return (
<span
className={days < 0 ? "text-destructive" : "text-amber-700 dark:text-amber-400"}

View file

@ -1,6 +1,8 @@
"use client";
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useQueryStates } from "nuqs";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
@ -13,7 +15,11 @@ import { backgroundFetchClient } from "@/lib/http/api";
import type { components } from "@/lib/http/schema";
import { keyDetailHref, modelGroupHref } from "@/utils/entityLinks";
import { copyToClipboard } from "@/utils/dataUtils";
import { AlertStatus, effectiveAlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
import {
ALERT_STATUS_LABEL_KEYS,
AlertStatus,
effectiveAlertStatus,
} from "@/components/ModelDeprecationAlerts/migrationState";
import { migrationUrlParsers } from "./migrationRoute";
import type { DeprecationAlert, deprecationUsageRange } from "@/components/ModelDeprecationAlerts/deprecationAlerts";
import RecommendedReplacement from "@/components/ModelDeprecationAlerts/RecommendedReplacement";
@ -25,11 +31,11 @@ import { affectedTeams } from "./migrationData";
const TABLE_STATE = { sortFields: [], defaultSort: { id: "", desc: false }, defaultPageSize: 50, filterColumns: [] };
type KeyRow = components["schemas"]["KeySpendActivityRow"];
function migrationHeading(alert: DeprecationAlert, status: AlertStatus): string {
if (status === AlertStatus.Resolved) return "Migration marked resolved";
if (status === AlertStatus.Dismissed) return "Model deprecation dismissed";
if (alert.model.status === "deprecated") return "Migration needed: model is deprecated";
return `Migration due ${alert.model.deprecation_date}`;
function migrationHeading(alert: DeprecationAlert, status: AlertStatus, t: TFunction): string {
if (status === AlertStatus.Resolved) return t("alerts.migrationDetail.headings.resolved");
if (status === AlertStatus.Dismissed) return t("alerts.migrationDetail.headings.dismissed");
if (alert.model.status === "deprecated") return t("alerts.migrationDetail.headings.deprecated");
return t("alerts.migrationDetail.headings.due", { date: alert.model.deprecation_date });
}
function migrationFollowUp(
@ -52,6 +58,7 @@ export default function MigrationDetail({
range: ReturnType<typeof deprecationUsageRange>;
isViewOnly: boolean;
}) {
const { t } = useTranslation();
const [params, setParams] = useQueryStates(migrationUrlParsers);
const { data: teams = [] } = useTeams();
const teamNames = useMemo(
@ -144,27 +151,30 @@ export default function MigrationDetail({
() => [
{
id: "key",
header: "Virtual key",
header: t("alerts.migrationDetail.columns.key"),
cell: ({ row }) => (
<a href={keyDetailHref(row.original.api_key)} className="font-medium text-primary hover:underline">
{row.original.metadata.key_alias || "Unnamed key"}
{row.original.metadata.key_alias || t("alerts.migrationDetail.unnamedKey")}
</a>
),
},
{
id: "team",
header: "Team",
header: t("alerts.migrationDetail.columns.team"),
cell: ({ row }) =>
teamNames.get(row.original.metadata.team_id ?? "") || row.original.metadata.team_id || "No team",
teamNames.get(row.original.metadata.team_id ?? "") ||
row.original.metadata.team_id ||
t("alerts.migrationDetail.noTeam"),
},
{
id: "owner",
header: "Owner",
cell: ({ row }) => row.original.metadata.user_email || row.original.metadata.user_id || "Not recorded",
header: t("alerts.migrationDetail.columns.owner"),
cell: ({ row }) =>
row.original.metadata.user_email || row.original.metadata.user_id || t("alerts.migrationDetail.notRecorded"),
},
{
id: "tags",
header: "Usage tags",
header: t("alerts.migrationDetail.columns.tags"),
cell: ({ row }) => (
<UsageTags
tags={tagsByKey.get(row.original.api_key) ?? []}
@ -175,15 +185,19 @@ export default function MigrationDetail({
},
{
id: "requests",
header: "Requests · 30d",
header: t("alerts.migrationDetail.columns.requests"),
cell: ({ row }) => <span className="tabular-nums">{row.original.metrics.api_requests.toLocaleString()}</span>,
},
{ id: "spend", header: "Spend · 30d", cell: ({ row }) => `$${row.original.metrics.spend.toFixed(2)}` },
{
id: "spend",
header: t("alerts.migrationDetail.columns.spend"),
cell: ({ row }) => `$${row.original.metrics.spend.toFixed(2)}`,
},
],
[teamNames, tagsByKey, tagQuery.isPending, tagQuery.isError],
[teamNames, tagsByKey, tagQuery.isPending, tagQuery.isError, t],
);
const status = effectiveAlertStatus(selected);
const heading = migrationHeading(selected, status);
const heading = migrationHeading(selected, status, t);
const copyFollowUp = () => {
const teamName = params.team ? teamNames.get(params.team) || params.team : "Team owners";
const count = keyQuery.data?.total_api_keys;
@ -192,23 +206,28 @@ export default function MigrationDetail({
: selected.lastUsedDate;
const context = { team: teamName, count, lastSeen, url: window.location.href };
const message = migrationFollowUp(selected, context);
void copyToClipboard(message, "Follow-up copied");
void copyToClipboard(message, t("alerts.migrationDetail.followUpCopied"));
};
return (
<>
<section className="flex flex-wrap items-start justify-between gap-3 rounded-xl border bg-card p-3">
<div>
<span className="rounded-full border px-2 py-0.5 text-xs capitalize">{status}</span>
<span className="rounded-full border px-2 py-0.5 text-xs capitalize">
{t(ALERT_STATUS_LABEL_KEYS[status])}
</span>
<h2 className="mt-3 text-lg font-semibold">{heading}</h2>
<p className="mt-1 text-sm text-muted-foreground">
Last seen {selected.lastUsedDate} UTC · {selected.requests.toLocaleString()} requests in the last 30 days
{t("alerts.migrationDetail.lastSeen", {
date: selected.lastUsedDate,
requests: selected.requests.toLocaleString(),
})}
</p>
<RecommendedReplacement recommendation={selected.model} />
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={copyFollowUp}>
<Copy />
Copy follow-up
{t("alerts.migrationDetail.copyFollowUp")}
</Button>
{!isViewOnly && (
<>
@ -219,7 +238,7 @@ export default function MigrationDetail({
onClick={() => statusMutation.mutate(AlertStatus.Resolved)}
>
<Check />
Mark resolved
{t("alerts.migrationDetail.markResolved")}
</Button>
<Button
variant="ghost"
@ -227,46 +246,48 @@ export default function MigrationDetail({
onClick={() => statusMutation.mutate(AlertStatus.Dismissed)}
>
<X />
Dismiss
{t("alerts.migrationDetail.dismiss")}
</Button>
</>
) : (
<Button disabled={statusMutation.isPending} onClick={() => statusMutation.mutate(AlertStatus.Open)}>
<RotateCcw />
Reopen
{t("alerts.migrationDetail.reopen")}
</Button>
)}
</>
)}
{modelGroupHref(selected.model.model_name) && (
<a className={buttonVariants({ variant: "outline" })} href={modelGroupHref(selected.model.model_name)}>
Model routing <ArrowUpRight size={14} />
{t("alerts.migrationDetail.modelRouting")} <ArrowUpRight size={14} />
</a>
)}
</div>
</section>
<p className="text-xs text-muted-foreground">
Resolve across all teams once owners confirm migration. New traffic reopens the alert.
</p>
<p className="text-xs text-muted-foreground">{t("alerts.migrationDetail.resolveHint")}</p>
{statusMutation.isError && (
<p role="alert" className="text-destructive">
Could not save alert status. Please retry.
{t("alerts.migrationDetail.saveError")}
</p>
)}
<ModelUsageChart model={model} team={params.team} scope={scope} range={range} />
<div className="grid shrink-0 grid-cols-3 divide-x rounded-xl border bg-card">
<div className="p-3">
<p className="text-xs text-muted-foreground">{params.team ? "Keys in this team" : "Affected keys"}</p>
<p className="text-xs text-muted-foreground">
{params.team
? t("alerts.migrationDetail.summary.teamKeys")
: t("alerts.migrationDetail.summary.affectedKeys")}
</p>
<p className="mt-1 text-xl font-semibold tabular-nums">
{keyQuery.data?.total_api_keys.toLocaleString() ?? "—"}
</p>
</div>
<div className="p-3">
<p className="text-xs text-muted-foreground">Teams using this model</p>
<p className="text-xs text-muted-foreground">{t("alerts.migrationDetail.summary.teams")}</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{teamQuery.data?.length ?? "—"}</p>
</div>
<div className="p-3">
<p className="text-xs text-muted-foreground">Deprecation</p>
<p className="text-xs text-muted-foreground">{t("alerts.migrationDetail.summary.deprecation")}</p>
<p className="mt-1 text-lg font-semibold">
<DeprecationTiming alert={selected} />
</p>
@ -276,14 +297,12 @@ export default function MigrationDetail({
<div className="flex flex-wrap items-center justify-between gap-3 border-b p-4">
<div>
<h2 className="font-medium">
Affected keys{" "}
{t("alerts.migrationDetail.keysTable.title")}{" "}
<span className="ml-1 text-muted-foreground">
{keyQuery.data?.total_api_keys.toLocaleString() ?? "…"}
</span>
</h2>
<p className="mt-1 text-xs text-muted-foreground">
Keys and owners that used this model in the last 30 days. Tags show actual usage with this model.
</p>
<p className="mt-1 text-xs text-muted-foreground">{t("alerts.migrationDetail.keysTable.description")}</p>
</div>
<Select
value={params.team ?? "__all"}
@ -292,14 +311,21 @@ export default function MigrationDetail({
table.onPaginationChange({ ...table.pagination, pageIndex: 0 });
}}
>
<SelectTrigger className="min-w-52" aria-label="Filter affected keys by team">
<SelectValue>{params.team ? teamNames.get(params.team) || params.team : "All teams"}</SelectValue>
<SelectTrigger className="min-w-52" aria-label={t("alerts.migrationDetail.keysTable.teamFilter")}>
<SelectValue>
{params.team
? teamNames.get(params.team) || params.team
: t("alerts.migrationDetail.keysTable.allTeams")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all">All teams</SelectItem>
<SelectItem value="__all">{t("alerts.migrationDetail.keysTable.allTeams")}</SelectItem>
{(teamQuery.data ?? []).map((team) => (
<SelectItem key={team.id} value={team.id}>
{teamNames.get(team.id) || team.id} · {team.keys} keys
{t("alerts.migrationDetail.keysTable.teamOption", {
team: teamNames.get(team.id) || team.id,
keys: team.keys,
})}
</SelectItem>
))}
</SelectContent>
@ -307,9 +333,9 @@ export default function MigrationDetail({
</div>
{keyQuery.isError ? (
<div className="p-5">
<p>Could not load affected keys.</p>
<p>{t("alerts.migrationDetail.keysTable.loadError")}</p>
<Button variant="outline" onClick={() => void keyQuery.refetch()}>
Retry
{t("alerts.migrationDetail.keysTable.retry")}
</Button>
</div>
) : (
@ -327,9 +353,7 @@ export default function MigrationDetail({
/>
)}
</section>
{teamQuery.isError && (
<p role="alert">Could not load team filters. The keys table still includes all matching teams.</p>
)}
{teamQuery.isError && <p role="alert">{t("alerts.migrationDetail.teamsError")}</p>}
</>
);
}

View file

@ -1,6 +1,7 @@
"use client";
import { useQueryStates } from "nuqs";
import { useTranslation } from "react-i18next";
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import { deprecationProvider } from "./DeprecationModel";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
@ -14,6 +15,7 @@ import AlertsInbox from "./AlertsInbox";
import MigrationDetail from "./MigrationDetail";
export default function ModelDeprecationsPanel() {
const { t } = useTranslation();
const { accessToken, userRole, isViewOnly } = useAuthorized();
const allowed = hasCapability(userRole, "viewGlobalSpend");
const [params, setParams] = useQueryStates(migrationUrlParsers);
@ -25,8 +27,8 @@ export default function ModelDeprecationsPanel() {
if (!allowed)
return (
<Page className="h-full gap-3 overflow-y-auto p-0 sm:p-0">
<h1 className="text-xl font-semibold">Model deprecations</h1>
<p>Gateway admin access is required to view affected keys and teams.</p>
<h1 className="text-xl font-semibold">{t("alerts.panel.title")}</h1>
<p>{t("alerts.panel.adminRequired")}</p>
</Page>
);
return (
@ -42,15 +44,15 @@ export default function ModelDeprecationsPanel() {
</h1>
</div>
<a className={buttonVariants({ variant: "outline" })} href={migrationHref()}>
← Model deprecations
{t("alerts.panel.back")}
</a>
</header>
)}
<DeprecationEmailSetup />
{alerts.isError ? (
<div role="alert">
<p>Could not load alerts. No alert status has been changed.</p>
<Button onClick={() => void alerts.refetch()}>Retry</Button>
<p>{t("alerts.panel.loadError")}</p>
<Button onClick={() => void alerts.refetch()}>{t("alerts.panel.retry")}</Button>
</div>
) : null}
{!alerts.isError && !params.model && (
@ -63,8 +65,8 @@ export default function ModelDeprecationsPanel() {
onStatusChange={(state) => void setParams({ state })}
/>
)}
{loadingDetail && <p role="status">Loading alert…</p>}
{missingDetail && <p>No matching model alert was found in the last 30 days.</p>}
{loadingDetail && <p role="status">{t("alerts.panel.loadingAlert")}</p>}
{missingDetail && <p>{t("alerts.panel.missingAlert")}</p>}
{!alerts.isError && selected && (
<MigrationDetail
selected={selected}

View file

@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { StackedUsageChart } from "@/components/shared/charts";
import { Button } from "@/components/ui/button";
import { backgroundFetchClient } from "@/lib/http/api";
@ -25,6 +26,7 @@ export default function ModelUsageChart({
scope: string;
range: ReturnType<typeof deprecationUsageRange>;
}) {
const { t } = useTranslation();
const [legendExpanded, setLegendExpanded] = useState(false);
const activityOptions = {
queryKey: ["deprecationDailyUsage", scope, model, team, range],
@ -56,7 +58,8 @@ export default function ModelUsageChart({
const aliases = new Map(
days.flatMap((day) =>
Object.entries(day.breakdown.api_keys).map(
([id, entry]) => [id, entry.metadata.key_alias || `Key · ${id.slice(0, 6)}`] as const,
([id, entry]) =>
[id, entry.metadata.key_alias || t("alerts.usageChart.keyFallback", { id: id.slice(0, 6) })] as const,
),
),
);
@ -66,15 +69,15 @@ export default function ModelUsageChart({
result.data = daily.map((day) => byDate.get(day.date) ?? { date: day.date, label: formatDate(day.date), ...zeros });
result.labels = result.labels.map((label) => aliases.get(label) || label);
return result;
}, [activity.data, daily]);
}, [activity.data, daily, t]);
function renderContent() {
if (activity.isPending) return <ChartSkeleton className="h-64" />;
if (activity.isError)
return (
<div role="alert" className="py-6 text-sm">
Could not load daily requests.{" "}
{t("alerts.usageChart.loadError")}{" "}
<Button variant="outline" size="sm" onClick={() => void activity.refetch()}>
Retry
{t("alerts.usageChart.retry")}
</Button>
</div>
);
@ -106,7 +109,7 @@ export default function ModelUsageChart({
aria-expanded={legendExpanded}
onClick={() => setLegendExpanded(!legendExpanded)}
>
{legendExpanded ? "Show fewer key labels" : "Show all key labels"}
{legendExpanded ? t("alerts.usageChart.showFewer") : t("alerts.usageChart.showAll")}
</button>
)}
</>
@ -114,14 +117,12 @@ export default function ModelUsageChart({
}
return (
<Panel
title="Daily requests"
subtitle={`Requests to this model, stacked by key · ${team ? "Selected team" : "All teams"}`}
title={t("alerts.usageChart.title")}
subtitle={team ? t("alerts.usageChart.subtitleTeam") : t("alerts.usageChart.subtitleAll")}
className="shrink-0"
>
{renderContent()}
<p className="mt-2 text-xs text-muted-foreground">
Last 30 days · UTC · Includes zero-usage days. Today is partial.
</p>
<p className="mt-2 text-xs text-muted-foreground">{t("alerts.usageChart.footnote")}</p>
</Panel>
);
}

View file

@ -1,3 +1,5 @@
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
import type { components } from "@/lib/http/schema";
type Recommendation = Pick<
@ -5,26 +7,34 @@ type Recommendation = Pick<
"successor_model" | "successor_model_source" | "deprecation_source"
>;
const SOURCE_LABELS = {
model_cost_map: "LiteLLM model cost map",
deployment_config: "Deployment configuration",
} as const;
const SOURCE_LABEL_KEYS = {
model_cost_map: "alerts.recommendation.sources.modelCostMap",
deployment_config: "alerts.recommendation.sources.deploymentConfig",
} as const satisfies Record<string, ParseKeys>;
export default function RecommendedReplacement({ recommendation }: { recommendation: Recommendation }) {
const { t } = useTranslation();
const { successor_model: model, successor_model_source: source, deprecation_source: notice } = recommendation;
const sourceLabel = (source && SOURCE_LABELS[source]) || "Not provided";
const sourceLabel =
source && Object.hasOwn(SOURCE_LABEL_KEYS, source)
? t(SOURCE_LABEL_KEYS[source])
: t("alerts.recommendation.notProvided");
const noticeUrl = notice && /^https?:\/\//i.test(notice) ? notice : null;
return (
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<p>
Recommended new model:{" "}
{model ? <code className="rounded bg-muted px-1.5 py-0.5 text-foreground">{model}</code> : "Not provided"}
{t("alerts.recommendation.recommendedModel")}{" "}
{model ? (
<code className="rounded bg-muted px-1.5 py-0.5 text-foreground">{model}</code>
) : (
t("alerts.recommendation.notProvided")
)}
</p>
{model && (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>
Source:{" "}
{t("alerts.recommendation.source")}{" "}
{source === "model_cost_map" ? (
<a
href="https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json"
@ -40,7 +50,7 @@ export default function RecommendedReplacement({ recommendation }: { recommendat
</span>
{noticeUrl && (
<a href={noticeUrl} target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
Provider deprecation notice ↗
{t("alerts.recommendation.providerNotice")}
</a>
)}
</p>

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
export default function UsageTags({
@ -12,10 +13,11 @@ export default function UsageTags({
loading: boolean;
failed: boolean;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
if (loading) return <span className="text-xs text-muted-foreground">Loading tags…</span>;
if (failed) return <span className="text-xs text-muted-foreground">Tags unavailable</span>;
if (tags.length === 0) return <span className="text-xs text-muted-foreground">No recorded tags</span>;
if (loading) return <span className="text-xs text-muted-foreground">{t("alerts.usageTags.loading")}</span>;
if (failed) return <span className="text-xs text-muted-foreground">{t("alerts.usageTags.failed")}</span>;
if (tags.length === 0) return <span className="text-xs text-muted-foreground">{t("alerts.usageTags.none")}</span>;
const visible = expanded ? tags : tags.slice(0, 5);
return (
<div className="flex max-w-72 flex-wrap items-center gap-1">
@ -29,10 +31,12 @@ export default function UsageTags({
type="button"
className="text-xs text-primary hover:underline"
aria-expanded={expanded}
aria-label={expanded ? "Show fewer usage tags" : `Show ${tags.length - 5} more usage tags`}
aria-label={
expanded ? t("alerts.usageTags.showFewer") : t("alerts.usageTags.showMore", { hidden: tags.length - 5 })
}
onClick={() => setExpanded(!expanded)}
>
{expanded ? "Show less" : `+${tags.length - 5} more`}
{expanded ? t("alerts.usageTags.showLess") : t("alerts.usageTags.more", { hidden: tags.length - 5 })}
</button>
)}
</div>

View file

@ -1,3 +1,4 @@
import type { ParseKeys } from "i18next";
import type { DeprecationAlert } from "./deprecationAlerts";
export enum AlertStatus {
@ -6,6 +7,12 @@ export enum AlertStatus {
Dismissed = "dismissed",
}
export const ALERT_STATUS_LABEL_KEYS = {
[AlertStatus.Open]: "alerts.status.open",
[AlertStatus.Resolved]: "alerts.status.resolved",
[AlertStatus.Dismissed]: "alerts.status.dismissed",
} as const satisfies Record<AlertStatus, ParseKeys>;
export function effectiveAlertStatus(alert: DeprecationAlert): AlertStatus {
const state = alert.state;
if (!state || state.status === "open") return AlertStatus.Open;

View file

@ -5710,6 +5710,151 @@
"userBanner": {
"dismiss": "Dismiss banner"
},
"alerts": {
"page": {
"tabs": {
"modelDeprecations": "Model deprecations"
}
},
"panel": {
"title": "Model deprecations",
"adminRequired": "Gateway admin access is required to view affected keys and teams.",
"back": "← Model deprecations",
"loadError": "Could not load alerts. No alert status has been changed.",
"retry": "Retry",
"loadingAlert": "Loading alert…",
"missingAlert": "No matching model alert was found in the last 30 days."
},
"status": {
"open": "open",
"resolved": "resolved",
"dismissed": "dismissed"
},
"inbox": {
"columns": {
"model": "Model",
"deprecation": "Deprecation",
"requests": "Requests · 30d",
"lastSeen": "Last seen (UTC)"
},
"empty": {
"open": "No open model deprecations in the last 30 days.",
"resolved": "No resolved model deprecations in the last 30 days.",
"dismissed": "No dismissed model deprecations in the last 30 days."
},
"reviewImpact": "Review impact →",
"loading": "Loading model deprecations…",
"searchPlaceholder": "Search models…"
},
"timing": {
"deprecatesIn_one": "Deprecates in {{count}} day",
"deprecatesIn_other": "Deprecates in {{count}} days",
"today": "Deprecates today",
"deprecatedAgo_one": "Deprecated {{count}} day ago",
"deprecatedAgo_other": "Deprecated {{count}} days ago"
},
"recommendation": {
"sources": {
"modelCostMap": "LiteLLM model cost map",
"deploymentConfig": "Deployment configuration"
},
"notProvided": "Not provided",
"recommendedModel": "Recommended new model:",
"source": "Source:",
"providerNotice": "Provider deprecation notice ↗"
},
"alertItem": {
"deprecatedTitle": "{{model}} is deprecated",
"retiresTitle": "{{model}} retires in {{days}} days",
"usage": "{{requests}} requests in the last 30 days · Last seen {{date}} UTC",
"reviewMigration": "Review migration"
},
"emailSetup": {
"steps": {
"connect": {
"title": "Connect email delivery",
"description": "Add your SMTP settings and public gateway URL in Email Settings. Send a test email to verify delivery."
},
"enable": {
"title": "Enable model deprecation emails",
"description": "In Email events, turn on Model Deprecations and save. This is off by default."
},
"reminders": {
"title": "Owners get two reminders",
"description": "Affected key owners and team admins receive a reminder 30 days before retirement, and one final reminder 1 day before. Recipients are based on actual usage in the last 30 days. Dates are checked in UTC."
}
},
"summary": "Give affected owners time to migrate. Email reminders at 30 days and 1 day.",
"trigger": "Set up email reminders",
"title": "Set up model deprecation emails",
"description": "Help key owners and team admins act before a provider retires a model. Only their affected usage is included.",
"openSettings": "Open Email Settings"
},
"migrationDetail": {
"headings": {
"resolved": "Migration marked resolved",
"dismissed": "Model deprecation dismissed",
"deprecated": "Migration needed: model is deprecated",
"due": "Migration due {{date}}"
},
"columns": {
"key": "Virtual key",
"team": "Team",
"owner": "Owner",
"tags": "Usage tags",
"requests": "Requests · 30d",
"spend": "Spend · 30d"
},
"summary": {
"teamKeys": "Keys in this team",
"affectedKeys": "Affected keys",
"teams": "Teams using this model",
"deprecation": "Deprecation"
},
"keysTable": {
"title": "Affected keys",
"description": "Keys and owners that used this model in the last 30 days. Tags show actual usage with this model.",
"teamFilter": "Filter affected keys by team",
"allTeams": "All teams",
"teamOption": "{{team}} · {{keys}} keys",
"loadError": "Could not load affected keys.",
"retry": "Retry"
},
"unnamedKey": "Unnamed key",
"noTeam": "No team",
"notRecorded": "Not recorded",
"followUpCopied": "Follow-up copied",
"lastSeen": "Last seen {{date}} UTC · {{requests}} requests in the last 30 days",
"copyFollowUp": "Copy follow-up",
"markResolved": "Mark resolved",
"dismiss": "Dismiss",
"reopen": "Reopen",
"modelRouting": "Model routing",
"resolveHint": "Resolve across all teams once owners confirm migration. New traffic reopens the alert.",
"saveError": "Could not save alert status. Please retry.",
"teamsError": "Could not load team filters. The keys table still includes all matching teams."
},
"usageChart": {
"keyFallback": "Key · {{id}}",
"loadError": "Could not load daily requests.",
"retry": "Retry",
"showFewer": "Show fewer key labels",
"showAll": "Show all key labels",
"title": "Daily requests",
"subtitleTeam": "Requests to this model, stacked by key · Selected team",
"subtitleAll": "Requests to this model, stacked by key · All teams",
"footnote": "Last 30 days · UTC · Includes zero-usage days. Today is partial."
},
"usageTags": {
"loading": "Loading tags…",
"failed": "Tags unavailable",
"none": "No recorded tags",
"showFewer": "Show fewer usage tags",
"showMore": "Show {{hidden}} more usage tags",
"showLess": "Show less",
"more": "+{{hidden}} more"
}
},
"caching": {
"dashboard": {
"tabs": {