FN-6969: show provider icons beside Command Center models
Show inferred provider icons beside model names across Command Center bar and table surfaces. - Add dashboard-side provider-icon inference for model/provider strings. - Render provider icons on Command Center model bar rows and token table rows while keeping pie chart labels text-only. - Cover overview, token, and ecosystem model-icon surfaces with tests and add a changeset. Files changed: .changeset/fn-6969-provider-icons.md | 7 ++ .../components/command-center/CommandCenter.tsx | 15 ++- .../__tests__/CommandCenter.test.tsx | 33 ++++++- .../command-center/areas/EcosystemArea.tsx | 15 ++- .../components/command-center/areas/TokensArea.tsx | 37 +++++++- .../areas/__tests__/TokensArea.test.tsx | 105 +++++++++++++++++++++ .../command-center/areas/__tests__/areas.test.tsx | 42 ++++++++- .../app/components/command-center/charts/Bar.tsx | 25 ++++- .../components/command-center/charts/charts.css | 42 +++++++++ .../app/utils/__tests__/providerIconKey.test.ts | 32 +++++++ packages/dashboard/app/utils/providerIconKey.ts | 45 +++++++++ 11 files changed, 377 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-6969 Fusion-Task-Lineage: 6caf8864-1ecc-46e3-b7fa-bfa8e9a70aaa
This commit is contained in:
7
.changeset/fn-6969-provider-icons.md
Normal file
7
.changeset/fn-6969-provider-icons.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Show provider icons next to Command Center model names.
|
||||
category: feature
|
||||
dev: Infers provider icons from model ids for Command Center model tables and bar charts; pie charts remain text-only.
|
||||
@@ -21,6 +21,7 @@ import { NodesView } from "../NodesView";
|
||||
import type { ToastType } from "../../hooks/useToast";
|
||||
import type { TaskView } from "../../hooks/useViewState";
|
||||
import { SdlcFunnel } from "./SdlcFunnel";
|
||||
import { inferProviderIconKey } from "../../utils/providerIconKey";
|
||||
import { Bar, type BarDatum } from "./charts/Bar";
|
||||
import { Sparkline } from "./charts/Sparkline";
|
||||
import { LineChart as RechartsLineChart, PieChart } from "./charts/recharts";
|
||||
@@ -182,11 +183,15 @@ function OverviewTab({
|
||||
[...(tokens.data?.groups ?? [])]
|
||||
.sort((a, b) => b.totalTokens - a.totalTokens || (a.key ?? "").localeCompare(b.key ?? ""))
|
||||
.slice(0, 8)
|
||||
.map((g) => ({
|
||||
label: g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)"),
|
||||
value: g.totalTokens,
|
||||
valueLabel: formatCount(g.totalTokens),
|
||||
})),
|
||||
.map((g) => {
|
||||
const label = g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)");
|
||||
return {
|
||||
label,
|
||||
value: g.totalTokens,
|
||||
valueLabel: formatCount(g.totalTokens),
|
||||
iconProvider: inferProviderIconKey(g.key ?? ""),
|
||||
};
|
||||
}),
|
||||
[tokens.data?.groups, t],
|
||||
);
|
||||
const toolCategoryData = useMemo<BarDatum[]>(
|
||||
|
||||
@@ -44,6 +44,10 @@ vi.mock("../../NodesView", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
function providerIconIn(element: HTMLElement, provider: string): Element | null {
|
||||
return element.querySelector(`.provider-icon[data-provider="${provider}"]`);
|
||||
}
|
||||
|
||||
function tokenFixture(totalTokens = 1_500) {
|
||||
return {
|
||||
from: "2026-06-08",
|
||||
@@ -514,9 +518,15 @@ describe("CommandCenter shell", () => {
|
||||
|
||||
const charts = screen.getByTestId("command-center-overview-charts");
|
||||
expect(within(charts).getByText("Tokens by model")).toBeTruthy();
|
||||
expect(within(screen.getByTestId("command-center-overview-chart-tokens")).getByText("gpt-4o")).toBeTruthy();
|
||||
const overviewTokenChart = screen.getByTestId("command-center-overview-chart-tokens");
|
||||
expect(within(overviewTokenChart).getByText("gpt-4o")).toBeTruthy();
|
||||
expect(providerIconIn(overviewTokenChart, "openai")).toBeTruthy();
|
||||
expect(providerIconIn(overviewTokenChart, "anthropic")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "openai gpt-4o: 900" })).toBeTruthy();
|
||||
expect(within(screen.getByTestId("command-center-overview-chart-tools")).getByText("read")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-overview-pie")).toBeTruthy();
|
||||
expect(providerIconIn(screen.getByTestId("cc-overview-pie"), "openai")).toBeNull();
|
||||
expect(providerIconIn(screen.getByTestId("cc-overview-pie"), "anthropic")).toBeNull();
|
||||
expect(screen.getByTestId("cc-overview-line")).toBeTruthy();
|
||||
expectDailyActivityLineBeforeTrend();
|
||||
expect(screen.getByRole("img", { name: "Token share by model" })).toBeTruthy();
|
||||
@@ -525,6 +535,27 @@ describe("CommandCenter shell", () => {
|
||||
expectThroughputLastAfter("command-center-stat-tokens", "command-center-live-strip", "command-center-overview-charts");
|
||||
});
|
||||
|
||||
it("renders Overview providerless and unknown model icons without touching pie labels", async () => {
|
||||
mockOverviewApi({
|
||||
tokens: {
|
||||
...tokenFixture(),
|
||||
groups: [
|
||||
{ ...tokenFixture().groups[0], key: "legacy-model", totalTokens: 50, nTasks: 1 },
|
||||
{ ...tokenFixture().groups[1], key: null, totalTokens: 25, nTasks: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
render(<CommandCenter />);
|
||||
|
||||
const overviewTokenChart = await screen.findByTestId("command-center-overview-chart-tokens");
|
||||
expect(providerIconIn(overviewTokenChart, "legacy-model")).toBeTruthy();
|
||||
expect(providerIconIn(overviewTokenChart, "")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "legacy-model: 50" })).toBeTruthy();
|
||||
expect(within(overviewTokenChart).getByText("legacy-model").closest(".cc-bar-label")).toBeTruthy();
|
||||
expect(within(overviewTokenChart).getByText("(unknown)").closest(".cc-bar-label")).toBeTruthy();
|
||||
expect(providerIconIn(screen.getByTestId("cc-overview-pie"), "legacy-model")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders large comma-grouped token totals unchanged in Overview stat surfaces", async () => {
|
||||
mockOverviewApi({ tokens: tokenFixture(1_234_567_890) });
|
||||
render(<CommandCenter />);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PluginActivationAnalytics, TokenAnalytics } from "@fusion/core";
|
||||
import type { DateRange } from "../DateRangePicker";
|
||||
import { inferProviderIconKey } from "../../../utils/providerIconKey";
|
||||
import { Bar } from "../charts/Bar";
|
||||
import { LineChart, PieChart } from "../charts/recharts";
|
||||
import { AreaShell } from "./AreaShell";
|
||||
@@ -43,11 +44,15 @@ export function EcosystemArea({ range }: { range: DateRange }) {
|
||||
[...models]
|
||||
.sort((a, b) => b.nTasks - a.nTasks || (a.key ?? "").localeCompare(b.key ?? ""))
|
||||
.slice(0, 12)
|
||||
.map((g) => ({
|
||||
label: g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)"),
|
||||
value: g.nTasks,
|
||||
valueLabel: formatCount(g.nTasks),
|
||||
})),
|
||||
.map((g) => {
|
||||
const label = g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)");
|
||||
return {
|
||||
label,
|
||||
value: g.nTasks,
|
||||
valueLabel: formatCount(g.nTasks),
|
||||
iconProvider: inferProviderIconKey(g.key ?? ""),
|
||||
};
|
||||
}),
|
||||
[models, t],
|
||||
);
|
||||
/*
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
TokenTimeGranularity,
|
||||
} from "@fusion/core";
|
||||
import type { DateRange } from "../DateRangePicker";
|
||||
import { ProviderIcon } from "../../ProviderIcon";
|
||||
import { inferProviderIconKey } from "../../../utils/providerIconKey";
|
||||
import { Bar } from "../charts/Bar";
|
||||
import { TokenSeriesChart } from "../charts/TokenSeriesChart";
|
||||
import { LineChart as RechartsLineChart, PieChart } from "../charts/recharts";
|
||||
@@ -36,6 +38,32 @@ function costSortValue(cost: CostResult): number {
|
||||
return cost.unavailable || cost.usd === null ? -1 : cost.usd;
|
||||
}
|
||||
|
||||
function modelGroupIdentity(group: TokenGroupSummary): string {
|
||||
return group.key ?? "unknown";
|
||||
}
|
||||
|
||||
function modelGroupDisplayLabel(group: TokenGroupSummary, unknownLabel: string): string {
|
||||
return group.key ?? unknownLabel;
|
||||
}
|
||||
|
||||
function modelGroupIconProvider(group: TokenGroupSummary): string {
|
||||
return inferProviderIconKey(group.key ?? "");
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:CommandCenterProviderIcons 2026-06-25-00:00:
|
||||
Every textual Command Center model-name surface needs an adjacent ProviderIcon inferred from the model id, including null/unknown ids where ProviderIcon's CPU fallback is the intended safe state.
|
||||
*/
|
||||
function ModelNameWithProviderIcon({ group, unknownLabel }: { group: TokenGroupSummary; unknownLabel: string }) {
|
||||
const label = group.key ?? unknownLabel;
|
||||
return (
|
||||
<span className="cc-model-label">
|
||||
<ProviderIcon provider={modelGroupIconProvider(group)} size="sm" />
|
||||
<span className="cc-model-label-text">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function sortGroups(groups: TokenGroupSummary[], key: SortKey, dir: 1 | -1): TokenGroupSummary[] {
|
||||
const sorted = [...groups];
|
||||
sorted.sort((a, b) => {
|
||||
@@ -99,9 +127,10 @@ export function TokensArea({ range }: { range: DateRange }) {
|
||||
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||
.slice(0, 12)
|
||||
.map((g) => ({
|
||||
label: g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)"),
|
||||
label: modelGroupDisplayLabel(g, t("commandCenter.tokens.unknownModel", "(unknown)")),
|
||||
value: g.totalTokens,
|
||||
valueLabel: formatCount(g.totalTokens),
|
||||
iconProvider: modelGroupIconProvider(g),
|
||||
})),
|
||||
[groups, t],
|
||||
);
|
||||
@@ -112,7 +141,7 @@ export function TokensArea({ range }: { range: DateRange }) {
|
||||
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||
.slice(0, 12)
|
||||
.map((g) => ({
|
||||
label: g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)"),
|
||||
label: modelGroupDisplayLabel(g, t("commandCenter.tokens.unknownModel", "(unknown)")),
|
||||
value: g.totalTokens,
|
||||
})),
|
||||
[groups, t],
|
||||
@@ -244,8 +273,8 @@ export function TokensArea({ range }: { range: DateRange }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedGroups.map((g) => (
|
||||
<tr key={g.key ?? "∅"} data-testid={`cc-tokens-row-${g.key ?? "unknown"}`}>
|
||||
<td>{g.key ?? t("commandCenter.tokens.unknownModel", "(unknown)")}</td>
|
||||
<tr key={modelGroupIdentity(g)} data-testid={`cc-tokens-row-${modelGroupIdentity(g)}`}>
|
||||
<td><ModelNameWithProviderIcon group={g} unknownLabel={t("commandCenter.tokens.unknownModel", "(unknown)")} /></td>
|
||||
<td>{formatCount(g.inputTokens)}</td>
|
||||
<td>{formatCount(g.outputTokens)}</td>
|
||||
<td>{formatCount(g.cachedTokens)}</td>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { TokensArea } from "../TokensArea";
|
||||
import type { DateRange } from "../../DateRangePicker";
|
||||
|
||||
const apiMock = vi.fn();
|
||||
|
||||
vi.mock("../../../../api/legacy", () => ({
|
||||
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
|
||||
}));
|
||||
|
||||
vi.mock("../../../ProviderIcon", () => ({
|
||||
ProviderIcon: ({ provider, size }: { provider: string; size?: "sm" | "md" | "lg" }) => (
|
||||
<span className="provider-icon" data-provider={provider.toLowerCase()} data-size={size} data-testid={`provider-icon-${provider}`} />
|
||||
),
|
||||
}));
|
||||
|
||||
const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" };
|
||||
|
||||
function tokenFixture() {
|
||||
return {
|
||||
from: "2026-06-08",
|
||||
to: null,
|
||||
groupBy: "model",
|
||||
totals: {
|
||||
inputTokens: 1_350,
|
||||
outputTokens: 675,
|
||||
cachedTokens: 225,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 2_250,
|
||||
nTasks: 5,
|
||||
},
|
||||
cost: { usd: 12.5, unavailable: false, stale: false },
|
||||
series: [],
|
||||
groups: [
|
||||
{
|
||||
key: "claude-sonnet-4-5",
|
||||
inputTokens: 600,
|
||||
outputTokens: 300,
|
||||
cachedTokens: 100,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 1_000,
|
||||
nTasks: 2,
|
||||
cost: { usd: 3.5, unavailable: false, stale: false },
|
||||
},
|
||||
{
|
||||
key: "gpt-4o-mini",
|
||||
inputTokens: 500,
|
||||
outputTokens: 250,
|
||||
cachedTokens: 100,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 850,
|
||||
nTasks: 2,
|
||||
cost: { usd: 7, unavailable: false, stale: false },
|
||||
},
|
||||
{
|
||||
key: null,
|
||||
inputTokens: 250,
|
||||
outputTokens: 125,
|
||||
cachedTokens: 25,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 400,
|
||||
nTasks: 1,
|
||||
cost: { usd: null, unavailable: true, stale: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
apiMock.mockResolvedValue(tokenFixture());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("TokensArea provider model icons", () => {
|
||||
it("renders inferred provider icons in the per-model table and Tokens by model bars", async () => {
|
||||
render(<TokensArea range={range7d} />);
|
||||
|
||||
const table = await screen.findByTestId("cc-tokens-table");
|
||||
const claudeRow = screen.getByTestId("cc-tokens-row-claude-sonnet-4-5");
|
||||
const gptRow = screen.getByTestId("cc-tokens-row-gpt-4o-mini");
|
||||
const unknownRow = screen.getByTestId("cc-tokens-row-unknown");
|
||||
|
||||
expect(within(claudeRow).getByText("claude-sonnet-4-5")).toBeTruthy();
|
||||
expect(within(claudeRow).getByTestId("provider-icon-anthropic")).toBeTruthy();
|
||||
expect(within(gptRow).getByText("gpt-4o-mini")).toBeTruthy();
|
||||
expect(within(gptRow).getByTestId("provider-icon-openai")).toBeTruthy();
|
||||
expect(within(unknownRow).getByText("(unknown)")).toBeTruthy();
|
||||
expect(within(unknownRow).getByTestId("provider-icon-")).toBeTruthy();
|
||||
|
||||
const byModelChart = screen.getByRole("list", { name: "Tokens by model" });
|
||||
const claudeBarLabel = within(byModelChart).getByText("claude-sonnet-4-5").closest(".cc-bar-label");
|
||||
const gptBarLabel = within(byModelChart).getByText("gpt-4o-mini").closest(".cc-bar-label");
|
||||
const unknownBarLabel = within(byModelChart).getByText("(unknown)").closest(".cc-bar-label");
|
||||
|
||||
expect(claudeBarLabel?.firstElementChild).toHaveAttribute("data-testid", "provider-icon-anthropic");
|
||||
expect(gptBarLabel?.firstElementChild).toHaveAttribute("data-testid", "provider-icon-openai");
|
||||
expect(unknownBarLabel?.firstElementChild).toHaveAttribute("data-testid", "provider-icon-");
|
||||
expect(table.querySelectorAll(".provider-icon").length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,10 @@ import { defaultPresets, rangeFromPreset, type DateRange } from "../../DateRange
|
||||
const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" };
|
||||
const customRange = (from: string, to: string): DateRange => ({ from, to, preset: "custom" });
|
||||
|
||||
function providerIconIn(element: HTMLElement, provider: string): Element | null {
|
||||
return element.querySelector(`.provider-icon[data-provider="${provider}"]`);
|
||||
}
|
||||
|
||||
function tokenFixture(totalTokens = 1500) {
|
||||
return {
|
||||
from: "2026-06-08",
|
||||
@@ -760,8 +764,36 @@ describe("TokensArea", () => {
|
||||
expect(screen.getByRole("img", { name: "Tokens trend" })).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Token share by model" })).toBeTruthy();
|
||||
expect(screen.getByLabelText("2026-06-09: 900")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-tokens-row-gpt-4o")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-tokens-row-claude-sonnet")).toBeTruthy();
|
||||
const tokensBar = screen.getByRole("list", { name: "Tokens by model" }) as HTMLElement;
|
||||
expect(providerIconIn(tokensBar, "openai")).toBeTruthy();
|
||||
expect(providerIconIn(tokensBar, "anthropic")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "openai gpt-4o: 900" })).toBeTruthy();
|
||||
expect(providerIconIn(screen.getByTestId("cc-tokens-pie"), "openai")).toBeNull();
|
||||
expect(providerIconIn(screen.getByTestId("cc-tokens-pie"), "anthropic")).toBeNull();
|
||||
expect(providerIconIn(screen.getByTestId("cc-tokens-row-gpt-4o"), "openai")).toBeTruthy();
|
||||
expect(providerIconIn(screen.getByTestId("cc-tokens-row-claude-sonnet"), "anthropic")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders providerless and unknown model rows correctly", async () => {
|
||||
apiMock.mockResolvedValue({
|
||||
...tokenFixture(),
|
||||
totals: { ...tokenFixture().totals, totalTokens: 75, nTasks: 2 },
|
||||
groups: [
|
||||
{ ...tokenFixture().groups[0], key: "legacy-model", totalTokens: 50, nTasks: 1 },
|
||||
{ ...tokenFixture().groups[1], key: null, totalTokens: 25, nTasks: 1 },
|
||||
],
|
||||
});
|
||||
render(<TokensArea range={range7d} />);
|
||||
|
||||
await screen.findByTestId("cc-area-tokens");
|
||||
expect(screen.getByRole("img", { name: "legacy-model: 50" })).toBeTruthy();
|
||||
|
||||
const legacyRow = screen.getByTestId("cc-tokens-row-legacy-model");
|
||||
const unknownRow = screen.getByTestId("cc-tokens-row-unknown");
|
||||
expect(legacyRow.textContent).toContain("legacy-model");
|
||||
expect(unknownRow.textContent).toContain("(unknown)");
|
||||
expect(providerIconIn(legacyRow, "legacy-model")).toBeTruthy();
|
||||
expect(providerIconIn(unknownRow, "")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a large comma-grouped total unchanged in the total tokens card", async () => {
|
||||
@@ -1623,6 +1655,12 @@ describe("EcosystemArea", () => {
|
||||
expect(screen.getByTestId("cc-ecosystem-pie")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-ecosystem-line")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Task share by model" })).toBeTruthy();
|
||||
const ecosystemBar = screen.getByRole("list", { name: "Tasks per model" }) as HTMLElement;
|
||||
expect(providerIconIn(ecosystemBar, "openai")).toBeTruthy();
|
||||
expect(providerIconIn(ecosystemBar, "anthropic")).toBeTruthy();
|
||||
expect(providerIconIn(screen.getByTestId("cc-ecosystem-pie"), "openai")).toBeNull();
|
||||
expect(providerIconIn(screen.getByTestId("cc-ecosystem-pie"), "anthropic")).toBeNull();
|
||||
expect(screen.getByRole("img", { name: "openai gpt-4o: 3" })).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Ecosystem trend" })).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-ecosystem-plugins-unavailable").textContent).toBe("—");
|
||||
expect(screen.getByTestId("cc-area-ecosystem").textContent).not.toContain("NaN");
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { ProviderIcon } from "../../ProviderIcon";
|
||||
import "./charts.css";
|
||||
|
||||
/*
|
||||
* FNXC:CommandCenterModelLabels 2026-06-25-00:00:
|
||||
* Command Center model-heavy bar analytics must render provider marks immediately before model names. Keep provider icons opt-in on BarDatum so non-model chart rows (tool categories, activity series, plugin labels) continue rendering as plain strings with no provider icon shell.
|
||||
*/
|
||||
function hasIconProvider(datum: BarDatum): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(datum, "iconProvider");
|
||||
}
|
||||
|
||||
export interface BarDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
/** Optional display string for the value (defaults to the number). */
|
||||
valueLabel?: string;
|
||||
/** Optional ProviderIcon provider key for model-name rows; omitted for non-model charts. */
|
||||
iconProvider?: string;
|
||||
}
|
||||
|
||||
export interface BarProps {
|
||||
@@ -35,18 +46,24 @@ export function Bar({ data, max, ariaLabel }: BarProps) {
|
||||
|
||||
return (
|
||||
<ul className="cc-bar-chart" role="list" aria-label={ariaLabel}>
|
||||
{data.map((d) => {
|
||||
{data.map((d, index) => {
|
||||
const width = safeWidthPercent(d.value, computedMax);
|
||||
const valueText = d.valueLabel ?? String(Number.isFinite(d.value) ? d.value : 0);
|
||||
const labelText = d.label;
|
||||
const iconLabelText = d.iconProvider && d.iconProvider !== labelText ? `${d.iconProvider} ${labelText}` : labelText;
|
||||
const accessibleLabel = hasIconProvider(d) ? iconLabelText : labelText;
|
||||
return (
|
||||
<li key={d.label} className="cc-bar-row">
|
||||
<span className="cc-bar-label">{d.label}</span>
|
||||
<li key={`${accessibleLabel}-${d.value}-${index}`} className="cc-bar-row">
|
||||
<span className="cc-bar-label">
|
||||
{hasIconProvider(d) ? <ProviderIcon provider={d.iconProvider ?? ""} size="sm" /> : null}
|
||||
<span className="cc-bar-label-text">{d.label}</span>
|
||||
</span>
|
||||
<div className="cc-bar-track">
|
||||
<div
|
||||
className="cc-bar-fill"
|
||||
style={{ width: `${width}%` }}
|
||||
role="img"
|
||||
aria-label={`${d.label}: ${valueText}`}
|
||||
aria-label={`${accessibleLabel}: ${valueText}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="cc-bar-value">{valueText}</span>
|
||||
|
||||
@@ -57,6 +57,9 @@ FN-6680 re-checked FN-6664 in a real Blink layout engine because jsdom does not
|
||||
|
||||
.cc-bar-label {
|
||||
min-inline-size: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
@@ -64,6 +67,37 @@ FN-6680 re-checked FN-6664 in a real Blink layout engine because jsdom does not
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cc-bar-label > .provider-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cc-bar-label-text {
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cc-model-label {
|
||||
min-inline-size: 0;
|
||||
max-inline-size: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.cc-model-label .provider-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cc-model-label-text {
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cc-bar-track {
|
||||
min-inline-size: 0;
|
||||
position: relative;
|
||||
@@ -473,6 +507,10 @@ On narrow mobile, Recharts' default legend measured to 0px wide and positioned i
|
||||
.cc-recharts-empty {
|
||||
block-size: calc(var(--space-2xl) * 8 + var(--space-md));
|
||||
}
|
||||
|
||||
.cc-model-label {
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- RadialGauge ---- */
|
||||
@@ -679,6 +717,10 @@ On narrow mobile, Recharts' default legend measured to 0px wide and positioned i
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cc-bar-label {
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.cc-bar-value {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: end;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inferProviderIconKey } from "../providerIconKey";
|
||||
|
||||
describe("inferProviderIconKey", () => {
|
||||
it.each([
|
||||
["claude-sonnet-4-5", "anthropic"],
|
||||
["Anthropic Claude", "anthropic"],
|
||||
["gpt-4o", "openai"],
|
||||
["openai-codex", "openai"],
|
||||
["gemini-2.5-pro", "google"],
|
||||
["google-antigravity", "google"],
|
||||
["ollama/llama3", "ollama"],
|
||||
["minimax-text-01", "minimax"],
|
||||
["zhipu-glm-4", "zai"],
|
||||
["zai/glm-4.5", "zai"],
|
||||
["kimi-k2", "kimi"],
|
||||
["moonshot-v1", "kimi"],
|
||||
["amazon-bedrock-claude", "anthropic"],
|
||||
["bedrock-titan", "bedrock"],
|
||||
["xai-grok-4", "xai"],
|
||||
["opencode", "opencode"],
|
||||
["github copilot", "github-copilot"],
|
||||
["copilot-gpt-4", "openai"],
|
||||
])("maps %s to %s", (input, expected) => {
|
||||
expect(inferProviderIconKey(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns unknown ids unchanged so ProviderIcon can render its fallback", () => {
|
||||
expect(inferProviderIconKey("custom-model-v1")).toBe("custom-model-v1");
|
||||
expect(inferProviderIconKey("")).toBe("");
|
||||
});
|
||||
});
|
||||
45
packages/dashboard/app/utils/providerIconKey.ts
Normal file
45
packages/dashboard/app/utils/providerIconKey.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
FNXC:ProviderIcons 2026-06-25-00:00:
|
||||
Command Center model analytics group rows by model id only, so dashboard model-name surfaces must infer the provider icon from the model/provider text before handing off to ProviderIcon's fallback-safe renderer.
|
||||
*/
|
||||
export function inferProviderIconKey(modelOrProviderName: string): string {
|
||||
const normalized = modelOrProviderName.toLowerCase();
|
||||
|
||||
// Map common provider/model names to their icon keys.
|
||||
if (normalized.includes("claude") || normalized.includes("anthropic")) {
|
||||
return "anthropic";
|
||||
}
|
||||
if (normalized.includes("codex") || normalized.includes("openai") || normalized.includes("gpt")) {
|
||||
return "openai";
|
||||
}
|
||||
if (normalized.includes("gemini") || normalized.includes("google") || normalized.includes("antigravity")) {
|
||||
return "google";
|
||||
}
|
||||
if (normalized.includes("ollama")) {
|
||||
return "ollama";
|
||||
}
|
||||
if (normalized.includes("minimax")) {
|
||||
return "minimax";
|
||||
}
|
||||
if (normalized.includes("zai") || normalized.includes("zhipu")) {
|
||||
return "zai";
|
||||
}
|
||||
if (normalized.includes("kimi") || normalized.includes("moonshot")) {
|
||||
return "kimi";
|
||||
}
|
||||
if (normalized.includes("bedrock") || normalized.includes("amazon")) {
|
||||
return "bedrock";
|
||||
}
|
||||
if (normalized.includes("xai") || normalized.includes("grok")) {
|
||||
return "xai";
|
||||
}
|
||||
if (normalized.includes("opencode")) {
|
||||
return "opencode";
|
||||
}
|
||||
if (normalized.includes("copilot") || normalized === "github copilot") {
|
||||
return "github-copilot";
|
||||
}
|
||||
|
||||
// Return the original name as fallback (ProviderIcon will show a default icon).
|
||||
return modelOrProviderName;
|
||||
}
|
||||
Reference in New Issue
Block a user