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 +39 -23
- package/dist/assets/index-CHy6WqXK.js +1 -0
- package/dist/index.html +1 -1
- package/dist-cli/cli.mjs +7305 -0
- package/package.json +20 -5
- package/dist/assets/index-D9vQPXiw.js +0 -1
- package/server.ts +0 -51
- package/src/config.ts +0 -11
package/package.json
CHANGED
|
@@ -1,24 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-dashboard-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Front-end for the opencode token-usage dashboard. Ships the built SPA plus
|
|
5
|
+
"description": "Front-end for the opencode token-usage dashboard. Ships the built SPA plus a node CLI (opencode-dashboard serve/configure) that serves the SPA and 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
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"opencode-dashboard": "dist-cli/cli.mjs"
|
|
16
|
+
},
|
|
6
17
|
"files": [
|
|
7
18
|
"dist",
|
|
8
|
-
"
|
|
9
|
-
"src/config.ts"
|
|
19
|
+
"dist-cli"
|
|
10
20
|
],
|
|
11
21
|
"publishConfig": {
|
|
12
22
|
"access": "public"
|
|
13
23
|
},
|
|
14
24
|
"scripts": {
|
|
15
25
|
"dev": "vite",
|
|
16
|
-
"build": "tsc && vite build",
|
|
26
|
+
"build": "tsc && tsc -p tsconfig.node.json && vite build && bun build cli.ts --target=node --outfile=dist-cli/cli.mjs",
|
|
17
27
|
"preview": "vite preview",
|
|
18
28
|
"mock": "bun mock-server.ts",
|
|
29
|
+
"serve": "node dist-cli/cli.mjs serve",
|
|
19
30
|
"prepublishOnly": "npm run build"
|
|
20
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"yaml": "^2.8.0"
|
|
34
|
+
},
|
|
21
35
|
"devDependencies": {
|
|
36
|
+
"@types/bun": "^1.1.0",
|
|
22
37
|
"typescript": "^5.7.0",
|
|
23
38
|
"vite": "^7.0.0"
|
|
24
39
|
}
|
|
@@ -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/server.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
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)
|
|
5
|
-
import { serve } from "bun";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { servers } from "./src/config";
|
|
8
|
-
|
|
9
|
-
const DIST = join(import.meta.dir, "dist");
|
|
10
|
-
const PORT = Number(process.env.PORT ?? 5173);
|
|
11
|
-
|
|
12
|
-
const MIME: Record<string, string> = {
|
|
13
|
-
".html": "text/html; charset=utf-8",
|
|
14
|
-
".js": "text/javascript",
|
|
15
|
-
".css": "text/css",
|
|
16
|
-
".svg": "image/svg+xml",
|
|
17
|
-
".json": "application/json",
|
|
18
|
-
".png": "image/png",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const file = async (path: string) => {
|
|
22
|
-
const f = Bun.file(join(DIST, path));
|
|
23
|
-
return (await f.exists()) ? f : null;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
serve({
|
|
27
|
-
port: PORT,
|
|
28
|
-
async fetch(req) {
|
|
29
|
-
const url = new URL(req.url);
|
|
30
|
-
|
|
31
|
-
const m = url.pathname.match(/^\/api\/s\/(\d+)(\/.*)?$/);
|
|
32
|
-
if (m) {
|
|
33
|
-
const target = servers[Number(m[1])];
|
|
34
|
-
if (!target) return new Response("unknown server", { status: 404 });
|
|
35
|
-
const res = await fetch(target.url + (m[2] ?? "") + url.search, req);
|
|
36
|
-
return new Response(res.body, { status: res.status, headers: res.headers });
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const path = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
40
|
-
const f = await file(path);
|
|
41
|
-
if (f) {
|
|
42
|
-
const ext = path.slice(path.lastIndexOf("."));
|
|
43
|
-
return new Response(f, { headers: { "content-type": MIME[ext] ?? "application/octet-stream" } });
|
|
44
|
-
}
|
|
45
|
-
const idx = await file("index.html"); // SPA fallback
|
|
46
|
-
if (idx) return new Response(idx, { headers: { "content-type": MIME[".html"] } });
|
|
47
|
-
return new Response("not found", { status: 404 });
|
|
48
|
-
},
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
console.log(`front-end server http://localhost:${PORT} (${servers.length} backends proxied)`);
|
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
|
-
];
|