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 +29 -15
- package/config.ts +45 -0
- package/dashboard.yaml +25 -0
- package/dist/assets/index-CHy6WqXK.js +1 -0
- package/dist/index.html +1 -1
- package/package.json +16 -3
- package/server.ts +14 -8
- package/dist/assets/index-D9vQPXiw.js +0 -1
- package/src/config.ts +0 -11
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
|
-
##
|
|
19
|
+
## Quick start
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
49
|
+
## From source (development)
|
|
38
50
|
|
|
39
51
|
```bash
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
bun
|
|
43
|
-
bun
|
|
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-
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
3
|
-
// clients never reach the real backends (they may be unreachable from the
|
|
4
|
-
// client's network).
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
];
|