opencode-dashboard-client 0.2.0 → 0.4.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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "opencode-dashboard-client",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
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.",
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
6
  "homepage": "https://github.com/GCS-ZHN/opencode-dashboard#readme",
7
7
  "repository": {
8
8
  "type": "git",
@@ -11,28 +11,32 @@
11
11
  "bugs": {
12
12
  "url": "https://github.com/GCS-ZHN/opencode-dashboard/issues"
13
13
  },
14
+ "bin": {
15
+ "opencode-dashboard": "dist-cli/cli.mjs"
16
+ },
14
17
  "files": [
15
18
  "dist",
16
- "server.ts",
17
- "config.ts",
18
- "dashboard.yaml"
19
+ "dist-cli"
19
20
  ],
20
21
  "publishConfig": {
21
22
  "access": "public"
22
23
  },
23
24
  "scripts": {
24
25
  "dev": "vite",
25
- "build": "tsc && tsc -p tsconfig.node.json && vite build",
26
+ "build": "tsc && tsc -p tsconfig.node.json && vite build && bun build cli.ts --target=node --outfile=dist-cli/cli.mjs",
26
27
  "preview": "vite preview",
27
28
  "mock": "bun mock-server.ts",
29
+ "serve": "node dist-cli/cli.mjs serve",
28
30
  "prepublishOnly": "npm run build"
29
31
  },
30
32
  "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.30.0",
31
34
  "yaml": "^2.8.0"
32
35
  },
33
36
  "devDependencies": {
34
37
  "@types/bun": "^1.1.0",
35
38
  "typescript": "^5.7.0",
36
- "vite": "^7.0.0"
39
+ "vite": "^7.0.0",
40
+ "xlsx": "0.18.5"
37
41
  }
38
42
  }
package/config.ts DELETED
@@ -1,45 +0,0 @@
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 DELETED
@@ -1,25 +0,0 @@
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
@@ -1 +0,0 @@
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);
@@ -1 +0,0 @@
1
- :root{--bg: #0d1117;--panel: #161b22;--inset: #21262d;--border: #30363d;--text: #e6edf3;--muted: #8b949e;--accent: #58a6ff;--danger: #f85149;--warn: #d29922;--live: #3fb950;--in: #58a6ff;--out: #3fb950;--rea: #a371f7;--cr: #2f6f78;--cw: #8a6d1f;--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}.app-head{position:sticky;top:0;z-index:10;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 20px;background:var(--panel);border-bottom:1px solid var(--border)}.app-head h1{margin:0;font-size:16px;font-weight:600;letter-spacing:.01em}.legend{display:flex;gap:14px;font-size:12px;color:var(--muted)}.legend span{display:inline-flex;align-items:center;gap:5px}.dot{width:10px;height:10px;border-radius:3px;display:inline-block}#app{display:flex;flex-direction:column;gap:16px;padding:20px;max-width:1100px;margin:0 auto}#app.multi{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;max-width:1400px}@media(max-width:860px){#app.multi{grid-template-columns:1fr}}.tabs{display:flex;flex-wrap:wrap;gap:4px;max-width:1100px;margin:16px auto 0;padding:0 20px}.tab{background:none;border:1px solid var(--border);border-bottom:none;border-radius:8px 8px 0 0;color:var(--text-dim);font:inherit;font-size:13px;padding:7px 14px;cursor:pointer;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tab:hover{color:var(--text)}.tab.active{color:var(--text);background:var(--panel);border-color:var(--accent);border-bottom:2px solid var(--accent)}.server{background:var(--panel);border:1px solid var(--border);border-top:3px solid var(--accent);border-radius:10px;padding:16px 20px 20px}.srv-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}.srv-host{margin:0;font-size:18px;font-weight:650}.srv-ver,.srv-url{color:var(--muted);font-size:12px}.srv-url{font-family:var(--mono)}.srv-upd{margin-left:auto;color:var(--muted);font-size:12px}.live{font-size:12px;color:var(--warn)}.live.on{color:var(--live);animation:pulse 2s infinite}@media(prefers-reduced-motion:reduce){.live.on{animation:none}}@keyframes pulse{50%{opacity:.4}}.stats{display:flex;gap:12px;margin:16px 0 8px;flex-wrap:wrap}.stat{background:var(--inset);border-radius:8px;padding:10px 14px;min-width:96px;display:flex;flex-direction:column;gap:2px}.stat .num{font-family:var(--mono);font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}.stat .lbl{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em}.stat-tokens{flex:1;min-width:220px;gap:8px}.sec-head{margin:18px 0 8px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}.tbl{width:100%;border-collapse:collapse}.tbl th{text-align:left;color:var(--muted);font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:.04em;padding:6px 8px;border-bottom:1px solid var(--border)}.tbl td{padding:7px 8px;border-bottom:1px solid var(--border);vertical-align:middle}.tbl tbody tr:last-child td{border-bottom:none}.prow{cursor:pointer;transition:background .12s}.prow:hover,.prow:focus-visible{background:#ffffff0a}.prow:focus-visible,.srow:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.p-name{display:flex;align-items:center;gap:6px}.tgl{width:18px;flex:none;display:inline-flex;align-items:center;color:var(--muted);cursor:pointer;-webkit-user-select:none;user-select:none;font-size:11px}.badge{background:var(--inset);color:var(--muted);border-radius:8px;font-size:10px;padding:1px 5px;margin-left:2px;font-family:var(--mono)}.cost{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap}td.cost{text-align:right}.num{font-family:var(--mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted);font-size:12px}.stree{display:flex;flex-direction:column;padding:4px 0}.srow{display:grid;grid-template-columns:18px minmax(0,1fr) 150px 70px;align-items:center;gap:8px;padding:6px 8px;padding-left:calc(8px + var(--depth) * 20px);cursor:pointer;border-radius:6px;transition:background .12s}.srow:hover{background:#ffffff0a}.s-main{display:flex;flex-direction:column;min-width:0}.s-title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.s-meta{color:var(--muted);font-size:11px}.s-detail{padding-left:calc(8px + var(--depth) * 20px + 18px);margin:4px 0 8px}.note{margin-top:8px}.tbl-models{background:var(--inset);border:1px solid var(--border);border-radius:8px;overflow:hidden}.bar-wrap{width:100%}.bar{display:flex;height:8px;border-radius:2px;overflow:hidden;background:var(--inset)}.hero .bar{height:16px;border-radius:3px}.bar-empty{background:var(--inset)}.seg{height:100%;min-width:2px}.seg-in{background:var(--in)}.seg-out{background:var(--out)}.seg-rea{background:var(--rea)}.seg-cr{background:var(--cr)}.seg-cw{background:var(--cw)}.tcell{display:flex;flex-direction:column;gap:4px;min-width:130px;max-width:200px}.tcell .num{font-family:var(--mono);font-size:12px;color:var(--text);text-align:right;font-variant-numeric:tabular-nums}.tcell.hero{min-width:220px;max-width:320px}.tcell.hero .num{font-size:20px;font-weight:600}.empty{color:var(--muted);padding:16px 8px;text-align:center}.more{align-self:flex-start;margin:8px 0 4px 26px;background:var(--inset);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;transition:border-color .12s,background .12s}.more:hover{border-color:var(--accent)}.skeleton-row td{padding:12px 8px}.skeleton{height:14px;border-radius:4px;background:linear-gradient(90deg,var(--inset) 25%,#2d333b 50%,var(--inset) 75%);background-size:200% 100%;animation:shimmer 1.2s infinite}@keyframes shimmer{to{background-position:-200% 0}}.error{color:var(--danger);padding:8px}.error h3{color:inherit;text-transform:none;letter-spacing:0;margin:0 0 4px;font-size:15px}.error p{margin:0 0 8px;font-size:13px;color:var(--muted)}.error button{background:var(--inset);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 14px;cursor:pointer}.error-bar{background:#f8514914;border:1px solid rgba(248,81,73,.4);border-radius:6px;margin:8px 0;font-size:12px}
package/server.ts DELETED
@@ -1,57 +0,0 @@
1
- // Front-end server: single entry point for clients. Serves dist/ and proxies
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
6
- import { serve } from "bun";
7
- import { join } from "node:path";
8
- import { loadDashboardConfig } from "./config";
9
-
10
- const cfg = loadDashboardConfig();
11
- const DIST = join(import.meta.dir, "dist");
12
-
13
- const MIME: Record<string, string> = {
14
- ".html": "text/html; charset=utf-8",
15
- ".js": "text/javascript",
16
- ".css": "text/css",
17
- ".svg": "image/svg+xml",
18
- ".json": "application/json",
19
- ".png": "image/png",
20
- };
21
-
22
- const file = async (path: string) => {
23
- const f = Bun.file(join(DIST, path));
24
- return (await f.exists()) ? f : null;
25
- };
26
-
27
- serve({
28
- hostname: cfg.host,
29
- port: cfg.port,
30
- async fetch(req) {
31
- const url = new URL(req.url);
32
-
33
- if (url.pathname === "/api/config") {
34
- return Response.json({ servers: cfg.servers, ui: cfg.ui });
35
- }
36
-
37
- const m = url.pathname.match(/^\/api\/s\/(\d+)(\/.*)?$/);
38
- if (m) {
39
- const target = cfg.servers[Number(m[1])];
40
- if (!target) return new Response("unknown server", { status: 404 });
41
- const res = await fetch(target.url + (m[2] ?? "") + url.search, req);
42
- return new Response(res.body, { status: res.status, headers: res.headers });
43
- }
44
-
45
- const path = url.pathname === "/" ? "/index.html" : url.pathname;
46
- const f = await file(path);
47
- if (f) {
48
- const ext = path.slice(path.lastIndexOf("."));
49
- return new Response(f, { headers: { "content-type": MIME[ext] ?? "application/octet-stream" } });
50
- }
51
- const idx = await file("index.html"); // SPA fallback
52
- if (idx) return new Response(idx, { headers: { "content-type": MIME[".html"] } });
53
- return new Response("not found", { status: 404 });
54
- },
55
- });
56
-
57
- console.log(`front-end server http://${cfg.host}:${cfg.port} (${cfg.servers.length} backends proxied)`);