frontend: declutter Provider & Models cost dashboard and provider form

Cost dashboard: cut vanity cards (total tokens, provider/model pairs),
remove the broken duplicate-row "Usage by task" table, label trend
sparklines with the daily average instead of a misleading last-point
value, sort the provider/model table by spend so the cost driver
surfaces first, and collapse the dense unpriced-model list into a
concise note with a hover tooltip.

Provider form: stop auto-expanding every configured provider into a
wall of API-key inputs. Configured providers now start collapsed (the
per-provider badges and health callout still convey status); first-time
setup still expands the default provider, and saves preserve the
operator's own expand/collapse choices.
This commit is contained in:
rcourtman
2026-06-24 09:17:02 +01:00
parent 1f6fe16e49
commit fd2b3ebc2c
4 changed files with 87 additions and 169 deletions
@@ -25,8 +25,6 @@ import {
AI_COST_PANEL_DESCRIPTION,
AI_COST_PANEL_TITLE,
AI_COST_PATROL_USE_CASE_LABEL,
AI_COST_PROVIDER_MODEL_PAIR_LABEL,
AI_COST_TARGET_TABLE_LABEL,
AI_COST_RESET_HISTORY_LABEL,
buildAICostExportFilename,
getAICostBudgetNote,
@@ -37,7 +35,6 @@ import {
getAICostResetHistoryConfirmationMessage,
getAICostResetHistoryErrorMessage,
getAICostResetHistorySuccessMessage,
getAICostTargetPresentation,
} from '@/utils/aiCostPresentation';
const usdFormatter = new Intl.NumberFormat(undefined, {
@@ -142,16 +139,34 @@ export const AICostDashboard: Component = () => {
const dailyTokenValues = createMemo(() => dailyTotals().map((d) => d.total_tokens));
const dailyUSDValues = createMemo(() => dailyTotals().map((d) => d.estimated_usd ?? 0));
const lastDailyTokens = createMemo(() => {
// Trend cards characterise the range by its daily average, not the last
// data point. Showing the last point read as "you spent $0 today" when
// earlier days in the range carried the actual spend.
const avgDailyTokens = createMemo(() => {
const values = dailyTokenValues();
if (values.length === 0) return null;
return values[values.length - 1];
return Math.round(values.reduce((sum, v) => sum + v, 0) / values.length);
});
const lastDailyUSD = createMemo(() => {
const avgDailyUSD = createMemo(() => {
const values = dailyUSDValues();
if (values.length === 0) return null;
return values[values.length - 1];
return values.reduce((sum, v) => sum + v, 0) / values.length;
});
// Surface the real cost driver first: priced models by spend (desc),
// then unpriced models by tokens (desc) so the rows the operator can
// actually act on aren't buried under flat / unknown rows.
const sortedProviderModels = createMemo(() => {
const data = summary();
if (!data) return [];
return [...data.provider_models].sort((a, b) => {
const aPriced = a.pricing_known ? 1 : 0;
const bPriced = b.pricing_known ? 1 : 0;
if (aPriced !== bPriced) return bPriced - aPriced;
if (aPriced) return (b.estimated_usd ?? 0) - (a.estimated_usd ?? 0);
return (b.total_tokens ?? 0) - (a.total_tokens ?? 0);
});
});
const formatUSD = (usd: number) => usdFormatter.format(usd);
@@ -377,7 +392,7 @@ export const AICostDashboard: Component = () => {
<Show when={summary()}>
{(data) => (
<>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="p-3 rounded-md bg-surface-alt border border-border">
<div class="text-xs text-muted">Estimated spend</div>
<div class="text-lg font-semibold text-base-content">
@@ -388,22 +403,27 @@ export const AICostDashboard: Component = () => {
{formatUSD(estimatedTotalUSD() ?? 0)}
</Show>
</div>
<div class="text-[11px] text-muted mt-0.5">over {data().effective_days} days</div>
</div>
<div class="p-3 rounded-md bg-surface-alt border border-border">
<div class="text-xs text-muted">Total tokens</div>
<div class="text-lg font-semibold text-base-content">
{formatNumber(data().totals.total_tokens)}
<div class="text-xs text-muted">{AI_COST_BUDGET_LABEL}</div>
<div class="text-lg font-semibold text-base-content mt-0.5">
<Show
when={parsedBudgetUSD30d() != null}
fallback={<span class="text-muted" aria-hidden="true"></span>}
>
{formatUSD(parsedBudgetUSD30d() ?? 0)}
</Show>
</div>
</div>
<div class="p-3 rounded-md bg-surface-alt border border-border">
<div class="text-xs text-muted">{AI_COST_PROVIDER_MODEL_PAIR_LABEL}</div>
<div class="text-lg font-semibold text-base-content">
{formatNumber(data().provider_models.length)}
<div class="text-[11px] text-muted mt-0.5">
<Show when={budgetForRange() != null} fallback={<span>No budget set</span>}>
{getAICostBudgetNote(days())} {formatUSD(budgetForRange() ?? 0)}
</Show>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="p-3 rounded-md bg-surface-alt border border-border">
<div class="text-xs text-muted">{AI_COST_ASSISTANT_USE_CASE_LABEL}</div>
<div class="text-sm font-semibold text-base-content">
@@ -426,35 +446,18 @@ export const AICostDashboard: Component = () => {
</Show>
</div>
</div>
<div class="p-3 rounded-md bg-surface-alt border border-border">
<div class="text-xs text-muted">{AI_COST_BUDGET_LABEL}</div>
<div class="text-sm font-semibold text-base-content mt-1">
<Show
when={parsedBudgetUSD30d() != null}
fallback={<span class="text-muted" aria-hidden="true"></span>}
>
{formatUSD(parsedBudgetUSD30d() ?? 0)}
</Show>
</div>
<div class="text-[11px] text-muted mt-1">
{getAICostBudgetNote(days())}{' '}
<Show when={budgetForRange() != null} fallback={<span></span>}>
{formatUSD(budgetForRange() ?? 0)}
</Show>
</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="p-3 rounded-md bg-surface-alt border border-border">
<div class="flex items-center justify-between">
<div class="text-xs text-muted">Daily estimated USD</div>
<div class="text-xs text-muted">Spend trend</div>
<div class="text-xs text-muted">
<Show
when={anyPricingKnown() && lastDailyUSD() != null}
when={anyPricingKnown() && avgDailyUSD() != null}
fallback={<span></span>}
>
{formatUSD(lastDailyUSD() ?? 0)}
{formatUSD(avgDailyUSD() ?? 0)} avg/day
</Show>
</div>
</div>
@@ -471,10 +474,10 @@ export const AICostDashboard: Component = () => {
</div>
<div class="p-3 rounded-md bg-surface-alt border border-border">
<div class="flex items-center justify-between">
<div class="text-xs text-muted">Daily total tokens</div>
<div class="text-xs text-muted">Token trend</div>
<div class="text-xs text-muted">
<Show when={lastDailyTokens() != null} fallback={<span></span>}>
{formatNumber(lastDailyTokens() ?? 0)}
<Show when={avgDailyTokens() != null} fallback={<span></span>}>
{formatNumber(avgDailyTokens() ?? 0)} avg/day
</Show>
</div>
</div>
@@ -494,19 +497,9 @@ export const AICostDashboard: Component = () => {
<div class="text-xs text-muted">
USD is an estimate based on public list prices. It may differ from billing.
<Show when={unpricedProviderModels().length > 0}>
<span class="ml-2">
Estimated spend is partial. Pricing is unknown for{' '}
{unpricedProviderModels()
.slice(0, 6)
.map(
(pm) =>
`${getAIProviderDisplayName(pm.provider) || pm.provider}/${pm.model}`,
)
.join(', ')}
<Show when={unpricedProviderModels().length > 6}>
<span> (+{unpricedProviderModels().length - 6} more)</span>
</Show>
.
<span class="ml-2" title={`Pricing unknown for:\n${unpricedProviderModels().map((pm) => `${getAIProviderDisplayName(pm.provider) || pm.provider}/${pm.model}`).join('\n')}`}>
Estimated spend is partial {unpricedProviderModels().length} model
{unpricedProviderModels().length === 1 ? '' : 's'} have unknown pricing.
</span>
</Show>
<Show when={summary()?.pricing_as_of}>
@@ -546,56 +539,6 @@ export const AICostDashboard: Component = () => {
</div>
</div>
<Show when={(data().targets?.length ?? 0) > 0}>
<Table class="min-w-full text-sm">
<TableHeader class="text-xs text-muted uppercase tracking-wide">
<TableRow class="border-b border-border">
<TableHead class="text-left py-2 pr-4">
{AI_COST_TARGET_TABLE_LABEL}
</TableHead>
<TableHead class="text-right py-2 px-2">Est. USD</TableHead>
<TableHead class="text-right py-2 px-2">Calls</TableHead>
<TableHead class="text-right py-2 px-2">Tokens</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For each={data().targets}>
{(t) => {
const target = getAICostTargetPresentation(t);
return (
<TableRow class="border-b border-border-subtle">
<TableCell class="py-2 pr-4 text-base-content">
<span class="font-medium">{target.label}</span>
<Show when={target.detail}>
<span class="block text-xs text-muted">{target.detail}</span>
</Show>
</TableCell>
<TableCell class="py-2 px-2 text-right text-base-content">
<Show
when={t.pricing_known}
fallback={
<span class="text-muted" aria-hidden="true">
</span>
}
>
{formatUSD(t.estimated_usd ?? 0)}
</Show>
</TableCell>
<TableCell class="py-2 px-2 text-right text-base-content">
{formatNumber(t.calls)}
</TableCell>
<TableCell class="py-2 px-2 text-right text-base-content">
{formatNumber(t.total_tokens)}
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</Show>
<Table class="min-w-full text-sm">
<TableHeader class="text-xs text-muted uppercase tracking-wide">
<TableRow class="border-b border-border">
@@ -608,7 +551,7 @@ export const AICostDashboard: Component = () => {
</TableRow>
</TableHeader>
<TableBody>
<For each={data().provider_models}>
<For each={sortedProviderModels()}>
{(pm) => (
<TableRow class="border-b border-border-subtle">
<TableCell class="py-2 pr-4 font-medium text-base-content">
@@ -190,26 +190,6 @@ describe('AICostDashboard', () => {
// ---- summary cards ----
it('displays total tokens', async () => {
renderDashboard();
await waitFor(() => {
expect(screen.getByText('Total tokens')).toBeInTheDocument();
});
// 60000 formatted — appears in summary card and provider table, so use getAllByText
const matches = screen.getAllByText('60,000');
expect(matches.length).toBeGreaterThanOrEqual(1);
});
it('displays model/provider pair count', async () => {
renderDashboard();
await waitFor(() => {
expect(screen.getByText('Provider/model pairs')).toBeInTheDocument();
});
// Verify the count is rendered in the same card container
const pairCard = screen.getByText('Provider/model pairs').closest('.p-3')!;
expect(pairCard.textContent).toContain('1');
});
it('displays estimated spend in USD', async () => {
renderDashboard();
await waitFor(() => {
@@ -419,47 +399,11 @@ describe('AICostDashboard', () => {
}),
);
renderDashboard();
await waitFor(() => {
expect(screen.getByText(/Pricing is unknown for/)).toBeInTheDocument();
});
expect(screen.getByText(/Ollama\/llama3/)).toBeInTheDocument();
});
// ---- targets table ----
it('renders target table when targets are present', async () => {
getCostSummaryMock.mockResolvedValue(
baseSummary({
targets: [
{
target_type: 'assistant_session_title',
target_id: '7f5941d9-a503-416d-b84e-5a46c9e1e11f',
calls: 5,
input_tokens: 10000,
output_tokens: 2000,
total_tokens: 12000,
estimated_usd: 0.08,
pricing_known: true,
},
],
}),
);
renderDashboard();
await waitFor(() => {
expect(screen.getByText('Usage by task')).toBeInTheDocument();
});
expect(screen.getByText('Assistant sessions')).toBeInTheDocument();
expect(screen.queryByText(/assistant_session_title/)).not.toBeInTheDocument();
expect(screen.queryByText(/7f5941d9/)).not.toBeInTheDocument();
expect(screen.getByText('12,000')).toBeInTheDocument();
});
it('does not render target table when no targets', async () => {
renderDashboard();
await waitFor(() => {
expect(screen.getByText('Estimated spend')).toBeInTheDocument();
});
expect(screen.queryByText('Usage by task')).not.toBeInTheDocument();
const note = await screen.findByText(/unknown pricing/);
expect(note.textContent).toMatch(/partial/);
// The full unpriced-model list is carried in a hover tooltip so the
// visible flow stays concise.
expect(note.getAttribute('title')).toContain('Ollama/llama3');
});
// ---- error states ----
@@ -726,14 +670,26 @@ describe('AICostDashboard', () => {
it('shows daily trend sparklines when multiple daily totals exist', async () => {
renderDashboard();
await waitFor(() => {
expect(screen.getByText('Daily estimated USD')).toBeInTheDocument();
expect(screen.getByText('Daily total tokens')).toBeInTheDocument();
expect(screen.getByText('Spend trend')).toBeInTheDocument();
expect(screen.getByText('Token trend')).toBeInTheDocument();
});
// With 2 daily totals, sparklines should render (SVGs present)
const svgs = document.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThanOrEqual(2);
});
it('labels trend cards with the daily average, not a single last point', async () => {
// daily_totals fixture: two days, $0.21 each → avg $0.21/day. A last-point
// label previously read as the headline and could misleadingly show $0.
renderDashboard();
await waitFor(() => {
expect(screen.getByText('Spend trend')).toBeInTheDocument();
});
const spendCard = screen.getByText('Spend trend').closest('.p-3')!;
expect(spendCard.textContent).toMatch(/avg\/day/);
expect(spendCard.textContent).toMatch(/0\.21/);
});
it('shows the shared daily token empty state when less than 2 daily totals', async () => {
getCostSummaryMock.mockResolvedValue(baseSummary({ daily_totals: [] }));
renderDashboard();
@@ -867,6 +867,9 @@ describe('AISettings Ollama provider options', () => {
renderComponent();
// Configured providers start collapsed; open the Ollama accordion to edit
// its advanced options, the same way an operator would.
fireEvent.click(await screen.findByRole('button', { name: /ollama/i }));
fireEvent.input(await screen.findByLabelText('Ollama Keep Alive'), {
target: { value: '24h' },
});
@@ -245,6 +245,11 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
const [expandedProviders, setExpandedProviders] = createSignal<Set<AIProvider>>(
new Set(['anthropic']),
);
// Tracks whether the initial expand policy has been applied, so later
// resetForm calls (after every save) preserve the operator's own
// expand/collapse choices instead of clobbering them.
const [providersExpandedInitialized, setProvidersExpandedInitialized] =
createSignal(false);
const [testingProvider, setTestingProvider] = createSignal<AIProvider | null>(null);
const [providerTestResult, setProviderTestResult] = createSignal<ProviderTestResult | null>(null);
@@ -482,8 +487,19 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
if (data.together_configured) configured.add('together');
if (data.fireworks_configured) configured.add('fireworks');
if (data.ollama_configured) configured.add('ollama');
if (configured.size === 0) configured.add('anthropic');
setExpandedProviders(configured);
// Apply the expand policy only on the first load. Guide first-time
// setup by expanding the default provider when nothing is configured
// yet; once at least one provider is configured, keep the list
// collapsed — the per-provider badges and the health callout already
// convey status, and expanding every configured provider just produces
// a wall of API-key inputs. Later saves preserve the operator's own
// expand/collapse selections.
if (!providersExpandedInitialized()) {
setProvidersExpandedInitialized(true);
setExpandedProviders(
configured.size === 0 ? new Set<AIProvider>(['anthropic']) : new Set<AIProvider>(),
);
}
};
const loadModels = async () => {