opencode-dashboard-client 0.1.1 → 0.2.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
@@ -8,7 +8,7 @@ The npm package ships the **built SPA** plus the **front-end server**, which is
8
8
  for browsers: it serves the SPA and proxies `/api/s/{i}/*` to the configured aggregation backends.
9
9
  Real backends stay hidden from the client's network.
10
10
 
11
- ## Install
11
+ ## Install (npm)
12
12
 
13
13
  ```bash
14
14
  npm install opencode-dashboard-client
@@ -16,31 +16,45 @@ npm install opencode-dashboard-client
16
16
 
17
17
  Requires Node ≥ 22 and [bun](https://bun.sh) (the front-end server runs on bun).
18
18
 
19
- ## Usage
19
+ ## Quick start
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
- ];
21
+ ```bash
22
+ cd node_modules/opencode-dashboard-client # or wherever npm installed the package
23
+ ```
24
+
25
+ 1. **Point it at your backends** — edit `dashboard.yaml`:
26
+ ```yaml
27
+ servers:
28
+ - name: main
29
+ url: http://127.0.0.1:8791
30
+ - name: backup
31
+ url: http://127.0.0.1:8792
32
+ ui:
33
+ sessionPage: 30 # sessions loaded per page when a project is expanded
27
34
  ```
28
- 2. Run the front-end server (serves the built SPA + proxies `/api/s/{i}/*`):
35
+ `dashboard.yaml` is the single file you edit — the SPA fetches the server list + UI options from
36
+ `GET /api/config` at startup, so there is nothing to rebuild after a change.
37
+
38
+ 2. **Run the front-end server** (serves the built SPA + proxies `/api/s/{i}/*`):
29
39
  ```bash
30
- bun server.ts # PORT env, default 5173 → open http://localhost:5173/
40
+ bun server.ts
31
41
  ```
42
+ Env overrides: `PORT` (default 5173), `HOST` (default 0.0.0.0), `DASHBOARD_CONFIG` (config file
43
+ path). Open http://localhost:5173/.
32
44
 
33
45
  Each configured backend is one tab (with an **Overall** tab showing all of them in a grid). Sessions
34
46
  render as parent/child trees — subagents are collapsed by default and a parent's totals include all
35
47
  descendants. Live updates come over SSE, so the page refreshes itself as opencode writes.
36
48
 
37
- ## Development
49
+ ## From source (development)
38
50
 
39
51
  ```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
52
+ git clone https://github.com/GCS-ZHN/opencode-dashboard
53
+ cd opencode-dashboard/client
54
+ bun install # install dev deps (typescript, vite, yaml)
55
+ bun run dev # Vite dev server (same /api/s/{i} proxy + /api/config from dashboard.yaml) → :5173
56
+ bun run build # typecheck (tsc) + bundle (vite)
57
+ bun mock-server.ts # serve canned API data for frontend-only work (PORT env, default 8791)
44
58
  ```
45
59
 
46
60
  Full project docs (architecture, API contract, publishing) live in the repo
package/config.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import YAML from "yaml";
5
+
6
+ export interface ServerConfig {
7
+ name: string;
8
+ url: string;
9
+ }
10
+
11
+ export interface DashboardConfig {
12
+ host?: string;
13
+ port?: number;
14
+ servers: ServerConfig[];
15
+ ui?: { sessionPage?: number };
16
+ }
17
+
18
+ // Works under bun (import.meta.dir) and vite's config loader (import.meta.url).
19
+ function baseDir(): string {
20
+ try {
21
+ return import.meta.dir ?? dirname(fileURLToPath(import.meta.url));
22
+ } catch {
23
+ return process.cwd();
24
+ }
25
+ }
26
+
27
+ // Node-only module (front-end server / vite config / mock server). The browser
28
+ // never imports this — the SPA fetches the resolved values from /api/config.
29
+ export function loadDashboardConfig(
30
+ path: string = process.env.DASHBOARD_CONFIG ?? join(baseDir(), "dashboard.yaml"),
31
+ ): DashboardConfig {
32
+ const doc = (YAML.parse(readFileSync(path, "utf8")) ?? {}) as DashboardConfig;
33
+ const port = Number(process.env.PORT ?? doc.port ?? 5173);
34
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
35
+ throw new Error(`invalid port in ${path}: ${String(doc.port)}`);
36
+ }
37
+ const sessionPage = Number(doc.ui?.sessionPage ?? 30);
38
+ return {
39
+ host: process.env.HOST ?? doc.host ?? "0.0.0.0",
40
+ port,
41
+ servers: doc.servers ?? [],
42
+ ui: { sessionPage: Number.isInteger(sessionPage) && sessionPage > 0 ? sessionPage : 30 },
43
+ };
44
+ }
45
+
package/dashboard.yaml ADDED
@@ -0,0 +1,25 @@
1
+ # opencode-dashboard front-end server configuration.
2
+ # The front-end server (bun server.ts) and the vite dev server read this file.
3
+ # Everything a user might want to change lives here; env vars (PORT, HOST,
4
+ # DASHBOARD_CONFIG) override the matching fields. The SPA receives the resolved
5
+ # servers + ui options at runtime via GET /api/config (it never bundles them).
6
+
7
+ # Front-end server bind address (env HOST wins; default 0.0.0.0)
8
+ host: 0.0.0.0
9
+ # Front-end server port (env PORT wins; default 5173)
10
+ port: 5173
11
+
12
+ # Aggregation backends to proxy as /api/s/{index}/*
13
+ servers:
14
+ - name: main
15
+ url: http://127.0.0.1:8791
16
+ - name: backup
17
+ url: http://127.0.0.1:8792
18
+ - name: dev
19
+ url: http://127.0.0.1:8793
20
+ - name: staging
21
+ url: http://127.0.0.1:8794
22
+
23
+ # Browser-side UI options, delivered to the SPA via /api/config
24
+ ui:
25
+ sessionPage: 30 # sessions loaded per page when a project is expanded
@@ -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>
package/package.json CHANGED
@@ -1,24 +1,37 @@
1
1
  {
2
2
  "name": "opencode-dashboard-client",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Front-end for the opencode token-usage dashboard. Ships the built SPA plus the front-end server (bun server.ts) that proxies to dashboard backends.",
6
+ "homepage": "https://github.com/GCS-ZHN/opencode-dashboard#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/GCS-ZHN/opencode-dashboard.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/GCS-ZHN/opencode-dashboard/issues"
13
+ },
6
14
  "files": [
7
15
  "dist",
8
16
  "server.ts",
9
- "src/config.ts"
17
+ "config.ts",
18
+ "dashboard.yaml"
10
19
  ],
11
20
  "publishConfig": {
12
21
  "access": "public"
13
22
  },
14
23
  "scripts": {
15
24
  "dev": "vite",
16
- "build": "tsc && vite build",
25
+ "build": "tsc && tsc -p tsconfig.node.json && vite build",
17
26
  "preview": "vite preview",
18
27
  "mock": "bun mock-server.ts",
19
28
  "prepublishOnly": "npm run build"
20
29
  },
30
+ "dependencies": {
31
+ "yaml": "^2.8.0"
32
+ },
21
33
  "devDependencies": {
34
+ "@types/bun": "^1.1.0",
22
35
  "typescript": "^5.7.0",
23
36
  "vite": "^7.0.0"
24
37
  }
package/server.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  // Front-end server: single entry point for clients. Serves dist/ and proxies
2
- // /api/s/{i}/* to servers[i].url — same route scheme as vite.config.ts, so
3
- // clients never reach the real backends (they may be unreachable from the
4
- // client's network). Run: bun server.ts (PORT env, default 5173)
2
+ // /api/s/{i}/* to the configured backends — same route scheme as vite.config.ts,
3
+ // so clients never reach the real backends (they may be unreachable from the
4
+ // client's network). Configuration comes from dashboard.yaml (env overrides:
5
+ // DASHBOARD_CONFIG, PORT, HOST). Run: bun server.ts
5
6
  import { serve } from "bun";
6
7
  import { join } from "node:path";
7
- import { servers } from "./src/config";
8
+ import { loadDashboardConfig } from "./config";
8
9
 
10
+ const cfg = loadDashboardConfig();
9
11
  const DIST = join(import.meta.dir, "dist");
10
- const PORT = Number(process.env.PORT ?? 5173);
11
12
 
12
13
  const MIME: Record<string, string> = {
13
14
  ".html": "text/html; charset=utf-8",
@@ -24,13 +25,18 @@ const file = async (path: string) => {
24
25
  };
25
26
 
26
27
  serve({
27
- port: PORT,
28
+ hostname: cfg.host,
29
+ port: cfg.port,
28
30
  async fetch(req) {
29
31
  const url = new URL(req.url);
30
32
 
33
+ if (url.pathname === "/api/config") {
34
+ return Response.json({ servers: cfg.servers, ui: cfg.ui });
35
+ }
36
+
31
37
  const m = url.pathname.match(/^\/api\/s\/(\d+)(\/.*)?$/);
32
38
  if (m) {
33
- const target = servers[Number(m[1])];
39
+ const target = cfg.servers[Number(m[1])];
34
40
  if (!target) return new Response("unknown server", { status: 404 });
35
41
  const res = await fetch(target.url + (m[2] ?? "") + url.search, req);
36
42
  return new Response(res.body, { status: res.status, headers: res.headers });
@@ -48,4 +54,4 @@ serve({
48
54
  },
49
55
  });
50
56
 
51
- console.log(`front-end server http://localhost:${PORT} (${servers.length} backends proxied)`);
57
+ console.log(`front-end server http://${cfg.host}:${cfg.port} (${cfg.servers.length} backends proxied)`);
@@ -1 +0,0 @@
1
- (function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))o(i);new MutationObserver(i=>{for(const r of i)if(r.type==="childList")for(const a of r.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&o(a)}).observe(document,{childList:!0,subtree:!0});function n(i){const r={};return i.integrity&&(r.integrity=i.integrity),i.referrerPolicy&&(r.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?r.credentials="include":i.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function o(i){if(i.ep)return;i.ep=!0;const r=n(i);fetch(i.href,r)}})();async function b(s,e){const n=await fetch(s+e);if(!n.ok){let o=`${n.status} ${n.statusText}`;try{const i=await n.json();i.detail&&(o=String(i.detail))}catch{}throw new Error(o)}return n.json()}function v(s){return`/api/s/${s}`}const S={overview:s=>b(v(s),"/overview"),projects:s=>b(v(s),"/projects"),project:(s,e)=>b(v(s),`/projects/${encodeURIComponent(e)}`),session:(s,e)=>b(v(s),`/sessions/${encodeURIComponent(e)}`)},A=[["input","seg-in"],["output","seg-out"],["reasoning","seg-rea"],["cacheRead","seg-cr"],["cacheWrite","seg-cw"]];function t(s,e,n){const o=document.createElement(s);return e&&(o.className=e),n!==void 0&&(o.textContent=n),o}function M(s){return s>=1e6?(s/1e6).toFixed(1).replace(/\.0$/,"")+"M":s>=1e3?(s/1e3).toFixed(1).replace(/\.0$/,"")+"K":String(s)}function y(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 U(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 W(s){const e=t("div","bar");if(!s.total)return e.classList.add("bar-empty"),e.title="0 tokens",e;for(const[n,o]of A){const i=s[n]/s.total*100;if(i<=0)continue;const r=t("div",`seg ${o}`);r.style.width=`${i}%`,r.title=`${n}: ${M(s[n])}`,e.appendChild(r)}return e}function w(s,e=!1){const n=t("div","tcell"+(e?" hero":"")),o=t("div","bar-wrap");return o.appendChild(W(s)),n.append(o,t("span","num",M(s.total))),n}const m=[{name:"main",url:"http://127.0.0.1:8791"},{name:"backup",url:"http://127.0.0.1:8792"},{name:"dev",url:"http://127.0.0.1:8793"},{name:"staging",url:"http://127.0.0.1:8794"}],P=30,k=["#58a6ff","#3fb950","#a371f7","#d29922","#f85149"];function F(s){if(!s.data)return null;try{return JSON.parse(String(s.data))}catch{return null}}function B(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 E(s,e){s.tabIndex=0,s.addEventListener("click",e),s.addEventListener("keydown",n=>{(n.key==="Enter"||n.key===" ")&&(n.preventDefault(),e())})}class V{constructor(e){this.idx=e,this.base=m[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 ${U(this.overview.updatedAt)}`)}connect(){this.es?.close();const e=new EventSource(`${v(this.idx)}/stream`);this.es=e;const n=o=>this.onUpdate(F(o));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([S.overview(this.idx),S.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 S.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 S.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 o of this.expandedProjects)n.push(this.loadProject(o));for(const o of this.expandedSessions)n.push(this.loadSession(o));Promise.all(n)}render(){if(this.root.replaceChildren(),this.error&&!this.overview){const c=t("div","error");c.append(t("h3","",this.base),t("p","",this.error));const f=t("button","","Retry");f.addEventListener("click",()=>{this.refresh()}),c.appendChild(f),this.root.appendChild(c);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 o=t("span","live");this.liveRef=o,this.setLive(this.es?.readyState===EventSource.OPEN);const i=t("span","srv-upd");this.updRef=i,this.touch(),n.append(o,i),this.root.appendChild(n);const r=t("div","stats"),a=R("sessions",e?T(e.mainSessionCount,e.sessionCount):"…");a.title=e?`${e.mainSessionCount} main sessions · ${e.sessionCount} total (incl. subagents)`:"",r.append(a,R("projects",e?String(e.projectCount):"…"));const l=t("div","stat stat-tokens");if(l.appendChild(t("span","lbl","tokens")),e?l.appendChild(w(e.tokens,!0)):l.appendChild(t("div","skeleton","")),r.appendChild(l),r.appendChild(J(e?e.cost:null)),this.root.appendChild(r),this.error&&this.root.appendChild(t("div","error error-bar",`refresh failed: ${this.error}`)),this.root.appendChild(t("h3","sec-head","Projects")),!e){const c=t("table","tbl"),f=t("tbody");for(let C=0;C<3;C++){const g=t("tr","skeleton-row"),$=t("td");$.colSpan=4,$.appendChild(t("div","skeleton")),g.appendChild($),f.appendChild(g)}c.appendChild(f),this.root.appendChild(c);return}if(this.projects.length===0){this.root.appendChild(t("div","empty","No projects found on this host."));return}const p=t("table","tbl"),h=t("thead"),d=t("tr");for(const c of["Project","Sessions","Tokens","Cost"])d.appendChild(t("th","",c));h.appendChild(d),p.appendChild(h);const u=t("tbody");for(const c of this.projects)this.renderProject(u,c);p.appendChild(u),this.root.appendChild(p)}renderProject(e,n){const o=this.expandedProjects.has(n.id),i=t("td","p-name");i.append(t("span","tgl",o?"▾":"▸"),t("span","",n.name));const r=t("td");r.appendChild(w(n.tokens));const a=t("tr","prow"+(o?" open":""));if(a.append(i,G(n),r,t("td","cost",y(n.cost))),E(a,()=>this.toggleProject(n.id)),e.appendChild(a),o){const l=t("tr","pdetail"),p=t("td");p.colSpan=4;const h=this.projectDetails.get(n.id);if(!h)p.appendChild(t("div","muted","Loading…"));else{const d=this.sessionLimits.get(n.id)??P,u=t("div","stree");this.renderSessions(u,h.sessions,d,()=>{this.sessionLimits.set(n.id,d+P),this.render()}),p.appendChild(u)}l.appendChild(p),e.appendChild(l)}}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,o,i){const r=new Map(n.map(d=>[d.id,d])),a=new Map,l=[];for(const d of n)if(d.parentId&&r.has(d.parentId)){const u=a.get(d.parentId)??[];u.push(d),a.set(d.parentId,u)}else l.push(d);const p=new Map,h=d=>{let u={...d.tokens},c=d.cost;for(const C of a.get(d.id)??[]){const g=h(C);u=B(u,g.tokens),c+=g.cost}const f={tokens:u,cost:c};return p.set(d.id,f),f};for(const d of l)h(d);for(const d of l.slice(0,o))this.renderSession(e,d,0,a,p);if(l.length>o){const d=t("button","more",`Show ${l.length-o} more sessions`);d.addEventListener("click",i),e.appendChild(d)}}renderSession(e,n,o,i,r){const a=i.get(n.id)??[],l=this.expandedSessions.has(n.id),p=r.get(n.id)??{tokens:n.tokens,cost:n.cost},h=t("div","srow"+(l?" open":""));h.style.setProperty("--depth",String(o));const d=t("span","tgl",l?"▾":"▸");if(a.length){const c=t("span","badge",String(a.length));d.append(c)}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}`)),h.append(d,u,w(p.tokens),t("span","cost",y(p.cost))),E(h,()=>this.toggleSession(n.id)),e.appendChild(h),l){const c=t("div","s-detail");c.style.setProperty("--depth",String(o+1));const f=this.sessionDetails.get(n.id);f?f.models.length===0?c.appendChild(t("div","muted","No token usage recorded")):(c.appendChild(this.modelsTable(f.models)),a.length&&c.appendChild(t("div","muted note","model breakdown counts only this session — row totals include subagents"))):c.appendChild(t("div","muted","Loading…")),e.appendChild(c)}if(l)for(const c of a)this.renderSession(e,c,o+1,i,r)}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"),o=t("thead"),i=t("tr");for(const a of["Model","Mode","Msgs","Tokens","Cost"])i.appendChild(t("th","",a));o.appendChild(i),n.appendChild(o);const r=t("tbody");for(const a of e){const l=t("td","p-name");l.append(t("span","",a.model),t("span","muted",a.provider));const p=t("td");p.appendChild(w(a.tokens));const h=t("tr");h.append(l,t("td","",a.mode),t("td","num",String(a.messageCount)),p,t("td","cost",y(a.cost))),r.appendChild(h)}return n.appendChild(r),n}}function R(s,e){const n=t("div","stat");return n.append(t("span","num",e),t("span","lbl",s)),n}function T(s,e){return`${s} / ${e}`}function G(s){const e=t("td","num",T(s.mainSessionCount,s.sessionCount));return e.title=`${s.mainSessionCount} main sessions · ${s.sessionCount} total (incl. subagents)`,e}function J(s){const e=t("div","stat stat-cost");return e.append(t("span","num",s===null?"…":y(s)),t("span","lbl","cost")),e}const j=document.getElementById("app");m.length>1&&j.classList.add("multi");const x=[];for(let s=0;s<m.length;s++){const e=new V(s);e.root.style.borderTop=`3px solid ${k[s%k.length]}`,x.push(e),j.appendChild(e.root),e.start()}const L="overall",I=t("nav","tabs"),O=new Map,N=(s,e)=>{const n=t("button","tab",s);n.addEventListener("click",()=>D(e)),I.appendChild(n),O.set(e,n)};N("Overall",L);m.forEach((s,e)=>N(s.name,String(e)));j.before(I);function D(s){const e=s===L;j.classList.toggle("multi",e&&m.length>1),x.forEach((n,o)=>{n.root.hidden=!(e||String(o)===s)});for(const[n,o]of O)o.classList.toggle("active",n===s)}D(L);setInterval(()=>{for(const s of x)s.touch()},3e4);
package/src/config.ts DELETED
@@ -1,11 +0,0 @@
1
- export interface ServerConfig {
2
- name: string;
3
- url: string;
4
- }
5
-
6
- export const servers: ServerConfig[] = [
7
- { name: "main", url: "http://127.0.0.1:8791" },
8
- { name: "backup", url: "http://127.0.0.1:8792" },
9
- { name: "dev", url: "http://127.0.0.1:8793" },
10
- { name: "staging", url: "http://127.0.0.1:8794" },
11
- ];