Skip to content

Commit 0a05b26

Browse files
committed
feat: refine dashboard history and runtime logging
1 parent c75a166 commit 0a05b26

File tree

13 files changed

+1295
-169
lines changed

13 files changed

+1295
-169
lines changed

logbook.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,14 @@ What changed:
103103
- Added a built-in activity dashboard at `/dashboard` with summary cards, quota snapshots, a full-width live log, and a locally served Chart.js history plot.
104104
- Added a SQLite-backed dashboard telemetry store with backfill from persisted JSONL and gzipped capture files plus an SSE live event stream.
105105
- Captured telemetry for chat completions, Anthropic messages, Responses API, and embeddings, including completed requests, upstream errors, proxy errors, and client aborts.
106-
- Changed the history chart to stacked bars by base model so hourly, daily, weekly, and monthly buckets are visible in one plot with a legend.
107-
- Moved dashboard controls into the history panel and removed the separate by-model pane in favor of the chart breakdown.
108-
- Replaced the default Hono request logger with `consola` request start/finish lines to align terminal logging with the existing session token logs.
106+
- Changed the history chart to stacked `input` and `output` bars with hover breakdown by model, while keeping the model filter as a scope over the plotted data.
107+
- Filled missing hour/day/week/month buckets so zero-traffic intervals still appear in the history series.
108+
- Moved the model filter to the history pane header and compacted the bucket/range controls into the chart toolbar with responsive native selects on narrower screens.
109+
- Replaced the default Hono request logger with a timestamped `consola` reporter and a single standardized HTTP access log line that includes method, path, status, and duration.
110+
- Added a `--log-level` startup option and centralized runtime log formatting in `src/lib/logger.ts`, while preserving `--verbose` as a compatibility shortcut.
109111
- Hardened dashboard telemetry persistence so SQLite I/O failures degrade the dashboard store to in-memory mode instead of breaking proxied requests.
110112
- Added dashboard coverage in `tests/dashboard.test.ts`, including API aggregation, backfill, live stream, and local chart asset checks.
113+
- Added startup logging coverage in `tests/start-idle-timeout.test.ts`.
111114

112115
## Commit Trail
113116

src/lib/dashboard-store-time.ts

Lines changed: 248 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
DashboardModelBreakdownRow,
55
DashboardRange,
66
DashboardRangeCounters,
7-
DashboardSeriesDataset,
7+
DashboardSeriesBreakdownRow,
88
DashboardSeriesPoint,
99
TelemetryEvent,
1010
} from "~/lib/dashboard-store-types"
@@ -18,6 +18,11 @@ interface ZonedParts {
1818
year: number
1919
}
2020

21+
interface SeriesBounds {
22+
end: ZonedParts
23+
start: ZonedParts
24+
}
25+
2126
export function clearDashboardTimeCache(): void {
2227
formatterCache.clear()
2328
}
@@ -85,14 +90,24 @@ export function buildSeries(
8590
timeZone: string
8691
},
8792
): {
88-
datasets: Array<DashboardSeriesDataset>
8993
series: Array<DashboardSeriesPoint>
9094
} {
9195
const { bucket, range, timeZone } = options
9296
const filteredEvents = filterEventsForRange(events, range, timeZone)
97+
.slice()
98+
.sort((left, right) => left.timestamp.localeCompare(right.timestamp))
99+
100+
if (filteredEvents.length === 0) {
101+
return {
102+
series: [],
103+
}
104+
}
105+
93106
const buckets = new Map<string, DashboardCounter>()
94-
const modelBuckets = new Map<string, Map<string, number>>()
95-
const modelTotals = new Map<string, number>()
107+
const modelBreakdowns = new Map<
108+
string,
109+
Map<string, DashboardSeriesBreakdownRow>
110+
>()
96111

97112
for (const event of filteredEvents) {
98113
const key = getBucketKey(
@@ -104,49 +119,82 @@ export function buildSeries(
104119
buckets.set(key, counter)
105120

106121
const model = event.baseModel ?? "unknown"
107-
const bucketTotals = modelBuckets.get(model) ?? new Map<string, number>()
122+
const bucketBreakdowns =
123+
modelBreakdowns.get(key) ?? new Map<string, DashboardSeriesBreakdownRow>()
124+
const modelCounter: DashboardSeriesBreakdownRow = bucketBreakdowns.get(
125+
model,
126+
) ?? {
127+
...createCounter(),
128+
model,
129+
}
108130

109-
bucketTotals.set(key, (bucketTotals.get(key) ?? 0) + event.totalTokens)
110-
modelBuckets.set(model, bucketTotals)
111-
modelTotals.set(model, (modelTotals.get(model) ?? 0) + event.totalTokens)
131+
addEventToCounter(modelCounter, event)
132+
bucketBreakdowns.set(model, modelCounter)
133+
modelBreakdowns.set(key, bucketBreakdowns)
112134
}
113135

114-
const keys = Array.from(buckets.keys()).sort((left, right) =>
115-
left.localeCompare(right),
116-
)
136+
const keys = buildSeriesKeys(filteredEvents, {
137+
bucket,
138+
range,
139+
timeZone,
140+
})
117141
const series = keys.map((key) => {
118142
const counter = buckets.get(key) ?? createCounter()
143+
const bucketBreakdowns =
144+
modelBreakdowns.get(key) ?? new Map<string, DashboardSeriesBreakdownRow>()
145+
const breakdown = Array.from(bucketBreakdowns.values()).sort(
146+
(left, right) => {
147+
if (left.totalTokens === right.totalTokens) {
148+
return left.model.localeCompare(right.model)
149+
}
150+
151+
return right.totalTokens - left.totalTokens
152+
},
153+
)
119154

120155
return {
156+
breakdown,
121157
key,
122158
requests: counter.requests,
123159
inputTokens: counter.inputTokens,
124160
outputTokens: counter.outputTokens,
125161
totalTokens: counter.totalTokens,
126162
}
127163
})
128-
const datasets = Array.from(modelBuckets.entries())
129-
.sort(([leftModel], [rightModel]) => {
130-
const leftTotal = modelTotals.get(leftModel) ?? 0
131-
const rightTotal = modelTotals.get(rightModel) ?? 0
132-
133-
if (leftTotal === rightTotal) {
134-
return leftModel.localeCompare(rightModel)
135-
}
136-
137-
return rightTotal - leftTotal
138-
})
139-
.map(([model, totalsByBucket]) => ({
140-
model,
141-
totals: keys.map((key) => totalsByBucket.get(key) ?? 0),
142-
}))
143164

144165
return {
145-
datasets,
146166
series,
147167
}
148168
}
149169

170+
function buildSeriesKeys(
171+
events: Array<TelemetryEvent>,
172+
options: {
173+
bucket: DashboardBucket
174+
range: DashboardRange
175+
timeZone: string
176+
},
177+
): Array<string> {
178+
const bounds = getSeriesBounds(events, options)
179+
180+
if (!bounds) {
181+
return []
182+
}
183+
184+
const keys: Array<string> = []
185+
const end = getBucketStartParts(bounds.end, options.bucket)
186+
187+
for (
188+
let cursor = getBucketStartParts(bounds.start, options.bucket);
189+
compareZonedParts(cursor, end) <= 0;
190+
cursor = incrementBucketParts(cursor, options.bucket)
191+
) {
192+
keys.push(getBucketKey(cursor, options.bucket))
193+
}
194+
195+
return keys
196+
}
197+
150198
export function buildModelBreakdown(
151199
events: Array<TelemetryEvent>,
152200
range: DashboardRange,
@@ -242,6 +290,179 @@ function getBucketKey(parts: ZonedParts, bucket: DashboardBucket): string {
242290
}
243291
}
244292

293+
function getSeriesBounds(
294+
events: Array<TelemetryEvent>,
295+
options: {
296+
bucket: DashboardBucket
297+
range: DashboardRange
298+
timeZone: string
299+
},
300+
): SeriesBounds | undefined {
301+
if (events.length === 0) {
302+
return undefined
303+
}
304+
305+
if (options.range === "total") {
306+
const firstEvent = events[0]
307+
const lastEvent = events.at(-1) ?? firstEvent
308+
309+
return {
310+
end: getZonedParts(new Date(lastEvent.timestamp), options.timeZone),
311+
start: getZonedParts(new Date(firstEvent.timestamp), options.timeZone),
312+
}
313+
}
314+
315+
const current = getZonedParts(new Date(), options.timeZone)
316+
317+
return {
318+
end: current,
319+
start: getRangeStartParts(current, options.range),
320+
}
321+
}
322+
323+
function getRangeStartParts(
324+
current: ZonedParts,
325+
range: DashboardRange,
326+
): ZonedParts {
327+
switch (range) {
328+
case "current_hour": {
329+
return current
330+
}
331+
case "today": {
332+
return {
333+
...current,
334+
hour: 0,
335+
}
336+
}
337+
case "week_to_date": {
338+
return getIsoWeekStartParts(current)
339+
}
340+
case "month_to_date": {
341+
return {
342+
day: 1,
343+
hour: 0,
344+
month: current.month,
345+
year: current.year,
346+
}
347+
}
348+
default: {
349+
return current
350+
}
351+
}
352+
}
353+
354+
function getBucketStartParts(
355+
parts: ZonedParts,
356+
bucket: DashboardBucket,
357+
): ZonedParts {
358+
switch (bucket) {
359+
case "hour": {
360+
return parts
361+
}
362+
case "day": {
363+
return {
364+
...parts,
365+
hour: 0,
366+
}
367+
}
368+
case "week": {
369+
return getIsoWeekStartParts(parts)
370+
}
371+
case "month": {
372+
return {
373+
day: 1,
374+
hour: 0,
375+
month: parts.month,
376+
year: parts.year,
377+
}
378+
}
379+
default: {
380+
return parts
381+
}
382+
}
383+
}
384+
385+
function incrementBucketParts(
386+
parts: ZonedParts,
387+
bucket: DashboardBucket,
388+
): ZonedParts {
389+
switch (bucket) {
390+
case "hour": {
391+
return addHours(parts, 1)
392+
}
393+
case "day": {
394+
return addDays(parts, 1)
395+
}
396+
case "week": {
397+
return addDays(parts, 7)
398+
}
399+
case "month": {
400+
return addMonths(parts, 1)
401+
}
402+
default: {
403+
return addDays(parts, 1)
404+
}
405+
}
406+
}
407+
408+
function getIsoWeekStartParts(parts: ZonedParts): ZonedParts {
409+
const date = createUtcCalendarDate({
410+
...parts,
411+
hour: 0,
412+
})
413+
const weekday = date.getUTCDay() === 0 ? 7 : date.getUTCDay()
414+
415+
date.setUTCDate(date.getUTCDate() + 1 - weekday)
416+
417+
return getUtcCalendarParts(date)
418+
}
419+
420+
function addHours(parts: ZonedParts, amount: number): ZonedParts {
421+
const date = createUtcCalendarDate(parts)
422+
423+
date.setUTCHours(date.getUTCHours() + amount)
424+
425+
return getUtcCalendarParts(date)
426+
}
427+
428+
function addDays(parts: ZonedParts, amount: number): ZonedParts {
429+
const date = createUtcCalendarDate(parts)
430+
431+
date.setUTCDate(date.getUTCDate() + amount)
432+
433+
return getUtcCalendarParts(date)
434+
}
435+
436+
function addMonths(parts: ZonedParts, amount: number): ZonedParts {
437+
const date = createUtcCalendarDate(parts)
438+
439+
date.setUTCMonth(date.getUTCMonth() + amount, 1)
440+
441+
return getUtcCalendarParts(date)
442+
}
443+
444+
function compareZonedParts(left: ZonedParts, right: ZonedParts): number {
445+
return (
446+
left.year - right.year
447+
|| left.month - right.month
448+
|| left.day - right.day
449+
|| left.hour - right.hour
450+
)
451+
}
452+
453+
function createUtcCalendarDate(parts: ZonedParts): Date {
454+
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour))
455+
}
456+
457+
function getUtcCalendarParts(date: Date): ZonedParts {
458+
return {
459+
day: date.getUTCDate(),
460+
hour: date.getUTCHours(),
461+
month: date.getUTCMonth() + 1,
462+
year: date.getUTCFullYear(),
463+
}
464+
}
465+
245466
function getFormatter(timeZone: string): Intl.DateTimeFormat {
246467
const cached = formatterCache.get(timeZone)
247468
if (cached) {

src/lib/dashboard-store-types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,12 @@ export interface DashboardCounter {
8080
export type DashboardRangeCounters = Record<DashboardRange, DashboardCounter>
8181

8282
export interface DashboardSeriesPoint extends DashboardCounter {
83+
breakdown: Array<DashboardSeriesBreakdownRow>
8384
key: string
8485
}
8586

86-
export interface DashboardSeriesDataset {
87+
export interface DashboardSeriesBreakdownRow extends DashboardCounter {
8788
model: string
88-
totals: Array<number>
8989
}
9090

9191
export interface DashboardModelBreakdownRow extends DashboardCounter {

0 commit comments

Comments
 (0)