diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e7b4546 --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..13f05d1 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -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 }} diff --git a/playwright.config.ts b/playwright.config.ts index ff7028a..13e44cf 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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({ diff --git a/src/dashboard.ts b/src/dashboard.ts index a42725c..2588090 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -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(); @@ -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"; @@ -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); @@ -619,9 +621,23 @@ function renderSessionsFragment( ? '
No sessions recorded yet.
' : sessions.map(renderSessionCard).join(""); + const dirOptions = directories + .map( + (d) => + ``, + ) + .join(""); + const dirDropdown = ` +
+ +
`; + const rightPanel = `
-
Sessions
+ ${dirDropdown} ${sessionCards}
`; @@ -634,6 +650,8 @@ function renderHTML( daily: DailyTokens[], dailyModel: DailyModelTokens[], toolGroups: ToolGroupSummary[], + directories: string[], + selectedDir?: string, ): string { return ` @@ -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; + } @@ -962,7 +995,7 @@ function renderHTML(
- ${renderSessionsFragment(sessions, summary, daily, dailyModel, toolGroups)} + ${renderSessionsFragment(sessions, summary, daily, dailyModel, toolGroups, directories, selectedDir)}
`; @@ -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); @@ -1117,6 +1169,8 @@ if (import.meta.main) { daily, dailyModel, toolGroups, + directories, + dirFilter, ), { headers: { "Content-Type": "text/html; charset=utf-8" }, @@ -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" }, }, diff --git a/src/db/session/session-repo.ts b/src/db/session/session-repo.ts index e459eda..2507f06 100644 --- a/src/db/session/session-repo.ts +++ b/src/db/session/session-repo.ts @@ -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; } diff --git a/src/db/session/sqlite-session-repo.ts b/src/db/session/sqlite-session-repo.ts index b7485e3..dc33ae8 100644 --- a/src/db/session/sqlite-session-repo.ts +++ b/src/db/session/sqlite-session-repo.ts @@ -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, @@ -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(` diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 1f29b26..db39954 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -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(); }); diff --git a/tests/unit/handlers.test.ts b/tests/unit/handlers.test.ts index 1b2520d..30c5ee5 100644 --- a/tests/unit/handlers.test.ts +++ b/tests/unit/handlers.test.ts @@ -115,6 +115,7 @@ function createReposDouble(opts?: { getRootSessions: () => [], getChildSessions: () => [], deleteOrphaned: () => 0, + getDistinctDirectories: () => [], }, messages: { upsert: (data) => messageUpsert.impl(data), diff --git a/tests/unit/sqlite-session-repo.test.ts b/tests/unit/sqlite-session-repo.test.ts index e935657..a3129a1 100644 --- a/tests/unit/sqlite-session-repo.test.ts +++ b/tests/unit/sqlite-session-repo.test.ts @@ -55,4 +55,87 @@ describe("sqlite session repo", () => { repos.close(); }); + + test("getRootSessions filters by directory", () => { + const { dir, dbPath } = createTempDbPath("opencode-usage-stats-repos-"); + cleanupDirs.push(dir); + + const repos = createSqliteRepos(dbPath); + repos.sessions.upsertFull({ + sessionId: "s-a", + projectId: "p1", + parentId: null, + title: "Session A", + directory: "/projects/alpha", + }); + repos.sessions.upsertFull({ + sessionId: "s-b", + projectId: "p2", + parentId: null, + title: "Session B", + directory: "/projects/beta", + }); + + const filtered = repos.sessions.getRootSessions("/projects/alpha"); + expect(filtered.length).toBe(1); + expect(filtered[0]?.session_id).toBe("s-a"); + + const all = repos.sessions.getRootSessions(); + expect(all.length).toBe(2); + + repos.close(); + }); + + test("getDistinctDirectories returns unique sorted directories", () => { + const { dir, dbPath } = createTempDbPath("opencode-usage-stats-repos-"); + cleanupDirs.push(dir); + + const repos = createSqliteRepos(dbPath); + repos.sessions.upsertFull({ + sessionId: "s-1", + projectId: "p1", + parentId: null, + title: "A", + directory: "/projects/beta", + }); + repos.sessions.upsertFull({ + sessionId: "s-2", + projectId: "p1", + parentId: null, + title: "B", + directory: "/projects/alpha", + }); + repos.sessions.upsertFull({ + sessionId: "s-3", + projectId: "p1", + parentId: null, + title: "C", + directory: "/projects/beta", + }); + + const dirs = repos.sessions.getDistinctDirectories(); + expect(dirs).toEqual(["/projects/alpha", "/projects/beta"]); + + repos.close(); + }); + + test("getDistinctDirectories excludes null directories", () => { + const { dir, dbPath } = createTempDbPath("opencode-usage-stats-repos-"); + cleanupDirs.push(dir); + + const repos = createSqliteRepos(dbPath); + repos.sessions.upsert({ sessionId: "s-null", projectId: "p1" }); + repos.sessions.upsertFull({ + sessionId: "s-with-dir", + projectId: "p1", + parentId: null, + title: "Has dir", + directory: "/projects/gamma", + }); + + const dirs = repos.sessions.getDistinctDirectories(); + expect(dirs).toEqual(["/projects/gamma"]); + + repos.close(); + }); });