From ff29f2ca2ebaf5c7ce735fa4b5fbf1c5186de532 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Tue, 4 Aug 2026 13:34:13 +0200 Subject: [PATCH 1/2] refactor(usage): redesign Rancher usage panel and integrate new overview API - Updated the Rancher usage panel to provide a more interactive and reusable interface, consolidating usage data across all agents. - Introduced a new API endpoint for fetching 30-day usage overview, including total costs and agent-specific breakdowns. - Refactored existing components to utilize the new `UsagePanel`, which supports collapsible views and displays aggregated usage data. - Enhanced data models and types to accommodate the new overview structure, ensuring compatibility with existing usage reporting mechanisms. - Added comprehensive tests for the new functionality to ensure reliability and correctness. This update aims to improve the user experience by providing clearer insights into agent usage and costs, while maintaining the integrity of existing data structures. --- .specify/feature.json | 2 +- .../agent/agent/components/agent/chat/Tab.vue | 56 +-- .../components/agent/overview/UsageCard.vue | 69 +--- .../rancher/components/rancher/Provider.vue | 215 ++--------- .../api/data/repositories/api/sdk.gen.ts | 17 + .../api/data/repositories/api/types.gen.ts | 11 + admin/slices/usage/components/usage/Panel.vue | 353 ++++++++++++++++++ admin/slices/usage/data/usage.gateway.ts | 9 +- admin/slices/usage/data/usage.mapper.ts | 29 ++ admin/slices/usage/domain/usage.gateway.ts | 3 +- admin/slices/usage/domain/usage.service.ts | 6 +- admin/slices/usage/domain/usage.types.ts | 21 ++ admin/slices/usage/stores/usage.ts | 21 +- api/src/slices/usage/data/usage.gateway.ts | 12 + api/src/slices/usage/domain/usage.gateway.ts | 5 + api/src/slices/usage/domain/usage.types.ts | 27 ++ api/src/slices/usage/usage.controller.spec.ts | 212 +++++++++++ api/src/slices/usage/usage.controller.ts | 100 +++-- .../checklists/requirements.md | 35 ++ .../contracts/api-usage-overview.md | 73 ++++ .../contracts/ui-usage-panel.md | 44 +++ specs/002-rancher-usage-panel/data-model.md | 106 ++++++ specs/002-rancher-usage-panel/plan.md | 98 +++++ specs/002-rancher-usage-panel/quickstart.md | 62 +++ specs/002-rancher-usage-panel/research.md | 82 ++++ specs/002-rancher-usage-panel/spec.md | 115 ++++++ specs/002-rancher-usage-panel/tasks.md | 171 +++++++++ 27 files changed, 1659 insertions(+), 295 deletions(-) create mode 100644 admin/slices/usage/components/usage/Panel.vue create mode 100644 api/src/slices/usage/usage.controller.spec.ts create mode 100644 specs/002-rancher-usage-panel/checklists/requirements.md create mode 100644 specs/002-rancher-usage-panel/contracts/api-usage-overview.md create mode 100644 specs/002-rancher-usage-panel/contracts/ui-usage-panel.md create mode 100644 specs/002-rancher-usage-panel/data-model.md create mode 100644 specs/002-rancher-usage-panel/plan.md create mode 100644 specs/002-rancher-usage-panel/quickstart.md create mode 100644 specs/002-rancher-usage-panel/research.md create mode 100644 specs/002-rancher-usage-panel/spec.md create mode 100644 specs/002-rancher-usage-panel/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json index b3e8ead8..309f89df 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/001-stabilize-agent-startup" + "feature_directory": "specs/002-rancher-usage-panel" } diff --git a/admin/slices/agent/agent/components/agent/chat/Tab.vue b/admin/slices/agent/agent/components/agent/chat/Tab.vue index 77cd1d99..3d17399f 100644 --- a/admin/slices/agent/agent/components/agent/chat/Tab.vue +++ b/admin/slices/agent/agent/components/agent/chat/Tab.vue @@ -22,11 +22,12 @@ const emit = defineEmits<{ restart: []; toggleRunning: [] }>(); const authStore = useAuthStore(); -// Side-by-side logs panel — the only logs surface on this page. Open by -// default; closing collapses it into a page-level "Logs" button next to the -// chat's top-right corner (the chat widget itself stays uniform across hosts -// — no host-specific controls in its header). The panel is only mounted -// while open, so its 5s polling stops the moment it's collapsed. +// Side stack next to the chat: logs on top, usage panel below, each +// independently collapsible into a compact button. Logs stay the primary +// operational surface (open by default); collapsing one hands its height to +// the other via flex. The logs panel is only mounted while open, so its 5s +// polling stops the moment it's collapsed. The usage panel manages its own +// collapsed state (`collapsible` prop). const showSideLogs = ref(true); // One "restart is underway" signal for every surface (bridle header status, @@ -150,28 +151,35 @@ watch(
- + +
+ + + - diff --git a/admin/slices/agent/agent/components/agent/overview/UsageCard.vue b/admin/slices/agent/agent/components/agent/overview/UsageCard.vue index 75fd65bc..f2d416fe 100644 --- a/admin/slices/agent/agent/components/agent/overview/UsageCard.vue +++ b/admin/slices/agent/agent/components/agent/overview/UsageCard.vue @@ -1,70 +1,9 @@ diff --git a/admin/slices/rancher/components/rancher/Provider.vue b/admin/slices/rancher/components/rancher/Provider.vue index 7f8f1a8b..109001d7 100644 --- a/admin/slices/rancher/components/rancher/Provider.vue +++ b/admin/slices/rancher/components/rancher/Provider.vue @@ -16,14 +16,8 @@ import { IconCheck, IconCircle, IconExternalLink, - IconTemplate, - IconBolt, - IconBrain, - IconBook2, - IconActivity, IconCloud, } from '@tabler/icons-vue'; -import { Bot } from 'lucide-vue-next'; const rancherStore = useRancherStore(); const agentStore = useAgentStore(); @@ -31,10 +25,6 @@ const agentStatusStore = useAgentStatusStore(); const bridleStore = useBridleStore(); const authStore = useAuthStore(); const llmStore = useLlmStore(); -const templateStore = useTemplateStore(); -const skillStore = useSkillStore(); -const knowledgeStore = useKnowledgeStore(); -const usageStore = useUsageStore(); const config = useRuntimeConfig(); const { t } = useI18n(); @@ -54,94 +44,12 @@ const { data: status, pending, refresh } = useAsyncData( { lazy: true }, ); -const { data: dashboard, refresh: refreshDashboard } = useAsyncData( - 'rancher-dashboard', - async () => { - const [agents, templates, skills, llms, knowledges] = await Promise.all([ - agentStore.fetchAll(), - templateStore.fetchAll(), - skillStore.fetchAll(), - llmStore.fetchAll(), - knowledgeStore.fetchAll(), - ]); - return { - agents: agents.length, - templates: templates.length, - skills: skills.length, - llms: llms.length, - llmsActive: llms.filter((l) => l.status === 'active').length, - knowledges: knowledges.length, - }; - }, - { lazy: true }, -); - -const stats = computed(() => [ - { - label: 'Agents', - value: dashboard.value?.agents ?? 0, - href: '/agents', - icon: Bot, - }, - { - label: 'Templates', - value: dashboard.value?.templates ?? 0, - href: '/templates', - icon: IconTemplate, - }, - { - label: 'Skills', - value: dashboard.value?.skills ?? 0, - href: '/skills', - icon: IconBolt, - }, - { - label: 'LLMs', - value: dashboard.value?.llms ?? 0, - hint: dashboard.value - ? `${dashboard.value.llmsActive} active` - : undefined, - href: '/llms', - icon: IconBrain, - }, - { - label: 'Knowledges', - value: dashboard.value?.knowledges ?? 0, - href: '/knowledges', - icon: IconBook2, - }, -]); - -const { data: adminUsage, refresh: refreshUsage } = useAsyncData( - 'rancher-admin-usage', - async () => { - const adminAgent = await agentStore.fetchAdmin(); - if (!adminAgent) return null; - return await usageStore.fetchForAgent(adminAgent.id); - }, - { lazy: true }, -); - -const usageStats = computed(() => { - const u = adminUsage.value; - if (!u) return null; - const fmt = new Intl.NumberFormat('en-US'); - const cost = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 4, - }); - return { - cost30d: cost.format(u.totals.costUsd), - tokens30d: fmt.format(u.totals.inputTokens + u.totals.outputTokens), - calls30d: fmt.format(u.totals.callCount), - callsToday: fmt.format(u.today.callCount), - topModel: u.topModel ?? '—', - }; -}); +// The usage panel owns its own data; the page-level refresh just re-triggers +// the panel's active view alongside the setup status. +const usagePanel = ref<{ refresh: () => Promise } | null>(null); async function onRefreshAll() { - await Promise.all([refresh(), refreshDashboard(), refreshUsage()]); + await Promise.all([refresh(), usagePanel.value?.refresh()]); } const hasLlm = computed(() => !!status.value?.hasLlm); @@ -271,79 +179,14 @@ async function onDeploy() { -
- -
-
- -
-
- - {{ stat.label }} - - {{ stat.value }} - {{ stat.hint }} -
- -
-
- -
-
- - Rancher usage · 30d - - -
-
-
- Cost - {{ usageStats.cost30d }} -
-
- Tokens - {{ usageStats.tokens30d }} -
-
- Calls - - {{ usageStats.calls30d }} - ({{ usageStats.callsToday }} today) - -
-
- Top model - {{ usageStats.topModel }} -
-
-
-
-
- - - -
-
- - - - -
+
+ + + + +
-
+
@@ -497,17 +340,33 @@ async function onDeploy() {
- + + +
+
+
+
diff --git a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts index c70cab81..f7fa62ae 100644 --- a/admin/slices/setup/api/data/repositories/api/sdk.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/sdk.gen.ts @@ -194,6 +194,7 @@ import type { UsageControllerReportData, UsageControllerReportResponse, UsageControllerFindForCredentialData, + UsageControllerFindOverviewData, RancherControllerStatusData, RancherControllerEnsureTemplateData, UpgradeControllerStatusData, @@ -2746,6 +2747,22 @@ export class UsageService { ...options, }); } + + /** + * Get 30-day usage across all agents with cost + */ + public static usageControllerFindOverview< + ThrowOnError extends boolean = false, + >(options?: Options) { + return (options?.client ?? _heyApiClient).get< + unknown, + unknown, + ThrowOnError + >({ + url: "/usage/overview", + ...options, + }); + } } export class RancherService { diff --git a/admin/slices/setup/api/data/repositories/api/types.gen.ts b/admin/slices/setup/api/data/repositories/api/types.gen.ts index 0006b316..28428889 100644 --- a/admin/slices/setup/api/data/repositories/api/types.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/types.gen.ts @@ -3391,6 +3391,17 @@ export type UsageControllerFindForCredentialResponses = { 200: unknown; }; +export type UsageControllerFindOverviewData = { + body?: never; + path?: never; + query?: never; + url: "/usage/overview"; +}; + +export type UsageControllerFindOverviewResponses = { + 200: unknown; +}; + export type RancherControllerStatusData = { body?: never; path?: never; diff --git a/admin/slices/usage/components/usage/Panel.vue b/admin/slices/usage/components/usage/Panel.vue new file mode 100644 index 00000000..9e998ddd --- /dev/null +++ b/admin/slices/usage/components/usage/Panel.vue @@ -0,0 +1,353 @@ + + + diff --git a/admin/slices/usage/data/usage.gateway.ts b/admin/slices/usage/data/usage.gateway.ts index c86bae37..7e031f57 100644 --- a/admin/slices/usage/data/usage.gateway.ts +++ b/admin/slices/usage/data/usage.gateway.ts @@ -2,7 +2,7 @@ import { UsageService as UsageApi } from '#api/data'; import { BaseGateway } from '#common/data/BaseGateway'; import { unwrapEnvelope } from '#common/data/unwrapEnvelope'; import { IUsageGateway } from '../domain/usage.gateway'; -import type { IAgentUsage } from '../domain/usage.types'; +import type { IAgentUsage, IOverviewUsage } from '../domain/usage.types'; import { UsageMapper } from './usage.mapper'; export class UsageGateway extends BaseGateway implements IUsageGateway { @@ -16,4 +16,11 @@ export class UsageGateway extends BaseGateway implements IUsageGateway { return this.mapper.toAgentUsage(unwrapEnvelope(res.data)); }); } + + findOverview(): Promise { + return this.execute(async () => { + const res = await UsageApi.usageControllerFindOverview(); + return this.mapper.toOverviewUsage(unwrapEnvelope(res.data)); + }); + } } diff --git a/admin/slices/usage/data/usage.mapper.ts b/admin/slices/usage/data/usage.mapper.ts index cab758a5..f9814971 100644 --- a/admin/slices/usage/data/usage.mapper.ts +++ b/admin/slices/usage/data/usage.mapper.ts @@ -1,5 +1,7 @@ import type { IAgentUsage, + IOverviewAgentUsage, + IOverviewUsage, IUsageDailyEntry, IUsageToday, IUsageTotals, @@ -59,4 +61,31 @@ export class UsageMapper { callCount: num(o.callCount), }; } + + toOverviewUsage(raw: unknown): IOverviewUsage | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + return { + last30days: Array.isArray(o.last30days) + ? o.last30days.map((e) => this.toDaily(e)) + : [], + totals: this.toTotals(o.totals), + topModel: typeof o.topModel === 'string' ? o.topModel : null, + byAgent: Array.isArray(o.byAgent) + ? o.byAgent.map((e) => this.toOverviewAgent(e)) + : [], + }; + } + + private toOverviewAgent(raw: unknown): IOverviewAgentUsage { + const o = raw && typeof raw === 'object' ? (raw as Record) : {}; + return { + agentId: str(o.agentId), + agentName: str(o.agentName), + inputTokens: num(o.inputTokens), + outputTokens: num(o.outputTokens), + callCount: num(o.callCount), + costUsd: num(o.costUsd), + }; + } } diff --git a/admin/slices/usage/domain/usage.gateway.ts b/admin/slices/usage/domain/usage.gateway.ts index 384d4c90..87e53c12 100644 --- a/admin/slices/usage/domain/usage.gateway.ts +++ b/admin/slices/usage/domain/usage.gateway.ts @@ -1,6 +1,7 @@ -import type { IAgentUsage } from './usage.types'; +import type { IAgentUsage, IOverviewUsage } from './usage.types'; /** Contract for per-agent usage. Implemented by `UsageGateway`. */ export abstract class IUsageGateway { abstract findForAgent(agentId: string): Promise; + abstract findOverview(): Promise; } diff --git a/admin/slices/usage/domain/usage.service.ts b/admin/slices/usage/domain/usage.service.ts index fbe2df1f..6dad1091 100644 --- a/admin/slices/usage/domain/usage.service.ts +++ b/admin/slices/usage/domain/usage.service.ts @@ -1,5 +1,5 @@ import type { IUsageGateway } from './usage.gateway'; -import type { IAgentUsage } from './usage.types'; +import type { IAgentUsage, IOverviewUsage } from './usage.types'; /** Domain service for per-agent usage. The store layers a per-agent cache. */ export class UsageService { @@ -8,4 +8,8 @@ export class UsageService { findForAgent(agentId: string): Promise { return this.gateway.findForAgent(agentId); } + + findOverview(): Promise { + return this.gateway.findOverview(); + } } diff --git a/admin/slices/usage/domain/usage.types.ts b/admin/slices/usage/domain/usage.types.ts index 1fdcb907..da95d7bd 100644 --- a/admin/slices/usage/domain/usage.types.ts +++ b/admin/slices/usage/domain/usage.types.ts @@ -29,3 +29,24 @@ export interface IAgentUsage { topModel: string | null; today: IUsageToday; } + +export interface IOverviewAgentUsage { + agentId: string; + agentName: string; + inputTokens: number; + outputTokens: number; + callCount: number; + costUsd: number; +} + +/** + * Workspace-wide usage across all agents (30-day window). DB-backed only — + * today's not-yet-reported runtime usage is excluded until agents report, + * unlike the live-merged per-agent shape. + */ +export interface IOverviewUsage { + last30days: IUsageDailyEntry[]; + totals: IUsageTotals; + topModel: string | null; + byAgent: IOverviewAgentUsage[]; +} diff --git a/admin/slices/usage/stores/usage.ts b/admin/slices/usage/stores/usage.ts index a1ca2765..2be44a92 100644 --- a/admin/slices/usage/stores/usage.ts +++ b/admin/slices/usage/stores/usage.ts @@ -1,10 +1,16 @@ import { createServiceGetter } from '#common/composables/createServiceGetter'; -import type { IAgentUsage, UsageService } from '#usage/domain'; +import type { + IAgentUsage, + IOverviewUsage, + UsageService, +} from '#usage/domain'; // Re-export the domain types for consumers importing from // `#usage/stores/usage`. export type { IAgentUsage, + IOverviewAgentUsage, + IOverviewUsage, IUsageDailyEntry, IUsageToday, IUsageTotals, @@ -14,6 +20,7 @@ const getService = createServiceGetter('$usageService'); export const useUsageStore = defineStore('usage', () => { const byAgent = ref>({}); + const overview = ref(null); async function fetchForAgent(agentId: string) { const data = await getService().findForAgent(agentId); @@ -25,5 +32,15 @@ export const useUsageStore = defineStore('usage', () => { return byAgent.value[agentId] ?? null; } - return { byAgent, fetchForAgent, getForAgent }; + async function fetchOverview() { + const data = await getService().findOverview(); + if (data) overview.value = data; + return data; + } + + function getOverview(): IOverviewUsage | null { + return overview.value; + } + + return { byAgent, overview, fetchForAgent, getForAgent, fetchOverview, getOverview }; }); diff --git a/api/src/slices/usage/data/usage.gateway.ts b/api/src/slices/usage/data/usage.gateway.ts index 3b954109..90f28036 100644 --- a/api/src/slices/usage/data/usage.gateway.ts +++ b/api/src/slices/usage/data/usage.gateway.ts @@ -99,4 +99,16 @@ export class UsageGateway extends IUsageGateway { }); return records.map((r) => this.mapper.toEntity(r)); } + + async findRecentAll(days: number): Promise { + const since = new Date(); + since.setUTCDate(since.getUTCDate() - days); + since.setUTCHours(0, 0, 0, 0); + + const records = await this.prisma.usage.findMany({ + where: { date: { gte: since } }, + orderBy: [{ date: 'desc' }, { model: 'asc' }], + }); + return records.map((r) => this.mapper.toEntity(r)); + } } diff --git a/api/src/slices/usage/domain/usage.gateway.ts b/api/src/slices/usage/domain/usage.gateway.ts index 964a84fe..ca812115 100644 --- a/api/src/slices/usage/domain/usage.gateway.ts +++ b/api/src/slices/usage/domain/usage.gateway.ts @@ -14,4 +14,9 @@ export abstract class IUsageGateway { credentialId: string, days: number, ): Promise; + /** + * Every agent's usage rows within the last N days. Used to roll up + * workspace-wide spend for the admin usage overview. + */ + abstract findRecentAll(days: number): Promise; } diff --git a/api/src/slices/usage/domain/usage.types.ts b/api/src/slices/usage/domain/usage.types.ts index c35e82b0..7f57358b 100644 --- a/api/src/slices/usage/domain/usage.types.ts +++ b/api/src/slices/usage/domain/usage.types.ts @@ -50,6 +50,33 @@ export interface IAgentUsageResponse { }; } +/** + * Workspace-wide usage across ALL agents over the window. Same shape as + * ICredentialUsageResponse (daily grain + per-agent breakdown). Computed + * from DB rows only — today's not-yet-reported runtime usage is excluded + * until agents POST their daily report; the per-agent endpoint stays live. + */ +export interface IOverviewUsageResponse { + /** Daily totals across all agents, rolled up per date|model. */ + last30days: IUsageDailyEntry[]; + totals: { + inputTokens: number; + outputTokens: number; + callCount: number; + costUsd: number; + }; + topModel: string | null; + /** One row per agent that has reported usage, sorted by cost desc. */ + byAgent: Array<{ + agentId: string; + agentName: string; + inputTokens: number; + outputTokens: number; + callCount: number; + costUsd: number; + }>; +} + /** * Aggregate usage for a single LlmCredential across all agents using it. * Shape mirrors IAgentUsageResponse so the admin UI can render the same diff --git a/api/src/slices/usage/usage.controller.spec.ts b/api/src/slices/usage/usage.controller.spec.ts new file mode 100644 index 00000000..d475dc2b --- /dev/null +++ b/api/src/slices/usage/usage.controller.spec.ts @@ -0,0 +1,212 @@ +import { UsageController } from './usage.controller'; +import { IUsageGateway } from './domain'; +import { costUsd } from './domain/model-pricing'; +import { IUsageData } from './domain/usage.types'; +import { IFileGateway } from '#/agent/file/domain'; +import { IAgentGateway } from '#/agent/agent/domain'; +import { ILlmGateway } from '#/llm/domain'; + +const HAIKU = 'claude-haiku-4-5'; +const SONNET = 'claude-sonnet-4-6'; + +const DAY_1 = new Date('2026-08-01T00:00:00.000Z'); +const DAY_2 = new Date('2026-08-02T00:00:00.000Z'); + +function row( + p: Partial & { + agentId: string; + model: string; + date: Date; + }, +): IUsageData { + return { + id: `${p.agentId}|${p.model}|${p.date.toISOString()}`, + llmCredentialId: null, + inputTokens: 0, + outputTokens: 0, + callCount: 0, + createdAt: new Date(0), + updatedAt: new Date(0), + ...p, + }; +} + +function makeController( + rows: IUsageData[], + agentNames: Record = {}, +) { + const findRecentAll = jest.fn(async () => rows); + const gateway = { findRecentAll } as unknown as IUsageGateway; + const fileGateway = { + read: jest.fn(async () => { + throw Object.assign(new Error('no file'), { status: 404 }); + }), + } as unknown as IFileGateway; + const findById = jest.fn(async (id: string) => { + const name = agentNames[id]; + if (!name) throw new Error('agent not found'); + return { id, name }; + }); + const agentGateway = { findById } as unknown as IAgentGateway; + const llmGateway = {} as ILlmGateway; + const controller = new UsageController( + gateway, + fileGateway, + agentGateway, + llmGateway, + ); + return { controller, findRecentAll, findById }; +} + +describe('UsageController.findOverview', () => { + it('aggregates multi-agent rows to date|model grain with totals matching the entries', async () => { + // Two agents on the same model+day must merge into ONE daily entry; + // a second model and a second day stay separate. + const rows = [ + row({ + agentId: 'a1', + model: HAIKU, + date: DAY_2, + inputTokens: 1000, + outputTokens: 500, + callCount: 3, + }), + row({ + agentId: 'a2', + model: HAIKU, + date: DAY_2, + inputTokens: 2000, + outputTokens: 100, + callCount: 2, + }), + row({ + agentId: 'a1', + model: SONNET, + date: DAY_2, + inputTokens: 300, + outputTokens: 30, + callCount: 1, + }), + row({ + agentId: 'a2', + model: HAIKU, + date: DAY_1, + inputTokens: 50, + outputTokens: 5, + callCount: 1, + }), + ]; + const { controller } = makeController(rows, { a1: 'One', a2: 'Two' }); + + const res = await controller.findOverview(); + + expect(res.last30days).toHaveLength(3); + // Newest first. + expect(res.last30days.map((e) => e.date)).toEqual([ + '2026-08-02', + '2026-08-02', + '2026-08-01', + ]); + + const merged = res.last30days.find( + (e) => e.date === '2026-08-02' && e.model === HAIKU, + ); + expect(merged).toMatchObject({ + inputTokens: 3000, + outputTokens: 600, + callCount: 5, + }); + expect(merged?.costUsd).toBeCloseTo( + costUsd(HAIKU, 1000, 500) + costUsd(HAIKU, 2000, 100), + 10, + ); + + // Invariant: totals equal the sum of the returned entries. + const sum = res.last30days.reduce( + (acc, e) => ({ + inputTokens: acc.inputTokens + e.inputTokens, + outputTokens: acc.outputTokens + e.outputTokens, + callCount: acc.callCount + e.callCount, + costUsd: acc.costUsd + e.costUsd, + }), + { inputTokens: 0, outputTokens: 0, callCount: 0, costUsd: 0 }, + ); + expect(res.totals.inputTokens).toBe(sum.inputTokens); + expect(res.totals.outputTokens).toBe(sum.outputTokens); + expect(res.totals.callCount).toBe(sum.callCount); + expect(res.totals.costUsd).toBeCloseTo(sum.costUsd, 10); + }); + + it('picks the model with the most tokens as topModel', async () => { + const rows = [ + row({ + agentId: 'a1', + model: HAIKU, + date: DAY_1, + inputTokens: 100, + outputTokens: 10, + callCount: 1, + }), + row({ + agentId: 'a1', + model: SONNET, + date: DAY_1, + inputTokens: 5000, + outputTokens: 500, + callCount: 1, + }), + ]; + const { controller } = makeController(rows, { a1: 'One' }); + + const res = await controller.findOverview(); + + expect(res.topModel).toBe(SONNET); + }); + + it('sorts byAgent by cost desc and falls back to the raw id for deleted agents', async () => { + // a2 spends more than a1; a2 is deleted (no name resolvable). + const rows = [ + row({ + agentId: 'a1', + model: HAIKU, + date: DAY_1, + inputTokens: 1000, + outputTokens: 100, + callCount: 1, + }), + row({ + agentId: 'a2', + model: SONNET, + date: DAY_1, + inputTokens: 100000, + outputTokens: 10000, + callCount: 4, + }), + ]; + const { controller } = makeController(rows, { a1: 'Alive' }); + + const res = await controller.findOverview(); + + expect(res.byAgent.map((a) => a.agentId)).toEqual(['a2', 'a1']); + expect(res.byAgent[0].agentName).toBe('a2'); + expect(res.byAgent[0].costUsd).toBeCloseTo( + costUsd(SONNET, 100000, 10000), + 10, + ); + expect(res.byAgent[1].agentName).toBe('Alive'); + }); + + it('returns a zeroed shape (not an error) when no usage exists', async () => { + const { controller, findById } = makeController([]); + + const res = await controller.findOverview(); + + expect(res).toEqual({ + last30days: [], + totals: { inputTokens: 0, outputTokens: 0, callCount: 0, costUsd: 0 }, + topModel: null, + byAgent: [], + }); + expect(findById).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/slices/usage/usage.controller.ts b/api/src/slices/usage/usage.controller.ts index a3c0b7ae..c3dd61e5 100644 --- a/api/src/slices/usage/usage.controller.ts +++ b/api/src/slices/usage/usage.controller.ts @@ -14,7 +14,9 @@ import { costUsd } from './domain/model-pricing'; import { IAgentUsageResponse, ICredentialUsageResponse, + IOverviewUsageResponse, IUsageDailyEntry, + IUsageData, } from './domain/usage.types'; import { ReportUsageDto } from './dtos'; import { BridleApiKeyGuard } from '#/bridle/guards/bridleApiKey.guard'; @@ -209,11 +211,51 @@ export class UsageController { @Param('id') credentialId: string, ): Promise { const rows = await this.gateway.findRecentForCredential(credentialId, 30); + const { last30days, totals, topModel, agentTotals } = + this.rollUpAcrossAgents(rows); + const byAgent = await this.resolveAgentNames(agentTotals); + return { last30days, totals, topModel, byAgent }; + } + + @Get('usage/overview') + @ApiOperation({ + summary: 'Get 30-day usage across all agents with cost', + }) + async findOverview(): Promise { + // DB rows only — today's not-yet-reported runtime usage is excluded + // until agents POST their daily report. The per-agent endpoint keeps + // the live usage.json merge; doing that here would cost one S3 read + // per agent per page view. + const rows = await this.gateway.findRecentAll(30); + const { last30days, totals, topModel, agentTotals } = + this.rollUpAcrossAgents(rows); + const byAgent = await this.resolveAgentNames(agentTotals); + return { last30days, totals, topModel, byAgent }; + } - // Daily roll-up keyed by `${date}|${model}` — preserves the per-model - // grain that the per-agent endpoint already uses. + /** + * Rolls multi-agent usage rows up to the `${date}|${model}` grain the + * per-agent endpoint uses, plus per-agent totals. Shared by the + * credential and overview endpoints. + */ + private rollUpAcrossAgents(rows: IUsageData[]): { + last30days: IUsageDailyEntry[]; + totals: { + inputTokens: number; + outputTokens: number; + callCount: number; + costUsd: number; + }; + topModel: string | null; + agentTotals: Array<{ + agentId: string; + inputTokens: number; + outputTokens: number; + callCount: number; + costUsd: number; + }>; + } { const dailyMap = new Map(); - // Per-agent roll-up keyed by agentId. const agentMap = new Map< string, { @@ -294,26 +336,40 @@ export class UsageController { } } - // Resolve agent names. Failed lookups (deleted agents) fall back to the - // raw ID so the row is still visible — usage outlives the agent record. - const byAgent = await Promise.all( - Array.from(agentMap.values()) - .sort((a, b) => b.costUsd - a.costUsd) - .map(async (entry) => { - const agent = await this.agentGateway - .findById(entry.agentId) - .catch(() => null); - return { - agentId: entry.agentId, - agentName: agent?.name ?? entry.agentId, - inputTokens: entry.inputTokens, - outputTokens: entry.outputTokens, - callCount: entry.callCount, - costUsd: entry.costUsd, - }; - }), + const agentTotals = Array.from(agentMap.values()).sort( + (a, b) => b.costUsd - a.costUsd, ); - return { last30days, totals, topModel, byAgent }; + return { last30days, totals, topModel, agentTotals }; + } + + /** + * Resolve agent names. Failed lookups (deleted agents) fall back to the + * raw ID so the row is still visible — usage outlives the agent record. + */ + private async resolveAgentNames( + agentTotals: Array<{ + agentId: string; + inputTokens: number; + outputTokens: number; + callCount: number; + costUsd: number; + }>, + ): Promise { + return Promise.all( + agentTotals.map(async (entry) => { + const agent = await this.agentGateway + .findById(entry.agentId) + .catch(() => null); + return { + agentId: entry.agentId, + agentName: agent?.name ?? entry.agentId, + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + callCount: entry.callCount, + costUsd: entry.costUsd, + }; + }), + ); } } diff --git a/specs/002-rancher-usage-panel/checklists/requirements.md b/specs/002-rancher-usage-panel/checklists/requirements.md new file mode 100644 index 00000000..8176b5e3 --- /dev/null +++ b/specs/002-rancher-usage-panel/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Rancher & Agent Usage Panel Redesign + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-03 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation iteration 1: all items pass. No [NEEDS CLARIFICATION] markers were needed — the two genuinely ambiguous points ("total cost" scope and what pagination applies to) have defensible defaults recorded in the Assumptions section, and the agent-page layout was explicitly delegated to design by the requester. +- Key assumption worth confirming during `/speckit-clarify` or planning: "total cost" = aggregate across ALL agents (a new capability — only per-agent figures exist today). diff --git a/specs/002-rancher-usage-panel/contracts/api-usage-overview.md b/specs/002-rancher-usage-panel/contracts/api-usage-overview.md new file mode 100644 index 00000000..8fc4dc94 --- /dev/null +++ b/specs/002-rancher-usage-panel/contracts/api-usage-overview.md @@ -0,0 +1,73 @@ +# API Contract: GET /usage/overview + +**Feature**: 002-rancher-usage-panel · **Status**: new endpoint · **Module**: `api/src/slices/usage/usage.controller.ts` + +## Request + +``` +GET /usage/overview +``` + +- **Auth**: same as the existing usage GET endpoints (admin API auth; no `BridleApiKeyGuard` — that guard is only for the agent-report POST). +- **Query params**: none in v1. The window is fixed at 30 days, matching `GET /agents/:agentId/usage` and `GET /llms/:id/usage`. + +## Response `200 OK` + +```jsonc +{ + "last30days": [ // rolled up per date|model across ALL agents, newest first + { + "date": "2026-08-03", // UTC day key (YYYY-MM-DD) + "model": "claude-sonnet-5", + "inputTokens": 123456, + "outputTokens": 23456, + "callCount": 42, + "costUsd": 1.2345 // computed server-side via model-pricing costUsd() + } + ], + "totals": { // sums over last30days (invariant: totals == Σ entries) + "inputTokens": 999999, + "outputTokens": 88888, + "callCount": 512, + "costUsd": 12.3456 + }, + "topModel": "claude-sonnet-5", // most total tokens in window; null when no usage + "byAgent": [ // one row per agent with usage, sorted by costUsd desc + { + "agentId": "agt_...", + "agentName": "Rancher", // falls back to agentId when the agent was deleted + "inputTokens": 123, + "outputTokens": 45, + "callCount": 6, + "costUsd": 0.0123 + } + ] +} +``` + +Envelope: whatever the global interceptor applies to the existing usage GETs (admin unwraps via `unwrapEnvelope`) — this endpoint must not differ. + +## Semantics & guarantees + +- **Source**: DB `Usage` rows only (`date >= today-30d`, UTC day starts). Does **not** merge per-agent live `data/usage.json` snapshots — the current day may be understated until agents report (accepted limitation, research R2). The per-agent endpoint keeps its live merge; clients needing fresh today-figures for one agent use `GET /agents/:agentId/usage`. +- **Empty workspace / no usage**: `200` with `last30days: []`, zeroed `totals`, `topModel: null`, `byAgent: []` — never an error (spec edge case "partial data in total view"). +- **Deleted agents**: usage rows outlive agents; `agentName` falls back to the raw `agentId` (same behavior as `GET /llms/:id/usage`). +- **Cost**: computed server-side per `date|model` entry; clients never price tokens. + +## Errors + +| Status | Condition | +|--------|-----------| +| 401/403 | Standard admin auth failures (global guard) | +| 500 | Unexpected — DB unavailable etc.; no partial responses | + +## SDK impact + +Swagger regen exposes the operation as `usageControllerFindOverview` in `admin/slices/setup/api/data/repositories/api/sdk.gen.ts` (via `cd admin && bun run build:api`). Generated files are never hand-edited. + +## Test obligations (API Jest) + +1. Multi-agent, multi-model rows aggregate to correct `date|model` grain and `totals` (invariant: totals equal the sum of entries). +2. `topModel` picks the highest-token model; `null` on empty. +3. `byAgent` sorted by cost desc; deleted agent → name falls back to ID. +4. Empty table → zeroed shape, `200`. diff --git a/specs/002-rancher-usage-panel/contracts/ui-usage-panel.md b/specs/002-rancher-usage-panel/contracts/ui-usage-panel.md new file mode 100644 index 00000000..ce0a6f4c --- /dev/null +++ b/specs/002-rancher-usage-panel/contracts/ui-usage-panel.md @@ -0,0 +1,44 @@ +# UI Contract: and host layouts + +**Feature**: 002-rancher-usage-panel · **Component**: `admin/slices/usage/components/usage/Panel.vue` (auto-imported as ``) + +## Props + +| Prop | Type | Required | Meaning | +|------|------|----------|---------| +| `agentId` | `string \| null` | yes | Scope of the "Agent" view. `null` ⇒ Agent tab disabled with hint (Rancher admin agent missing edge case). | +| `collapsible` | `boolean` | no (default `false`) | When true, panel can collapse to a compact button (agent chat tab side stack). | +| `title` | `string` | no | Header label; defaults to `Usage · 30d` (Rancher page passes `Rancher usage · 30d`). | + +## Behavior (maps to spec FRs) + +1. **Views** (FR-003): segmented control (theme `tabs`) — `Total` / `Calls` / `Agent`; active view visually indicated; switching resets pagination to page 1. + - `Total`: all-agents 30d cost emphasized + totals, top model, per-agent breakdown, paginated daily rows. Source: `GET /usage/overview`. + - `Calls`: same window with call volume emphasized (total calls + per-day calls). Source: `GET /usage/overview`. + - `Agent`: this agent only — today snapshot (model, input/output tokens, calls) and 30d totals (cost, top model, input, output, calls) + paginated daily rows. Source: `GET /agents/:agentId/usage` (live today-merge preserved). Full field parity with the legacy `UsageCard` (FR-005). +2. **Pagination** (FR-004): client-side over the active view's `last30days`; page size 7; Prev/Next + "N–M of T" indicator; controls not rendered when T ≤ 7. +3. **Empty state** (FR-008): `totals.callCount === 0` ⇒ "No usage reported yet." — never bare zeros without context; fetch error ⇒ inline error, recoverable by refresh. +4. **Refresh** (FR-010): panel exposes a `refresh()` method (or reacts to store refetch) so the host page's existing refresh button updates the active view. +5. **Formatting**: `Intl.NumberFormat` for counts; USD with up to 4 fraction digits for sub-cent costs; large values never rendered raw. + +## Host layout contracts + +### Rancher page — `rancher/components/rancher/Provider.vue` (FR-001, FR-002, FR-009) + +- Post-setup state: count tiles (Agents/Templates/Skills/LLMs/Knowledges) and their data fetching are **removed**; the chat (`BridleProvider`) is the central column; `` sits in a right-hand column; below `lg` the panel stacks under the chat (spec edge case "narrow screens"). +- Pre-setup state: wizard flow byte-for-byte unchanged; no panel is rendered before the admin agent exists. +- Exactly **one** metrics block on the page (SC-005). + +### Agent chat tab — `agent/.../chat/Tab.vue` (FR-006, FR-007) + +- Right side becomes a vertical stack: `AgentLogsPanel` (top, existing collapse behavior) + `` (bottom). +- Each collapses independently to a compact button (existing "Logs" button pattern); collapsing one gives the other the freed height; chat sizing/centrality unchanged. +- Both collapsed ⇒ chat keeps center, two compact buttons remain reachable (SC-006: chat and logs never permanently hidden). + +### Agent overview tab — `agent/.../overview/UsageCard.vue` + +- Replaces its bespoke `
` body by rendering `` (non-collapsible, full-width card) so both surfaces share one implementation and cannot drift (FR-005/FR-006 parity, SC-003). + +## Non-goals + +- No custom date ranges (30d fixed), no CSV export, no per-model filtering, no localization beyond existing hardcoded-English convention (research R9). diff --git a/specs/002-rancher-usage-panel/data-model.md b/specs/002-rancher-usage-panel/data-model.md new file mode 100644 index 00000000..5c8ce0f7 --- /dev/null +++ b/specs/002-rancher-usage-panel/data-model.md @@ -0,0 +1,106 @@ +# Data Model: Rancher & Agent Usage Panel Redesign + +**Feature**: 002-rancher-usage-panel · **Date**: 2026-08-03 + +## Persistence + +**No database changes.** The feature is computed entirely from the existing `Usage` table: + +| Field | Type | Notes | +|-------|------|-------| +| `id` | string | PK | +| `agentId` | string | Usage outlives the agent record (name lookups fall back to raw ID) | +| `llmCredentialId` | string \| null | Optional link; irrelevant to this feature | +| `model` | string | Pricing key for `costUsd(model, in, out)` | +| `date` | Date (UTC day start) | Unique with `agentId + model` (`agentId_model_date`) | +| `inputTokens` / `outputTokens` / `callCount` | number | Upserted by agent reports | + +Today's live figures for a *single* agent additionally come from the S3 file `data/usage.json` (existing mechanism, per-agent endpoint only — see research R2). + +## API contract types (new) + +### `IOverviewUsageResponse` — `api/src/slices/usage/domain/usage.types.ts` + +Mirrors `ICredentialUsageResponse` (same grain, same consumers' expectations), scoped to *all* agents: + +``` +IOverviewUsageResponse { + last30days: IUsageDailyEntry[] // rolled up per `${date}|${model}` across all agents, newest first + totals: { inputTokens, outputTokens, callCount, costUsd } + topModel: string | null // most tokens over the window + byAgent: Array<{ // one row per agent with usage, sorted by costUsd desc + agentId, agentName, // agentName falls back to agentId for deleted agents + inputTokens, outputTokens, callCount, costUsd + }> +} +``` + +`IUsageDailyEntry` is reused as-is (`date, model, inputTokens, outputTokens, callCount, costUsd`). + +### Gateway addition — `IUsageGateway.findRecentAll(days: number): Promise` + +Same shape as `findRecentForAgent`/`findRecentForCredential` minus the filter; `where: { date: { gte: since } }`. + +## Admin domain types (new) — `admin/slices/usage/domain/usage.types.ts` + +``` +IOverviewUsage { + last30days: IUsageDailyEntry[] // existing admin domain type reused + totals: IUsageTotals // existing + topModel: string | null + byAgent: IOverviewAgentUsage[] +} + +IOverviewAgentUsage { + agentId: string + agentName: string + inputTokens: number + outputTokens: number + callCount: number + costUsd: number +} +``` + +Mapped defensively from the generated SDK response by `UsageMapper` (new `toOverviewUsage(raw: unknown)`), following the existing `toAgentUsage` pattern. + +## Store state — `admin/slices/usage/stores/usage.ts` + +| State | Type | Purpose | +|-------|------|---------| +| `byAgent` | `Record` | Existing per-agent cache (unchanged) | +| `overview` | `IOverviewUsage \| null` | New: cached all-agents aggregate | + +New actions: `fetchOverview()` (delegates to service → gateway → SDK), `getOverview()`. + +## UsagePanel view model (component-local, not persisted) + +| State | Type / values | Rules | +|-------|---------------|-------| +| `view` | `'total' \| 'calls' \| 'agent'` | Default `'total'`. Switching resets `page` to 1. `'agent'` requires an `agentId` prop; when absent (Rancher admin agent missing — spec edge case) the tab is disabled and the panel stays on `'total'` with an explanatory hint. | +| `page` | number ≥ 1 | Client-side over the active view's `last30days`; page size 7; controls hidden when total rows ≤ 7 (FR-004). | +| `collapsed` | boolean | Only when the host passes `collapsible` (agent chat tab side stack). | + +Data sources per view: + +| View | Source | Fields shown | +|------|--------|--------------| +| `total` | `fetchOverview()` | 30d totals (costUsd emphasized), top model, paginated daily rows, per-agent breakdown (`byAgent`) | +| `calls` | `fetchOverview()` | Same window, callCount emphasized (totals + per-day callCount) | +| `agent` | `fetchForAgent(agentId)` | Today snapshot (model, in/out tokens, calls) + 30d totals (cost, top model, input, output, calls) + paginated daily rows — full parity with today's `UsageCard` fields (FR-005) | + +Empty states (FR-008): view resolves but `totals.callCount === 0` → "No usage reported yet"; fetch failure → non-blocking error text with retry via the page-level refresh (FR-010). + +## Validation rules + +- All token/call/cost numbers render through `Intl.NumberFormat` (counts) and USD currency format with up to 4 fraction digits (sub-cent costs, spec edge case). +- Daily rows are sorted newest-first server-side; the panel must not re-sort (single source of ordering). +- Aggregate totals MUST equal the sum of the returned `last30days` entries (server computes both from the same rolled-up array — invariant tested in the API unit test). + +## State transitions + +``` +panel mounted ──fetch(view sources)──▶ loading ──ok──▶ data | empty + └──err──▶ error (retryable via refresh) +view switched ──▶ page := 1 ──▶ (reuse cached store data if present, else fetch) +day rollover / refresh ──▶ refetch active sources (FR-010, edge case "day boundary") +``` diff --git a/specs/002-rancher-usage-panel/plan.md b/specs/002-rancher-usage-panel/plan.md new file mode 100644 index 00000000..8bbf927e --- /dev/null +++ b/specs/002-rancher-usage-panel/plan.md @@ -0,0 +1,98 @@ +# Implementation Plan: Rancher & Agent Usage Panel Redesign + +**Branch**: `feat/dashboard-agent-costs` | **Date**: 2026-08-03 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/002-rancher-usage-panel/spec.md` + +## Summary + +Declutter the admin Rancher page (drop the five count tiles, keep only the usage block), turn the "Rancher usage · 30d" block into an interactive, reusable **UsagePanel** with three switchable views (total cost across all agents / calls / this-agent cost) and a paginated day-by-day breakdown, and mount that same panel on every agent detail page next to the chat and logs. + +Technical approach: add one new API endpoint `GET /usage/overview` that aggregates the existing `Usage` table across all agents (mirroring the roll-up already implemented in `GET /llms/:id/usage`) — **no schema change**. On the admin side, extend the layered `usage` slice (gateway → service → store) with the overview fetch, build a shared `UsagePanel` component in that slice, restructure the Rancher page layout (chat centered, panel right), and add the panel to the agent chat tab side stack (with logs) while replacing the Overview tab's `UsageCard` with the same component. + +## Technical Context + +**Language/Version**: TypeScript 5.x across the monorepo (Bun 1.2 workspaces, Turbo) + +**Primary Dependencies**: API — NestJS 10 + Prisma (PostgreSQL) + @nestjs/swagger; Admin — Nuxt 3 (Vue 3), Pinia, shadcn-nuxt/reka-ui theme components (`#theme/components/ui/*`), Tailwind, @hey-api/openapi-ts generated SDK (`admin/slices/setup/api`) + +**Storage**: Existing PostgreSQL `Usage` table (rows keyed `agentId × model × date`); today's live snapshot read from S3 `data/usage.json` per agent (existing mechanism). **No new tables or migrations.** + +**Testing**: API — Jest (`cd api && bun run test`); Admin — no test harness (`admin test: no tests yet`); pure-TS changes type-checked via `tsc` (no vue-tsc in repo) + +**Target Platform**: Web (admin panel at `admin/`, Nuxt SSR/SPA), API server (NestJS) + +**Project Type**: Web application (monorepo: `api/` backend + `admin/` frontend, slice-based architecture on both sides) + +**Performance Goals**: Usage panel renders with data in a comparable time to the current tile (single fetch per view); overview aggregation is one indexed DB query over ≤30 days of rows + +**Constraints**: Follow the layered slice pattern (domain/data/stores) used by the `usage` slice; SDK is generated — never hand-edit `*.gen.ts`; UI components come from `#theme/components/ui`; labels stay hardcoded English like the existing usage surfaces + +**Scale/Scope**: Single-workspace admin tool, tens of agents; 30-day window ⇒ ≤ ~30 × models × agents usage rows per query. Scope: 1 new API endpoint, 1 new shared component, 2 page-layout changes, usage-slice extension, SDK regen + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +`.specify/memory/constitution.md` is the unfilled placeholder template — no project-specific principles are ratified. Default gates applied instead: + +| Gate | Status | Notes | +|------|--------|-------| +| Simplicity — no new projects/services | ✅ PASS | Extends existing `api` and `admin` workspaces only | +| No schema/data migration unless required | ✅ PASS | Aggregate computed from the existing `Usage` table | +| Follow established repo patterns | ✅ PASS | New endpoint mirrors `findForCredential`; admin follows layered slice pattern (memory: slices migrating to layered pattern — `usage` slice already conforms) | +| No hand-editing generated artifacts | ✅ PASS | SDK regenerated via `openapi-ts` (`admin build:api`) | + +**Post-Phase-1 re-check**: design introduces no new violations — one endpoint, one shared component, no new dependencies. ✅ PASS + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-rancher-usage-panel/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ ├── api-usage-overview.md # New endpoint contract +│ └── ui-usage-panel.md # Shared panel behavior contract +└── tasks.md # Phase 2 output (/speckit-tasks — NOT created by /speckit-plan) +``` + +### Source Code (repository root) + +```text +api/src/slices/usage/ +├── usage.controller.ts # MODIFY: add GET /usage/overview +├── domain/usage.types.ts # MODIFY: add IOverviewUsageResponse +├── domain/usage.gateway.ts # MODIFY: add findRecentAll(days) abstract +└── data/usage.gateway.ts # MODIFY: implement findRecentAll(days) + +admin/slices/usage/ +├── nuxt.config.ts # (components auto-scanned by Nuxt layer; verify) +├── components/usage/Panel.vue # NEW: shared UsagePanel (views + pagination) +├── domain/usage.types.ts # MODIFY: add IOverviewUsage domain type +├── domain/usage.gateway.ts # MODIFY: add findOverview() +├── domain/usage.service.ts # MODIFY: add findOverview() +├── data/usage.gateway.ts # MODIFY: call generated SDK overview op +├── data/usage.mapper.ts # MODIFY: map overview response defensively +└── stores/usage.ts # MODIFY: overview cache + fetchOverview() + +admin/slices/rancher/components/rancher/ +└── Provider.vue # MODIFY: drop count tiles; chat centered, UsagePanel right + +admin/slices/agent/agent/components/agent/ +├── chat/Tab.vue # MODIFY: side stack = logs + UsagePanel (both collapsible) +└── overview/UsageCard.vue # MODIFY: delegate to shared UsagePanel (field parity) + +admin/slices/setup/api/data/repositories/api/ +└── *.gen.ts # REGENERATED: bun run build:api (openapi-ts) +``` + +**Structure Decision**: Web application monorepo. Backend work stays inside `api/src/slices/usage/` (one endpoint following the existing controller's aggregation pattern). Frontend work is centered on `admin/slices/usage/` — the panel lives in the slice that owns the domain so both the `rancher` and `agent` slices consume it via Nuxt layer auto-import (``), keeping cross-slice coupling limited to component usage, as done today with `AgentLogsPanel`/`BridleProvider`. + +## Complexity Tracking + +No constitution gate violations — table not required. diff --git a/specs/002-rancher-usage-panel/quickstart.md b/specs/002-rancher-usage-panel/quickstart.md new file mode 100644 index 00000000..d6f44cd5 --- /dev/null +++ b/specs/002-rancher-usage-panel/quickstart.md @@ -0,0 +1,62 @@ +# Quickstart: Validating the Rancher & Agent Usage Panel Redesign + +**Feature**: 002-rancher-usage-panel + +## Prerequisites + +- Local stack set up once: `make setup` (deps, PostgreSQL via Docker, migrations, k3d) — see `Makefile` at repo root. +- At least one LLM credential, the Rancher template, and the deployed Rancher admin agent (the Rancher page wizard walks through this). +- Usage data present: agents report usage on graceful shutdown / 23:50 UTC; for local validation, insert a few `Usage` rows spanning several days/models/agents via `cd api && bun run studio` (Prisma Studio) — enough rows (>7 day|model entries) to exercise pagination. + +## Run + +```bash +# API (NestJS, watch mode) +bun run dev:api + +# Admin (regenerates the SDK from swagger on predev, then Nuxt on :3001) +bun run dev:admin +``` + +If the SDK was generated before the new endpoint existed: restart `dev:admin` or run `cd admin && bun run build:api` once the API is up, and confirm `usageControllerFindOverview` appears in `admin/slices/setup/api/data/repositories/api/sdk.gen.ts`. + +## API checks + +```bash +cd api && bun run test # includes the overview aggregation unit tests +curl -s http://localhost:3333/usage/overview -H "Authorization: Bearer " | jq +``` + +Expected: shape per [contracts/api-usage-overview.md](./contracts/api-usage-overview.md) — `totals.costUsd` equals the sum of `last30days[].costUsd`; `byAgent` sorted by cost desc; empty DB gives zeroed shape, not an error. + +## UI validation scenarios (map to spec acceptance scenarios) + +Open the admin at `http://localhost:3001`. + +### Rancher page (`/rancher`) — User Story 1 & 2 + +1. **Tiles gone, layout right**: no Agents/Templates/Skills/LLMs/Knowledges tiles anywhere; chat centered; usage panel to its right; exactly one metrics block (SC-005). +2. **Views**: switch Total → Calls → Agent; numbers change accordingly (Total/Calls = all agents from `/usage/overview`; Agent = Rancher agent only, with live today figures). Active tab visibly highlighted. +3. **Pagination**: with >7 daily entries, page through Prev/Next; position indicator updates; all days reachable (SC-004). With ≤7 entries, no pagination controls. +4. **Refresh**: the page's refresh button updates the panel's active view (FR-010). +5. **Wizard untouched**: with an incomplete setup (fresh DB), the step wizard renders exactly as before and no panel appears (FR-009). +6. **Narrow screen**: shrink below `lg` — panel stacks under the chat, no truncation. + +### Agent page (`/agents/`, any non-Rancher agent) — User Story 3 + +7. **Panel present with full parity**: chat tab shows logs (top) and usage panel (bottom) on the right; Agent view shows today model / in-out tokens / calls and 30d cost / top model / input / output / calls — every field the old UsageCard had (SC-003). +8. **Coexistence**: collapse logs → usage panel gets the height and logs collapse to a button; collapse usage → logs expand back; chat never moves or shrinks (SC-006). +9. **Overview tab**: the Usage card there renders the same panel (same fields/views). +10. **Empty agent**: open an agent that never reported usage → "No usage reported yet" state, tabs still visible (FR-008). + +## Type/lint gates + +```bash +cd api && bun run lint && bun run test +# Admin has no test harness; pure-TS additions (domain/data/stores) are checked via tsc +# (repo convention — no vue-tsc; see memory note "Ranch typecheck via tsc") +``` + +## Done when + +All 10 scenarios above pass, API tests are green, and the generated SDK contains the overview operation without hand edits. diff --git a/specs/002-rancher-usage-panel/research.md b/specs/002-rancher-usage-panel/research.md new file mode 100644 index 00000000..b2e1e547 --- /dev/null +++ b/specs/002-rancher-usage-panel/research.md @@ -0,0 +1,82 @@ +# Research: Rancher & Agent Usage Panel Redesign + +**Feature**: 002-rancher-usage-panel · **Date**: 2026-08-03 + +No NEEDS CLARIFICATION markers remained in the Technical Context; the research below records the decisions for each open design question, grounded in the current codebase. + +## R1 — Source of the "total cost across all agents" aggregate + +**Decision**: Add `GET /usage/overview` to `api/src/slices/usage/usage.controller.ts`, backed by a new `findRecentAll(days)` gateway method (`prisma.usage.findMany({ where: { date: { gte: since } } })`). Roll up rows at `date|model` grain for `last30days`, compute `totals`, `topModel`, and a `byAgent` breakdown (agent name resolved with fallback to raw ID) — exactly the shape and algorithm of the existing `GET /llms/:id/usage` (`findForCredential`), minus the credential filter. + +**Rationale**: The aggregation, cost computation (`costUsd(model, in, out)` from `model-pricing`), sorting, and name-resolution logic already exist and are proven in `findForCredential` (usage.controller.ts:204-318). Reusing the response shape (`ICredentialUsageResponse` → new `IOverviewUsageResponse`) means the admin mapper pattern also transfers directly. One indexed query over ≤30 days of rows; no schema change. + +**Alternatives considered**: +- *Client-side aggregation* — admin fetches every agent's usage and sums. Rejected: N requests per page view, duplicated cost logic, no single source of truth. +- *Database view / materialized rollup* — rejected: premature for tens of agents × 30 days; a plain query suffices. +- *Extending `GET /agents/:agentId/usage` with a query flag* — rejected: muddles a per-agent contract with a global one; separate endpoint is clearer in the generated SDK. + +## R2 — Today's live snapshot in the aggregate view + +**Decision**: The overview endpoint reads the **database only** — it does not merge each agent's live `data/usage.json` S3 snapshot the way the per-agent endpoint does. + +**Rationale**: The per-agent live merge exists because a single agent's today row would otherwise be empty until the 23:50 UTC report (usage.controller.ts:71-76). Doing that for the overview means one S3 read per agent per panel load, growing linearly with fleet size, for a number that self-corrects at the daily report. The "this agent" view — where today-freshness actually matters to the admin watching one agent — continues to use the live-merged per-agent endpoint. + +**Accepted limitation** (documented in the contract): the total view may understate the *current* day until agents report; per-agent view remains live. + +**Alternatives considered**: parallel S3 reads for running agents only — deferred; can be layered in later without contract change (values only get fresher). + +## R3 — What pagination applies to + +**Decision**: Client-side pagination over the `last30days` array (entries at `date|model` grain, newest first), page size 7, Prev/Next buttons plus a "N–M of T" position indicator. Controls hidden when everything fits one page (spec FR-004). + +**Rationale**: The window is bounded — 30 days × few models ⇒ tens of rows, always fully returned by the existing endpoints. Server-side pagination would add contract surface for no payload savings. Client-side also makes the page reset trivial when the user switches views. Existing admin slices (`chat/list`, `agent/file`) already paginate client-side; no shared pagination component exists in `#theme/components/ui` (no `pagination` dir), so simple Prev/Next buttons follow the repo's current convention. + +**Alternatives considered**: server-side `?page=` params — rejected (bounded dataset); infinite scroll — rejected (panel is a compact side block; discrete pages match "установить пагинацию"). + +## R4 — Shared panel component placement & reuse + +**Decision**: New `admin/slices/usage/components/usage/Panel.vue`, auto-registered by the Nuxt layer as ``. Consumed by: `rancher/components/rancher/Provider.vue` (right of chat), `agent/.../chat/Tab.vue` (side stack with logs), and `agent/.../overview/UsageCard.vue` (delegates to the panel so the Overview tab keeps field parity with one implementation). + +**Rationale**: The `usage` slice owns the domain and already follows the layered pattern (domain/data/stores) — the component belongs with its data. Nuxt layers auto-scan each layer's `components/` directory (the `rancher` slice's `components/rancher/Provider.vue` is consumed as `` the same way); the usage slice currently has no `components/` dir, so adding one is additive. Props: `agentId` (scope for the "this agent" view; on the Rancher page this is the admin agent's id) and optional layout hints (e.g. `collapsible`). + +**Alternatives considered**: duplicating a panel per slice — rejected (guaranteed drift, violates FR-006 parity); placing it in the `setup`/theme layer — rejected (it is domain UI, not a primitive). + +## R5 — View switcher UI + +**Decision**: Use the existing `#theme/components/ui/tabs` component as a compact segmented control inside the panel header with three values: **Total** (all-agents cost), **Calls** (call volume), **Agent** (this-agent cost). Active view visually indicated by the tabs primitive (FR-003). + +**Rationale**: `tabs` exists in the theme (`admin/slices/setup/theme/components/ui/tabs`); no toggle-group primitive is present. Tabs give the required "active view" affordance without adding a dependency. + +**Alternatives considered**: `select` dropdown — rejected (hides the available views; toggling is the primary interaction); adding a shadcn toggle-group — rejected (new primitive for no gain). + +## R6 — Rancher page layout + +**Decision**: In `rancher/components/rancher/Provider.vue` (post-setup state): delete the five count tiles (`stats` computed + its grid) and the wizard-column split; render the chat (`BridleProvider`) as the central column (keep existing sticky/height treatment) with `` in a right-hand column (~`w-96`-class width); the panel column stacks below the chat under `lg`. The incomplete-setup wizard branch is untouched (FR-009), and the wizard keeps its current position while setup is in progress. + +**Rationale**: Matches the explicit requirement "ранчер по центру экрана, блок со стоимостью — справа". The dashboard/refresh plumbing for the removed tiles (`fetchAll` of templates/skills/llms/knowledges in `rancher-dashboard` asyncData) is deleted with them — the page stops fetching data it no longer shows. + +## R7 — Agent page layout (delegated to design by the requester) + +**Decision**: In `agent/.../chat/Tab.vue`, the right side (currently only `AgentLogsPanel`, `basis-1/2`) becomes a vertical stack: **logs on top, usage panel below**, each independently collapsible. Collapsed states mirror the existing logs pattern (collapse → compact button, like the current "Logs" button at chat/Tab.vue). Chat keeps its central/primary position and current sizing. On the Overview tab, `UsageCard.vue` renders the same `` so both surfaces stay in parity. + +**Rationale**: Preserves the established logs UX (open by default, collapsible, polling stops when unmounted) while satisfying FR-007: chat central, logs reachable, usage visible. Stacking beats a third column (three columns starve the chat at 1440px) and beats tabs-within-the-side-area (hides logs during incidents). + +**Alternatives considered**: third column — rejected (width); usage above logs — rejected (logs are the operational surface users watch live; usage is glanceable); panel only on Overview tab — rejected (user asked for it "в каждом агенте, в которого мы заходим" — the landing tab is Chat). + +## R8 — SDK regeneration workflow + +**Decision**: After the API endpoint lands, regenerate the admin SDK with `cd admin && bun run build:api` (runs `openapi-ts` against the API's swagger; `predev` does the same via `wait-for-swagger.mjs`). The admin data layer calls the newly generated `usageControllerFindOverview` operation; the mapper reads the response defensively (`unknown` → domain), consistent with `UsageMapper`. + +**Rationale**: `*.gen.ts` files are generated artifacts (hey-api); the existing usage operations (`usageControllerFindForAgent`) arrived the same way. Defensive mapping means loose swagger typing cannot break the UI. + +## R9 — Labels, formatting, i18n + +**Decision**: Hardcoded English labels inside the panel ("Usage · 30d", "Cost", "Calls", "Tokens", "Top model", "No usage reported yet"), `Intl.NumberFormat` for counts and USD (4 fraction digits for sub-cent costs), matching the current tile (rancher Provider.vue:128-141) and `UsageCard.vue` formatting helpers. + +**Rationale**: Every existing usage surface is hardcoded English; the rancher i18n file covers only title/subtitle. Localizing one component while its siblings are hardcoded adds inconsistency, not value. Large numbers/sub-cent costs formatting is already solved by the existing helpers (edge case in spec). + +## R10 — Testing strategy + +**Decision**: API — Jest unit test for the overview aggregation (controller or extracted roll-up helper): multi-agent rows aggregate correctly, agents with zero usage don't break totals, deleted-agent name falls back to ID. Admin — no test harness exists (`admin test: no tests yet`); validation is manual via quickstart.md scenarios; pure-TS domain/data additions are covered by `tsc` type-checking (repo has no vue-tsc). + +**Rationale**: Matches the repo's current testing reality; the only new *logic* (aggregation) lives on the API side where Jest exists. UI changes are layout/composition, verified against the quickstart checklist. diff --git a/specs/002-rancher-usage-panel/spec.md b/specs/002-rancher-usage-panel/spec.md new file mode 100644 index 00000000..88936f1c --- /dev/null +++ b/specs/002-rancher-usage-panel/spec.md @@ -0,0 +1,115 @@ +# Feature Specification: Rancher & Agent Usage Panel Redesign + +**Feature Branch**: `feat/dashboard-agent-costs` + +**Created**: 2026-08-03 + +**Status**: Draft + +**Input**: User description: "наша задача - импрувнуть rancher в админке, дропнув лишние метрики, все кроме Rancher usage · 30d. нужно этому блоку установить пагинацию, и переключать между общей стоимостью и вызовами, и стоимостью только этого агента. это относится к каждому агенту, в которого мы заходим — там должен быть блок со стоимостью и всем прочим что сейчас в usage есть. ранчер устанавливаем по центру экрана, блок со стоимостью - справа от него. у остальных агентов, придумай как лучше расположить, ведь там логи еще" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Focused Rancher page: chat centered, usage on the right (Priority: P1) + +An administrator opens the Rancher page in the admin panel. Instead of a grid of count tiles (Agents, Templates, Skills, LLMs, Knowledges) competing for attention, they see the Rancher chat placed in the center of the screen with a single usage panel to its right. The count tiles are gone — the navigation they provided is already available in the main menu. + +**Why this priority**: This is the explicit core request — declutter the Rancher page so cost/usage is the only metrics surface, and give the chat the primary, central position. Every other story builds on this panel. + +**Independent Test**: Open the Rancher page with setup complete. Verify the count tiles are absent, the chat occupies the central area, and the usage panel sits to its right. Delivers immediate value as a cleaner, cost-focused landing page. + +**Acceptance Scenarios**: + +1. **Given** setup is complete and the Rancher agent is deployed, **When** the administrator opens the Rancher page, **Then** the Rancher chat is displayed in the center of the screen and the usage panel is displayed to its right. +2. **Given** setup is complete, **When** the administrator opens the Rancher page, **Then** no count tiles (Agents, Templates, Skills, LLMs, Knowledges) are displayed anywhere on the page. +3. **Given** setup is NOT complete, **When** the administrator opens the Rancher page, **Then** the existing step-by-step setup wizard is shown unchanged. + +--- + +### User Story 2 - Usage panel with switchable views and pagination (Priority: P1) + +The usage panel ("Rancher usage · 30d") becomes interactive. The administrator can switch it between three views: total cost across all agents, call volume, and the cost of only the current agent (on the Rancher page, the Rancher agent itself). Within the panel, the day-by-day breakdown of the last 30 days is paginated so the administrator can step through history instead of seeing only aggregate totals. + +**Why this priority**: The view toggle and pagination are the explicitly requested behavioral upgrades to the panel; without them the redesign is only cosmetic. + +**Independent Test**: On the Rancher page, switch the panel between the three views and page through the daily breakdown. Each view shows distinct, correct numbers. + +**Acceptance Scenarios**: + +1. **Given** the usage panel is visible, **When** the administrator selects the "total cost" view, **Then** the panel shows the combined 30-day cost across all agents in the workspace. +2. **Given** the usage panel is visible, **When** the administrator selects the "calls" view, **Then** the panel shows call volume for the same period. +3. **Given** the usage panel is visible, **When** the administrator selects the "this agent" view, **Then** the panel shows cost figures for the current agent only. +4. **Given** the daily breakdown contains more entries than fit on one page, **When** the administrator uses the pagination controls, **Then** the next/previous set of daily entries is shown and the current position is indicated. +5. **Given** the daily breakdown fits on a single page, **When** the panel renders, **Then** pagination controls are hidden or disabled (no dead controls). +6. **Given** the selected view has no data (e.g., the agent has never made a call), **When** the panel renders, **Then** a clear empty state is shown instead of zeros without context. + +--- + +### User Story 3 - The same usage panel on every agent page (Priority: P2) + +When the administrator opens any individual agent, the same usage panel is present: cost plus everything the current usage block reports — today's model, input/output tokens and calls, and the 30-day totals (cost, top model, input tokens, output tokens, call count) — with the same view toggle and pagination. Because agent pages also surface live logs next to the chat, the panel is arranged so that chat remains primary and logs remain accessible: the chat stays centered, and the right-hand side area holds logs and the usage panel stacked together, each collapsible so neither permanently crowds out the other. + +**Why this priority**: Extends the same capability to every agent, but depends on the panel built in Stories 1–2. The Rancher page alone already delivers value. + +**Independent Test**: Open any non-Rancher agent. Verify the usage panel appears with full field parity to the Rancher page panel, scoped to that agent, and that logs and chat both remain usable alongside it. + +**Acceptance Scenarios**: + +1. **Given** any agent's page, **When** the administrator opens it, **Then** a usage panel is available showing today's snapshot (model, input/output tokens, calls) and 30-day totals (cost, top model, input tokens, output tokens, calls). +2. **Given** an agent's page, **When** the administrator switches the panel to "this agent" view, **Then** figures reflect only that agent. +3. **Given** an agent's page with the logs panel open, **When** the usage panel is shown, **Then** the chat remains the central, primary surface and logs remain reachable (both can be shown, collapsed, or expanded without navigating away). +4. **Given** an agent that has never reported usage, **When** its page is opened, **Then** the panel shows an explicit "no usage reported yet" state. + +--- + +### Edge Cases + +- **Setup incomplete on the Rancher page**: the wizard keeps precedence; the usage panel appears only once the Rancher agent exists (there is no agent to report usage before that). +- **Rancher admin agent missing/deleted**: the "this agent" view has no subject; the panel falls back to the total view with an explanatory empty state for the agent-scoped view. +- **Partial data in the total view**: agents that never reported usage contribute zero and must not break the aggregate. +- **Narrow screens**: when there is no horizontal room for a right-hand panel, the usage panel stacks below the chat (Rancher page) or below the logs area (agent pages) rather than truncating. +- **Day boundary**: "today" figures and the 30-day window shift at day rollover; a refresh reflects the new window without stale mixed periods. +- **Very large numbers**: token counts in the millions/billions and sub-cent costs render legibly (formatted, not raw). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The Rancher page MUST NOT display the count tiles (Agents, Templates, Skills, LLMs, Knowledges); the usage panel is the only metrics block on the page. +- **FR-002**: On the Rancher page, the Rancher chat MUST occupy the central area of the screen with the usage panel positioned to its right. +- **FR-003**: The usage panel MUST offer three switchable views: (a) total cost across all agents, (b) call volume, (c) cost of the current agent only. The active view MUST be visually indicated. +- **FR-004**: The usage panel MUST include a paginated day-by-day breakdown of the last 30 days; pagination controls MUST indicate position and MUST NOT appear when a single page suffices. +- **FR-005**: The usage panel MUST present all data the current usage block reports: today's snapshot (model, input tokens, output tokens, calls) and 30-day totals (cost, top model, input tokens, output tokens, call count). +- **FR-006**: Every individual agent page MUST provide the same usage panel with identical capabilities (views, pagination, fields), where "this agent" is scoped to the agent being viewed. +- **FR-007**: On individual agent pages, the usage panel MUST coexist with chat and logs: chat remains the primary central surface, and logs remain reachable while the usage panel is visible (recommended arrangement: logs and usage stacked in the side area, each collapsible). +- **FR-008**: The panel MUST show a clear empty state when no usage has been reported for the selected view/scope. +- **FR-009**: The Rancher setup wizard flow MUST remain unchanged for incomplete setups; the redesigned layout applies to the post-setup state. +- **FR-010**: The existing page-level refresh action MUST also refresh the usage panel's data in whichever view is active. + +### Key Entities + +- **Usage daily entry**: one day of one agent's activity — date, model, input tokens, output tokens, call count, cost. +- **Usage summary**: an agent's 30-day totals (cost, input/output tokens, calls), top model, and today's snapshot. +- **Aggregate usage**: the combined 30-day cost and call volume across all agents in the workspace (new concept — today only per-agent figures exist). +- **Agent**: the subject of the "this agent" view; on the Rancher page this is the Rancher admin agent, on an agent page it is that agent. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An administrator landing on the Rancher page can state the workspace's 30-day spend within 5 seconds, without navigating anywhere else. +- **SC-002**: An administrator can find any single agent's 30-day cost in at most 2 navigation steps from anywhere in the admin panel. +- **SC-003**: The usage panel exposes 100% of the fields available today (today's model/tokens/calls + 30-day cost/top model/tokens/calls) on both the Rancher page and every agent page — zero field regressions. +- **SC-004**: All 30 days of the breakdown are reachable through pagination — no day of the window is inaccessible. +- **SC-005**: On the Rancher page, the count tiles are gone and the number of distinct metric blocks is exactly one. +- **SC-006**: On agent pages, chat and logs remain simultaneously usable after the panel is added — neither is permanently hidden or displaced off-screen at standard desktop sizes. + +## Assumptions + +- **"Total cost" means all agents combined**: the request contrasts "общей стоимостью" (total cost) with "стоимостью только этого агента" (cost of only this agent), so the total view aggregates cost across all agents in the workspace over the same 30-day window. This aggregate does not exist today and is a new capability. +- **Pagination applies to the daily breakdown**: the panel currently shows only aggregate totals; the paginated content is the day-by-day usage history for the 30-day window (the only list-shaped data in scope). +- **The 30-day window stays the reporting period**; no custom date ranges are in scope. +- **Removing the count tiles loses no unique capability**: Agents, Templates, Skills, LLMs and Knowledges are all reachable from the main navigation. +- **Agent-page arrangement is delegated to design**: the user explicitly asked for a proposal ("придумай как лучше"). The recommended arrangement — chat centered, side area holding logs and the usage panel stacked with individual collapse controls — is a default, not a hard constraint; refinement during design is acceptable as long as FR-007 holds. +- **The setup wizard and chat behavior are otherwise untouched**; this feature changes layout and the usage panel only. +- **Access control is unchanged**: whoever can open the admin Rancher/agent pages today can see the usage panel; no new roles or permissions. diff --git a/specs/002-rancher-usage-panel/tasks.md b/specs/002-rancher-usage-panel/tasks.md new file mode 100644 index 00000000..0b377cde --- /dev/null +++ b/specs/002-rancher-usage-panel/tasks.md @@ -0,0 +1,171 @@ +# Tasks: Rancher & Agent Usage Panel Redesign + +**Input**: Design documents from `/specs/002-rancher-usage-panel/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md + +**Tests**: API Jest tests ARE included — the API contract explicitly lists test obligations (contracts/api-usage-overview.md) and research R10 defines the testing strategy. Admin has no test harness; UI is validated via quickstart.md scenarios. + +**Organization**: Tasks are grouped by user story. US1 and US2 are both P1 in the spec; US1 (Rancher page layout) is phased first per spec order and is independently testable thanks to the Foundational panel shell. US2 layers views + pagination onto that shell. US3 (agent pages) reuses the finished panel. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: US1 / US2 / US3 (maps to spec.md user stories) + +## Path Conventions + +Monorepo web app: backend `api/src/slices/usage/`, frontend `admin/slices/` (Nuxt layers). Paths per plan.md Project Structure. + +--- + +## Phase 1: Setup + +**Purpose**: Baseline the working environment — no project scaffolding is needed (existing monorepo). + +- [X] T001 Verify baseline: `bun install`, start `bun run dev:api` and `bun run dev:admin`, open `http://localhost:3001/rancher` and an agent page; confirm current tiles/usage block render and note the admin agent id (needed to smoke-test the panel later). Seed `Usage` rows across ≥2 agents, ≥2 models, ≥8 days via `cd api && bun run studio` if the local DB is empty (quickstart.md Prerequisites) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: The shared `` shell that ALL three stories mount. Uses only the existing per-agent endpoint — no API work needed yet. + +**⚠️ CRITICAL**: No user story phase can start before this completes. + +- [X] T002 Create panel shell `admin/slices/usage/components/usage/Panel.vue`: props `agentId: string | null`, `title?` (default `Usage · 30d`), `collapsible?` (default false) per contracts/ui-usage-panel.md; fetches via `useUsageStore().fetchForAgent(agentId)`; renders full parity fields (today model / input+output tokens / calls; 30d cost / top model / input / output / calls) with `Intl.NumberFormat` counts and USD (4 fraction digits); skeleton while loading; "No usage reported yet." when `totals.callCount === 0`; card built from `#theme/components/ui/card` +- [X] T003 Verify the Nuxt layer resolves `` (layers auto-scan `components/`); if not, add a `components` entry to `admin/slices/usage/nuxt.config.ts`; smoke-test by temporarily mounting `` on any page with the admin agent id from T001 + +**Checkpoint**: `` renders real per-agent data anywhere in the admin. + +--- + +## Phase 3: User Story 1 — Focused Rancher page: chat centered, usage on the right (Priority: P1) 🎯 MVP + +**Goal**: Rancher page shows no count tiles; chat is the central surface with the usage panel to its right; setup wizard untouched. + +**Independent Test**: Open `/rancher` with setup complete — tiles absent, chat centered, panel right (agent-scoped data is enough; views/pagination come with US2). With a fresh DB, the wizard renders exactly as before. Quickstart scenarios 1, 4, 5, 6. + +### Implementation for User Story 1 + +> All three tasks edit `admin/slices/rancher/components/rancher/Provider.vue` — sequential, no [P]. + +- [X] T004 [US1] Remove the count tiles from `admin/slices/rancher/components/rancher/Provider.vue`: delete the `stats` computed, the tiles' `NuxtLink` grid, the `rancher-dashboard` `useAsyncData` block, and the now-unused store/icon imports (keep `llmStore` — used by `onDeploy`; keep `agentStore` — used by status/usage fetches) +- [X] T005 [US1] Restructure the post-setup layout in `admin/slices/rancher/components/rancher/Provider.vue`: chat (`BridleProvider`) becomes the central primary column (preserve current sticky/height treatment); mount `` in a right-hand column (~`w-96`); panel stacks below the chat under `lg`; delete the old inline usage tile markup and the `usageStats` computed; the incomplete-setup wizard branch stays byte-for-byte unchanged (FR-009) +- [X] T006 [US1] Rewire refresh in `admin/slices/rancher/components/rancher/Provider.vue`: `onRefreshAll` drops `refreshDashboard`, keeps status refresh, and re-triggers the panel's data (existing `rancher-admin-usage` asyncData is superseded by the panel's own fetch — remove it or delegate to a panel `refresh()`), satisfying FR-010 + +**Checkpoint**: US1 acceptance scenarios 1–3 pass; exactly one metrics block on the page (SC-005). + +--- + +## Phase 4: User Story 2 — Usage panel with switchable views and pagination (Priority: P1) + +**Goal**: Panel toggles Total (all-agents cost) / Calls / Agent views and paginates the 30-day daily breakdown. + +**Independent Test**: On `/rancher`, switch the three views (distinct correct numbers per view), page through >7 daily rows, controls absent at ≤7 rows, empty view shows explicit empty state. Quickstart scenarios 2, 3, plus `curl /usage/overview` API check. + +### API — new overview endpoint + +- [X] T007 [P] [US2] Add `IOverviewUsageResponse` (last30days, totals, topModel, byAgent — shape mirrors `ICredentialUsageResponse` per data-model.md) to `api/src/slices/usage/domain/usage.types.ts` +- [X] T008 [US2] Add abstract `findRecentAll(days: number): Promise` to `api/src/slices/usage/domain/usage.gateway.ts` and implement in `api/src/slices/usage/data/usage.gateway.ts` (Prisma `usage.findMany({ where: { date: { gte: since } }, orderBy: [{ date: 'desc' }, { model: 'asc' }] })` — same `since` computation as the sibling finders) +- [X] T009 [US2] Implement `GET /usage/overview` in `api/src/slices/usage/usage.controller.ts`: roll up rows per `${date}|${model}`, compute totals + topModel, build `byAgent` sorted by `costUsd` desc with deleted-agent name fallback — extract the roll-up shared with `findForCredential` into a private helper instead of copy-pasting; DB-only, no S3 today-merge (research R2); empty table returns zeroed shape with 200 (contracts/api-usage-overview.md) +- [X] T010 [P] [US2] Jest tests in `api/src/slices/usage/usage.controller.spec.ts` (new file) covering the four contract obligations: (1) multi-agent/multi-model aggregation grain + totals ≡ Σ entries invariant, (2) topModel selection and null-on-empty, (3) byAgent cost-desc sort + deleted-agent ID fallback, (4) empty table → zeroed 200 shape; run `cd api && bun run test` + +### Admin — SDK + usage slice plumbing + +- [X] T011 [US2] Regenerate the admin SDK: with the API running, `cd admin && bun run build:api`; verify `usageControllerFindOverview` exists in `admin/slices/setup/api/data/repositories/api/sdk.gen.ts` (never hand-edit `*.gen.ts`) +- [X] T012 [P] [US2] Add admin domain types `IOverviewUsage` + `IOverviewAgentUsage` to `admin/slices/usage/domain/usage.types.ts`, export from `admin/slices/usage/domain/index.ts`, extend `IUsageGateway` (`admin/slices/usage/domain/usage.gateway.ts`) and `UsageService` (`admin/slices/usage/domain/usage.service.ts`) with `findOverview()` +- [X] T013 [US2] Implement `findOverview()` in `admin/slices/usage/data/usage.gateway.ts` (call `UsageApi.usageControllerFindOverview`, `unwrapEnvelope`) and add defensive `toOverviewUsage(raw: unknown)` to `admin/slices/usage/data/usage.mapper.ts` following the existing `toAgentUsage` pattern (depends on T011, T012) +- [X] T014 [US2] Extend `admin/slices/usage/stores/usage.ts`: `overview` state, `fetchOverview()` / `getOverview()`, re-export the new domain types + +### Panel — views + pagination + +- [X] T015 [US2] Add the view switcher to `admin/slices/usage/components/usage/Panel.vue`: segmented control from `#theme/components/ui/tabs` with `Total` / `Calls` / `Agent`; Total = all-agents 30d cost emphasized + totals + top model + `byAgent` breakdown; Calls = call volume emphasized (total + per-day); Agent = the T002 parity fields; `agentId === null` disables the Agent tab with a hint (data-model.md view rules); switching views resets pagination to page 1 +- [X] T016 [US2] Add client-side pagination to `admin/slices/usage/components/usage/Panel.vue`: daily rows table (date, model, tokens in/out, calls, cost) over the active view's `last30days`, page size 7, Prev/Next `#theme` buttons + "N–M of T" indicator, controls not rendered when T ≤ 7 (FR-004) +- [X] T017 [US2] Finalize per-view empty/error states and expose `refresh()` from `admin/slices/usage/components/usage/Panel.vue` (refetch active view's sources); wire the Rancher page refresh button to it in `admin/slices/rancher/components/rancher/Provider.vue` (FR-008, FR-010) + +**Checkpoint**: US2 acceptance scenarios 1–6 pass on the Rancher page; API tests green. + +--- + +## Phase 5: User Story 3 — The same usage panel on every agent page (Priority: P2) + +**Goal**: Every agent page carries the panel with full parity; chat stays central, logs stay reachable. + +**Independent Test**: Open any non-Rancher agent — chat tab shows logs (top) + usage panel (bottom) on the right, both independently collapsible; Overview tab renders the same panel; agent with no usage shows the empty state. Quickstart scenarios 7–10. + +### Implementation for User Story 3 + +- [X] T018 [US3] Restructure the side area in `admin/slices/agent/agent/components/agent/chat/Tab.vue`: right side becomes a vertical stack — `AgentLogsPanel` on top (existing collapse behavior preserved) and `` below; each collapses independently to a compact button (mirror the existing collapsed-"Logs"-button pattern); collapsing one gives the other the freed height; chat sizing/centrality untouched (FR-007, contracts/ui-usage-panel.md host layout) +- [X] T019 [P] [US3] Replace the bespoke `
` body of `admin/slices/agent/agent/components/agent/overview/UsageCard.vue` with `` (non-collapsible, full-width) so Overview and Chat surfaces share one implementation (SC-003) + +**Checkpoint**: All three stories functional; panel behavior identical across Rancher page, agent chat tab, and agent Overview tab. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T020 [P] Dead-code sweep: remove now-unused imports/helpers in `admin/slices/rancher/components/rancher/Provider.vue` (icons, stores after T004–T006) and any unused formatting helpers left in `admin/slices/agent/agent/components/agent/overview/UsageCard.vue` after T019 +- [X] T021 [P] Gates: `cd api && bun run lint && bun run test`; type-check pure-TS admin additions (usage slice domain/data/stores) via `tsc` per repo convention (no vue-tsc) +- [ ] T022 Run all 10 quickstart.md validation scenarios end-to-end and verify success criteria SC-001–SC-006; fix anything that fails before declaring the feature done + > Automated part DONE (2026-08-03): API unit tests (4 new, 122 total green), full admin production build, `nuxt prepare` component registration, tsc typecheck of changed TS, SDK regen verified. REMAINING: the 10 in-browser scenarios need a manual pass against a running stack (`bun run dev:api` + `bun run dev:admin` + seeded Usage rows) — not runnable in the headless implementation session. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: no dependencies +- **Foundational (Phase 2)**: after Setup — **blocks all stories** (every story mounts ``) +- **US1 (Phase 3)**: after Phase 2 only — does NOT need the overview endpoint (panel shell shows agent-scoped data) +- **US2 (Phase 4)**: after Phase 2; edits the panel created in T002. T017 touches the Rancher Provider, so finishing US1 first avoids same-file churn (recommended order, not a hard block) +- **US3 (Phase 5)**: after Phase 2; best after US2 so agent pages get the finished panel, but mounting the shell alone is already testable +- **Polish (Phase 6)**: after all desired stories + +### Task-level dependencies + +- T002 → T003 (component must exist to verify resolution) +- T004 → T005 → T006 (same file, sequential) +- T007 → T009; T008 → T009; T009 → T010 (tests target the implemented endpoint; may be *written* in parallel with T009 — different file); T009 → T011 (swagger must expose the op) → T013; T012 → T013 → T014 → T015 → T016 → T017 +- T018 and T019 are independent of each other ([P]) + +### Parallel Opportunities + +- **T007 ∥ T008** (different files) once Phase 2 is done; **T010** can be authored while T009 is in progress +- **T012 ∥ T011** (admin domain types don't depend on the generated SDK) +- **T018 ∥ T019** (different files) — two people can finish US3 in one pass +- **US1 (T004–T006) ∥ US2 API half (T007–T010)** — disjoint file sets, different workspaces + +## Parallel Example: User Story 2 + +```bash +# After Phase 2, kick off the API half while US1 is still in review: +Task: "Add IOverviewUsageResponse to api/src/slices/usage/domain/usage.types.ts" # T007 +Task: "Add findRecentAll to domain + data usage gateways" # T008 +# Then: +Task: "Implement GET /usage/overview in api/src/slices/usage/usage.controller.ts" # T009 +Task: "Write Jest tests in api/src/slices/usage/usage.controller.spec.ts" # T010 (parallel file) +# Admin half: +Task: "Regenerate SDK (bun run build:api)" # T011 +Task: "Admin domain types + gateway/service signatures in admin/slices/usage/domain/" # T012 (parallel with T011) +``` + +## Implementation Strategy + +### MVP First (US1) + +1. Phase 1 → Phase 2 (panel shell) → Phase 3 (Rancher page). +2. **STOP and VALIDATE**: quickstart scenarios 1, 4, 5, 6 — decluttered Rancher page with a live cost panel is already a shippable increment. + +### Incremental Delivery + +1. + US2 → the panel becomes interactive (views + pagination) and the platform-wide cost number appears (SC-001) → validate scenarios 2–3 + API checks → ship. +2. + US3 → every agent page gets the panel (SC-003, SC-006) → validate scenarios 7–10 → ship. +3. Polish → gates + full quickstart sweep. + +### Notes + +- Same-file tasks are intentionally sequential (rancher Provider.vue: T004–T006; Panel.vue: T015–T017) — do not parallelize them. +- Commit after each task or logical group; deploys happen only on `v*` tag runs (repo release convention). +- `*.gen.ts` files are regenerated, never edited (T011). From 788396ed00711442f56f414bc74d8d7191191877 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Tue, 4 Aug 2026 14:04:44 +0200 Subject: [PATCH 2/2] feat(usage): add new API endpoint for 30-day usage overview - Introduced `usageControllerFindOverview` method in `UsageService` to fetch a comprehensive overview of usage data across all agents for the past 30 days. - Defined new types `UsageControllerFindOverviewData` and `UsageControllerFindOverviewResponses` to support the new API endpoint. - This enhancement aims to provide users with clearer insights into overall usage and associated costs, complementing existing usage reporting functionalities. --- .../setup/api/data/repositories/api/sdk.gen.ts | 17 +++++++++++++++++ .../api/data/repositories/api/types.gen.ts | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/app/slices/setup/api/data/repositories/api/sdk.gen.ts b/app/slices/setup/api/data/repositories/api/sdk.gen.ts index c70cab81..f7fa62ae 100644 --- a/app/slices/setup/api/data/repositories/api/sdk.gen.ts +++ b/app/slices/setup/api/data/repositories/api/sdk.gen.ts @@ -194,6 +194,7 @@ import type { UsageControllerReportData, UsageControllerReportResponse, UsageControllerFindForCredentialData, + UsageControllerFindOverviewData, RancherControllerStatusData, RancherControllerEnsureTemplateData, UpgradeControllerStatusData, @@ -2746,6 +2747,22 @@ export class UsageService { ...options, }); } + + /** + * Get 30-day usage across all agents with cost + */ + public static usageControllerFindOverview< + ThrowOnError extends boolean = false, + >(options?: Options) { + return (options?.client ?? _heyApiClient).get< + unknown, + unknown, + ThrowOnError + >({ + url: "/usage/overview", + ...options, + }); + } } export class RancherService { diff --git a/app/slices/setup/api/data/repositories/api/types.gen.ts b/app/slices/setup/api/data/repositories/api/types.gen.ts index 0006b316..28428889 100644 --- a/app/slices/setup/api/data/repositories/api/types.gen.ts +++ b/app/slices/setup/api/data/repositories/api/types.gen.ts @@ -3391,6 +3391,17 @@ export type UsageControllerFindForCredentialResponses = { 200: unknown; }; +export type UsageControllerFindOverviewData = { + body?: never; + path?: never; + query?: never; + url: "/usage/overview"; +}; + +export type UsageControllerFindOverviewResponses = { + 200: unknown; +}; + export type RancherControllerStatusData = { body?: never; path?: never;