@webfueler/oc-dash 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import { serveStatic } from "@hono/node-server/serve-static";
3
3
  import { Hono } from "hono";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { errorMessage, getOpencode, ocGetJson } from "./opencode.js";
6
- import { contextStatsRange, localTimezone, parseRangePreset, resolveRange } from "./ranges.js";
6
+ import { contextStatsRange, localTimezone, parseProjectParam, parseRangePreset, resolveRange } from "./ranges.js";
7
7
  import { walkSessions } from "./walk.js";
8
8
  const PAGE_LIMIT = 100;
9
9
  const PORT = Number(process.env.PORT) || 4021;
@@ -25,14 +25,20 @@ async function listPage(oc, cursor) {
25
25
  return (await ocGetJson(oc, `/api/session?${params}`));
26
26
  }
27
27
  }
28
- /** session.stats via the typed client with a raw-fetch fallback. */
29
- async function statsCall(oc, range, tz) {
28
+ /**
29
+ * session.stats via the typed client with a raw-fetch fallback.
30
+ * Mission 014 (PD Q4a): an optional project pass-through — the upstream
31
+ * param already exists (project id); only the per-project tier-2 call
32
+ * sends it, the hero's main stats call never does.
33
+ */
34
+ async function statsCall(oc, range, tz, project) {
30
35
  try {
31
36
  return await oc.client.session.stats({
32
37
  from: range.from,
33
38
  to: range.to,
34
39
  timezone: tz,
35
40
  tools: "summary",
41
+ ...(project ? { project } : {}),
36
42
  });
37
43
  }
38
44
  catch {
@@ -41,6 +47,8 @@ async function statsCall(oc, range, tz) {
41
47
  params.set("from", String(range.from));
42
48
  if (range.to != null)
43
49
  params.set("to", String(range.to));
50
+ if (project)
51
+ params.set("project", project);
44
52
  return await ocGetJson(oc, `/api/session/stats?${params}`);
45
53
  }
46
54
  }
@@ -76,8 +84,15 @@ app.get("/api/summary", async (c) => {
76
84
  }
77
85
  const range = resolveRange(preset);
78
86
  const tz = localTimezone();
87
+ // Mission 014 (PD Q4a): additive project param. When present, ONE extra
88
+ // best-effort upstream stats call with project=<id> feeds the filtered
89
+ // card's tier-2 tiles. Fired in parallel with the main call so the hero's
90
+ // latency is untouched; on failure it resolves to undefined and the field
91
+ // is omitted (the contextActivity pattern), never an error surface.
92
+ const project = parseProjectParam(c.req.query("project"));
79
93
  try {
80
94
  const oc = await getOpencode();
95
+ const projectCall = project ? statsCall(oc, range, tz, project).catch(() => undefined) : undefined;
81
96
  const raw = await statsCall(oc, range, tz);
82
97
  // The promise client returns SessionStatsInfo directly; a raw fetch
83
98
  // returns { data: SessionStatsInfo }. Normalize both.
@@ -88,6 +103,18 @@ app.get("/api/summary", async (c) => {
88
103
  !Array.isArray(data.models)) {
89
104
  throw new Error("unexpected /api/session/stats payload shape");
90
105
  }
106
+ let projectStats;
107
+ if (project && projectCall) {
108
+ const pRaw = await projectCall;
109
+ const pData = pRaw?.data ?? pRaw;
110
+ if (pData &&
111
+ typeof pData === "object" &&
112
+ typeof pData.cost === "number") {
113
+ // The project id rides along so the client can verify the field
114
+ // belongs to the filter currently on screen before trusting it.
115
+ projectStats = { project, data: pData };
116
+ }
117
+ }
91
118
  // Additive, Today-only: trailing 7 days of activity so the chart can
92
119
  // render the in-range day next to muted context days. Fired in parallel
93
120
  // with the main stats call (mission 008, 007's F4) so Today pays one
@@ -109,6 +136,7 @@ app.get("/api/summary", async (c) => {
109
136
  timezone: tz,
110
137
  data,
111
138
  ...(contextActivity !== undefined ? { contextActivity } : {}),
139
+ ...(projectStats !== undefined ? { projectStats } : {}),
112
140
  });
113
141
  }
114
142
  catch (err) {
@@ -37,3 +37,16 @@ export function localTimezone() {
37
37
  export function contextStatsRange(preset, now = new Date()) {
38
38
  return preset === "today" ? resolveRange("7d", now) : null;
39
39
  }
40
+ /**
41
+ * Mission 014 (PD Q4a): additive project pass-through for /api/summary.
42
+ * Trimmed and non-empty or it is not a project filter at all; hard-capped
43
+ * so a junk param cannot build an absurd upstream URL — anything unusable
44
+ * becomes undefined and the server makes no extra upstream call. The
45
+ * upstream call itself stays best-effort in the handler.
46
+ */
47
+ export function parseProjectParam(value) {
48
+ const v = value?.trim();
49
+ if (!v || v.length > 200)
50
+ return undefined;
51
+ return v;
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webfueler/oc-dash",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local dashboard for opencode2 session costs, token totals, and subagent spend",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@hono/node-server": "^2.1.1",
33
- "@opencode/client": "^0.0.0-beta-19296",
33
+ "@opencode/client": "0.0.0-beta-19296",
34
34
  "hono": "^4.13.7",
35
35
  "react": "^19.2.8",
36
36
  "react-dom": "^19.2.8"
@@ -1 +0,0 @@
1
- :root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--bg:#0d1117;--panel:#161b22;--panel2:#1c2128;--border:#30363d;--text:#e6edf3;--dim:#8b949e;--accent:#4493f8;--ok:#3fb950;--warn:#d29922;--bad:#f85149;--money:#7ee2a8}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}@media (prefers-color-scheme:light){:root{--bg:#f6f8fa;--panel:#fff;--panel2:#f6f8fa;--border:#d0d7de;--text:#1f2328;--dim:#656d76;--accent:#0969da;--ok:#1a7f37;--warn:#9a6700;--bad:#cf222e;--money:#1a7f37}}*{box-sizing:border-box}body{background:var(--bg);color:var(--text);margin:0;font:14px/1.45 ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif}.app{max-width:1080px;margin:0 auto;padding:16px 20px 48px}.topbar{flex-wrap:wrap;align-items:center;gap:16px;margin-bottom:12px;display:flex}h1{margin:0;font-size:20px}h2{color:var(--text);margin:0 0 8px;font-size:15px}.dim{color:var(--dim)}.updated{margin-left:auto;font-size:12px}.tabs{border:1px solid var(--border);border-radius:8px;display:inline-flex;overflow:hidden}.tab{color:var(--dim);cursor:pointer;font:inherit;background:0 0;border:0;padding:6px 14px}.tab+.tab{border-left:1px solid var(--border)}.tab.active{background:var(--accent);color:#fff}.health{border:1px solid var(--border);color:var(--dim);border-radius:8px;align-items:center;gap:8px;margin-bottom:12px;padding:6px 12px;font-size:12.5px;display:flex}.health.ok .dot{background:var(--ok)}.health.bad .dot{background:var(--bad)}.dot{border-radius:50%;flex:none;width:8px;height:8px}.error{border:1px solid var(--bad);color:var(--bad);border-radius:8px;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;padding:8px 12px;display:flex}button{font:inherit}.error button{color:var(--bad);border:1px solid var(--bad);cursor:pointer;background:0 0;border-radius:6px;padding:2px 10px}.loading{color:var(--dim);padding:24px 0}.badge{border:1px solid var(--border);color:var(--dim);border-radius:999px;padding:1px 8px;font-size:11px;display:inline-block}.badge.warn{border-color:var(--warn);color:var(--warn)}.badge.succeeded{border-color:var(--ok);color:var(--ok)}.badge.failed,.badge.interrupted{border-color:var(--bad);color:var(--bad)}.degraded-note{margin-bottom:12px;padding:6px 12px;display:block}.kpis{margin-bottom:20px}.hero-kpi{background:linear-gradient(180deg, var(--panel2), var(--panel));border:1px solid var(--border);border-radius:12px;flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:18px;padding:18px 20px;display:flex}.hero-label{color:var(--dim);letter-spacing:.04em;text-transform:uppercase;font-size:12px}.hero-value{color:var(--money);font-variant-numeric:tabular-nums;margin-top:4px;font-size:42px;font-weight:700;line-height:1.05}.hero-sub{color:var(--dim);margin-top:6px;font-size:12.5px}.hero-kpi .badge.warn{margin-top:10px;display:inline-block}.statline{flex-wrap:wrap;gap:8px;display:flex}.stat{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:7px 12px}.stat b{font-variant-numeric:tabular-nums;font-size:13.5px;font-weight:600;display:block}.stat span{color:var(--dim);letter-spacing:.04em;text-transform:uppercase;font-size:10.5px}section{margin-bottom:24px}.section-head{justify-content:space-between;align-items:center;gap:12px;margin-bottom:8px;display:flex}.section-head h2{margin:0}.section-tools{flex-wrap:wrap;align-items:center;gap:12px;display:flex}.tree-controls{gap:6px;display:flex}.tree-controls button{background:var(--panel);color:var(--text);border:1px solid var(--border);cursor:pointer;border-radius:6px;padding:3px 10px;font-size:12px}.tree-controls button:hover{border-color:var(--dim)}.filter select{font:inherit;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:3px 8px}table{border-collapse:collapse;background:var(--panel);border:1px solid var(--border);border-radius:10px;width:100%;overflow:hidden}th,td{text-align:left;border-top:1px solid var(--border);white-space:nowrap;text-overflow:ellipsis;max-width:340px;padding:6px 10px;overflow:hidden}th{text-transform:uppercase;letter-spacing:.04em;color:var(--dim);border-top:0;font-size:11.5px;font-weight:600}td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}.root-row td{background:var(--panel2)}.root-row td.title{font-weight:600}.togglable{cursor:pointer}.togglable:hover td{filter:brightness(1.08)}tr:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.child-row td{background:color-mix(in srgb, var(--panel) 88%, var(--text) 12%)}.child-row td.title{color:var(--dim);font-size:12.5px;position:relative}.child-row td.title:before{content:"";left:calc(var(--indent,34px) - 12px);background:var(--border);width:1px;position:absolute;top:0;bottom:0}.title{white-space:normal;min-width:200px}.desc{color:var(--dim);margin-top:2px;font-size:11.5px;font-weight:400}.money{color:var(--money);font-weight:600}.model{color:var(--dim);font-size:12.5px}.mchip{border:1px solid var(--border);color:var(--dim);text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;border-radius:6px;max-width:100%;padding:1px 7px;font-size:11px;display:inline-block;overflow:hidden}.check{color:var(--dim)}.unpriced{margin-left:8px}.empty{color:var(--dim);background:var(--panel);border:1px dashed var(--border);border-radius:10px;padding:14px}.activity{background:var(--panel);border:1px solid var(--border);border-radius:10px;width:100%}.activity .bar{fill:var(--accent)}.activity .bar.muted,.activity .bar.zero{fill:var(--border)}.activity .grid{stroke:var(--border);stroke-dasharray:3 4}.activity .tick{fill:var(--dim);font-size:11px}.footnotes{border-top:1px solid var(--border);color:var(--dim);padding-top:12px;font-size:12.5px}.footnotes ol{margin:0;padding-left:20px}.footnotes li{margin-bottom:4px}