mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
fix(dashboard): show custom provider given-name instead of internal id across dashboard pages (#4603) (#4960)
Integrated into release/v3.8.36 (fixes #4603)
This commit is contained in:
committed by
GitHub
parent
d575691d90
commit
3d37242993
@@ -10,6 +10,10 @@ _In development — bullets added per PR; finalized at release._
|
||||
|
||||
- **feat(providers):** update volcengine-ark model list, adding DeepSeek-V4-Flash and DeepSeek-V4-Pro. (thanks @kenlin8827)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **fix(dashboard):** show custom provider given-name instead of internal id across dashboard pages — cache, combo health, compression analytics, cost overview, health/autopilot, provider stats, route explainability, provider utilization, runtime. Adds shared `resolveProviderName` resolver and `useProviderNodeMap` hook. (#4603)
|
||||
|
||||
---
|
||||
|
||||
## [3.8.35] — 2026-06-23
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
|
||||
import Card from "@/shared/components/Card";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
import { Skeleton, Spinner } from "@/shared/components/Loading";
|
||||
@@ -156,6 +157,7 @@ function DistributionBar({ label, value, meta }: { label: string; value: number;
|
||||
}
|
||||
|
||||
function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
|
||||
const nodeMap = useProviderNodeMap();
|
||||
const topTargets = useMemo(
|
||||
() =>
|
||||
[...forecast.targets]
|
||||
@@ -227,7 +229,8 @@ function ComboForecastPanel({ forecast }: { forecast: ComboForecastMetrics }) {
|
||||
{target.label || target.model}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
{target.provider} · traffic {formatShare(target.trafficShare)}
|
||||
{resolveProviderName(target.provider, nodeMap)} · traffic{" "}
|
||||
{formatShare(target.trafficShare)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getRiskVariant(target.quota.risk)} size="sm">
|
||||
@@ -379,6 +382,7 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) {
|
||||
}
|
||||
|
||||
function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInspectorCombo }) {
|
||||
const nodeMap = useProviderNodeMap();
|
||||
const topTargets = inspector.targets.slice(0, 3);
|
||||
|
||||
return (
|
||||
@@ -431,7 +435,8 @@ function ComboScoringInspectorPanel({ inspector }: { inspector: ComboScoringInsp
|
||||
#{target.rank} {target.label || target.model}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
{target.provider} · score {target.score.toFixed(3)}
|
||||
{resolveProviderName(target.provider, nodeMap)} · score{" "}
|
||||
{target.score.toFixed(3)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={target.rank === 1 ? "success" : "default"} size="sm">
|
||||
@@ -488,6 +493,7 @@ function ComboHealthCard({
|
||||
scoringInspector?: ComboScoringInspectorCombo;
|
||||
}) {
|
||||
const t = useTranslations("analytics");
|
||||
const nodeMap = useProviderNodeMap();
|
||||
|
||||
const sortedDistribution = useMemo(
|
||||
() =>
|
||||
@@ -563,7 +569,9 @@ function ComboHealthCard({
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-text-main">{provider.provider}</div>
|
||||
<div className="text-sm font-medium text-text-main">
|
||||
{resolveProviderName(provider.provider, nodeMap)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
Remaining quota {formatPercent(provider.remainingPct, 1)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
|
||||
|
||||
interface CompressionAnalyticsSummary {
|
||||
totalRequests: number;
|
||||
@@ -122,6 +123,7 @@ export default function CompressionAnalyticsTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [since, setSince] = useState<"24h" | "7d" | "30d" | "all">("24h");
|
||||
const nodeMap = useProviderNodeMap();
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/analytics/compression?since=${since}`)
|
||||
@@ -303,7 +305,7 @@ export default function CompressionAnalyticsTab() {
|
||||
{providers.map(([prov, data]) => (
|
||||
<ProviderBar
|
||||
key={prov}
|
||||
provider={prov}
|
||||
provider={resolveProviderName(prov, nodeMap)}
|
||||
count={data.count}
|
||||
total={stats.totalRequests}
|
||||
tokensSaved={data.tokensSaved}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
@@ -91,6 +92,7 @@ function getLatestPoints(points: ProviderUtilizationPoint[]) {
|
||||
|
||||
export default function ProviderUtilizationTab() {
|
||||
const t = useTranslations("analytics");
|
||||
const nodeMap = useProviderNodeMap();
|
||||
const [range, setRange] = useState<UtilizationTimeRange>("24h");
|
||||
const [aggregateBy, setAggregateBy] = useState<"provider" | "connection">("provider");
|
||||
const [data, setData] = useState<ProviderUtilizationResponse | null>(null);
|
||||
@@ -350,7 +352,7 @@ export default function ProviderUtilizationTab() {
|
||||
key={provider}
|
||||
type="monotone"
|
||||
dataKey={provider}
|
||||
name={provider}
|
||||
name={resolveProviderName(provider, nodeMap)}
|
||||
stroke={providerColors.get(provider) ?? "var(--color-primary)"}
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
@@ -374,7 +376,9 @@ export default function ProviderUtilizationTab() {
|
||||
<ProviderIcon providerId={point.provider} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-main">{point.provider}</p>
|
||||
<p className="text-sm font-semibold text-text-main">
|
||||
{resolveProviderName(point.provider, nodeMap)}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("providerUtilizationLatestSnapshot")}
|
||||
</p>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Badge from "@/shared/components/Badge";
|
||||
import Card from "@/shared/components/Card";
|
||||
import { Skeleton } from "@/shared/components/Loading";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
|
||||
|
||||
type CallLogOption = {
|
||||
id: string;
|
||||
@@ -247,6 +248,7 @@ function FactorCard({ factor }: { factor: ExplanationFactor }) {
|
||||
}
|
||||
|
||||
function TargetTimeline({ targets }: { targets: ExplainTarget[] }) {
|
||||
const nodeMap = useProviderNodeMap();
|
||||
if (targets.length === 0) {
|
||||
return <div className="text-sm text-text-muted">No related target evidence persisted yet.</div>;
|
||||
}
|
||||
@@ -267,7 +269,7 @@ function TargetTimeline({ targets }: { targets: ExplainTarget[] }) {
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-text-main">
|
||||
{target.provider || "unknown"} / {target.model || "unknown"}
|
||||
{resolveProviderName(target.provider, nodeMap)} / {target.model || "unknown"}
|
||||
</span>
|
||||
{target.outcome === "selected" ? (
|
||||
<Badge variant="primary" size="sm">
|
||||
@@ -308,6 +310,7 @@ function replayAlignmentVariant(alignment: NonNullable<DecisionReplay["recompute
|
||||
}
|
||||
|
||||
function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
|
||||
const nodeMap = useProviderNodeMap();
|
||||
if (!replay) return null;
|
||||
const recompute = replay.recompute;
|
||||
const candidates = recompute?.candidates ?? [];
|
||||
@@ -324,7 +327,8 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-text-main">Exact runtime log</div>
|
||||
<div className="mt-1 truncate text-xs text-text-muted">
|
||||
{replay.runtime.provider || "unknown"} / {replay.runtime.model || "unknown"}
|
||||
{resolveProviderName(replay.runtime.provider, nodeMap)} /{" "}
|
||||
{replay.runtime.model || "unknown"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
{formatDate(replay.runtime.timestamp)} · {replay.runtime.comboStepId || "no step"}
|
||||
@@ -400,7 +404,7 @@ function WhyThisTargetCard({ replay }: { replay: DecisionReplay | undefined }) {
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-medium text-text-main">
|
||||
<span>#{candidate.rank}</span>
|
||||
<span className="truncate">
|
||||
{candidate.provider} / {candidate.model}
|
||||
{resolveProviderName(candidate.provider, nodeMap)} / {candidate.model}
|
||||
</span>
|
||||
{candidate.isRuntimeSelected ? (
|
||||
<Badge variant="primary" size="sm">
|
||||
@@ -447,6 +451,7 @@ export default function RouteExplainabilityTab({
|
||||
initialRequestId?: string;
|
||||
}) {
|
||||
const t = useTranslations("analytics") as AnalyticsTranslator;
|
||||
const nodeMap = useProviderNodeMap();
|
||||
const [logs, setLogs] = useState<CallLogOption[]>([]);
|
||||
const [selectedId, setSelectedId] = useState(initialRequestId);
|
||||
const [explanation, setExplanation] = useState<RouteExplainabilityResponse | null>(null);
|
||||
@@ -564,7 +569,7 @@ export default function RouteExplainabilityTab({
|
||||
{logs.map((log) => (
|
||||
<option key={log.id} value={log.id}>
|
||||
{formatDate(log.timestamp)} · HTTP {log.status} ·{" "}
|
||||
{log.comboName || log.provider || "direct"} ·{" "}
|
||||
{log.comboName || resolveProviderName(log.provider, nodeMap) || "direct"} ·{" "}
|
||||
{log.model || log.requestedModel || log.id}
|
||||
</option>
|
||||
))}
|
||||
@@ -662,7 +667,7 @@ export default function RouteExplainabilityTab({
|
||||
<Card title="Selected target" icon="my_location">
|
||||
<div className="grid gap-3 text-sm">
|
||||
{[
|
||||
["Provider", explanation.selectedTarget.provider || "n/a"],
|
||||
["Provider", resolveProviderName(explanation.selectedTarget.provider, nodeMap)],
|
||||
["Model", explanation.selectedTarget.model || "n/a"],
|
||||
["Account", explanation.selectedTarget.account || "n/a"],
|
||||
["Connection", explanation.selectedTarget.connectionId || "n/a"],
|
||||
|
||||
6
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
6
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { Card, Button, EmptyState } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
|
||||
import CacheEntriesTab from "./components/CacheEntriesTab";
|
||||
import ReasoningCacheTab from "./components/ReasoningCacheTab";
|
||||
|
||||
@@ -351,6 +352,7 @@ export default function CachePage() {
|
||||
const tSettings = useTranslations("settings");
|
||||
const tRoot = useTranslations();
|
||||
const notify = useNotificationStore();
|
||||
const nodeMap = useProviderNodeMap();
|
||||
|
||||
const [stats, setStats] = useState<CacheStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -600,7 +602,9 @@ export default function CachePage() {
|
||||
className="border-b border-border/15 last:border-b-0"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-text-main">{provider}</div>
|
||||
<div className="font-medium text-text-main">
|
||||
{resolveProviderName(provider, nodeMap)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
{totalRequests.toLocaleString()} {t("requests").toLowerCase()}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
|
||||
import { Card, EmptyState, SegmentedControl, CardSkeleton } from "@/shared/components";
|
||||
import {
|
||||
getServiceTierDisplayLabel,
|
||||
@@ -282,6 +283,7 @@ function downloadFile(content: string, filename: string, mimeType: string) {
|
||||
export default function CostOverviewTab() {
|
||||
const t = useTranslations("costs");
|
||||
const locale = useLocale();
|
||||
const nodeMap = useProviderNodeMap();
|
||||
const searchParams = useSearchParams();
|
||||
const apiKeyIdsParam = searchParams.get("apiKeyIds");
|
||||
const selectedApiKeyIds = useMemo(() => parseApiKeyIds(apiKeyIdsParam), [apiKeyIdsParam]);
|
||||
@@ -377,7 +379,8 @@ export default function CostOverviewTab() {
|
||||
|
||||
const providersByCost = [...(analytics?.byProvider || [])]
|
||||
.filter((provider) => (hasCostData ? provider.cost > 0 : provider.requests > 0))
|
||||
.sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests));
|
||||
.sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests))
|
||||
.map((row) => ({ ...row, provider: resolveProviderName(row.provider, nodeMap) }));
|
||||
const modelsByCost = [...(analytics?.byModel || [])]
|
||||
.filter((model) => (hasCostData ? model.cost > 0 : model.requests > 0))
|
||||
.sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { getProviderDisplayName } from "@/lib/display/names";
|
||||
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
|
||||
|
||||
type AutopilotAction = {
|
||||
type: string;
|
||||
@@ -99,6 +100,7 @@ function formatConnectionEvidence(issue: AutopilotIssue): string | null {
|
||||
}
|
||||
|
||||
export default function ProviderHealthAutopilotCard() {
|
||||
const nodeMap = useProviderNodeMap();
|
||||
const [report, setReport] = useState<AutopilotReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -241,7 +243,7 @@ export default function ProviderHealthAutopilotCard() {
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-main">
|
||||
{getProviderDisplayName(provider.provider)}
|
||||
{resolveProviderName(provider.provider, nodeMap)}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
score {(provider.score * 100).toFixed(0)}% · active{" "}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { getProviderDisplayName } from "@/lib/display/names";
|
||||
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
|
||||
import { compareTr } from "@/shared/utils/turkishText";
|
||||
import { useTranslations } from "next-intl";
|
||||
import TelemetryCard from "./TelemetryCard";
|
||||
@@ -59,6 +60,7 @@ export default function HealthPage() {
|
||||
const t = useTranslations("health");
|
||||
const tc = useTranslations("common");
|
||||
const tp = useTranslations("providers");
|
||||
const nodeMap = useProviderNodeMap();
|
||||
const [data, setData] = useState(null);
|
||||
const [dbHealth, setDbHealth] = useState(null);
|
||||
const [dbHealthError, setDbHealthError] = useState(null);
|
||||
@@ -763,7 +765,10 @@ export default function HealthPage() {
|
||||
{unhealthy.map(([provider, cb]: [string, any]) => {
|
||||
const style = CB_STYLES[cb.state] || CB_STYLES.OPEN;
|
||||
const providerInfo = AI_PROVIDERS[provider];
|
||||
const displayName = getProviderDisplayName(provider, providerInfo);
|
||||
const displayName = getProviderDisplayName(
|
||||
provider,
|
||||
nodeMap.get(provider) ?? providerInfo
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
@@ -821,7 +826,10 @@ export default function HealthPage() {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||
{healthy.map(([provider]) => {
|
||||
const providerInfo = AI_PROVIDERS[provider];
|
||||
const displayName = getProviderDisplayName(provider, providerInfo);
|
||||
const displayName = getProviderDisplayName(
|
||||
provider,
|
||||
nodeMap.get(provider) ?? providerInfo
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
@@ -857,25 +865,9 @@ export default function HealthPage() {
|
||||
const connectionId = parts[1] || "";
|
||||
const model = parts.slice(2).join(":") || null;
|
||||
|
||||
// Resolve friendly name
|
||||
let displayName;
|
||||
// Resolve friendly name — prefer user-given name from provider node map
|
||||
let providerInfo = AI_PROVIDERS[providerId];
|
||||
|
||||
if (providerId.startsWith("openai-compatible-")) {
|
||||
const customName = providerId.replace("openai-compatible-", "");
|
||||
displayName = tp("openaiCompatibleName");
|
||||
providerInfo = { color: "#10A37F", textIcon: "OC" };
|
||||
if (customName.length > 12) displayName += ` (${customName.slice(0, 8)}…)`;
|
||||
else if (customName) displayName += ` (${customName})`;
|
||||
} else if (providerId.startsWith("anthropic-compatible-")) {
|
||||
const customName = providerId.replace("anthropic-compatible-", "");
|
||||
displayName = tp("anthropicCompatibleName");
|
||||
providerInfo = { color: "#D97757", textIcon: "AC" };
|
||||
if (customName.length > 12) displayName += ` (${customName.slice(0, 8)}…)`;
|
||||
else if (customName) displayName += ` (${customName})`;
|
||||
} else {
|
||||
displayName = getProviderDisplayName(providerId, providerInfo);
|
||||
}
|
||||
const displayName = resolveProviderName(providerId, nodeMap);
|
||||
|
||||
return { providerId, displayName, providerInfo, connectionId, model };
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback, Fragment } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
|
||||
|
||||
interface ProviderStat {
|
||||
provider: string;
|
||||
@@ -33,7 +34,14 @@ interface ToolLatencyStat {
|
||||
measurementCount: number;
|
||||
}
|
||||
|
||||
type SortKey = "totalRequests" | "successfulRequests" | "avgLatencyMs" | "totalTokensIn" | "totalTokensOut" | "avgTtftAfterToolMs" | "avgGapAfterToolMs";
|
||||
type SortKey =
|
||||
| "totalRequests"
|
||||
| "successfulRequests"
|
||||
| "avgLatencyMs"
|
||||
| "totalTokensIn"
|
||||
| "totalTokensOut"
|
||||
| "avgTtftAfterToolMs"
|
||||
| "avgGapAfterToolMs";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
function formatNumber(n: number | null): string {
|
||||
@@ -55,6 +63,7 @@ function successRate(successful: number, total: number): string {
|
||||
}
|
||||
|
||||
export default function ProviderStatsPage() {
|
||||
const nodeMap = useProviderNodeMap();
|
||||
const [data, setData] = useState<{
|
||||
providers: ProviderStat[];
|
||||
models: ModelStat[];
|
||||
@@ -99,12 +108,11 @@ export default function ProviderStatsPage() {
|
||||
// Compute summary stats
|
||||
const totalRequests = data?.providers.reduce((s, p) => s + (p.totalRequests || 0), 0) ?? 0;
|
||||
const totalSuccessful = data?.providers.reduce((s, p) => s + (p.successfulRequests || 0), 0) ?? 0;
|
||||
const avgLatency =
|
||||
data?.providers.length
|
||||
? Math.round(
|
||||
data.providers.reduce((s, p) => s + (p.avgLatencyMs || 0), 0) / data.providers.length
|
||||
)
|
||||
: 0;
|
||||
const avgLatency = data?.providers.length
|
||||
? Math.round(
|
||||
data.providers.reduce((s, p) => s + (p.avgLatencyMs || 0), 0) / data.providers.length
|
||||
)
|
||||
: 0;
|
||||
const activeProviders = data?.providers.length ?? 0;
|
||||
|
||||
// Sorted providers
|
||||
@@ -112,8 +120,8 @@ export default function ProviderStatsPage() {
|
||||
if (sortKey === "avgTtftAfterToolMs" || sortKey === "avgGapAfterToolMs") {
|
||||
const aLatency = data?.toolLatency?.[a.provider] ?? null;
|
||||
const bLatency = data?.toolLatency?.[b.provider] ?? null;
|
||||
const va = aLatency ? (aLatency[sortKey] as number) ?? 0 : 0;
|
||||
const vb = bLatency ? (bLatency[sortKey] as number) ?? 0 : 0;
|
||||
const va = aLatency ? ((aLatency[sortKey] as number) ?? 0) : 0;
|
||||
const vb = bLatency ? ((bLatency[sortKey] as number) ?? 0) : 0;
|
||||
return sortDir === "desc" ? vb - va : va - vb;
|
||||
}
|
||||
const va = (a[sortKey] as number) ?? 0;
|
||||
@@ -294,17 +302,20 @@ export default function ProviderStatsPage() {
|
||||
{sortedProviders.map((p) => {
|
||||
const isExpanded = expandedProvider === p.provider;
|
||||
const models = modelsByProvider.get(p.provider) ?? [];
|
||||
const rate = p.totalRequests > 0 ? (p.successfulRequests / p.totalRequests) * 100 : 0;
|
||||
const rate =
|
||||
p.totalRequests > 0 ? (p.successfulRequests / p.totalRequests) * 100 : 0;
|
||||
return (
|
||||
<Fragment key={p.provider}>
|
||||
<tr
|
||||
className="border-b border-border/50 hover:bg-surface/50 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
setExpandedProvider(isExpanded ? null : models.length > 0 ? p.provider : null)
|
||||
setExpandedProvider(
|
||||
isExpanded ? null : models.length > 0 ? p.provider : null
|
||||
)
|
||||
}
|
||||
>
|
||||
<td className="py-2.5 px-3 font-medium text-text-main">
|
||||
{p.provider}
|
||||
{resolveProviderName(p.provider, nodeMap)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right tabular-nums text-text-main">
|
||||
{formatNumber(p.totalRequests)}
|
||||
@@ -363,7 +374,9 @@ export default function ProviderStatsPage() {
|
||||
<th className="text-right py-1.5 px-3 font-medium">Requests</th>
|
||||
<th className="text-right py-1.5 px-3 font-medium">Success</th>
|
||||
<th className="text-right py-1.5 px-3 font-medium">Rate</th>
|
||||
<th className="text-right py-1.5 px-3 font-medium">Avg Latency</th>
|
||||
<th className="text-right py-1.5 px-3 font-medium">
|
||||
Avg Latency
|
||||
</th>
|
||||
<th className="px-3 w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -444,7 +457,10 @@ export default function ProviderStatsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(data.comboMetrics).map(([name, m]: [string, any]) => (
|
||||
<tr key={name} className="border-b border-border/50 hover:bg-surface/50 transition-colors">
|
||||
<tr
|
||||
key={name}
|
||||
className="border-b border-border/50 hover:bg-surface/50 transition-colors"
|
||||
>
|
||||
<td className="py-2 px-3 font-medium text-text-main">{name}</td>
|
||||
<td className="py-2 px-3 text-right tabular-nums text-text-main">
|
||||
{formatNumber(m.requestCount ?? m.totalRequests ?? 0)}
|
||||
@@ -456,7 +472,9 @@ export default function ProviderStatsPage() {
|
||||
{formatLatency(m.avgLatency ?? m.avgTotalMs ?? null)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right tabular-nums">
|
||||
{typeof m.successRate === "number" ? `${(m.successRate * 100).toFixed(1)}%` : "—"}
|
||||
{typeof m.successRate === "number"
|
||||
? `${(m.successRate * 100).toFixed(1)}%`
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -478,10 +496,7 @@ export default function ProviderStatsPage() {
|
||||
{Object.entries(data.telemetry).map(([key, val]: [string, any]) => {
|
||||
if (val == null || typeof val === "object") return null;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg border border-border/40 bg-surface/30 p-3"
|
||||
>
|
||||
<div key={key} className="rounded-lg border border-border/40 bg-surface/30 p-3">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wide">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/_/g, " ")}
|
||||
</p>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import Card from "@/shared/components/Card";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import ModelCooldownsCard from "./components/ModelCooldownsCard";
|
||||
import { useProviderNodeMap, resolveProviderName } from "@/lib/display/useProviderNodeMap";
|
||||
|
||||
type KnownBreakerState = "CLOSED" | "OPEN" | "HALF_OPEN" | "DEGRADED";
|
||||
type BreakerState = KnownBreakerState | (string & {});
|
||||
@@ -399,6 +400,7 @@ function diffSnapshots(
|
||||
|
||||
export default function RuntimePageClient() {
|
||||
const t = useTranslations("runtime");
|
||||
const nodeMap = useProviderNodeMap();
|
||||
const [health, setHealth] = useState<HealthPayload | null>(null);
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -673,11 +675,13 @@ export default function RuntimePageClient() {
|
||||
key={b.provider}
|
||||
className="rounded-md border px-2.5 py-2 flex flex-col gap-0.5"
|
||||
style={{ borderColor: tone.ring, background: tone.bg }}
|
||||
title={`${b.provider} · ${state || "UNKNOWN"} · failures ${b.failureCount}`}
|
||||
title={`${resolveProviderName(b.provider, nodeMap)} · ${state || "UNKNOWN"} · failures ${b.failureCount}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-text-main">
|
||||
<ProviderIcon providerId={b.provider} size={14} />
|
||||
<span className="truncate flex-1">{b.provider}</span>
|
||||
<span className="truncate flex-1">
|
||||
{resolveProviderName(b.provider, nodeMap)}
|
||||
</span>
|
||||
<span style={{ color: tone.dot }} className="text-[10px] font-bold">
|
||||
{tone.label}
|
||||
</span>
|
||||
@@ -721,7 +725,7 @@ export default function RuntimePageClient() {
|
||||
<ProviderIcon providerId={c.provider} size={18} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] text-text-main truncate font-medium">
|
||||
{c.provider}/{label}
|
||||
{resolveProviderName(c.provider, nodeMap)}/{label}
|
||||
</div>
|
||||
{c.lastErrorType && (
|
||||
<div className="text-[10px] text-text-muted truncate">
|
||||
@@ -1174,7 +1178,7 @@ function QuotaGroup({
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium text-text-main truncate">
|
||||
{m.accountId ?? "—"}
|
||||
{m.provider ? ` / ${m.provider}` : ""}
|
||||
{m.provider ? ` / ${resolveProviderName(m.provider, nodeMap)}` : ""}
|
||||
</div>
|
||||
<div className="text-[10px] text-text-muted">{m.window ?? ""}</div>
|
||||
</div>
|
||||
|
||||
68
src/lib/display/useProviderNodeMap.ts
Normal file
68
src/lib/display/useProviderNodeMap.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Shared resolver for custom provider display names.
|
||||
*
|
||||
* Provides:
|
||||
* - `resolveProviderName(id, nodeMap)` — pure function for tests and SSR
|
||||
* - `useProviderNodeMap()` — React hook that fetches /api/provider-nodes once
|
||||
* and returns a Map<id, {name?, prefix?}>
|
||||
*
|
||||
* Usage in components:
|
||||
* const nodeMap = useProviderNodeMap();
|
||||
* const label = resolveProviderName(providerId, nodeMap);
|
||||
*
|
||||
* @module lib/display/useProviderNodeMap
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getProviderDisplayName, type ProviderNodeLike } from "./names";
|
||||
|
||||
export type ProviderNodeEntry = { name?: string | null; prefix?: string | null };
|
||||
|
||||
/**
|
||||
* Pure resolver — usable in tests without React.
|
||||
*
|
||||
* Delegates to the canonical `getProviderDisplayName` helper, supplying the
|
||||
* node entry from the map as the second argument so custom names take priority.
|
||||
*/
|
||||
export function resolveProviderName(
|
||||
id: string | null | undefined,
|
||||
nodeMap: Map<string, ProviderNodeEntry> | null | undefined
|
||||
): string {
|
||||
const node = id ? (nodeMap?.get(id) ?? null) : null;
|
||||
return getProviderDisplayName(id, node as ProviderNodeLike | null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches /api/provider-nodes once per mount and returns a stable Map.
|
||||
* Returns an empty Map while loading or on error — callers degrade gracefully
|
||||
* because `resolveProviderName` falls back to the de-UUIDed id.
|
||||
*/
|
||||
export function useProviderNodeMap(): Map<string, ProviderNodeEntry> {
|
||||
const [nodeMap, setNodeMap] = useState<Map<string, ProviderNodeEntry>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/provider-nodes")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (cancelled || !data?.nodes) return;
|
||||
const map = new Map<string, ProviderNodeEntry>();
|
||||
for (const node of data.nodes) {
|
||||
if (typeof node.id === "string") {
|
||||
map.set(node.id, { name: node.name ?? null, prefix: node.prefix ?? null });
|
||||
}
|
||||
}
|
||||
setNodeMap(map);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently degrade — custom providers will show de-UUIDed fallback label
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return nodeMap;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const { createErrorResponse, createErrorResponseFromUnknown } =
|
||||
await import("../../src/lib/api/errorResponse.ts");
|
||||
const { getAccountDisplayName, getProviderDisplayName } =
|
||||
await import("../../src/lib/display/names.ts");
|
||||
const { resolveProviderName } = await import("../../src/lib/display/useProviderNodeMap.ts");
|
||||
|
||||
test("toJsonErrorPayload: preserves upstream error objects that already have error payloads", () => {
|
||||
const payload = {
|
||||
@@ -201,6 +202,32 @@ test("getAccountDisplayName: respects priority order and fallback", () => {
|
||||
assert.equal(getAccountDisplayName(null), "Unknown Account");
|
||||
});
|
||||
|
||||
test("resolveProviderName: returns user-given name from nodeMap for custom provider", () => {
|
||||
const nodeMap = new Map([
|
||||
["openai-compatible-chat-abc123", { name: "My LLM", prefix: "my-llm" }],
|
||||
]);
|
||||
assert.equal(
|
||||
resolveProviderName("openai-compatible-chat-abc123", nodeMap),
|
||||
"My LLM",
|
||||
"should return the user-given name when present in the map"
|
||||
);
|
||||
// Falls back to prefix when name is blank
|
||||
const mapNoName = new Map([
|
||||
["openai-compatible-chat-abc123", { name: null, prefix: "my-prefix" }],
|
||||
]);
|
||||
assert.equal(resolveProviderName("openai-compatible-chat-abc123", mapNoName), "my-prefix");
|
||||
// Falls back to de-UUIDed label when no node entry exists
|
||||
assert.equal(
|
||||
resolveProviderName("openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441", new Map()),
|
||||
"Compatible (openai)"
|
||||
);
|
||||
// Falls back gracefully for unknown ids not in the map
|
||||
assert.equal(resolveProviderName("plain-provider", nodeMap), "plain-provider");
|
||||
// Handles null/undefined id
|
||||
assert.equal(resolveProviderName(null, nodeMap), "Unknown Provider");
|
||||
assert.equal(resolveProviderName(undefined, null), "Unknown Provider");
|
||||
});
|
||||
|
||||
test("getProviderDisplayName: prefers node metadata and simplifies compatible IDs", () => {
|
||||
assert.equal(
|
||||
getProviderDisplayName("openai-compatible-chat-02669115-2545-4896-b003-cb4dac09d441", {
|
||||
|
||||
Reference in New Issue
Block a user