@webfueler/oc-dash 0.1.1 → 0.1.3

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,19 @@ 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
+ // Mission 026: one best-effort upstream stats call per project id behind
96
+ // the directory filter, all fired in parallel with the main call so the
97
+ // hero's latency is untouched. A failed call drops out of the list; the
98
+ // field is omitted when none succeed (never an error surface).
99
+ const projectCalls = project.map((id) => statsCall(oc, range, tz, id).catch(() => null));
81
100
  const raw = await statsCall(oc, range, tz);
82
101
  // The promise client returns SessionStatsInfo directly; a raw fetch
83
102
  // returns { data: SessionStatsInfo }. Normalize both.
@@ -88,6 +107,21 @@ app.get("/api/summary", async (c) => {
88
107
  !Array.isArray(data.models)) {
89
108
  throw new Error("unexpected /api/session/stats payload shape");
90
109
  }
110
+ const projectStats = [];
111
+ if (projectCalls.length > 0) {
112
+ const settled = await Promise.all(projectCalls);
113
+ for (let i = 0; i < settled.length; i++) {
114
+ const pRaw = settled[i];
115
+ const pData = pRaw?.data ?? pRaw;
116
+ if (pData &&
117
+ typeof pData === "object" &&
118
+ typeof pData.cost === "number") {
119
+ // The project id rides along so the client can verify the field
120
+ // belongs to the filter currently on screen before trusting it.
121
+ projectStats.push({ project: project[i], data: pData });
122
+ }
123
+ }
124
+ }
91
125
  // Additive, Today-only: trailing 7 days of activity so the chart can
92
126
  // render the in-range day next to muted context days. Fired in parallel
93
127
  // with the main stats call (mission 008, 007's F4) so Today pays one
@@ -109,6 +143,7 @@ app.get("/api/summary", async (c) => {
109
143
  timezone: tz,
110
144
  data,
111
145
  ...(contextActivity !== undefined ? { contextActivity } : {}),
146
+ ...(projectStats.length > 0 ? { projectStats } : {}),
112
147
  });
113
148
  }
114
149
  catch (err) {
@@ -37,3 +37,24 @@ 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
+ * Mission 026: the param now carries a comma-separated list of project ids
43
+ * (a directory can map to more than one id — the personal directory maps
44
+ * to two), and the parser returns them deduped, order-preserved. Trimmed
45
+ * and non-empty or it is not a project filter at all; hard-capped so a
46
+ * junk param cannot build an absurd upstream URL — anything unusable
47
+ * becomes undefined and the server makes no extra upstream call. Mission
48
+ * 028 (L1): the total cap is now 4096 chars — the old 200 predates id
49
+ * lists and would reject five real 40-hex ids plus separators. The
50
+ * upstream call itself stays best-effort in the handler.
51
+ */
52
+ export function parseProjectParam(value) {
53
+ const v = value?.trim();
54
+ if (!v || v.length > 4096)
55
+ return undefined;
56
+ const ids = [...new Set(v.split(",").map((s) => s.trim()).filter((s) => s.length > 0))];
57
+ if (ids.length === 0 || ids.length > 10)
58
+ return undefined;
59
+ return ids;
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webfueler/oc-dash",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Local dashboard for opencode2 session costs, token totals, and subagent spend",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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}