Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/dashboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@ export function createDashboard(deps: DashboardDeps) {
const dailyTokens = createDailyTokensService(readRepos);

const routes = [
createStatsRoute(sessionStats, dailyTokens, readRepos, maintenance),
createStatsRoute(sessionStats, dailyTokens, readRepos),
createDirectoriesRoute(sessionStats),
createPageRoute(sessionStats, dailyTokens, readRepos),
];

startServer(port, routes);

setInterval(() => maintenance.maybeAggregate(), 60_000);
setInterval(() => maintenance.maybeGC(), 60_000);
},
};
}
Expand Down
55 changes: 37 additions & 18 deletions src/dashboard/routes/stats-route.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,66 @@
import type { Repos } from "../../db/repos";
import type { DailyTokensService } from "../services/daily-tokens-service";
import type { MaintenanceService } from "../services/maintenance-service";
import type { SessionStatsService } from "../services/session-stats-service";
import { renderSessionsFragment } from "../templates/sessions-fragment";
import type { RouteHandler } from "./route-handler";

interface CacheEntry {
html: string;
expiry: number;
}

interface StatsRouteOptions {
cacheTtlMs?: number;
}

export function createStatsRoute(
sessionStats: SessionStatsService,
dailyTokens: DailyTokensService,
repos: Repos,
maintenance: MaintenanceService,
options?: StatsRouteOptions,
): RouteHandler {
const cache = new Map<string, CacheEntry>();
const CACHE_TTL_MS = options?.cacheTtlMs ?? 2000;

return {
match(url: URL): boolean {
return url.pathname === "/api/stats";
},

handle(_req: Request, url: URL): Response {
maintenance.maybeAggregate();
maintenance.maybeGC();

try {
const dirFilter = url.searchParams.get("dir") || undefined;
const cacheKey = dirFilter ?? "__all__";
const now = Date.now();
const cached = cache.get(cacheKey);

if (cached && now < cached.expiry) {
return new Response(cached.html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}

const directories = sessionStats.getDistinctDirectories();
const sessions = sessionStats.getSessionStats(dirFilter);
const summary = dailyTokens.getTokenSummary();
const daily = dailyTokens.getDailyTokens();
const dailyModel = dailyTokens.getDailyTokensByModel();
const toolGroups = repos.toolCalls.getToolUsageSummary();
return new Response(
renderSessionsFragment(
sessions,
summary,
daily,
dailyModel,
toolGroups,
directories,
dirFilter,
),
{
headers: { "Content-Type": "text/html; charset=utf-8" },
},
const html = renderSessionsFragment(
sessions,
summary,
daily,
dailyModel,
toolGroups,
directories,
dirFilter,
);

cache.set(cacheKey, { html, expiry: now + CACHE_TTL_MS });

return new Response(html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
} catch (e) {
return new Response(`<div class="empty">DB error: ${e}</div>`, {
headers: { "Content-Type": "text/html; charset=utf-8" },
Expand Down
59 changes: 35 additions & 24 deletions src/db/message/sqlite-message-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {

export class SqliteMessageRepo implements MessageRepo {
private readonly upsertMessageStmt;
private readonly tokenSummaryStmt;
private readonly todayTokensStmt;

constructor(private readonly db: Database) {
this.upsertMessageStmt = this.db.prepare(`
Expand All @@ -28,6 +30,28 @@ export class SqliteMessageRepo implements MessageRepo {
cost = excluded.cost,
agent = COALESCE(excluded.agent, messages.agent)
`);

this.tokenSummaryStmt = this.db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN timestamp >= date('now') AND timestamp < date('now', '+1 day')
THEN input_tokens + cache_read_tokens + output_tokens + reasoning_tokens END), 0) AS today,
COALESCE(SUM(CASE WHEN timestamp >= date('now', 'weekday 1', '-7 days')
THEN input_tokens + cache_read_tokens + output_tokens + reasoning_tokens END), 0) AS this_week,
COALESCE(SUM(CASE WHEN timestamp >= date('now', 'start of month')
THEN input_tokens + cache_read_tokens + output_tokens + reasoning_tokens END), 0) AS this_month,
COALESCE(SUM(CASE WHEN timestamp >= date('now', 'start of month', '-1 month')
AND timestamp < date('now', 'start of month')
THEN input_tokens + cache_read_tokens + output_tokens + reasoning_tokens END), 0) AS last_month
FROM messages
WHERE timestamp >= date('now', 'start of month', '-1 month')
`);

this.todayTokensStmt = this.db.prepare(`
SELECT ? AS date,
COALESCE(SUM(input_tokens + cache_read_tokens + output_tokens + reasoning_tokens), 0) AS total
FROM messages
WHERE timestamp >= ? AND timestamp < date(?, '+1 day')
`);
}

upsert(data: MessageData): void {
Expand Down Expand Up @@ -65,35 +89,22 @@ export class SqliteMessageRepo implements MessageRepo {
}

getTokenSummary(): TokenSummary {
const sum = (where: string): number => {
const row = this.db
.prepare(`
SELECT COALESCE(SUM(input_tokens + cache_read_tokens + output_tokens + reasoning_tokens), 0) AS total
FROM messages WHERE ${where}
`)
.get() as { total?: number };
return Number(row?.total ?? 0);
const row = this.tokenSummaryStmt.get() as {
today: number;
this_week: number;
this_month: number;
last_month: number;
};

return {
today: sum("date(timestamp) = date('now')"),
thisWeek: sum("timestamp >= date('now', 'weekday 1', '-7 days')"),
thisMonth: sum("timestamp >= date('now', 'start of month')"),
lastMonth: sum(
"timestamp >= date('now', 'start of month', '-1 month') AND timestamp < date('now', 'start of month')",
),
today: Number(row.today),
thisWeek: Number(row.this_week),
thisMonth: Number(row.this_month),
lastMonth: Number(row.last_month),
};
}

getTodayTokens(today: string): DailyTokens {
return this.db
.prepare(`
SELECT ? AS date,
COALESCE(SUM(input_tokens + cache_read_tokens + output_tokens + reasoning_tokens), 0) AS total
FROM messages
WHERE date(timestamp) = ?
`)
.get(today, today) as DailyTokens;
return this.todayTokensStmt.get(today, today, today) as DailyTokens;
}

getDailyTokensByModel(): DailyModelTokens[] {
Expand All @@ -112,7 +123,7 @@ export class SqliteMessageRepo implements MessageRepo {

deleteOlderThan(cutoffDate: string): number {
const result = this.db
.prepare("DELETE FROM messages WHERE date(timestamp) < ?")
.prepare("DELETE FROM messages WHERE timestamp < ?")
.run(cutoffDate);
return result.changes;
}
Expand Down
5 changes: 5 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export const MIGRATIONS: Migration[] = [
`CREATE INDEX IF NOT EXISTS idx_sessions_first_seen ON sessions(first_seen)`,
);
},
(db) => {
db.run(
`CREATE INDEX IF NOT EXISTS idx_sessions_last_seen ON sessions(last_seen)`,
);
},
];

export function getSchemaVersion(): number {
Expand Down
2 changes: 2 additions & 0 deletions src/db/sqlite-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ function setupConnection(db: Database, readonly: boolean): void {
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA synchronous = NORMAL");
}
db.run("PRAGMA mmap_size = 268435456"); // 256MB; OS only maps pages actually read
db.run("PRAGMA cache_size = -8000"); // 8MB page cache
}

function assertReadableVersion(db: Database): void {
Expand Down
Loading