From 35573ce3b4d96af6f240651788736462361acc30 Mon Sep 17 00:00:00 2001 From: ennann Date: Mon, 20 Jul 2026 13:37:33 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=96=20release(cli):=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=201.7.8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/package.json | 2 +- packages/cli/pricing-catalog.json | 2 +- .../scanners/__tests__/copilot-vscode.test.ts | 17 +- packages/cli/src/scanners/copilot-vscode.ts | 9 + packages/dashboard/package.json | 1 + packages/dashboard/scripts/generate-demo.ts | 12 +- packages/dashboard/src/app.tsx | 430 +++++++++++------- .../dashboard/src/components/kpi-card.tsx | 18 +- packages/dashboard/src/demo-data.ts | 12 +- packages/dashboard/src/embed/embed-app.tsx | 2 +- packages/dashboard/src/hooks/use-overview.ts | 16 +- packages/dashboard/src/i18n.ts | 8 +- .../dashboard/src/pages/embed-docs-page.tsx | 2 +- packages/dashboard/src/utils/data.test.ts | 25 + packages/dashboard/src/utils/data.ts | 20 +- .../src/utils/metric-availability.test.ts | 15 + .../src/utils/metric-availability.ts | 5 +- packages/pricing/catalog.json | 2 +- packages/shared/src/pricing/catalog.ts | 2 +- packages/shared/src/types.ts | 29 +- packages/worker/src/routes/overview.test.ts | 40 ++ packages/worker/src/routes/overview.ts | 278 ++++++++--- .../worker/src/routes/trae-filters.test.ts | 14 +- pnpm-lock.yaml | 46 ++ pnpm-workspace.yaml | 4 + 25 files changed, 727 insertions(+), 284 deletions(-) create mode 100644 packages/dashboard/src/utils/data.test.ts create mode 100644 packages/worker/src/routes/overview.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index e4bdefe..423c531 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@aiusage/cli", - "version": "1.7.7", + "version": "1.7.8", "description": "Track and sync AI tool token usage across devices.", "license": "MIT", "private": false, diff --git a/packages/cli/pricing-catalog.json b/packages/cli/pricing-catalog.json index 9d56f75..6ab787f 100644 --- a/packages/cli/pricing-catalog.json +++ b/packages/cli/pricing-catalog.json @@ -1,5 +1,5 @@ { - "version": "2026-07-18-kimi-k3-v1", + "version": "2026-07-20-gpt-5.6-kimi-k3-v1", "fx": { "CNY": 7.2 }, diff --git a/packages/cli/src/scanners/__tests__/copilot-vscode.test.ts b/packages/cli/src/scanners/__tests__/copilot-vscode.test.ts index 5d9f10a..62922f9 100644 --- a/packages/cli/src/scanners/__tests__/copilot-vscode.test.ts +++ b/packages/cli/src/scanners/__tests__/copilot-vscode.test.ts @@ -94,6 +94,7 @@ describe('scanCopilotVscodeDates', () => { '2026-02-12 09:39:03.512 [info] Got Copilot token for hubeiqiao', '2026-02-12 09:39:05.000 [info] Open workspace file:///Users/test/project-one', '2026-02-12 09:40:20.336 [info] ccreq:4380bcbb.copilotmd | success | gpt-4.1 -> gpt-4.1-2025-04-14 | 2277ms | [settingsEditorSearchSuggestions]', + '2026-02-12 09:40:25.336 [info] ccreq:4480bcbb.copilotmd | success | copilot-suggestions-lysithea-0022 | 2277ms | [XtabProvider]', '2026-02-12 09:40:30.000 [info] ccreq:4390bcbb.copilotmd | error | gpt-4.1 -> gpt-4.1-2025-04-14 | 300ms | [settingsEditorSearchSuggestions]', '2026-02-12 09:41:20.336 [info] ccreq:4490bcbb.copilotmd | success | claude-sonnet-4.5 | 199ms | [chat]', ]); @@ -105,21 +106,7 @@ describe('scanCopilotVscodeDates', () => { ); const breakdowns = result.get('2026-02-12') ?? []; - expect(breakdowns).toHaveLength(2); - - expect(breakdowns).toContainEqual({ - provider: 'github', - product: 'copilot-vscode', - channel: 'ide', - model: 'gpt-4.1-2025-04-14', - project: 'Project One', - eventCount: 1, - inputTokens: 0, - cachedInputTokens: 0, - cacheWriteTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0, - }); + expect(breakdowns).toHaveLength(1); expect(breakdowns).toContainEqual({ provider: 'github', diff --git a/packages/cli/src/scanners/copilot-vscode.ts b/packages/cli/src/scanners/copilot-vscode.ts index eff44eb..7eb91ab 100644 --- a/packages/cli/src/scanners/copilot-vscode.ts +++ b/packages/cli/src/scanners/copilot-vscode.ts @@ -113,10 +113,19 @@ function extractSuccessEvent(line: string): { requestId: string; timestamp: Date const requestId = requestIdMatch?.[1] ?? `${timestamp.getTime()}`; const model = normalizeModel(parts[2]); if (!model) return null; + if (isBackgroundRequest(parts[4], model)) return null; return { requestId, timestamp, model }; } +function isBackgroundRequest(rawScope: string | undefined, model: string): boolean { + const scope = rawScope?.replace(/^\[|\]$/g, '').trim().toLowerCase(); + if (scope === 'xtabprovider' || scope === 'settingseditorsearchsuggestions') return true; + return model.startsWith('copilot-suggestions-') + || model.startsWith('copilot-nes-') + || model.startsWith('dd_'); +} + function normalizeModel(raw: string): string { const parts = raw.split('->').map((part) => part.trim()).filter(Boolean); return parts[parts.length - 1] ?? raw.trim(); diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 2d6c632..94d0ed0 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@aiusage/shared": "workspace:*", + "@lobehub/icons-static-svg": "^1.94.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.542.0", diff --git a/packages/dashboard/scripts/generate-demo.ts b/packages/dashboard/scripts/generate-demo.ts index 0f37723..3214257 100644 --- a/packages/dashboard/scripts/generate-demo.ts +++ b/packages/dashboard/scripts/generate-demo.ts @@ -364,12 +364,12 @@ async function main() { filters: { selection: { range: 'all', - deviceId: null, - provider: null, - product: null, - channel: null, - model: null, - project: null, + deviceId: [], + provider: [], + product: [], + channel: [], + model: [], + project: [], }, options: { devices: [{ diff --git a/packages/dashboard/src/app.tsx b/packages/dashboard/src/app.tsx index 1a0b315..851d855 100644 --- a/packages/dashboard/src/app.tsx +++ b/packages/dashboard/src/app.tsx @@ -1,5 +1,8 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { RotateCw, Github, Heart, Sun, Moon, Monitor } from 'lucide-react'; +import { + RotateCw, Github, Heart, Sun, Moon, Monitor, + ChevronDown, Check, BrainCircuit, +} from 'lucide-react'; import type { Locale, T } from './i18n'; import { I18N, getStoredLocale } from './i18n'; import type { ThemeMode } from './theme'; @@ -25,6 +28,25 @@ import { buildActivityHeatmapData } from './utils/activity-heatmap-data'; import { HeaderLogo, FooterLogo, useFaviconFromLogo } from './components/site-logo'; import { SITE_TITLE } from './site-config'; import type { InteractionMetricItem, InteractionMetricsPayload } from '@aiusage/shared'; +import codexIcon from '@lobehub/icons-static-svg/icons/codex-color.svg?url'; +import claudeCodeIcon from '@lobehub/icons-static-svg/icons/claudecode-color.svg?url'; +import claudeIcon from '@lobehub/icons-static-svg/icons/claude-color.svg?url'; +import anthropicIcon from '@lobehub/icons-static-svg/icons/anthropic.svg?url'; +import deepseekIcon from '@lobehub/icons-static-svg/icons/deepseek-color.svg?url'; +import openaiIcon from '@lobehub/icons-static-svg/icons/openai.svg?url'; +import geminiIcon from '@lobehub/icons-static-svg/icons/gemini-color.svg?url'; +import geminiCliIcon from '@lobehub/icons-static-svg/icons/geminicli-color.svg?url'; +import glmvIcon from '@lobehub/icons-static-svg/icons/glmv-color.svg?url'; +import kimiIcon from '@lobehub/icons-static-svg/icons/kimi.svg?url'; +import copilotIcon from '@lobehub/icons-static-svg/icons/githubcopilot.svg?url'; +import traeIcon from '@lobehub/icons-static-svg/icons/trae-color.svg?url'; +import qwenIcon from '@lobehub/icons-static-svg/icons/qwen-color.svg?url'; +import openrouterIcon from '@lobehub/icons-static-svg/icons/openrouter-color.svg?url'; +import antigravityIcon from '@lobehub/icons-static-svg/icons/antigravity-color.svg?url'; +import ampIcon from '@lobehub/icons-static-svg/icons/amp-color.svg?url'; +import cursorIcon from '@lobehub/icons-static-svg/icons/cursor.svg?url'; +import opencodeIcon from '@lobehub/icons-static-svg/icons/opencode.svg?url'; +import moonshotIcon from '@lobehub/icons-static-svg/icons/moonshot.svg?url'; // ──────────────────────────────────────── // Constants @@ -36,10 +58,65 @@ function getRanges(t: T) { { value: '7d', label: t.range7d }, { value: '30d', label: t.range30d }, { value: '90d', label: t.range90d }, - { value: '180d', label: t.range180d }, + { value: 'month', label: t.thisMonth }, ] as const; } +function formatDeltaPercent(current?: number | null, previous?: number | null): string | undefined { + const currentValue = Number(current ?? 0); + const previousValue = Number(previous ?? 0); + if (!Number.isFinite(currentValue) || !Number.isFinite(previousValue) || previousValue <= 0) return undefined; + const delta = ((currentValue - previousValue) / previousValue) * 100; + if (!Number.isFinite(delta)) return undefined; + const normalized = Math.abs(delta) < 0.05 ? 0 : delta; + return `${normalized > 0 ? '+' : ''}${normalized.toFixed(1)}%`; +} + +type FilterIconAsset = { + src: string; + tone?: 'color' | 'mono'; +} | { + Icon: typeof BrainCircuit; + tone: 'component'; +}; + +const colorIcon = (src: string): FilterIconAsset => ({ src, tone: 'color' }); +const monoIcon = (src: string): FilterIconAsset => ({ src, tone: 'mono' }); +const componentIcon = (Icon: typeof BrainCircuit): FilterIconAsset => ({ Icon, tone: 'component' }); + +function productIcon(value: string): FilterIconAsset | undefined { + const id = value.toLowerCase(); + if (id.includes('claude')) return colorIcon(claudeCodeIcon); + if (id.includes('codex')) return colorIcon(codexIcon); + if (id.includes('gemini')) return colorIcon(geminiCliIcon); + if (id.includes('kimi')) return monoIcon(kimiIcon); + if (id.includes('copilot')) return monoIcon(copilotIcon); + if (id.includes('trae')) return colorIcon(traeIcon); + if (id.includes('qwen')) return colorIcon(qwenIcon); + if (id.includes('antigravity')) return colorIcon(antigravityIcon); + if (id.includes('amp')) return colorIcon(ampIcon); + if (id.includes('cursor')) return monoIcon(cursorIcon); + if (id.includes('opencode')) return monoIcon(opencodeIcon); + return undefined; +} + +function modelIcon(value: string, label: string): FilterIconAsset { + const id = `${value} ${label}`.toLowerCase(); + if (id.includes('claude')) return colorIcon(claudeIcon); + if (id.includes('anthropic')) return monoIcon(anthropicIcon); + if (id.includes('deepseek')) return colorIcon(deepseekIcon); + if (id.includes('gemini')) return colorIcon(geminiIcon); + if (id.includes('glm') || id.includes('zhipu') || id.includes('智谱')) return colorIcon(glmvIcon); + if (id.includes('kimi')) return monoIcon(kimiIcon); + if (id.includes('moonshot')) return monoIcon(moonshotIcon); + if (id.includes('openrouter')) return colorIcon(openrouterIcon); + if (id.includes('qwen') || id.includes('通义')) return colorIcon(qwenIcon); + if (id.includes('copilot')) return monoIcon(copilotIcon); + if (id.includes('trae')) return colorIcon(traeIcon); + if (id.includes('gpt') || /\bo\d/.test(id) || id.includes('openai')) return monoIcon(openaiIcon); + return componentIcon(BrainCircuit); +} + // ──────────────────────────────────────── // Theme & Language Toggles // ──────────────────────────────────────── @@ -135,114 +212,168 @@ function SegmentedControl({ ); } -function FilterTabs({ +function FilterIcon({ icon }: { icon?: FilterIconAsset }) { + if (!icon) return null; + if (icon.tone === 'component') { + const Icon = icon.Icon; + return ; + } + if (icon.tone === 'mono') { + return ( + + ); + } + return ( + + ); +} + +function MultiSelectFilter({ + label, value, options, onChange, allLabel = 'All', + locale, + formatLabel = (text) => text, + getIcon, tooltips, }: { - value: string; + label: string; + value: string[]; options: FacetOption[]; - onChange: (v: string) => void; + onChange: (v: string[]) => void; allLabel?: string; + locale: Locale; + formatLabel?: (label: string, value: string) => string; + getIcon?: (option: FacetOption) => FilterIconAsset | undefined; tooltips?: Record; }) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const selected = new Set(value); + const selectedOptions = options.filter((option) => selected.has(option.value)); + const hasSelection = value.length > 0; + const summary = !hasSelection + ? allLabel + : value.length === 1 + ? formatLabel(selectedOptions[0]?.label ?? value[0], value[0]) + : locale === 'zh' ? `${value.length} 项` : `${value.length} selected`; + const selectedIcon = hasSelection && value.length === 1 + ? getIcon?.(selectedOptions[0] ?? { value: value[0], label: value[0] }) + : undefined; + + useEffect(() => { + if (!open) return; + const closeOnOutside = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener('pointerdown', closeOnOutside); + return () => document.removeEventListener('pointerdown', closeOnOutside); + }, [open]); + if (!options.length) return null; - const activeClass = 'bg-white text-slate-900 shadow-sm dark:bg-[#222222] dark:text-slate-300'; - const inactiveClass = 'text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300'; + + const toggleValue = (next: string) => { + onChange(selected.has(next) + ? value.filter((item) => item !== next) + : [...value, next]); + }; + return ( -
+
- {options.map((o) => { - const tip = tooltips?.[o.value]; - return ( - + + {open && ( +
+
- {tip && ( - - {tip} + + {value.length === 0 && } - )} - - ); - })} -
- ); -} + {allLabel} + -function FilterChips({ - label, - value, - options, - onChange, - allLabel = 'All', - tooltips, -}: { - label: string; - value: string; - options: FacetOption[]; - onChange: (v: string) => void; - allLabel?: string; - tooltips?: Record; -}) { - if (!options.length) return null; - const active = 'bg-slate-900 text-white dark:bg-slate-200 dark:text-slate-900'; - const inactive = 'bg-white text-slate-500 border border-slate-200 dark:bg-[#1a1a1a] dark:text-slate-400 dark:border-white/10'; - return ( -
- {label && {label}} -
-
-
- {allLabel && ( + {options.map((option) => { + const checked = selected.has(option.value); + const tip = tooltips?.[option.value]; + const icon = getIcon?.(option); + return ( - )} - {options.map((o) => { - const tip = tooltips?.[o.value]; - return ( - - - {tip && ( - - {tip} - - )} + + {checked && } + + {icon && ( + + + + )} + {formatLabel(option.label, option.value)} + + {formatCompact(option.eventCount ?? 0, locale)} + ); })}
-
-
+ )}
); } @@ -341,10 +472,9 @@ function InteractionMetricsSection({ />
-
+
-
); @@ -356,7 +486,10 @@ function InteractionMetricsSection({ export function App() { const [filters, setFilters] = useState({ - range: '30d', deviceId: '', product: '', + range: '30d', + deviceIds: [], + products: [], + models: [], }); const { @@ -425,6 +558,25 @@ export function App() { })); }, [overview, t, locale, isDark]); const unavailable = metricAvailability.tokenMetricsUnavailable; + const kpiDeltas = useMemo>(() => { + const comparison = overview?.comparison; + if (!overview || !comparison || filters.range === 'all') return {}; + const userMessageCount = typeof overview.interactionMetrics?.userMessageCount === 'number' + ? overview.interactionMetrics.userMessageCount + : undefined; + return { + totalCostUsd: formatDeltaPercent(overview.totalCostUsd, comparison.totalCostUsd), + totalTokens: formatDeltaPercent(kpis?.totalTokens, comparison.totalTokens), + inputTokens: formatDeltaPercent(kpis?.inputTokens, comparison.inputTokens), + outputTokens: formatDeltaPercent(kpis?.outputTokens, comparison.outputTokens + comparison.reasoningOutputTokens), + cachedTokens: formatDeltaPercent(kpis?.cachedTokens, comparison.cachedInputTokens), + activeDays: formatDeltaPercent(overview.activeDays, comparison.activeDays), + totalEvents: formatDeltaPercent(overview.totalEvents, comparison.totalEvents), + userMessages: formatDeltaPercent(userMessageCount, comparison.userMessageCount), + avgDailyCost: formatDeltaPercent(overview.averageDailyCostUsd, comparison.averageDailyCostUsd), + cacheHitRate: formatDeltaPercent(kpis?.cacheHitRate, comparison.cacheHitRate), + }; + }, [overview, kpis, filters.range]); const activityHeatmap = useMemo(() => buildActivityHeatmapData({ heatmap: overview?.heatmap ?? [], dailyTrend: overview?.dailyTrend ?? [], @@ -466,77 +618,48 @@ export function App() {
)} - {/* ── Range + Filters (desktop) ── */} -
-
+ {/* ── Range + Filters ── */} +
+
setFilters((f) => ({ ...f, range: v }))} /> -
- {overview && fOpts.products.length > 1 && ( - <> -
- + setFilters((f) => ({ ...f, product: v }))} + locale={locale} + formatLabel={(label) => formatProductLabel(label)} + getIcon={(option) => productIcon(option.value)} + onChange={(values) => setFilters((f) => ({ ...f, products: values }))} tooltips={{ 'claude-code': t.claudeCodeDataNotice }} /> - - )} - {overview && fOpts.devices.length >= 1 && ( - <> -
- formatModelName(label, isMobile)} + getIcon={(option) => modelIcon(option.value, option.label)} + onChange={(values) => setFilters((f) => ({ ...f, models: values }))} + /> + setFilters((f) => ({ ...f, deviceId: v }))} + locale={locale} + onChange={(values) => setFilters((f) => ({ ...f, deviceIds: values }))} /> - - )} -
- - {/* ── Filters (mobile) ── */} -
- ({ value: r.value, label: r.label, eventCount: 0, estimatedCostUsd: 0 }))} - allLabel="" - onChange={(v) => setFilters((f) => ({ ...f, range: v || '30d' }))} - /> - {overview && fOpts.products.length > 1 && ( - setFilters((f) => ({ ...f, product: v }))} - tooltips={{ 'claude-code': t.claudeCodeDataNotice }} - /> - )} - {overview && fOpts.devices.length >= 1 && ( - setFilters((f) => ({ ...f, deviceId: v }))} - /> +
)}
@@ -579,19 +702,20 @@ export function App() {
- +
- +
- +
- +
@@ -605,13 +729,14 @@ export function App() { label={t.activeDays} value={String(overview?.activeDays ?? 0)} suffix={` / ${overview?.totalDays ?? 0}`} + delta={kpiDeltas.activeDays} />
0 ? overview!.totalSessions : (overview?.totalEvents ?? 0))} - suffix={(overview?.totalSessions ?? 0) > 0 && overview!.totalSessions !== overview!.totalEvents ? ` / ${formatNumber(overview!.totalEvents)}` : undefined} + label={t.totalEvents} + value={formatNumber(overview?.totalEvents ?? 0)} + delta={kpiDeltas.totalEvents} />
@@ -620,13 +745,14 @@ export function App() { value={typeof overview?.interactionMetrics?.userMessageCount === 'number' ? formatNumber(overview.interactionMetrics.userMessageCount) : t.unavailable} + delta={kpiDeltas.userMessages} />
- +
- +
diff --git a/packages/dashboard/src/components/kpi-card.tsx b/packages/dashboard/src/components/kpi-card.tsx index 595ed62..f9702bb 100644 --- a/packages/dashboard/src/components/kpi-card.tsx +++ b/packages/dashboard/src/components/kpi-card.tsx @@ -7,14 +7,16 @@ export function KpiCard({ value, highlight = false, suffix, + delta, }: { label: string; value: string; highlight?: boolean; suffix?: string; + delta?: string; }) { return ( -
+
{label}
@@ -26,6 +28,11 @@ export function KpiCard({ {value} {suffix && {suffix}}
+ {delta && ( +
+ {delta} +
+ )}
); } @@ -33,16 +40,18 @@ export function KpiCard({ export function CostKpiCard({ label, value, + delta, }: { label: string; value: string; + delta?: string; }) { const [hovered, setHovered] = useState(false); const { showCny, rate } = useCurrencyStore(); return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > @@ -65,6 +74,11 @@ export function CostKpiCard({ )}
+ {delta && ( +
+ {delta} +
+ )}
); } diff --git a/packages/dashboard/src/demo-data.ts b/packages/dashboard/src/demo-data.ts index 636ca46..4b987c5 100644 --- a/packages/dashboard/src/demo-data.ts +++ b/packages/dashboard/src/demo-data.ts @@ -9840,12 +9840,12 @@ export const DEMO_OVERVIEW: OverviewResponse & { ok: boolean } = { "filters": { "selection": { "range": "all", - "deviceId": null, - "provider": null, - "product": null, - "channel": null, - "model": null, - "project": null + "deviceId": [], + "provider": [], + "product": [], + "channel": [], + "model": [], + "project": [] }, "options": { "devices": [ diff --git a/packages/dashboard/src/embed/embed-app.tsx b/packages/dashboard/src/embed/embed-app.tsx index ff194ef..1488603 100644 --- a/packages/dashboard/src/embed/embed-app.tsx +++ b/packages/dashboard/src/embed/embed-app.tsx @@ -160,7 +160,7 @@ function WidgetRenderer({ case 'stats-row2': { const cards = [ { label: t.activeDays, value: String(overview?.activeDays ?? 0), suffix: ` / ${overview?.totalDays ?? 0}` }, - { label: t.sessions, value: formatNumber((overview?.totalSessions ?? 0) > 0 ? overview!.totalSessions : (overview?.totalEvents ?? 0)) }, + { label: t.totalEvents, value: formatNumber(overview?.totalEvents ?? 0) }, { label: t.userMessages, value: typeof overview?.interactionMetrics?.userMessageCount === 'number' diff --git a/packages/dashboard/src/hooks/use-overview.ts b/packages/dashboard/src/hooks/use-overview.ts index 91ccc1a..a30a99e 100644 --- a/packages/dashboard/src/hooks/use-overview.ts +++ b/packages/dashboard/src/hooks/use-overview.ts @@ -9,13 +9,17 @@ import { getMetricAvailability } from '../utils/metric-availability'; export interface FiltersState { range: string; - deviceId: string; - product: string; + deviceId?: string; + product?: string; + deviceIds?: string[]; + products?: string[]; + models?: string[]; + projects?: string[]; } export interface HealthPayload { ok: boolean; siteId: string; version: string; siteTitle?: string } export interface OverviewPayload extends OverviewResponse { ok: boolean } -export interface FacetOption { value: string; label: string } +export interface FacetOption { value: string; label: string; estimatedCostUsd?: number; eventCount?: number } // ── Fetch helper ── @@ -88,17 +92,19 @@ export function useOverview(filters: FiltersState) { } return getMetricAvailability({ - selectedProduct: overview.filters.selection.product, + selectedProduct: filters.products?.length ? filters.products : overview.filters.selection.product, productOptions: overview.filters.options.products, totalEvents: overview.totalEvents, totalTokens: kpis.totalTokens, }); - }, [overview, kpis]); + }, [overview, kpis, filters.products]); // Filter options const fOpts = useMemo(() => ({ devices: overview?.filters.options.devices ?? [], + models: overview?.filters.options.models ?? [], products: overview?.filters.options.products ?? [], + projects: overview?.filters.options.projects ?? [], }), [overview]); const refresh = () => setTick((n) => n + 1); diff --git a/packages/dashboard/src/i18n.ts b/packages/dashboard/src/i18n.ts index d51731a..fbfe36c 100644 --- a/packages/dashboard/src/i18n.ts +++ b/packages/dashboard/src/i18n.ts @@ -10,7 +10,7 @@ export const I18N = { estimatedCost: 'Estimated Cost', totalTokens: 'Total Tokens', inputTokens: 'Input Tokens', outputTokens: 'Output Tokens', cachedTokens: 'Cached Tokens', activeDays: 'Active Days', - sessions: 'Sessions', userMessages: 'User Messages', + sessions: 'Sessions', totalEvents: 'Total Events', userMessages: 'User Messages', avgDailyCost: 'Avg Daily Cost', cacheHitRate: 'Cache Hit Rate', costTrend: 'Cost Trend', tokenTrend: 'Token Trend', tokenComposition: 'Token Composition', tokenFlow: 'Token Flow', @@ -22,7 +22,7 @@ export const I18N = { proxy: 'proxy', providerShare: 'Provider Share', modelShare: 'Model Share', channelShare: 'Channel Share', deviceShare: 'Device Share', thisMonth: 'This Month', - device: 'Device', product: 'Product', all: 'All', + device: 'Device', product: 'Product', tool: 'Tool', model: 'Model', project: 'Project', all: 'All', noData: 'No data', noFlowData: 'No flow data', failedToLoad: 'Failed to load data', unavailable: 'N/A', @@ -64,7 +64,7 @@ export const I18N = { estimatedCost: '预估费用', totalTokens: '总 Token', inputTokens: '输入 Token', outputTokens: '输出 Token', cachedTokens: '缓存 Token', activeDays: '活跃天数', - sessions: '会话数', userMessages: '用户对话数', + sessions: '会话数', totalEvents: '总事件数', userMessages: '用户对话数', avgDailyCost: '日均费用', cacheHitRate: '缓存命中率', costTrend: '费用趋势', tokenTrend: 'Token 趋势', tokenComposition: 'Token 构成', tokenFlow: 'Token 流向', @@ -76,7 +76,7 @@ export const I18N = { proxy: '代理', providerShare: '厂商占比', modelShare: '模型占比', channelShare: '渠道占比', deviceShare: '设备占比', thisMonth: '本月', - device: '设备', product: '产品', all: '全部', + device: '设备', product: '产品', tool: '工具', model: '模型', project: '项目', all: '全部', noData: '暂无数据', noFlowData: '暂无流向数据', failedToLoad: '加载失败', unavailable: 'N/A', diff --git a/packages/dashboard/src/pages/embed-docs-page.tsx b/packages/dashboard/src/pages/embed-docs-page.tsx index 13c38f1..d1215ba 100644 --- a/packages/dashboard/src/pages/embed-docs-page.tsx +++ b/packages/dashboard/src/pages/embed-docs-page.tsx @@ -11,7 +11,7 @@ import { buildEmbedUrl } from '../embed/parse-params'; const WIDGETS = [ { id: 'stats-row1', nameZh: '指标卡 \u00b7 第一行', nameEn: 'KPI Cards \u00b7 Row 1', descZh: '预估费用、总 Token、输入、输出、缓存命中', descEn: 'Estimated cost, total tokens, input, output, cached tokens', height: 128, supportsItems: true, itemsNoteZh: '索引 0-4', itemsNoteEn: 'Index 0-4' }, - { id: 'stats-row2', nameZh: '指标卡 \u00b7 第二行', nameEn: 'KPI Cards \u00b7 Row 2', descZh: '活跃天数、会话数、用户对话数、日均费用、缓存命中率', descEn: 'Active days, sessions, user messages, avg daily cost, cache hit rate', height: 128, supportsItems: true, itemsNoteZh: '索引 0-4', itemsNoteEn: 'Index 0-4' }, + { id: 'stats-row2', nameZh: '指标卡 \u00b7 第二行', nameEn: 'KPI Cards \u00b7 Row 2', descZh: '活跃天数、总事件数、用户对话数、日均费用、缓存命中率', descEn: 'Active days, total events, user messages, avg daily cost, cache hit rate', height: 128, supportsItems: true, itemsNoteZh: '索引 0-4', itemsNoteEn: 'Index 0-4' }, { id: 'cost-trend', nameZh: '费用趋势', nameEn: 'Cost Trend', descZh: '按天展示费用变化的柱状图,支持多厂商堆叠', descEn: 'Daily cost bar chart with multi-provider stacking', height: 360, supportsItems: false }, { id: 'token-trend', nameZh: 'Token 趋势', nameEn: 'Token Trend', descZh: '按天展示各类 Token 用量的面积图', descEn: 'Daily token usage area chart by type', height: 380, supportsItems: false }, { id: 'token-composition', nameZh: 'Token 构成', nameEn: 'Token Composition', descZh: '按天展示 Token 类型分布的堆叠柱状图', descEn: 'Daily token type distribution stacked bar chart', height: 380, supportsItems: false }, diff --git a/packages/dashboard/src/utils/data.test.ts b/packages/dashboard/src/utils/data.test.ts new file mode 100644 index 0000000..1666b8e --- /dev/null +++ b/packages/dashboard/src/utils/data.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildQuery } from './data'; + +test('buildQuery encodes multi-select filters as repeated params', () => { + const query = buildQuery({ + range: '30d', + products: ['codex', 'claude-code'], + models: ['gpt-5.5', 'gpt-5.4-mini'], + projects: ['aiusage'], + deviceIds: ['mbp-16', 'us1'], + }); + + const params = new URLSearchParams(query); + assert.equal(params.get('range'), '30d'); + assert.deepEqual(params.getAll('product'), ['codex', 'claude-code']); + assert.deepEqual(params.getAll('model'), ['gpt-5.5', 'gpt-5.4-mini']); + assert.deepEqual(params.getAll('project'), ['aiusage']); + assert.deepEqual(params.getAll('deviceId'), ['mbp-16', 'us1']); +}); + +test('buildQuery keeps month range for the API', () => { + const query = buildQuery({ range: 'month', products: [] }); + assert.equal(new URLSearchParams(query).get('range'), 'month'); +}); diff --git a/packages/dashboard/src/utils/data.ts b/packages/dashboard/src/utils/data.ts index 00c319e..34aa85a 100644 --- a/packages/dashboard/src/utils/data.ts +++ b/packages/dashboard/src/utils/data.ts @@ -1,6 +1,5 @@ import type { SankeyGraph } from '@aiusage/shared'; import type { OverviewPayload, FiltersState } from '../hooks/use-overview'; -import { arrSum } from './format'; /** Get all YYYY-MM-DD dates for the current month (1st to last day). */ export function currentMonthDates(): string[] { @@ -29,8 +28,8 @@ export function padMonth(ov: OverviewPayload): OverviewPayload { }); const monthTrend = dailyTrend.filter((d) => d.estimatedCostUsd > 0); - const totalCostUsd = arrSum(monthTrend.map((d) => d.estimatedCostUsd)); - const totalEvents = arrSum(monthTrend.map((d) => d.eventCount)); + const totalCostUsd = monthTrend.reduce((sum, d) => sum + Number(d.estimatedCostUsd || 0), 0); + const totalEvents = monthTrend.reduce((sum, d) => sum + Number(d.eventCount || 0), 0); const activeDays = monthTrend.length; // Scale share/sankey data by cost ratio (month vs full range) @@ -81,10 +80,21 @@ export function padMonth(ov: OverviewPayload): OverviewPayload { export function buildQuery(f: FiltersState): string { const p = new URLSearchParams(); + const aliases: Record = { + deviceIds: 'deviceId', + products: 'product', + models: 'model', + projects: 'project', + }; + for (const [k, v] of Object.entries(f)) { + if (Array.isArray(v)) { + const key = aliases[k] ?? k; + v.filter(Boolean).forEach((item) => p.append(key, item)); + continue; + } if (!v) continue; - // "month" is frontend-only; request 30d from API - p.set(k, k === 'range' && v === 'month' ? '30d' : v); + p.set(k, v); } return p.toString(); } diff --git a/packages/dashboard/src/utils/metric-availability.test.ts b/packages/dashboard/src/utils/metric-availability.test.ts index 9d7a8d4..d212b4d 100644 --- a/packages/dashboard/src/utils/metric-availability.test.ts +++ b/packages/dashboard/src/utils/metric-availability.test.ts @@ -40,3 +40,18 @@ test('does not mark mixed product sets as unavailable when a token-bearing produ assert.equal(availability.mode, 'standard'); assert.equal(availability.tokenMetricsUnavailable, false); }); + +test('supports multi-selected event-only product filters', () => { + const availability = getMetricAvailability({ + selectedProduct: ['copilot-vscode', 'antigravity'], + productOptions: [ + { value: 'copilot-vscode' }, + { value: 'antigravity' }, + ], + totalEvents: 817, + totalTokens: 0, + }); + + assert.equal(availability.mode, 'event-only'); + assert.equal(availability.tokenMetricsUnavailable, true); +}); diff --git a/packages/dashboard/src/utils/metric-availability.ts b/packages/dashboard/src/utils/metric-availability.ts index 17e8553..f509e1d 100644 --- a/packages/dashboard/src/utils/metric-availability.ts +++ b/packages/dashboard/src/utils/metric-availability.ts @@ -3,7 +3,7 @@ import type { FacetOption } from '@aiusage/shared'; const EVENT_ONLY_PRODUCTS = new Set(['antigravity', 'copilot-vscode']); export interface MetricAvailabilityInput { - selectedProduct?: string | null; + selectedProduct?: string | string[] | null; productOptions: Array>; totalEvents: number; totalTokens: number; @@ -15,9 +15,10 @@ export interface MetricAvailability { } function getScopedProducts( - selectedProduct: string | null | undefined, + selectedProduct: string | string[] | null | undefined, productOptions: Array>, ): string[] { + if (Array.isArray(selectedProduct)) return selectedProduct.filter(Boolean); if (selectedProduct) return [selectedProduct]; return productOptions.map((option) => option.value).filter(Boolean); } diff --git a/packages/pricing/catalog.json b/packages/pricing/catalog.json index 9d56f75..6ab787f 100644 --- a/packages/pricing/catalog.json +++ b/packages/pricing/catalog.json @@ -1,5 +1,5 @@ { - "version": "2026-07-18-kimi-k3-v1", + "version": "2026-07-20-gpt-5.6-kimi-k3-v1", "fx": { "CNY": 7.2 }, diff --git a/packages/shared/src/pricing/catalog.ts b/packages/shared/src/pricing/catalog.ts index 031e058..168f6cd 100644 --- a/packages/shared/src/pricing/catalog.ts +++ b/packages/shared/src/pricing/catalog.ts @@ -10,7 +10,7 @@ import { github } from './data/github.js'; import { sourcegraph } from './data/sourcegraph.js'; import { inflection, cursor, droid, opencode } from './data/placeholders.js'; -export const PRICING_VERSION = '2026-07-18-kimi-k3-v1'; +export const PRICING_VERSION = '2026-07-20-gpt-5.6-kimi-k3-v1'; /** * 模型别名(精确匹配优先于前缀回退)。 diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 36e48a2..22374be 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -120,9 +120,26 @@ export interface OverviewResponse { sankey: SankeyGraph; heatmap: HeatmapDay[]; interactionMetrics?: InteractionMetricsPayload; + comparison?: OverviewComparisonPayload | null; filters: DashboardFiltersPayload; } +export interface OverviewComparisonPayload { + activeDays: number; + totalEvents: number; + totalSessions: number; + totalCostUsd: number; + averageDailyCostUsd: number; + inputTokens: number; + cachedInputTokens: number; + cacheWriteTokens: number; + outputTokens: number; + reasoningOutputTokens: number; + totalTokens: number; + cacheHitRate: number; + userMessageCount?: number; +} + export interface InteractionMetricItem { value: string; label: string; @@ -202,12 +219,12 @@ export interface FacetOption { export interface DashboardFiltersPayload { selection: { range: string; - deviceId: string | null; - provider: string | null; - product: string | null; - channel: string | null; - model: string | null; - project: string | null; + deviceId: string[]; + provider: string[]; + product: string[]; + channel: string[]; + model: string[]; + project: string[]; }; options: { devices: FacetOption[]; diff --git a/packages/worker/src/routes/overview.test.ts b/packages/worker/src/routes/overview.test.ts new file mode 100644 index 0000000..f03fb32 --- /dev/null +++ b/packages/worker/src/routes/overview.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { buildDateWindow, buildWhere, parseFilters } from './overview'; + +describe('overview filters', () => { + it('builds inclusive date windows that include today without adding an extra day', () => { + const now = new Date('2026-07-20T10:00:00.000Z'); + + expect(buildDateWindow('7d', now)).toEqual({ + minDate: '2026-07-14', + maxDate: '2026-07-20', + days: 7, + }); + expect(buildDateWindow('30d', now)).toEqual({ + minDate: '2026-06-21', + maxDate: '2026-07-20', + days: 30, + }); + expect(buildDateWindow('month', now)).toEqual({ + minDate: '2026-07-01', + maxDate: '2026-07-20', + days: 20, + }); + }); + + it('parses repeated and comma-separated facet params as multi-select values', () => { + const filters = parseFilters(new URL('https://example.com/api/v1/public/overview?range=30d&product=codex&product=claude-code&model=gpt-5,claude-opus')); + + expect(filters?.product).toEqual(['codex', 'claude-code']); + expect(filters?.model).toEqual(['gpt-5', 'claude-opus']); + }); + + it('builds IN clauses for multi-selected facets', () => { + const filters = parseFilters(new URL('https://example.com/api/v1/public/overview?range=7d&deviceId=mac-a&deviceId=mac-b&project=AIUsage'))!; + const where = buildWhere(filters); + + expect(where.whereClause).toContain('b.device_id IN (?, ?)'); + expect(where.whereClause).toContain('COALESCE(b.project_alias, b.project_display) = ?'); + expect(where.params).toEqual([expect.any(String), expect.any(String), 'mac-a', 'mac-b', 'AIUsage']); + }); +}); diff --git a/packages/worker/src/routes/overview.ts b/packages/worker/src/routes/overview.ts index 80dc55f..6b8cfec 100644 --- a/packages/worker/src/routes/overview.ts +++ b/packages/worker/src/routes/overview.ts @@ -1,6 +1,7 @@ import { PUBLIC_READ_CACHE_HEADERS, jsonError, jsonOk } from '../utils/response.js'; import { toPublicProjectName } from '../utils/privacy.js'; import type { Env } from '../types.js'; +import type { OverviewComparisonPayload } from '@aiusage/shared'; export const TOTAL_TOKENS_SQL = ` COALESCE(b.input_tokens, 0) + @@ -17,13 +18,15 @@ export type FilterKey = 'deviceId' | 'provider' | 'product' | 'channel' | 'model export interface DashboardFilters { minDate: string | null; + maxDate: string | null; + rangeDays: number | null; range: string; - deviceId: string | null; - provider: string | null; - product: string | null; - channel: string | null; - model: string | null; - project: string | null; + deviceId: string[]; + provider: string[]; + product: string[]; + channel: string[]; + model: string[]; + project: string[]; } export interface WhereParts { @@ -43,6 +46,7 @@ export async function handleOverview(url: URL, env: Env): Promise { if (!filters) return jsonError(400, 'INVALID_PAYLOAD', 'Invalid range parameter', true); const where = buildWhere(filters); + const previousFilters = buildPreviousFilters(filters); // 热力图固定查最近 365 天(不受 range 过滤器影响,但保留 device/provider 等维度过滤) const heatmapMinDate = (() => { @@ -50,7 +54,7 @@ export async function handleOverview(url: URL, env: Env): Promise { d.setDate(d.getDate() - 364); return d.toISOString().split('T')[0]; })(); - const heatmapWhere = buildWhere({ ...filters, minDate: heatmapMinDate, range: '365d' }); + const heatmapWhere = buildWhere({ ...filters, minDate: heatmapMinDate, maxDate: todayDateString(), rangeDays: 365, range: '365d' }); const [ summary, @@ -68,6 +72,7 @@ export async function handleOverview(url: URL, env: Env): Promise { models, projects, interactionMetrics, + comparison, ] = await Promise.all([ env.DB.prepare(` SELECT @@ -199,6 +204,7 @@ export async function handleOverview(url: URL, env: Env): Promise { loadFacetOptions('model', filters, env), loadFacetOptions('project', filters, env), loadInteractionMetrics(filters, env), + previousFilters ? loadComparison(previousFilters, env) : Promise.resolve(null), ]); const activeDays = Number(summary?.active_days ?? 0); @@ -208,7 +214,7 @@ export async function handleOverview(url: URL, env: Env): Promise { const totalCostUsd = roundUsd(summary?.total_cost_usd ?? 0); return jsonOk({ - totalDays: activeDays, + totalDays: filters.rangeDays ?? activeDays, activeDays, totalEvents, totalSessions, @@ -258,6 +264,7 @@ export async function handleOverview(url: URL, env: Env): Promise { estimatedCostUsd: roundUsd(row.estimated_cost_usd ?? 0), })), interactionMetrics, + comparison, filters: { selection: { range: filters.range, @@ -282,21 +289,32 @@ export async function handleOverview(url: URL, env: Env): Promise { export function parseFilters(url: URL): DashboardFilters | null { const range = readTextParam(url, 'range') ?? '30d'; - const minDate = buildMinDate(range); - if (minDate === undefined) return null; + const window = buildDateWindow(range); + if (!window) return null; return { - minDate, + minDate: window.minDate, + maxDate: window.maxDate, + rangeDays: window.days, range, - deviceId: readTextParam(url, 'deviceId'), - provider: readTextParam(url, 'provider'), - product: readTextParam(url, 'product'), - channel: readTextParam(url, 'channel'), - model: readTextParam(url, 'model'), - project: readTextParam(url, 'project'), + deviceId: readTextParams(url, 'deviceId'), + provider: readTextParams(url, 'provider'), + product: readTextParams(url, 'product'), + channel: readTextParams(url, 'channel'), + model: readTextParams(url, 'model'), + project: readTextParams(url, 'project'), }; } +function readTextParams(url: URL, key: string): string[] { + const values = url.searchParams + .getAll(key) + .flatMap((value) => value.split(',')) + .map((value) => value.trim()) + .filter(Boolean); + return [...new Set(values)]; +} + function readTextParam(url: URL, key: string): string | null { const value = url.searchParams.get(key); if (!value) return null; @@ -304,6 +322,22 @@ function readTextParam(url: URL, key: string): string | null { return trimmed === '' ? null : trimmed; } +function addValueFilter( + clauses: string[], + params: (string | number)[], + expression: string, + values: string[], +) { + if (values.length === 0) return; + if (values.length === 1) { + clauses.push(`${expression} = ?`); + params.push(values[0]); + return; + } + clauses.push(`${expression} IN (${values.map(() => '?').join(', ')})`); + params.push(...values); +} + export function buildWhere(filters: DashboardFilters, omit?: FilterKey): WhereParts { const clauses: string[] = []; const params: (string | number)[] = []; @@ -312,29 +346,16 @@ export function buildWhere(filters: DashboardFilters, omit?: FilterKey): WherePa clauses.push('b.usage_date >= ?'); params.push(filters.minDate); } - if (filters.deviceId && omit !== 'deviceId') { - clauses.push('b.device_id = ?'); - params.push(filters.deviceId); - } - if (filters.provider && omit !== 'provider') { - clauses.push('b.provider = ?'); - params.push(filters.provider); - } - if (filters.product && omit !== 'product') { - appendProductFilter(clauses, params, 'b', filters.product); - } - if (filters.channel && omit !== 'channel') { - clauses.push('b.channel = ?'); - params.push(filters.channel); - } - if (filters.model && omit !== 'model') { - clauses.push('b.model = ?'); - params.push(filters.model); - } - if (filters.project && omit !== 'project') { - clauses.push(`${PROJECT_DISPLAY_SQL} = ?`); - params.push(filters.project); + if (filters.maxDate) { + clauses.push('b.usage_date <= ?'); + params.push(filters.maxDate); } + if (omit !== 'deviceId') addValueFilter(clauses, params, 'b.device_id', filters.deviceId); + if (omit !== 'provider') addValueFilter(clauses, params, 'b.provider', filters.provider); + if (omit !== 'product') addProductFilter(clauses, params, 'b', filters.product); + if (omit !== 'channel') addValueFilter(clauses, params, 'b.channel', filters.channel); + if (omit !== 'model') addValueFilter(clauses, params, 'b.model', filters.model); + if (omit !== 'project') addValueFilter(clauses, params, PROJECT_DISPLAY_SQL, filters.project); return { whereClause: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '', @@ -469,6 +490,73 @@ async function loadInteractionMetrics(filters: DashboardFilters, env: Env) { }; } +async function loadComparison(filters: DashboardFilters, env: Env): Promise { + const where = buildWhere(filters); + const activityWhere = buildActivityWhere(filters); + + const [summary, activity] = await Promise.all([ + env.DB.prepare(` + SELECT + COUNT(DISTINCT b.usage_date) AS active_days, + COALESCE(SUM(b.event_count), 0) AS total_events, + COALESCE(SUM(b.session_count), 0) AS total_sessions, + COALESCE(SUM(b.estimated_cost_usd), 0) AS total_cost_usd, + COALESCE(SUM(b.input_tokens), 0) AS input_tokens, + COALESCE(SUM(b.cached_input_tokens), 0) AS cached_input_tokens, + COALESCE(SUM(b.cache_write_tokens), 0) AS cache_write_tokens, + COALESCE(SUM(b.output_tokens), 0) AS output_tokens, + COALESCE(SUM(b.reasoning_output_tokens), 0) AS reasoning_output_tokens + FROM daily_usage_breakdown b + ${where.whereClause} + `).bind(...where.params).first<{ + active_days: number; + total_events: number; + total_sessions: number; + total_cost_usd: number; + input_tokens: number; + cached_input_tokens: number; + cache_write_tokens: number; + output_tokens: number; + reasoning_output_tokens: number; + }>(), + env.DB.prepare(` + SELECT + COALESCE(SUM(CASE WHEN a.kind = 'user_message' THEN a.event_count ELSE 0 END), 0) AS user_message_count + FROM daily_activity_breakdown a + ${activityWhere.whereClause} + `).bind(...activityWhere.params).first<{ user_message_count: number }>().catch((error) => { + if (String(error).includes('daily_activity_breakdown')) return null; + throw error; + }), + ]); + + const activeDays = Number(summary?.active_days ?? 0); + const inputTokens = Number(summary?.input_tokens ?? 0); + const cachedInputTokens = Number(summary?.cached_input_tokens ?? 0); + const cacheWriteTokens = Number(summary?.cache_write_tokens ?? 0); + const outputTokens = Number(summary?.output_tokens ?? 0); + const reasoningOutputTokens = Number(summary?.reasoning_output_tokens ?? 0); + const totalTokens = inputTokens + cachedInputTokens + cacheWriteTokens + outputTokens + reasoningOutputTokens; + const cacheDenominator = inputTokens + cachedInputTokens; + const totalCostUsd = roundUsd(summary?.total_cost_usd ?? 0); + + return { + activeDays, + totalEvents: Number(summary?.total_events ?? 0), + totalSessions: Number(summary?.total_sessions ?? 0), + totalCostUsd, + averageDailyCostUsd: activeDays > 0 ? roundUsd(totalCostUsd / activeDays) : 0, + inputTokens, + cachedInputTokens, + cacheWriteTokens, + outputTokens, + reasoningOutputTokens, + totalTokens, + cacheHitRate: cacheDenominator > 0 ? (cachedInputTokens / cacheDenominator) * 100 : 0, + userMessageCount: activity ? Number(activity.user_message_count ?? 0) : undefined, + }; +} + function emptyInteractionMetrics() { return { exactCount: 0, @@ -530,27 +618,20 @@ function buildActivityWhere(filters: DashboardFilters): WhereParts { clauses.push('a.usage_date >= ?'); params.push(filters.minDate); } - if (filters.deviceId) { - clauses.push('a.device_id = ?'); - params.push(filters.deviceId); - } - if (filters.provider) { - clauses.push('a.provider = ?'); - params.push(filters.provider); + if (filters.maxDate) { + clauses.push('a.usage_date <= ?'); + params.push(filters.maxDate); } - if (filters.product) { - appendProductFilter(clauses, params, 'a', filters.product); - } - if (filters.channel && filters.channel !== 'cli') { + addValueFilter(clauses, params, 'a.device_id', filters.deviceId); + addValueFilter(clauses, params, 'a.provider', filters.provider); + addProductFilter(clauses, params, 'a', filters.product); + if (filters.channel.length > 0 && !filters.channel.includes('cli')) { clauses.push('1 = 0'); } - if (filters.model) { + if (filters.model.length > 0) { clauses.push('1 = 0'); } - if (filters.project) { - clauses.push(`${ACTIVITY_PROJECT_DISPLAY_SQL} = ?`); - params.push(filters.project); - } + addValueFilter(clauses, params, ACTIVITY_PROJECT_DISPLAY_SQL, filters.project); return { whereClause: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '', @@ -628,34 +709,93 @@ function roundUsd(value: number): number { return Math.round(Number(value || 0) * 10000) / 10000; } -function buildMinDate(range: string): string | null | undefined { - if (range === 'all') return null; +export function buildDateWindow( + range: string, + now: Date = new Date(), +): { minDate: string | null; maxDate: string | null; days: number | null } | undefined { + if (range === 'all') return { minDate: null, maxDate: null, days: null }; - const now = new Date(); + const today = startOfUtcDay(now); + let start: Date; let days: number; + if (range === '7d') days = 7; else if (range === '30d') days = 30; else if (range === '3m' || range === '90d') days = 90; - else if (range === '6m' || range === '180d') days = 180; - else return undefined; + else if (range === 'month') { + start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1)); + days = diffUtcDays(start, today) + 1; + return { minDate: formatDate(start), maxDate: formatDate(today), days }; + } else return undefined; + + start = addUtcDays(today, -(days - 1)); + return { minDate: formatDate(start), maxDate: formatDate(today), days }; +} + +function buildPreviousFilters(filters: DashboardFilters): DashboardFilters | null { + if (!filters.minDate || !filters.rangeDays) return null; + const currentStart = parseDateOnly(filters.minDate); + const previousMax = addUtcDays(currentStart, -1); + const previousMin = addUtcDays(currentStart, -filters.rangeDays); + return { + ...filters, + minDate: formatDate(previousMin), + maxDate: formatDate(previousMax), + }; +} + +function todayDateString(): string { + return formatDate(startOfUtcDay(new Date())); +} + +function startOfUtcDay(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function parseDateOnly(value: string): Date { + const [year, month, day] = value.split('-').map(Number); + return new Date(Date.UTC(year, month - 1, day)); +} + +function addUtcDays(date: Date, days: number): Date { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + days); + return next; +} - const min = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); - return min.toISOString().split('T')[0]; +function diffUtcDays(start: Date, end: Date): number { + return Math.round((startOfUtcDay(end).getTime() - startOfUtcDay(start).getTime()) / 86400000); } -function appendProductFilter( +function formatDate(date: Date): string { + return date.toISOString().split('T')[0]; +} + +function addProductFilter( clauses: string[], params: (string | number)[], tableAlias: 'a' | 'b', - product: string, + products: string[], ): void { - if (product === 'trae') { - clauses.push(`${tableAlias}.product IN (?, ?, ?)`); - params.push('trae', 'trae-cn', 'trae-intl'); + if (products.length === 0) return; + const expanded = new Set(); + for (const product of products) { + if (product === 'trae') { + expanded.add('trae'); + expanded.add('trae-cn'); + expanded.add('trae-intl'); + } else { + expanded.add(product); + } + } + const values = [...expanded]; + if (values.length === 1) { + clauses.push(`${tableAlias}.product = ?`); + params.push(values[0]); return; } - clauses.push(`${tableAlias}.product = ?`); - params.push(product); + clauses.push(`${tableAlias}.product IN (${values.map(() => '?').join(', ')})`); + params.push(...values); } function productLabel(value: string, combined: boolean): string { diff --git a/packages/worker/src/routes/trae-filters.test.ts b/packages/worker/src/routes/trae-filters.test.ts index c3dfaa4..8e8d133 100644 --- a/packages/worker/src/routes/trae-filters.test.ts +++ b/packages/worker/src/routes/trae-filters.test.ts @@ -7,13 +7,15 @@ describe('Trae dashboard filters', () => { it('expands the combined Trae product to legacy, CN, and international rows', () => { const filters: DashboardFilters = { minDate: null, + maxDate: null, + rangeDays: null, range: 'all', - deviceId: null, - provider: null, - product: 'trae', - channel: null, - model: null, - project: null, + deviceId: [], + provider: [], + product: ['trae'], + channel: [], + model: [], + project: [], }; expect(buildWhere(filters)).toEqual({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daab7dd..942bc2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@aiusage/shared': specifier: workspace:* version: link:../shared + '@lobehub/icons-static-svg': + specifier: ^1.94.0 + version: 1.94.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -616,89 +619,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -742,6 +761,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lobehub/icons-static-svg@1.94.0': + resolution: {integrity: sha512-Inx1TYkjLH6YeHOIHeVW9+OM/xxRnk8TmcQVKquFUDBmE3X9sUuRGt7kALrrDBNNAbrWz7Qq6fAiFj9E9Mmw9Q==} + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -788,66 +810,79 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -927,24 +962,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -1335,24 +1374,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1468,6 +1511,7 @@ packages: recharts@2.15.4: resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -2120,6 +2164,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lobehub/icons-static-svg@1.94.0': {} + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e9..a0b32d9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,6 @@ packages: - "packages/*" +allowBuilds: + esbuild: true + sharp: true + workerd: true