feat(ui): add resource and usage dashboard (/usage route)
adds a new /usage page that lets board operators see how much each ai provider is consuming across any date window, with per-model breakdowns, rolling 5h/24h/7d burn windows, weekly budget bars, and a deficit notch when projected spend is on track to exceed the monthly budget. - new GET /companies/:id/costs/by-provider endpoint aggregates cost events by provider + model with pro-rated billing type splits from heartbeat runs - new GET /companies/:id/costs/window-spend endpoint returns rolling window spend (5h, 24h, 7d) per provider with no schema changes - QuotaBar: reusable boxed-border progress bar with green/yellow/red threshold fill colors and optional deficit notch - ProviderQuotaCard: per-provider card showing budget allocation bars, rolling windows, subscription usage, and model breakdown with token/cost share overlays - Usage page: date preset toggles (mtd, 7d, 30d, ytd, all, custom), provider tabs, 30s polling plus ws invalidation on cost_event - custom date range blocks queries until both dates are selected and treats boundaries as local-time (not utc midnight) so full days are included regardless of timezone - query key to timestamp is floored to the nearest minute to prevent cache churn on every 30s refetch tick
This commit is contained in:
@@ -62,6 +62,21 @@ export function costRoutes(db: Db) {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const rows = await costs.byProvider(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/window-spend", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const rows = await costs.windowSpend(companyId);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
@@ -153,6 +153,121 @@ export function costService(db: Db) {
|
||||
});
|
||||
},
|
||||
|
||||
byProvider: async (companyId: string, range?: CostDateRange) => {
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
const costRows = await db
|
||||
.select({
|
||||
provider: costEvents.provider,
|
||||
model: costEvents.model,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.provider, costEvents.model)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||
|
||||
const runConditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
|
||||
if (range?.from) runConditions.push(gte(heartbeatRuns.finishedAt, range.from));
|
||||
if (range?.to) runConditions.push(lte(heartbeatRuns.finishedAt, range.to));
|
||||
|
||||
const runRows = await db
|
||||
.select({
|
||||
agentId: heartbeatRuns.agentId,
|
||||
apiRunCount:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'api' then 1 else 0 end), 0)::int`,
|
||||
subscriptionRunCount:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then 1 else 0 end), 0)::int`,
|
||||
subscriptionInputTokens:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0) else 0 end), 0)::int`,
|
||||
subscriptionOutputTokens:
|
||||
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0) else 0 end), 0)::int`,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(and(...runConditions))
|
||||
.groupBy(heartbeatRuns.agentId);
|
||||
|
||||
// aggregate run billing splits across all agents (runs don't carry model info so we can't go per-model)
|
||||
const totals = runRows.reduce(
|
||||
(acc, r) => ({
|
||||
apiRunCount: acc.apiRunCount + r.apiRunCount,
|
||||
subscriptionRunCount: acc.subscriptionRunCount + r.subscriptionRunCount,
|
||||
subscriptionInputTokens: acc.subscriptionInputTokens + r.subscriptionInputTokens,
|
||||
subscriptionOutputTokens: acc.subscriptionOutputTokens + r.subscriptionOutputTokens,
|
||||
}),
|
||||
{ apiRunCount: 0, subscriptionRunCount: 0, subscriptionInputTokens: 0, subscriptionOutputTokens: 0 },
|
||||
);
|
||||
|
||||
// pro-rate billing split across models by token share
|
||||
const totalTokens = costRows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
|
||||
|
||||
return costRows.map((row) => {
|
||||
const rowTokens = row.inputTokens + row.outputTokens;
|
||||
const share = totalTokens > 0 ? rowTokens / totalTokens : 0;
|
||||
return {
|
||||
provider: row.provider,
|
||||
model: row.model,
|
||||
costCents: row.costCents,
|
||||
inputTokens: row.inputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
apiRunCount: Math.round(totals.apiRunCount * share),
|
||||
subscriptionRunCount: Math.round(totals.subscriptionRunCount * share),
|
||||
subscriptionInputTokens: Math.round(totals.subscriptionInputTokens * share),
|
||||
subscriptionOutputTokens: Math.round(totals.subscriptionOutputTokens * share),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* aggregates cost_events by provider for each of three rolling windows:
|
||||
* last 5 hours, last 24 hours, last 7 days.
|
||||
* purely internal consumption data, no external rate-limit sources.
|
||||
*/
|
||||
windowSpend: async (companyId: string) => {
|
||||
const windows = [
|
||||
{ label: "5h", hours: 5 },
|
||||
{ label: "24h", hours: 24 },
|
||||
{ label: "7d", hours: 168 },
|
||||
] as const;
|
||||
|
||||
const results = await Promise.all(
|
||||
windows.map(async ({ label, hours }) => {
|
||||
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
const rows = await db
|
||||
.select({
|
||||
provider: costEvents.provider,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
gte(costEvents.occurredAt, since),
|
||||
),
|
||||
)
|
||||
.groupBy(costEvents.provider)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||
|
||||
return rows.map((row) => ({
|
||||
provider: row.provider,
|
||||
window: label as string,
|
||||
windowHours: hours,
|
||||
costCents: row.costCents,
|
||||
inputTokens: row.inputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
return results.flat();
|
||||
},
|
||||
|
||||
byProject: async (companyId: string, range?: CostDateRange) => {
|
||||
const issueIdAsText = sql<string>`${issues.id}::text`;
|
||||
const runProjectLinks = db
|
||||
|
||||
Reference in New Issue
Block a user