Skip to content
Merged
28 changes: 28 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
version: 2

updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: friday
time: "13:00"
timezone: Europe/Berlin
groups:
minor-and-patch:
update-types:
- minor
- patch

- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: friday
time: "13:00"
timezone: Europe/Berlin
groups:
minor-and-patch:
update-types:
- minor
- patch
21 changes: 21 additions & 0 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Dependabot Auto-Merge

on: pull_request

permissions:
contents: write
pull-requests: write

jobs:
auto-merge:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- uses: dependabot/fetch-metadata@v2
id: metadata

- if: steps.metadata.outputs.update-type != 'version-update:semver-major'
run: gh pr merge "$PR_URL" --auto --squash
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 3 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { defineConfig } from "@playwright/test"
import { tmpdir } from "node:os"
import { join } from "node:path"

const testDbPath = "/var/folders/p2/0gbt1nps4m1_t9np42sx_kl00000gn/T/opencode/opencode-usage-stats-e2e.db"
const testDbPath = join(tmpdir(), "opencode", "opencode-usage-stats-e2e.db")
const port = 43434

export default defineConfig({
Expand Down
125 changes: 112 additions & 13 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ interface ModeStats {
provider_id: string | null;
}

function getStats(repos: Repos): SessionStats[] {
const rootSessions = repos.sessions.getRootSessions();
function getStats(repos: Repos, directory?: string): SessionStats[] {
const rootSessions = repos.sessions.getRootSessions(directory ?? undefined);
const childSessions = repos.sessions.getChildSessions();
const agentCalls = repos.toolCalls.getAgentCalls();
const modeRows = repos.messages.getModeStats();
Expand Down Expand Up @@ -266,9 +266,21 @@ export function renderTokens(
return html;
}

function recencyClass(lastSeen: string | null | undefined): string {
if (!lastSeen) return "";
const iso = `${lastSeen.replace(" ", "T")}Z`;
const ageSec = (Date.now() - Date.parse(iso)) / 1000;
if (Number.isNaN(ageSec) || ageSec < 0) return "";
if (ageSec < 30) return "session-card--active";
if (ageSec < 120) return "session-card--recent";
if (ageSec < 600) return "session-card--idle";
return "";
}

function renderSessionCard(s: SessionStats): string {
const title = s.title || s.directory?.split("/").pop() || s.session_id;
const time = s.last_seen?.replace("T", " ").slice(0, 16) ?? "";
const recency = recencyClass(s.last_seen);

const agentRows = s.agents
.map((a) => {
Expand Down Expand Up @@ -327,7 +339,7 @@ function renderSessionCard(s: SessionStats): string {
.join("");

return `
<div class="session-card">
<div class="session-card${recency ? ` ${recency}` : ""}">
<div class="session-header">
<div class="session-title">${esc(title)}</div>
<div class="session-time">${time}</div>
Expand Down Expand Up @@ -587,6 +599,8 @@ function renderSessionsFragment(
daily: DailyTokens[],
dailyModel: DailyModelTokens[],
toolGroups: ToolGroupSummary[],
directories: string[],
selectedDir?: string,
): string {
const bar = renderStatsBar(summary);
const chart = renderDailyChart(daily);
Expand All @@ -607,9 +621,23 @@ function renderSessionsFragment(
? '<div class="empty">No sessions recorded yet.</div>'
: sessions.map(renderSessionCard).join("");

const dirOptions = directories
.map(
(d) =>
`<option value="${esc(d)}"${d === selectedDir ? " selected" : ""}>${esc(d)}</option>`,
)
.join("");
const dirDropdown = `
<div class="filter-bar">
<select id="dir-filter">
<option value="">All directories</option>
${dirOptions}
</select>
</div>`;

const rightPanel = `
<div class="right-panel">
<div class="right-panel-title">Sessions</div>
${dirDropdown}
${sessionCards}
</div>`;

Expand All @@ -622,6 +650,8 @@ function renderHTML(
daily: DailyTokens[],
dailyModel: DailyModelTokens[],
toolGroups: ToolGroupSummary[],
directories: string[],
selectedDir?: string,
): string {
return `<!DOCTYPE html>
<html lang="en">
Expand Down Expand Up @@ -687,6 +717,16 @@ function renderHTML(
transition: border-color 0.2s;
}
.session-card:hover { border-color: #388bfd; }
.session-card--active {
border-color: #56d364;
box-shadow: 0 0 0 1px #56d364, 0 0 12px rgba(86, 211, 100, 0.35);
}
.session-card--recent {
border-color: #3fb950;
}
.session-card--idle {
border-color: #1a4d1f;
}
.session-header {
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 4px;
Expand Down Expand Up @@ -920,14 +960,29 @@ function renderHTML(
border-left: 1px solid #21262d;
padding-left: 24px;
}
.right-panel-title {
font-size: 12px; color: #8b949e; text-transform: uppercase;
letter-spacing: 0.5px; margin-bottom: 12px;
}
@media (max-width: 1000px) {
.two-col { flex-direction: column; }
.left-panel { position: static; }
}
#dir-filter {
appearance: none;
-webkit-appearance: none;
background: #161b22 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5l3 3 3-3' fill='none' stroke='%238b949e' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 12px center;
color: #c9d1d9;
border: 1px solid #30363d;
border-radius: 6px;
padding: 8px 36px 8px 12px;
font-size: 13px;
font-family: inherit;
cursor: pointer;
width: 100%;
transition: border-color 0.2s, box-shadow 0.2s;
}
#dir-filter:hover { border-color: #484f58; }
#dir-filter:focus { outline: none; border-color: #58a6ff; box-shadow: 0 0 0 2px rgba(56,139,253,0.25); }
.filter-bar {
margin-bottom: 16px;
}
</style>
</head>
<body>
Expand All @@ -940,7 +995,7 @@ function renderHTML(
</div>
</div>
<div id="sessions">
${renderSessionsFragment(sessions, summary, daily, dailyModel, toolGroups)}
${renderSessionsFragment(sessions, summary, daily, dailyModel, toolGroups, directories, selectedDir)}
</div>
<script>
function collectOpenToolGroups() {
Expand All @@ -966,13 +1021,29 @@ function renderHTML(

}

let currentDirFilter = "";

function attachDirFilter() {
const el = document.getElementById("dir-filter");
if (!el) return;
el.value = currentDirFilter;
el.addEventListener("change", function() {
currentDirFilter = el.value;
refresh();
});
}

async function refresh() {
const start = performance.now();
const openToolGroups = collectOpenToolGroups();
const dirEl = document.getElementById("dir-filter");
if (dirEl) currentDirFilter = dirEl.value;
const params = currentDirFilter ? "?dir=" + encodeURIComponent(currentDirFilter) : "";
try {
const res = await fetch("/api/stats");
const res = await fetch("/api/stats" + params);
const html = await res.text();
document.getElementById("sessions").innerHTML = html;
attachDirFilter();
restoreOpenToolGroups(openToolGroups);
const duration = Math.round(performance.now() - start);
updateRefreshTiming(duration);
Expand All @@ -997,6 +1068,7 @@ function renderHTML(
}
}
setInterval(refresh, 5000);
attachDirFilter();
</script>
</body>
</html>`;
Expand Down Expand Up @@ -1083,7 +1155,9 @@ if (import.meta.main) {
}

try {
const sessions = getStats(readRepos);
const dirFilter = url.searchParams.get("dir") || undefined;
const directories = readRepos.sessions.getDistinctDirectories();
const sessions = getStats(readRepos, dirFilter);
const summary = getTokenSummary(readRepos);
const daily = getDailyTokens(readRepos);
const dailyModel = getDailyTokensByModel(readRepos);
Expand All @@ -1095,6 +1169,8 @@ if (import.meta.main) {
daily,
dailyModel,
toolGroups,
directories,
dirFilter,
),
{
headers: { "Content-Type": "text/html; charset=utf-8" },
Expand All @@ -1107,14 +1183,37 @@ if (import.meta.main) {
}
}

if (url.pathname === "/api/directories") {
try {
const dirs = readRepos.sessions.getDistinctDirectories();
return new Response(JSON.stringify(dirs), {
headers: { "Content-Type": "application/json; charset=utf-8" },
});
} catch (_e) {
return new Response("[]", {
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}
}

try {
const sessions = getStats(readRepos);
const dirFilter = url.searchParams.get("dir") || undefined;
const directories = readRepos.sessions.getDistinctDirectories();
const sessions = getStats(readRepos, dirFilter);
const summary = getTokenSummary(readRepos);
const daily = getDailyTokens(readRepos);
const dailyModel = getDailyTokensByModel(readRepos);
const toolGroups = getToolUsageSummary(readRepos);
return new Response(
renderHTML(sessions, summary, daily, dailyModel, toolGroups),
renderHTML(
sessions,
summary,
daily,
dailyModel,
toolGroups,
directories,
dirFilter,
),
{
headers: { "Content-Type": "text/html; charset=utf-8" },
},
Expand Down
3 changes: 2 additions & 1 deletion src/db/session/session-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export interface ChildSessionRow {
export interface SessionRepo {
upsert(data: SessionUpsertData): void;
upsertFull(data: SessionFullData): void;
getRootSessions(): RootSessionRow[];
getRootSessions(directory?: string): RootSessionRow[];
getChildSessions(): ChildSessionRow[];
getDistinctDirectories(): string[];
deleteOrphaned(cutoffDate: string): number;
}
30 changes: 23 additions & 7 deletions src/db/session/sqlite-session-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,8 @@ export class SqliteSessionRepo implements SessionRepo {
);
}

getRootSessions(): RootSessionRow[] {
return this.db
.prepare(`
getRootSessions(directory?: string): RootSessionRow[] {
const baseQuery = `
SELECT
s.session_id, s.title, s.directory, s.first_seen, s.last_seen,
COALESCE(SUM(m.input_tokens), 0) AS input_tokens,
Expand All @@ -56,13 +55,30 @@ export class SqliteSessionRepo implements SessionRepo {
COALESCE(SUM(m.cost), 0) AS cost
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.session_id
WHERE s.parent_id IS NULL
GROUP BY s.session_id
ORDER BY s.last_seen DESC
`)
WHERE s.parent_id IS NULL`;

if (directory) {
return this.db
.prepare(
`${baseQuery} AND s.directory = ? GROUP BY s.session_id ORDER BY s.last_seen DESC`,
)
.all(directory) as RootSessionRow[];
}

return this.db
.prepare(`${baseQuery} GROUP BY s.session_id ORDER BY s.last_seen DESC`)
.all() as RootSessionRow[];
}

getDistinctDirectories(): string[] {
return this.db
.prepare(
`SELECT DISTINCT directory FROM sessions WHERE directory IS NOT NULL ORDER BY directory`,
)
.all()
.map((row: any) => row.directory as string);
}

getChildSessions(): ChildSessionRow[] {
return this.db
.prepare(`
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ test("dashboard page renders seeded data", async ({ page }) => {
page.getByRole("heading", { name: "OpenCode Usage Stats" }),
).toBeVisible();
await expect(page.getByText("E2E Session")).toBeVisible();
await expect(page.getByText("/tmp/e2e-project")).toBeVisible();
await expect(
page.locator(".session-dir", { hasText: "/tmp/e2e-project" }),
).toBeVisible();
await expect(page.getByText("Tool Usage")).toBeVisible();
});

Expand Down
1 change: 1 addition & 0 deletions tests/unit/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ function createReposDouble(opts?: {
getRootSessions: () => [],
getChildSessions: () => [],
deleteOrphaned: () => 0,
getDistinctDirectories: () => [],
},
messages: {
upsert: (data) => messageUpsert.impl(data),
Expand Down
Loading
Loading