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
2 changes: 2 additions & 0 deletions src/dashboard/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { join } from "node:path";
import type { Repos } from "../db/repos";
import { createSqliteRepos, gcOldData } from "../db/sqlite-repository";
import { createBudgetRoute } from "./routes/budget-route";
import { createDirectoriesRoute } from "./routes/directories-route";
import { createPageRoute } from "./routes/page-route";
import { createStatsRoute } from "./routes/stats-route";
Expand Down Expand Up @@ -35,6 +36,7 @@ export function createDashboard(deps: DashboardDeps) {
const dailyTokens = createDailyTokensService(readRepos);

const routes = [
createBudgetRoute(readRepos, () => deps.createWriteRepos(dbPath)),
createStatsRoute(sessionStats, dailyTokens, readRepos),
createDirectoriesRoute(sessionStats),
createPageRoute(sessionStats, dailyTokens, readRepos),
Expand Down
72 changes: 72 additions & 0 deletions src/dashboard/routes/budget-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { BudgetSettings } from "../../db/budget/budget-repo";
import type { Repos } from "../../db/repos";
import type { RouteHandler } from "./route-handler";

export function createBudgetRoute(
readRepos: Repos,
createWriteRepos: () => Repos,
): RouteHandler {
return {
match(url: URL): boolean {
return url.pathname === "/api/budget";
},

handle(req: Request, _url: URL): Response | Promise<Response> {
if (req.method === "GET") {
const settings = readRepos.budget.get();
if (!settings) return new Response(null, { status: 404 });
return new Response(JSON.stringify(settings), {
headers: { "Content-Type": "application/json" },
});
}

if (req.method === "POST") {
return req.json().then(
(body: unknown) => {
if (
typeof body !== "object" ||
body === null ||
typeof (body as Record<string, unknown>).amount !== "number" ||
typeof (body as Record<string, unknown>).workDays !== "number" ||
typeof (body as Record<string, unknown>).periodStartDay !==
"number"
) {
return new Response("Invalid body", { status: 400 });
}
const amount = (body as Record<string, unknown>).amount as number;
const workDays = (body as Record<string, unknown>)
.workDays as number;
const periodStartDay = (body as Record<string, unknown>)
.periodStartDay as number;

if (!(amount >= 0 && Number.isFinite(amount))) {
return new Response("Invalid values", { status: 400 });
}
if (!(workDays >= 0 && workDays <= 127)) {
return new Response("Invalid values", { status: 400 });
}
if (!(periodStartDay >= 1 && periodStartDay <= 28)) {
return new Response("Invalid values", { status: 400 });
}

const settings: BudgetSettings = {
amount,
workDays,
periodStartDay,
};
const writeRepos = createWriteRepos();
try {
writeRepos.budget.upsert(settings);
} finally {
writeRepos.close();
}
return new Response(null, { status: 200 });
},
() => new Response("Invalid JSON", { status: 400 }),
);
}

return new Response("Method Not Allowed", { status: 405 });
},
};
}
6 changes: 6 additions & 0 deletions src/dashboard/routes/page-route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Repos } from "../../db/repos";
import { calcBudgetStatus } from "../services/budget-service";
import type { DailyTokensService } from "../services/daily-tokens-service";
import type { SessionStatsService } from "../services/session-stats-service";
import { renderHTML } from "../templates/page-template";
Expand Down Expand Up @@ -26,6 +27,10 @@ export function createPageRoute(
const dailyCost = dailyTokens.getDailyCost();
const dailyModelCost = dailyTokens.getDailyModelCost();
const toolGroups = repos.toolCalls.getToolUsageSummary();
const budgetSettings = repos.budget.get();
const budgetStatus = budgetSettings
? calcBudgetStatus(budgetSettings, costSummary.thisMonth, new Date())
: null;
return new Response(
renderHTML(
sessions,
Expand All @@ -38,6 +43,7 @@ export function createPageRoute(
dirFilter,
dailyCost,
dailyModelCost,
budgetStatus,
),
{
headers: { "Content-Type": "text/html; charset=utf-8" },
Expand Down
2 changes: 1 addition & 1 deletion src/dashboard/routes/route-handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface RouteHandler {
match(url: URL): boolean;
handle(req: Request, url: URL): Response;
handle(req: Request, url: URL): Response | Promise<Response>;
}
6 changes: 6 additions & 0 deletions src/dashboard/routes/stats-route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Repos } from "../../db/repos";
import { calcBudgetStatus } from "../services/budget-service";
import type { DailyTokensService } from "../services/daily-tokens-service";
import type { SessionStatsService } from "../services/session-stats-service";
import { renderSessionsFragment } from "../templates/sessions-fragment";
Expand Down Expand Up @@ -49,6 +50,10 @@ export function createStatsRoute(
const dailyCost = dailyTokens.getDailyCost();
const dailyModelCost = dailyTokens.getDailyModelCost();
const toolGroups = repos.toolCalls.getToolUsageSummary();
const budgetSettings = repos.budget.get();
const budgetStatus = budgetSettings
? calcBudgetStatus(budgetSettings, costSummary.thisMonth, new Date())
: null;
const html = renderSessionsFragment(
sessions,
summary,
Expand All @@ -60,6 +65,7 @@ export function createStatsRoute(
dirFilter,
dailyCost,
dailyModelCost,
budgetStatus,
);

cache.set(cacheKey, { html, expiry: now + CACHE_TTL_MS });
Expand Down
60 changes: 60 additions & 0 deletions src/dashboard/services/budget-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { BudgetSettings } from "../../db/budget/budget-repo";

export interface BudgetStatus {
amount: number;
spent: number;
expected: number;
delta: number;
remaining: number;
remainingPct: number;
resetDate: Date;
workDaysTotal: number;
workDaysElapsed: number;
}

export function calcBudgetStatus(
settings: BudgetSettings,
spentThisMonth: number,
now: Date,
): BudgetStatus {
const year = now.getFullYear();
const month = now.getMonth();
const lastDay = new Date(year, month + 1, 0).getDate();
const startDay = Math.min(settings.periodStartDay, lastDay);
const todayDate = now.getDate();

let workDaysTotal = 0;
for (let d = startDay; d <= lastDay; d++) {
if ((settings.workDays >> new Date(year, month, d).getDay()) & 1)
workDaysTotal++;
}

let workDaysElapsed = 0;
const elapsedEnd = Math.min(todayDate, lastDay + 1);
for (let d = startDay; d < elapsedEnd; d++) {
if ((settings.workDays >> new Date(year, month, d).getDay()) & 1)
workDaysElapsed++;
}

const expected =
workDaysTotal > 0 ? settings.amount * (workDaysElapsed / workDaysTotal) : 0;
const delta = spentThisMonth - expected;
const remaining = settings.amount - spentThisMonth;
const remainingPct =
settings.amount > 0
? Math.max(0, Math.min(100, (remaining / settings.amount) * 100))
: 0;
const resetDate = new Date(year, month + 1, settings.periodStartDay);

return {
amount: settings.amount,
spent: spentThisMonth,
expected,
delta,
remaining,
remainingPct,
resetDate,
workDaysTotal,
workDaysElapsed,
};
}
121 changes: 119 additions & 2 deletions src/dashboard/templates/page-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
} from "../../db/message/message-repo";
import type { DailyTokens } from "../../db/shared-types";
import type { ToolGroupSummary } from "../../db/tool-call/tool-call-repo";
import type { BudgetStatus } from "../services/budget-service";
import type { SessionStats } from "../services/types";
import { renderSessionsFragment } from "./sessions-fragment";
import { DASHBOARD_CSS } from "./styles";
Expand Down Expand Up @@ -79,7 +80,88 @@ export const CLIENT_SCRIPT = `
}
}
setInterval(refresh, 5000);
attachDirFilter();`;
attachDirFilter();

function ordinal(n) {
const s = ['th','st','nd','rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
function updatePeriodHint() {
const day = parseInt(document.getElementById('budget-start-day').value, 10) || 1;
document.getElementById('period-hint').textContent =
day <= 1 ? 'ends last day of month' : 'ends ' + ordinal(day - 1) + ' of next month';
}

async function openBudgetModal() {
const modal = document.getElementById('budget-modal');
const toggles = modal.querySelectorAll('.day-toggle');

// Set defaults
let workDays = 62; // Mon-Fri
let periodStartDay = 1;
let amount = '';

try {
const res = await fetch('/api/budget');
if (res.ok) {
const data = await res.json();
workDays = data.workDays;
periodStartDay = data.periodStartDay;
amount = data.amount;
}
} catch {}

document.getElementById('budget-amount').value = amount;
document.getElementById('budget-start-day').value = periodStartDay;
updatePeriodHint();

toggles.forEach(btn => {
const bit = parseInt(btn.getAttribute('data-bit'), 10);
btn.classList.toggle('active', !!((workDays >> bit) & 1));
btn.onclick = () => btn.classList.toggle('active');
});

document.getElementById('budget-error').style.display = 'none';
document.getElementById('budget-error').textContent = '';
modal.showModal();
}

async function saveBudget() {
const modal = document.getElementById('budget-modal');
const amount = parseFloat(document.getElementById('budget-amount').value);
const periodStartDay = parseInt(document.getElementById('budget-start-day').value, 10);

if (isNaN(amount) || amount < 0) {
document.getElementById('budget-amount').focus();
return;
}

let workDays = 0;
modal.querySelectorAll('.day-toggle.active').forEach(btn => {
const bit = parseInt(btn.getAttribute('data-bit'), 10);
workDays |= (1 << bit);
});

const errorEl = document.getElementById('budget-error');
try {
const res = await fetch('/api/budget', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, workDays, periodStartDay }),
});
if (!res.ok) {
errorEl.textContent = 'Save failed. Please try again.';
errorEl.style.display = 'inline';
return;
}
modal.close();
refresh();
} catch {
errorEl.textContent = 'Save failed. Please try again.';
errorEl.style.display = 'inline';
}
}`;

export function renderHTML(
sessions: SessionStats[],
Expand All @@ -92,6 +174,7 @@ export function renderHTML(
selectedDir?: string,
dailyCost: DailyTokens[] = [],
dailyModelCost: DailyModelTokens[] = [],
budgetStatus: BudgetStatus | null = null,
): string {
return `<!DOCTYPE html>
<html lang="en">
Expand All @@ -102,16 +185,50 @@ export function renderHTML(
<style>${DASHBOARD_CSS}</style>
</head>
<body>
<dialog id="budget-modal">
<div class="modal-title">Budget Settings</div>
<div class="modal-field">
<label class="modal-label" for="budget-amount">Monthly Budget ($)</label>
<input class="modal-input" type="number" id="budget-amount" min="0" step="0.01" placeholder="100.00">
</div>
<div class="modal-field">
<label class="modal-label">Work Days</label>
<div class="day-toggles">
<button class="day-toggle" data-bit="1">Mo</button>
<button class="day-toggle" data-bit="2">Di</button>
<button class="day-toggle" data-bit="3">Mi</button>
<button class="day-toggle" data-bit="4">Do</button>
<button class="day-toggle" data-bit="5">Fr</button>
<button class="day-toggle" data-bit="6">Sa</button>
<button class="day-toggle" data-bit="0">So</button>
</div>
</div>
<div class="modal-field">
<label class="modal-label">Period</label>
<div class="period-row">
<span class="modal-muted">Starts day</span>
<input class="modal-input period-input" type="number" id="budget-start-day" min="1" max="28" value="1" oninput="updatePeriodHint()">
<span class="modal-muted">of month</span>
</div>
<div class="modal-hint" id="period-hint">ends last day of month</div>
</div>
<div class="modal-actions">
<span id="budget-error" style="color:#f0883e;font-size:12px;display:none"></span>
<button class="btn-cancel" onclick="document.getElementById('budget-modal').close()">Cancel</button>
<button class="btn-save" onclick="saveBudget()">Save</button>
</div>
</dialog>
<div class="header">
<h1>OpenCode Usage Stats</h1>
<div class="refresh-badge">
<div class="refresh-dot"></div>
<span>auto-refresh 5s</span>
<span id="refresh-timing" class="refresh-timing"></span>
<button class="gear-btn" onclick="openBudgetModal()" title="Budget settings"><svg width="13" height="13" viewBox="0 0 16 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="1" width="14" height="12" rx="2"/><path d="M1 5h14"/><rect x="3" y="7.5" width="4" height="2.5" rx="1" fill="currentColor" stroke="none"/></svg>Budget</button>
</div>
</div>
<div id="sessions">
${renderSessionsFragment(sessions, summary, costSummary, daily, dailyModel, toolGroups, directories, selectedDir, dailyCost, dailyModelCost)}
${renderSessionsFragment(sessions, summary, costSummary, daily, dailyModel, toolGroups, directories, selectedDir, dailyCost, dailyModelCost, budgetStatus)}
</div>
<script>${CLIENT_SCRIPT}</script>
</body>
Expand Down
6 changes: 5 additions & 1 deletion src/dashboard/templates/sessions-fragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
} from "../../db/message/message-repo";
import type { DailyTokens } from "../../db/shared-types";
import type { ToolGroupSummary } from "../../db/tool-call/tool-call-repo";
import type { BudgetStatus } from "../services/budget-service";
import type { SessionStats } from "../services/types";
import { renderDailyChart, renderDailyCostChart } from "./daily-chart";
import { esc } from "./formatters";
Expand All @@ -13,7 +14,7 @@ import {
renderDailyModelCostChart,
} from "./model-chart";
import { renderSessionCard } from "./session-card";
import { renderStatsBar } from "./stats-bar";
import { renderBudgetBar, renderStatsBar } from "./stats-bar";
import { renderToolUsage } from "./tool-usage";

export function renderSessionsFragment(
Expand All @@ -27,8 +28,10 @@ export function renderSessionsFragment(
selectedDir?: string,
dailyCost: DailyTokens[] = [],
dailyModelCost: DailyModelTokens[] = [],
budgetStatus: BudgetStatus | null = null,
): string {
const bar = renderStatsBar(summary, costSummary);
const budgetBar = renderBudgetBar(budgetStatus);
const chart = renderDailyChart(daily);
const costChart = renderDailyCostChart(dailyCost);
const modelChart = renderDailyModelChart(dailyModel);
Expand All @@ -38,6 +41,7 @@ export function renderSessionsFragment(
const leftPanel = `
<div class="left-panel">
${bar}
${budgetBar}
<hr class="section-divider">
${chart}
${costChart}
Expand Down
Loading