mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
refactor(ui): move alerts page text onto i18n (#50271)
This commit is contained in:
parent
588c79618c
commit
05e4190918
12 changed files with 324 additions and 114 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue