← back to AbramsEgo
auto-save: 2026-07-01T12:42:45 (12 files) — build-queue/tasks/06b-grids-sortdensity.md build-queue/tasks/07-swap-metric.md build-queue/tasks/08-provider-donut.md build-queue/tasks/99-replenish.md public/index.html
086cc6b51e86452b8af468aae82bc3b98a3f7167 · 2026-07-01 12:42:46 -0700 · Steve Abrams
Files touched
R100 build-queue/tasks/06b-grids-sortdensity.md build-queue/done/06b-grids-sortdensity.mdR100 build-queue/tasks/07-swap-metric.md build-queue/done/07-swap-metric.mdR100 build-queue/tasks/99-replenish.md build-queue/done/99-replenish.mdR100 build-queue/tasks/08-provider-donut.md build-queue/failed/08-provider-donut.mdA build-queue/tasks/10-finish-provider-donut.mdA build-queue/tasks/11-localfleet-fallback.mdM public/index.htmlM server.js
Diff
commit 086cc6b51e86452b8af468aae82bc3b98a3f7167
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 1 12:42:46 2026 -0700
auto-save: 2026-07-01T12:42:45 (12 files) — build-queue/tasks/06b-grids-sortdensity.md build-queue/tasks/07-swap-metric.md build-queue/tasks/08-provider-donut.md build-queue/tasks/99-replenish.md public/index.html
---
.../{tasks => done}/06b-grids-sortdensity.md | 0
build-queue/{tasks => done}/07-swap-metric.md | 0
build-queue/{tasks => done}/99-replenish.md | 0
build-queue/{tasks => failed}/08-provider-donut.md | 0
build-queue/tasks/10-finish-provider-donut.md | 24 ++++++++++++
build-queue/tasks/11-localfleet-fallback.md | 28 ++++++++++++++
public/index.html | 45 ++++++++++++++++++++++
server.js | 29 +++++++++++++-
8 files changed, 124 insertions(+), 2 deletions(-)
diff --git a/build-queue/tasks/06b-grids-sortdensity.md b/build-queue/done/06b-grids-sortdensity.md
similarity index 100%
rename from build-queue/tasks/06b-grids-sortdensity.md
rename to build-queue/done/06b-grids-sortdensity.md
diff --git a/build-queue/tasks/07-swap-metric.md b/build-queue/done/07-swap-metric.md
similarity index 100%
rename from build-queue/tasks/07-swap-metric.md
rename to build-queue/done/07-swap-metric.md
diff --git a/build-queue/tasks/99-replenish.md b/build-queue/done/99-replenish.md
similarity index 100%
rename from build-queue/tasks/99-replenish.md
rename to build-queue/done/99-replenish.md
diff --git a/build-queue/tasks/08-provider-donut.md b/build-queue/failed/08-provider-donut.md
similarity index 100%
rename from build-queue/tasks/08-provider-donut.md
rename to build-queue/failed/08-provider-donut.md
diff --git a/build-queue/tasks/10-finish-provider-donut.md b/build-queue/tasks/10-finish-provider-donut.md
new file mode 100644
index 0000000..c0b8913
--- /dev/null
+++ b/build-queue/tasks/10-finish-provider-donut.md
@@ -0,0 +1,24 @@
+# AbramsEgo task 10 — FINISH + verify + commit the per-provider cost donut (SPEC panel 3)
+
+Read SPEC.md. The donut is already CODED but not landed: `server.js` computes `cost.byProvider`
+(see the `byProvider` block ~line 444–495) and `public/index.html` has `renderProviderDonut()`
+(~line 376) + the `#costproviders` element. BUT the LIVE API returns `cost.byProvider: null`, so the
+card shows "no provider data". The prior attempt (08) timed out at 15 min (rc=142) mid-verify and left
+`server.js` + `public/index.html` uncommitted. Your job is the SHORT finish — do NOT rewrite the donut.
+
+KEEP IT SURGICAL — this failed once on timeout. Do the ONE wiring fix, verify, commit. ~10 min.
+
+1. `pm2 restart abramsego`, then:
+ `curl -s -u admin:'DW2024!' http://127.0.0.1:9773/api/data | python3 -c "import sys,json;print(json.load(sys.stdin)['cost'].get('byProvider'))"`
+ - If it now shows a non-empty array → the running instance was just stale. Skip to step 3.
+ - If still `null` → trace ONE bug: confirm the fn that builds `cost` actually assigns
+ `out.byProvider = series.byProvider` AND that the value returned by the cost collector is the
+ one wired into `/api/data`'s `cost` key (not a second, older cost path). Fix the single wiring gap.
+2. Re-restart + re-curl until `byProvider` is a non-empty `[{provider,total,...}]` array.
+3. Load http://127.0.0.1:9773/ (Basic Auth admin:DW2024!) and confirm the donut + legend render in the
+ "Cost to run · 30d" card, each provider shows its `$` (free/local → `$0 (local)`).
+4. Commit (this lands the uncommitted server.js + index.html too):
+ `git add -A && git commit -m "feat: finish per-provider cost donut (SPEC panel 3)" --author="Steve <steve@designerwallcoverings.com>"`
+
+Do NOT deploy. Local only. If byProvider genuinely can't populate in <12 min, commit what renders
+cleanly (donut with the "no provider data" fallback intact) and note the blocker — don't loop.
diff --git a/build-queue/tasks/11-localfleet-fallback.md b/build-queue/tasks/11-localfleet-fallback.md
new file mode 100644
index 0000000..667928a
--- /dev/null
+++ b/build-queue/tasks/11-localfleet-fallback.md
@@ -0,0 +1,28 @@
+# AbramsEgo task 11 — make "Fleet · Local" (panel 2) actually populate on Mac2
+
+Read SPEC.md panel 2 (Fleet health — pm2 Mac2 + Kamatera up/down). RIGHT NOW the LOCAL half is dark:
+`server.js` shells `~/.npm-global/bin/pm2 jlist` (~line 109–113) but that command HANGS on this Mac2
+(the pm2 daemon is unresponsive under memory pressure), so the 8s timeout fires and `localFleet`
+returns `{procs:[], error:"pm2 unavailable"}`. The Kamatera half works; only Mac2 is broken.
+
+Fix: add a FALLBACK so the panel shows local procs even when `pm2 jlist` hangs. `~/.pm2/dump.pm2`
+EXISTS (verified) — it's JSON pm2 writes on save, with the process list. Use it as the fallback source.
+
+KEEP IT SURGICAL — single function, ~10 min. Do NOT refactor unrelated fleet code.
+
+1. In `server.js`'s local-fleet collector, wrap the `pm2 jlist` call: if it errors/times out (or returns
+ empty), read + `JSON.parse` `~/.pm2/dump.pm2` and map its entries to the same shape the UI expects
+ (`{name, status, restarts, memMB, startedAt}` — dump entries carry `name`, and `pm2_env` with
+ `status`/`restart_time`/`pm_uptime`; memory may be absent → leave null). Keep a SHORT `jlist` timeout
+ (e.g. 3000ms) so the fallback kicks in fast instead of stalling the whole snapshot.
+ Mark fallback rows so the UI can show they're from the last saved dump (e.g. add `note:"from pm2 dump"`
+ on the fleet object, or `stale:true` per row) — Steve's "always be honest about staleness" spirit.
+2. `pm2 restart abramsego`, then:
+ `curl -s -u admin:'DW2024!' http://127.0.0.1:9773/api/data | python3 -c "import sys,json;d=json.load(sys.stdin)['localFleet'];print(len(d.get('procs',[])),'procs',d.get('error'),d.get('note'))"`
+ — expect a non-zero proc count (from the dump) instead of `0 procs pm2 unavailable`.
+3. Load http://127.0.0.1:9773/ and confirm "Fleet · Local" lists procs (not "no local pm2 procs").
+4. Commit:
+ `git add -A && git commit -m "fix: local fleet falls back to ~/.pm2/dump.pm2 when jlist hangs" --author="Steve <steve@designerwallcoverings.com>"`
+
+Do NOT deploy. Local only. Do NOT try to restart/repair the pm2 daemon itself — just make the panel
+resilient to it being slow.
diff --git a/public/index.html b/public/index.html
index dfd064d..acf83eb 100644
--- a/public/index.html
+++ b/public/index.html
@@ -52,6 +52,19 @@
.pill.live{color:var(--good);border-color:#3fd18b44;background:#3fd18b0f}
.bar{height:6px;border-radius:6px;background:#ffffff10;overflow:hidden;margin-top:6px}
.bar>span{display:block;height:100%;background:var(--accent)}
+ .prov-wrap{display:flex;gap:14px;align-items:center;margin-top:12px;flex-wrap:wrap}
+ .donut{position:relative;width:96px;height:96px;border-radius:50%;flex-shrink:0}
+ .donut::after{content:'';position:absolute;inset:22%;border-radius:50%;
+ background:linear-gradient(180deg,var(--panel),var(--panel2))}
+ .donut-c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
+ justify-content:center;text-align:center;z-index:1}
+ .donut-c b{font-size:12px;color:var(--ink)}
+ .donut-c span{font-size:8.5px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px}
+ .prov-legend{flex:1;min-width:150px;font-size:11px}
+ .prov-legend .row{align-items:center}
+ .prov-legend .dot{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:6px;flex-shrink:0}
+ .prov-legend .lbl{display:flex;align-items:center;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+ .prov-legend .amt{color:var(--dim);white-space:nowrap}
.pnlgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
@media(max-width:800px){.pnlgrid{grid-template-columns:repeat(2,1fr)}}
.stat{background:#0c1420;border:1px solid var(--line);border-radius:10px;padding:12px}
@@ -109,6 +122,7 @@
<h2>🔌 Cost to run · 30d</h2>
<div class="big" id="cost30">—</div>
<div class="muted" id="cost30sub"></div>
+ <div class="prov-wrap" id="costproviders"></div>
<div style="margin-top:10px" id="costapps"></div>
</section>
@@ -302,6 +316,7 @@ async function load(){
const c30=(d.cost&&d.cost.month)||{};
$('cost30').textContent=usd(c30.total);
$('cost30sub').textContent=`tokens ${usd(c30.tokens)} (${c30.tokensCount||0} calls) + energy ${usd(c30.energy)} est`;
+ renderProviderDonut(d);
const apps=Object.entries(c30.byApp||{}).sort((a,b)=>b[1]-a[1]).slice(0,6);
const max=Math.max(...apps.map(a=>a[1]),0.0001);
$('costapps').innerHTML=apps.map(([a,v])=>`<div class="row"><span class="dim">${a}</span><span>${usd(v)}</span></div><div class="bar"><span style="width:${Math.round(v/max*100)}%"></span></div>`).join('')||'<span class="muted">no cost data</span>';
@@ -354,6 +369,36 @@ async function load(){
if((p.selfFundingPct||0)<100) alerts.push('not yet self-funding ('+ (p.selfFundingPct||0) +'% of cost covered)');
const a=$('alert'); if(alerts.length){a.classList.add('show');a.textContent='⚠ '+alerts.join(' · ');}else{a.classList.remove('show');}
}
+// Per-provider share of 30d spend as a pure CSS conic-gradient donut + legend.
+// Palette cycles the theme accent colors; each provider always shows its $ figure
+// (Steve's "always show costs" rule) and free/local buckets read "$0 (local)".
+const PROV_COLORS=['#4d9fff','#3fd18b','#ffb454','#ff5d6c','#ffd166','#a78bfa','#22d3ee','#f472b6','#94a3b8'];
+function renderProviderDonut(d){
+ const el=$('costproviders'); if(!el) return;
+ const bp=((d.cost&&d.cost.byProvider)||[]).slice();
+ if(!bp.length){ el.innerHTML='<span class="muted">no provider data</span>'; return; }
+ const total=bp.reduce((s,p)=>s+(p.total||0),0);
+ // With no paid spend at all the conic-gradient would have nothing to draw —
+ // fall back to an even split so the local/$0 providers still render.
+ const denom=total>0?total:bp.reduce((s,p)=>s+1,0);
+ let acc=0; const stops=[];
+ bp.forEach((p,i)=>{
+ const share=total>0?(p.total||0):1;
+ const from=acc/denom*360, to=(acc+share)/denom*360; acc+=share;
+ const col=PROV_COLORS[i%PROV_COLORS.length];
+ stops.push(`${col} ${from.toFixed(2)}deg ${to.toFixed(2)}deg`);
+ });
+ const legend=bp.map((p,i)=>{
+ const col=PROV_COLORS[i%PROV_COLORS.length];
+ const pctv=total>0?Math.round((p.total||0)/total*100):Math.round(100/bp.length);
+ const amt=(p.local||(p.total||0)===0)?'$0 (local)':usd(p.total);
+ return `<div class="row"><span class="lbl"><span class="dot" style="background:${col}"></span>${p.provider}</span>`+
+ `<span class="amt">${amt} · ${pctv}%</span></div>`;
+ }).join('');
+ el.innerHTML=`<div class="donut" style="background:conic-gradient(${stops.join(',')})">`+
+ `<div class="donut-c"><b>${usd(total)}</b><span>${bp.length} prov</span></div></div>`+
+ `<div class="prov-legend">${legend}</div>`;
+}
function renderCostTrend(d){
const series=(d.cost&&d.cost.series7d)||[];
const el=$('trend');
diff --git a/server.js b/server.js
index d5bfdd4..f9507fe 100644
--- a/server.js
+++ b/server.js
@@ -404,6 +404,24 @@ function energyCost(sinceMs) {
const kwh = (ENERGY_AVG_WATTS / 1000) * hours;
return { hours: round(hours, 1), kwh: round(kwh, 3), usd: round(kwh * ENERGY_RATE * ENERGY_ATTRIB, 4), est: true, avgWatts: ENERGY_AVG_WATTS, rate: ENERGY_RATE, attribPct: round(ENERGY_ATTRIB * 100, 1) };
}
+// Map a cost-ledger row to a human provider bucket. Prefers an explicit
+// `provider` field, then falls back to inferring from the api/model/vendor
+// strings. Local/free engines (ollama, qwen) are labeled "(local)" so a $0
+// provider still shows up in the donut rather than vanishing.
+function deriveProvider(j) {
+ if (j.provider && String(j.provider).trim()) return String(j.provider).trim();
+ const s = `${j.api || ''} ${j.model || ''} ${j.vendor || ''}`.toLowerCase();
+ if (/anthropic|claude/.test(s)) return 'Anthropic';
+ if (/ollama|qwen|local/.test(s)) return 'Ollama (local)';
+ if (/gpt|openai|codex/.test(s)) return 'OpenAI';
+ if (/gemini|google/.test(s)) return 'Google';
+ if (/elevenlabs|eleven_labs|11labs/.test(s)) return 'ElevenLabs';
+ if (/replicate/.test(s)) return 'Replicate';
+ if (/\bexa\b/.test(s)) return 'Exa';
+ if (/moonshot|kimi/.test(s)) return 'Moonshot';
+ return 'Other';
+}
+
// 7-day daily buckets of cost (tokens+energy) + a per-api/model breakdown for
// 30d. One pass over the ledger keeps this cheap. Days are keyed by LOCAL
// calendar day so the trend lines up with the "today" the dashboard shows.
@@ -423,6 +441,7 @@ function collectCostSeries() {
}
const since30 = now - 30 * 24 * 3.6e6;
const byModel = {};
+ const byProvider = {};
try {
const lines = fs.readFileSync(COST_LEDGER, 'utf8').split('\n');
for (const ln of lines) {
@@ -433,9 +452,11 @@ function collectCostSeries() {
const c = j.cost_usd || 0;
const key = dayKey(t);
if (byKey[key]) byKey[key].tokens += c; // 7d daily buckets
- if (t >= since30) { // 30d per-model breakdown
+ if (t >= since30) { // 30d per-model + per-provider breakdown
const m = j.api || j.model || 'unknown';
byModel[m] = (byModel[m] || 0) + c;
+ const p = deriveProvider(j); // $0/local providers still counted
+ byProvider[p] = (byProvider[p] || 0) + c;
}
}
} catch (e) {}
@@ -452,7 +473,10 @@ function collectCostSeries() {
const byModelArr = Object.entries(byModel)
.sort((a, b) => b[1] - a[1])
.map(([api, usd]) => ({ api, usd: round(usd, 4) }));
- return { series7d, byModel: byModelArr };
+ const byProviderArr = Object.entries(byProvider)
+ .map(([provider, usd]) => ({ provider, total: round(usd, 4), local: /\(local\)/i.test(provider) }))
+ .sort((a, b) => b.total - a.total);
+ return { series7d, byModel: byModelArr, byProvider: byProviderArr };
}
function collectCost() {
@@ -468,6 +492,7 @@ function collectCost() {
const series = collectCostSeries();
out.series7d = series.series7d;
out.byModel = series.byModel;
+ out.byProvider = series.byProvider;
return out;
}
← 64b2613 feat: show swap in top metrics bar (SPEC panel 1)
·
back to AbramsEgo
·
chore: replenish build queue (3 tasks: finish donut, local-f 7ab32c9 →