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
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
103 changes: 90 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 @@ -268,7 +268,7 @@ export function renderTokens(

function recencyClass(lastSeen: string | null | undefined): string {
if (!lastSeen) return "";
const iso = lastSeen.replace(" ", "T") + "Z";
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";
Expand Down Expand Up @@ -599,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 @@ -619,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 @@ -634,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 @@ -942,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 @@ -962,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 @@ -988,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 @@ -1019,6 +1068,7 @@ function renderHTML(
}
}
setInterval(refresh, 5000);
attachDirFilter();
</script>
</body>
</html>`;
Expand Down Expand Up @@ -1105,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 @@ -1117,6 +1169,8 @@ if (import.meta.main) {
daily,
dailyModel,
toolGroups,
directories,
dirFilter,
),
{
headers: { "Content-Type": "text/html; charset=utf-8" },
Expand All @@ -1129,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