opencode-dashboard-client 0.1.1 → 0.3.0

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.
package/README.md CHANGED
@@ -4,44 +4,60 @@ Front end for [opencode-dashboard](https://github.com/GCS-ZHN/opencode-dashboard
4
4
  view of opencode token usage (server → project → session → model), with input / output / reasoning /
5
5
  cache tokens and cost at every level.
6
6
 
7
- The npm package ships the **built SPA** plus the **front-end server**, which is the single entry point
8
- for browsers: it serves the SPA and proxies `/api/s/{i}/*` to the configured aggregation backends.
9
- Real backends stay hidden from the client's network.
7
+ Installs as a CLI (`opencode-dashboard`): its `serve` command runs the **front-end server**, the single
8
+ entry point for browsers it serves the built SPA and proxies `/api/s/{i}/*` to the configured
9
+ aggregation backends. Real backends stay hidden from the client's network.
10
10
 
11
- ## Install
11
+ ## Install (npm)
12
12
 
13
13
  ```bash
14
- npm install opencode-dashboard-client
14
+ npm install -g opencode-dashboard-client
15
15
  ```
16
16
 
17
- Requires Node ≥ 22 and [bun](https://bun.sh) (the front-end server runs on bun).
17
+ Requires Node ≥ 22. Works anywhere with `npx opencode-dashboard ...` too.
18
18
 
19
- ## Usage
19
+ ## Configure
20
20
 
21
- 1. Point the front-end server at your backends edit `src/config.ts`:
22
- ```ts
23
- export const servers = [
24
- { name: "main", url: "http://127.0.0.1:8791" },
25
- { name: "backup", url: "http://127.0.0.1:8792" },
26
- ];
27
- ```
28
- 2. Run the front-end server (serves the built SPA + proxies `/api/s/{i}/*`):
29
- ```bash
30
- bun server.ts # PORT env, default 5173 open http://localhost:5173/
31
- ```
21
+ Write the config interactively to the standard XDG path
22
+ (`$XDG_CONFIG_HOME/opencode-dashboard/config.yaml`, `~/.config/...` by default):
23
+
24
+ ```bash
25
+ opencode-dashboard configure
26
+ ```
27
+
28
+ Add one backend per prompt (`name` + `url`, e.g. `main` / `http://127.0.0.1:8791`), then set the
29
+ front-end port, host, and `ui.sessionPage`. Re-run it any time to preview and extend the existing
30
+ config. You can also hand-edit the file — the SPA fetches the server list + UI options from
31
+ `GET /api/config` at startup, so there is nothing to rebuild after a change.
32
+
33
+ ## Run
34
+
35
+ ```bash
36
+ opencode-dashboard serve [--port N] [--host H]
37
+ ```
38
+
39
+ Serves the SPA on the configured host/port (default `0.0.0.0:5173`) and proxies `/api/s/{i}/*` to the
40
+ configured backends. CLI flags beat env vars (`PORT`, `HOST`), which beat the config file. Open
41
+ http://localhost:5173/.
32
42
 
33
43
  Each configured backend is one tab (with an **Overall** tab showing all of them in a grid). Sessions
34
44
  render as parent/child trees — subagents are collapsed by default and a parent's totals include all
35
45
  descendants. Live updates come over SSE, so the page refreshes itself as opencode writes.
36
46
 
37
- ## Development
47
+ ## From source (development)
38
48
 
39
49
  ```bash
40
- bun install # install dev deps (typescript, vite)
41
- bun run dev # Vite dev server (same /api/s/{i} proxy from config.ts) → :5173
42
- bun run build # typecheck (tsc) + bundle (vite)
43
- bun mock-server.ts # serve canned API data for frontend-only work
50
+ git clone https://github.com/GCS-ZHN/opencode-dashboard
51
+ cd opencode-dashboard/client
52
+ bun install # install dev deps (typescript, vite, yaml)
53
+ bun run dev # Vite dev server (reads repo dashboard.yaml) → :5173
54
+ bun run build # typecheck (tsc) + bundle (vite + CLI)
55
+ bun run serve # run the built CLI: node dist-cli/cli.mjs serve
56
+ bun mock-server.ts # serve canned API data for frontend-only work (PORT env, default 8791)
44
57
  ```
45
58
 
59
+ During dev the repo `dashboard.yaml` is used; the published package never ships it — runtime
60
+ configuration lives in the XDG file written by `opencode-dashboard configure`.
61
+
46
62
  Full project docs (architecture, API contract, publishing) live in the repo
47
63
  [README](https://github.com/GCS-ZHN/opencode-dashboard).
@@ -0,0 +1 @@
1
+ (function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))i(r);new MutationObserver(r=>{for(const d of r)if(d.type==="childList")for(const l of d.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&i(l)}).observe(document,{childList:!0,subtree:!0});function n(r){const d={};return r.integrity&&(d.integrity=r.integrity),r.referrerPolicy&&(d.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?d.credentials="include":r.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function i(r){if(r.ep)return;r.ep=!0;const d=n(r);fetch(r.href,d)}})();async function v(s,e){const n=await fetch(s+e);if(!n.ok){let i=`${n.status} ${n.statusText}`;try{const r=await n.json();r.detail&&(i=String(r.detail))}catch{}throw new Error(i)}return n.json()}function C(s){return`/api/s/${s}`}const b={config:()=>v("/","api/config"),overview:s=>v(C(s),"/overview"),projects:s=>v(C(s),"/projects"),project:(s,e)=>v(C(s),`/projects/${encodeURIComponent(e)}`),session:(s,e)=>v(C(s),`/sessions/${encodeURIComponent(e)}`)},I=[["input","seg-in"],["output","seg-out"],["reasoning","seg-rea"],["cacheRead","seg-cr"],["cacheWrite","seg-cw"]];function t(s,e,n){const i=document.createElement(s);return e&&(i.className=e),n!==void 0&&(i.textContent=n),i}function E(s){return s>=1e6?(s/1e6).toFixed(1).replace(/\.0$/,"")+"M":s>=1e3?(s/1e3).toFixed(1).replace(/\.0$/,"")+"K":String(s)}function w(s){if(!s)return"$0";if(s<1e-4)return"< $0.0001";const e=s.toFixed(4).replace(/0+$/,"").replace(/\.$/,"");return"$"+(e===""?"0":e)}function N(s){const e=Math.max(0,Math.round((Date.now()-s)/1e3));return e<60?`${e}s ago`:e<3600?`${Math.round(e/60)}m ago`:e<86400?`${Math.round(e/3600)}h ago`:`${Math.round(e/86400)}d ago`}function O(s){const e=t("div","bar");if(!s.total)return e.classList.add("bar-empty"),e.title="0 tokens",e;for(const[n,i]of I){const r=s[n]/s.total*100;if(r<=0)continue;const d=t("div",`seg ${i}`);d.style.width=`${r}%`,d.title=`${n}: ${E(s[n])}`,e.appendChild(d)}return e}function y(s,e=!1){const n=t("div","tcell"+(e?" hero":"")),i=t("div","bar-wrap");return i.appendChild(O(s)),n.append(i,t("span","num",E(s.total))),n}let $=30,R=[],j=[];const L=["#58a6ff","#3fb950","#a371f7","#d29922","#f85149"];function D(s){if(!s.data)return null;try{return JSON.parse(String(s.data))}catch{return null}}function A(s,e){return{input:s.input+e.input,output:s.output+e.output,reasoning:s.reasoning+e.reasoning,cacheRead:s.cacheRead+e.cacheRead,cacheWrite:s.cacheWrite+e.cacheWrite,total:s.total+e.total}}function P(s,e){s.tabIndex=0,s.addEventListener("click",e),s.addEventListener("keydown",n=>{(n.key==="Enter"||n.key===" ")&&(n.preventDefault(),e())})}class U{constructor(e){this.idx=e,this.base=R[e]?.url??"",this.root=t("section","server"),this.root.dataset.url=this.base}idx;overview=null;projects=[];projectDetails=new Map;sessionDetails=new Map;expandedProjects=new Set;expandedSessions=new Set;sessionLimits=new Map;error=null;es=null;liveRef=null;updRef=null;root;base;start(){this.connect(),this.refresh()}touch(){this.updRef&&this.overview&&(this.updRef.textContent=`updated ${N(this.overview.updatedAt)}`)}connect(){this.es?.close();const e=new EventSource(`${C(this.idx)}/stream`);this.es=e;const n=i=>this.onUpdate(D(i));e.addEventListener("update",n),e.onmessage=n,e.onopen=()=>this.setLive(!0),e.onerror=()=>this.setLive(!1)}setLive(e){this.liveRef&&(this.liveRef.textContent=e?"● live":"reconnecting…",this.liveRef.classList.toggle("on",e))}async refresh(){try{const[e,n]=await Promise.all([b.overview(this.idx),b.projects(this.idx)]);this.overview=e,this.projects=n,this.error=null}catch(e){this.error=e instanceof Error?e.message:String(e)}this.render()}async loadProject(e){if(this.expandedProjects.has(e)){try{this.projectDetails.set(e,await b.project(this.idx,e))}catch{this.expandedProjects.delete(e)}this.render()}}async loadSession(e){if(this.expandedSessions.has(e)){try{this.sessionDetails.set(e,await b.session(this.idx,e))}catch{this.expandedSessions.delete(e)}this.render()}}onUpdate(e){if(!e||e.type!=="updated")return;const n=[this.refresh()];for(const i of this.expandedProjects)n.push(this.loadProject(i));for(const i of this.expandedSessions)n.push(this.loadSession(i));Promise.all(n)}render(){if(this.root.replaceChildren(),this.error&&!this.overview){const h=t("div","error");h.append(t("h3","",this.base),t("p","",this.error));const f=t("button","","Retry");f.addEventListener("click",()=>{this.refresh()}),h.appendChild(f),this.root.appendChild(h);return}const e=this.overview,n=t("header","srv-head");n.append(t("h2","srv-host",e?e.host:this.base),t("span","srv-ver",e?`opencode ${e.opencodeVersion}`:""),t("span","srv-url",this.base));const i=t("span","live");this.liveRef=i,this.setLive(this.es?.readyState===EventSource.OPEN);const r=t("span","srv-upd");this.updRef=r,this.touch(),n.append(i,r),this.root.appendChild(n);const d=t("div","stats"),l=k("sessions",e?M(e.mainSessionCount,e.sessionCount):"…");l.title=e?`${e.mainSessionCount} main sessions · ${e.sessionCount} total (incl. subagents)`:"",d.append(l,k("projects",e?String(e.projectCount):"…"));const o=t("div","stat stat-tokens");if(o.appendChild(t("span","lbl","tokens")),e?o.appendChild(y(e.tokens,!0)):o.appendChild(t("div","skeleton","")),d.appendChild(o),d.appendChild(F(e?e.cost:null)),this.root.appendChild(d),this.error&&this.root.appendChild(t("div","error error-bar",`refresh failed: ${this.error}`)),this.root.appendChild(t("h3","sec-head","Projects")),!e){const h=t("table","tbl"),f=t("tbody");for(let S=0;S<3;S++){const g=t("tr","skeleton-row"),x=t("td");x.colSpan=4,x.appendChild(t("div","skeleton")),g.appendChild(x),f.appendChild(g)}h.appendChild(f),this.root.appendChild(h);return}if(this.projects.length===0){this.root.appendChild(t("div","empty","No projects found on this host."));return}const c=t("table","tbl"),p=t("thead"),a=t("tr");for(const h of["Project","Sessions","Tokens","Cost"])a.appendChild(t("th","",h));p.appendChild(a),c.appendChild(p);const u=t("tbody");for(const h of this.projects)this.renderProject(u,h);c.appendChild(u),this.root.appendChild(c)}renderProject(e,n){const i=this.expandedProjects.has(n.id),r=t("td","p-name");r.append(t("span","tgl",i?"▾":"▸"),t("span","",n.name));const d=t("td");d.appendChild(y(n.tokens));const l=t("tr","prow"+(i?" open":""));if(l.append(r,W(n),d,t("td","cost",w(n.cost))),P(l,()=>this.toggleProject(n.id)),e.appendChild(l),i){const o=t("tr","pdetail"),c=t("td");c.colSpan=4;const p=this.projectDetails.get(n.id);if(!p)c.appendChild(t("div","muted","Loading…"));else{const a=this.sessionLimits.get(n.id)??$,u=t("div","stree");this.renderSessions(u,p.sessions,a,()=>{this.sessionLimits.set(n.id,a+$),this.render()}),c.appendChild(u)}o.appendChild(c),e.appendChild(o)}}toggleProject(e){this.expandedProjects.has(e)?(this.expandedProjects.delete(e),this.sessionLimits.delete(e),this.render()):(this.expandedProjects.add(e),this.loadProject(e))}renderSessions(e,n,i,r){const d=new Map(n.map(a=>[a.id,a])),l=new Map,o=[];for(const a of n)if(a.parentId&&d.has(a.parentId)){const u=l.get(a.parentId)??[];u.push(a),l.set(a.parentId,u)}else o.push(a);const c=new Map,p=a=>{let u={...a.tokens},h=a.cost;for(const S of l.get(a.id)??[]){const g=p(S);u=A(u,g.tokens),h+=g.cost}const f={tokens:u,cost:h};return c.set(a.id,f),f};for(const a of o)p(a);for(const a of o.slice(0,i))this.renderSession(e,a,0,l,c);if(o.length>i){const a=t("button","more",`Show ${o.length-i} more sessions`);a.addEventListener("click",r),e.appendChild(a)}}renderSession(e,n,i,r,d){const l=r.get(n.id)??[],o=this.expandedSessions.has(n.id),c=d.get(n.id)??{tokens:n.tokens,cost:n.cost},p=t("div","srow"+(o?" open":""));p.style.setProperty("--depth",String(i));const a=t("span","tgl",o?"▾":"▸");if(l.length){const h=t("span","badge",String(l.length));a.append(h)}const u=t("div","s-main");if(u.append(t("span","s-title",n.title||"Untitled session"),t("span","s-meta",`${n.agent} · ${n.model}`)),p.append(a,u,y(c.tokens),t("span","cost",w(c.cost))),P(p,()=>this.toggleSession(n.id)),e.appendChild(p),o){const h=t("div","s-detail");h.style.setProperty("--depth",String(i+1));const f=this.sessionDetails.get(n.id);f?f.models.length===0?h.appendChild(t("div","muted","No token usage recorded")):(h.appendChild(this.modelsTable(f.models)),l.length&&h.appendChild(t("div","muted note","model breakdown counts only this session — row totals include subagents"))):h.appendChild(t("div","muted","Loading…")),e.appendChild(h)}if(o)for(const h of l)this.renderSession(e,h,i+1,r,d)}toggleSession(e){this.expandedSessions.has(e)?(this.expandedSessions.delete(e),this.render()):(this.expandedSessions.add(e),this.loadSession(e))}modelsTable(e){const n=t("table","tbl tbl-models"),i=t("thead"),r=t("tr");for(const l of["Model","Mode","Msgs","Tokens","Cost"])r.appendChild(t("th","",l));i.appendChild(r),n.appendChild(i);const d=t("tbody");for(const l of e){const o=t("td","p-name");o.append(t("span","",l.model),t("span","muted",l.provider));const c=t("td");c.appendChild(y(l.tokens));const p=t("tr");p.append(o,t("td","",l.mode),t("td","num",String(l.messageCount)),c,t("td","cost",w(l.cost))),d.appendChild(p)}return n.appendChild(d),n}}function k(s,e){const n=t("div","stat");return n.append(t("span","num",e),t("span","lbl",s)),n}function M(s,e){return`${s} / ${e}`}function W(s){const e=t("td","num",M(s.mainSessionCount,s.sessionCount));return e.title=`${s.mainSessionCount} main sessions · ${s.sessionCount} total (incl. subagents)`,e}function F(s){const e=t("div","stat stat-cost");return e.append(t("span","num",s===null?"…":w(s)),t("span","lbl","cost")),e}const m=document.getElementById("app");async function T(){let s=[],e=null;try{const o=await b.config();s=o.servers,$=o.ui?.sessionPage??$}catch(o){e=o instanceof Error?o.message:String(o)}if(R=s,e!==null||s.length===0){const o=t("div","error");o.append(t("h3","","No front-end server configuration"),t("p","",e??"No servers returned by /api/config — check dashboard.yaml."));const c=t("button","","Retry");c.addEventListener("click",()=>{T()}),o.appendChild(c),m.replaceChildren(o);return}s.length>1&&m.classList.add("multi"),j=[];for(let o=0;o<s.length;o++){const c=new U(o);c.root.style.borderTop=`3px solid ${L[o%L.length]}`,j.push(c),m.appendChild(c.root),c.start()}const n="overall",i=t("nav","tabs"),r=new Map,d=(o,c)=>{const p=t("button","tab",o);p.addEventListener("click",()=>l(c)),i.appendChild(p),r.set(c,p)};d("Overall",n),s.forEach((o,c)=>d(o.name,String(c))),m.before(i);function l(o){const c=o===n;m.classList.toggle("multi",c&&s.length>1),j.forEach((p,a)=>{p.root.hidden=!(c||String(a)===o)});for(const[p,a]of r)a.classList.toggle("active",p===o)}l(n)}T();setInterval(()=>{for(const s of j)s.touch()},3e4);
package/dist/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>opencode token usage</title>
7
- <script type="module" crossorigin src="/assets/index-D9vQPXiw.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-CHy6WqXK.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-DsbIuDpC.css">
9
9
  </head>
10
10
  <body>