@enigmax/dashboard 0.1.36 → 0.1.42

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.
Files changed (2) hide show
  1. package/assets/index.html +410 -107
  2. package/package.json +1 -1
package/assets/index.html CHANGED
@@ -198,6 +198,18 @@
198
198
  border-radius: var(--radius-sm); padding: 6px 10px; font-size: 12px; font-family: var(--mono); cursor: pointer;
199
199
  }
200
200
  .set-note { color: var(--accent); font-size: 12px; min-height: 16px; margin-top: 10px; }
201
+ /* A note must read as success vs. failure at a glance, not the same accent for both. */
202
+ .set-note.ok { color: var(--good); }
203
+ .set-note.err { color: var(--bad); }
204
+ /* Toasts: failures are easy to miss in a small inline note, so every error is also
205
+ raised here - fixed, coloured, and self-dismissing - so a failed action is unmissable. */
206
+ #toasts { position: fixed; right: 18px; bottom: 18px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; max-width: 360px; }
207
+ .toast { padding: 11px 14px; border-radius: 10px; font-size: 13px; line-height: 1.35; color: var(--text);
208
+ background: var(--surface2); border: 1px solid var(--border); box-shadow: var(--shadow-1); animation: toastIn 0.18s ease-out; }
209
+ .toast.err { border-color: var(--bad); }
210
+ .toast.ok { border-color: var(--good); }
211
+ .toast.hide { opacity: 0; transition: opacity 0.3s; }
212
+ @keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
201
213
  /* Settings: two-column grid of rows on wide screens, so a long config list stays compact. */
202
214
  .set-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 0 32px; }
203
215
  .set-grid .set-row { padding: 10px 0; }
@@ -277,13 +289,18 @@
277
289
  .fxtitle { font-size: 13px; display: flex; align-items: center; gap: 8px; }
278
290
  .fxmsg { color: var(--muted); font-size: 12px; margin-top: 2px; }
279
291
  .fxpath { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; color: var(--muted); margin-top: 3px; word-break: break-all; }
280
- .sys-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px; }
281
- .sys-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--surface2); font-size: 13px; }
282
- .sys-item .k { color: var(--muted); }
292
+ /* Each system reads as its own lit chip-card (name left, state pill right) in a grid,
293
+ rather than a dense list of underlined rows - easier to scan at a glance. */
294
+ .sys-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: 10px; }
295
+ .sys-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 14px; font-size: 13px; background: var(--surface2); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); }
296
+ .sys-item .k { color: var(--text); }
297
+ .sys-item .v { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; flex: 0 0 auto; }
298
+ /* On a surface2 card the surface2 "off" pill would vanish - keep it outlined instead. */
299
+ .sys-item .pill.off { background: transparent; }
283
300
  /* Rows whose value is a list of chips span the full width and let the chips wrap with
284
301
  even spacing, instead of cramming many tags into one compact half-column cell. */
285
302
  .sys-item.wide { grid-column: 1 / -1; flex-direction: column; align-items: stretch; gap: 8px; }
286
- .sys-item.wide .v { display: flex; flex-wrap: wrap; gap: 6px; }
303
+ .sys-item.wide .v { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-start; }
287
304
  .sys-item.wide .v .tag { margin-left: 0; }
288
305
  .pill { font-size: 11px; border-radius: 6px; padding: 2px 9px; border: 1px solid var(--border); white-space: nowrap; }
289
306
  .pill.on { color: #06210f; background: var(--good); border-color: var(--good); font-weight: 600; }
@@ -332,8 +349,26 @@
332
349
  border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 9px 12px; font-size: 13px; font-family: var(--mono);
333
350
  }
334
351
  .modal-input:focus { outline: none; border-color: var(--accent); }
352
+ /* Usage view provider/account pickers. */
353
+ .useg-pick { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
354
+ .useg-pick select { width: auto; min-width: 170px; }
355
+ /* Icon picker: clickable app cards (a native <select> can't render SVG in options). */
356
+ .modal-picker { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
357
+ .pick-card {
358
+ display: flex; align-items: center; gap: 11px; width: 100%; text-align: left; cursor: pointer;
359
+ background: var(--surface2); color: var(--text); border: 1px solid var(--border);
360
+ border-radius: var(--radius-sm); padding: 11px 13px; font-size: 13px; font-family: var(--mono);
361
+ transition: border-color .15s, background .15s, transform .1s;
362
+ }
363
+ .pick-card:hover { border-color: #2f3744; }
364
+ .pick-card:active { transform: scale(0.98); }
365
+ .pick-card.sel { border-color: var(--accent); }
366
+ .pick-card svg { width: 20px; height: 20px; display: block; flex: none; color: var(--muted); }
367
+ .pick-card.sel svg, .pick-card:hover svg { color: var(--text); }
335
368
  .modal-acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
336
369
  .modal-ok.danger { background: var(--accent2); border-color: var(--accent2); color: #2a0f08; }
370
+ .modal-ok:disabled { opacity: .45; cursor: not-allowed; }
371
+ .modal-err { color: var(--bad); font-size: 12px; margin-top: 8px; }
337
372
  </style>
338
373
  </head>
339
374
  <body>
@@ -461,14 +496,18 @@
461
496
  <section id="view-usage" style="display:none">
462
497
  <div class="page-head" style="display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap">
463
498
  <div style="flex:1;min-width:280px">
464
- <h1>Claude Usage</h1>
465
- <p>Measured token consumption and estimated cost, read-only from your own Claude Code session transcripts (<code>~/.claude/projects</code>). Cost is an estimate from a per-model price table; real spend is billed by Anthropic. Nothing is sent anywhere.</p>
499
+ <h1 id="usageTitle">Claude Usage</h1>
500
+ <p id="usageDesc">Measured token consumption and estimated cost, read-only from your own Claude Code session transcripts (<code>~/.claude/projects</code>). Cost is an estimate from a per-model price table; real spend is billed by Anthropic. Nothing is sent anywhere.</p>
466
501
  </div>
467
502
  <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px">
468
- <span id="claudeStatus" class="cstatus"><span class="cdot"></span><span class="ctext">Checking Claude status...</span></span>
469
- <a href="https://claude.ai/settings/usage" target="_blank" rel="noopener noreferrer" class="toggle" style="white-space:nowrap">View in Claude -&gt;</a>
503
+ <span id="claudeStatus" class="cstatus"><span class="cdot"></span><span class="ctext">Checking status...</span></span>
504
+ <a id="usageViewLink" href="https://claude.ai/settings/usage" target="_blank" rel="noopener noreferrer" class="toggle" style="white-space:nowrap">View in Claude -&gt;</a>
470
505
  </div>
471
506
  </div>
507
+ <div class="useg-controls" style="display:flex;gap:14px;flex-wrap:wrap;align-items:center;margin-bottom:16px">
508
+ <label class="useg-pick"><span>Provider</span><select id="uProvider" class="modal-input"></select></label>
509
+ <label class="useg-pick" id="uAccountWrap"><span>Account</span><select id="uAccount" class="modal-input"></select></label>
510
+ </div>
472
511
  <div class="panel" id="usagePanel" style="display:none">
473
512
  <h2 style="margin:0 0 4px">Usage limits <small>same windows Claude Code shows</small></h2>
474
513
  <div class="sub" style="margin-bottom:12px">Cards marked <span class="tag meas">live</span> show the <b>real % and reset</b> Anthropic reports (the same as Claude's own UI). Turn on <code>enigma config usage-api on</code> to fetch them with your local Claude Code login - no proxy or plan limit needed. (The proxy also captures them from <code>enigma claude</code> traffic.) Until live data lands, the card falls back to tokens used, or a % against a plan limit you set (on the card, or <code>enigma config plan-weekly-limit &lt;tokens&gt;</code>). Per-model live data appears only when Anthropic sends it.</div>
@@ -492,8 +531,10 @@
492
531
  </div>
493
532
  </div>
494
533
  <div id="uProviders" class="sub" style="margin:4px 0 0"></div>
495
- <h2 style="margin:18px 0 10px">By Account <small>per Claude login</small></h2>
496
- <div id="uByAccount"></div>
534
+ <div id="uByAccountWrap">
535
+ <h2 style="margin:18px 0 10px">By Account <small>per Claude login</small></h2>
536
+ <div id="uByAccount"></div>
537
+ </div>
497
538
  <h2 style="margin:18px 0 10px">By Model</h2>
498
539
  <div id="uByModel"></div>
499
540
  <h2 style="margin:18px 0 10px">By Project</h2>
@@ -505,6 +546,10 @@
505
546
  <h2 style="margin-bottom:8px">Real tool usage <small>opt-in</small></h2>
506
547
  <div class="sub">Turn on <b>Real tool-usage stats</b> in <a href="#/settings">Settings</a> to read your Claude Code sessions for token consumption and cost. enigma reads only your local session transcripts; nothing is sent anywhere.</div>
507
548
  </div>
549
+ <div class="panel" id="usageUnavail" style="display:none">
550
+ <h2 id="unavailTitle" style="margin-bottom:8px">No local usage data</h2>
551
+ <div class="sub" id="unavailBody"></div>
552
+ </div>
508
553
  </section>
509
554
 
510
555
  <section id="view-accounts" style="display:none">
@@ -565,6 +610,19 @@
565
610
  </div>
566
611
  <div id="settingsNote" class="set-note"></div>
567
612
  <div id="settingsBody"><div class="empty" style="padding:24px 0">Loading settings...</div></div>
613
+ <div class="panel" id="dashPortPanel">
614
+ <div class="set-cat-h">Dashboard port</div>
615
+ <div class="set-cat-b">the port this dashboard binds. Leave 0 for auto (tries 80, then 24282, then an ephemeral port). Changing it needs a dashboard restart.</div>
616
+ <div class="set-grid">
617
+ <div class="set-row">
618
+ <div class="set-meta"><div class="set-name">Preferred port</div><div class="set-hint" id="dashPortHint">loopback only; never network-facing</div></div>
619
+ <div class="set-ctl" style="display:flex;gap:8px;align-items:center">
620
+ <input id="dashPortInput" type="number" min="0" max="65535" step="1" class="choice" style="width:120px" inputmode="numeric" />
621
+ <button id="dashPortSave" type="button" class="toggle on">Save</button>
622
+ </div>
623
+ </div>
624
+ </div>
625
+ </div>
568
626
  </section>
569
627
 
570
628
  <section id="view-resources" style="display:none">
@@ -1663,6 +1721,22 @@ export {
1663
1721
  // Real tool-usage panel (Claude Code transcripts). null = the opt-in flag is off.
1664
1722
  function shortProject(p) { p = String(p || ""); return p.length > 44 ? "..." + p.slice(-41) : p; }
1665
1723
 
1724
+ // --- provider / account scoping for the Usage view ---------------------------------
1725
+ // Only Claude Code keeps a local per-message usage log enigma can read; Codex/OpenCode
1726
+ // are honestly reported as having no readable store. The header (title, status pill,
1727
+ // "View in ..." link) adapts to the selected provider.
1728
+ const CLAUDE_USAGE_DESC = $("usageDesc").innerHTML; // preserve the rich Claude copy
1729
+ const USAGE_PROVIDERS = {
1730
+ claude: { label: "Claude Code", status: "https://status.claude.com/api/v2/status.json", viewHref: "https://claude.ai/settings/usage", viewLabel: "View in Claude" },
1731
+ codex: { label: "Codex", status: "https://status.openai.com/api/v2/status.json", viewHref: "https://platform.openai.com/usage", viewLabel: "View in OpenAI" },
1732
+ opencode: { label: "OpenCode", status: null, viewHref: "https://opencode.ai", viewLabel: "View OpenCode" },
1733
+ };
1734
+ let usageData = null; // the latest /api/stats usage report (null when opt-in is off)
1735
+ let usageProvider = "claude";
1736
+ let usageAccount = "__all__"; // "__all__" or a Claude account name
1737
+ let usageAccountsMeta = []; // claude accounts from /api/accounts, for email labels
1738
+ let usageAccountsLoaded = false;
1739
+
1666
1740
  // --- Claude-style usage windows (current session / weekly all / weekly Sonnet) ---
1667
1741
  function fmtResetIn(ms) {
1668
1742
  const d = ms - Date.now();
@@ -1732,28 +1806,83 @@ export {
1732
1806
  + "</div>";
1733
1807
  }
1734
1808
 
1735
- // Claude operational status from the public statuspage (CORS-open, no auth). Maps the
1736
- // indicator to a colored dot; degrades quietly to "unavailable" if the fetch is blocked.
1737
- async function loadClaudeStatus() {
1809
+ // Community incident signal for Claude Code: even when the official statuspage reads
1810
+ // green, a sudden cluster of freshly-opened "500/529 server error" issues on the
1811
+ // claude-code repo is an early outage indicator (the statuspage usually lags a live
1812
+ // incident by many minutes). Browser-side, unauthenticated GitHub API (CORS-open,
1813
+ // 60 req/hr per IP) - throttled to one query / 5 min to stay well under the limit.
1814
+ const CLAUDE_ISSUES_API = "https://api.github.com/repos/anthropics/claude-code/issues?state=open&sort=created&direction=desc&per_page=30";
1815
+ const CLAUDE_ISSUES_VIEW = "https://github.com/anthropics/claude-code/issues?q=is%3Aissue+is%3Aopen+500";
1816
+ // A title is a server-error report when it carries an HTTP 500/529 AND error wording
1817
+ // ("API Error: 500", "500 errors", "Internal Server Error (500)", "API Error 529 and 500"...).
1818
+ const SERVER_ERR_NUM_RE = /\b(500|529)\b/;
1819
+ const SERVER_ERR_WORD_RE = /error|internal server/i;
1820
+ const INCIDENT_WINDOW_MS = 30 * 60 * 1000; // only issues opened in the last 30 minutes
1821
+ const INCIDENT_MIN = 2; // need a small cluster, not a single one-off
1822
+ const INCIDENT_TTL_MS = 5 * 60 * 1000; // re-query GitHub at most every 5 minutes
1823
+ let claudeIncidents = { at: 0, count: 0 }; // last GitHub probe (cached count)
1824
+ async function checkClaudeIncidents() {
1825
+ if (Date.now() - claudeIncidents.at < INCIDENT_TTL_MS) return claudeIncidents.count;
1826
+ try {
1827
+ const r = await fetch(CLAUDE_ISSUES_API, { cache: "no-store", headers: { Accept: "application/vnd.github+json" } });
1828
+ if (!r.ok) throw 0;
1829
+ const list = await r.json();
1830
+ const cut = Date.now() - INCIDENT_WINDOW_MS;
1831
+ const count = (Array.isArray(list) ? list : []).filter((it) => {
1832
+ if (!it || it.pull_request) return false; // the issues endpoint also returns PRs
1833
+ if (new Date(it.created_at).getTime() < cut) return false;
1834
+ const t = it.title || "";
1835
+ return SERVER_ERR_NUM_RE.test(t) && SERVER_ERR_WORD_RE.test(t);
1836
+ }).length;
1837
+ claudeIncidents = { at: Date.now(), count };
1838
+ } catch { /* keep last known count; a probe failure must never break the pill */ }
1839
+ return claudeIncidents.count;
1840
+ }
1841
+
1842
+ // Operational status for the selected provider, from its public statuspage (CORS-open,
1843
+ // no auth; same Atlassian Statuspage shape for Claude and OpenAI). Maps the indicator to
1844
+ // a colored dot; degrades quietly to "unavailable", and hides for providers with no page.
1845
+ async function loadProviderStatus() {
1738
1846
  const el = $("claudeStatus");
1739
1847
  if (!el || document.hidden) return;
1848
+ const p = USAGE_PROVIDERS[usageProvider] || USAGE_PROVIDERS.claude;
1849
+ if (!p.status) { el.style.display = "none"; return; }
1850
+ el.style.display = "";
1851
+ el.onclick = null; el.style.cursor = ""; el.title = ""; // clear any prior incident affordance
1852
+ let ind = "unknown", desc = p.label + " status unavailable";
1740
1853
  try {
1741
- const r = await fetch("https://status.claude.com/api/v2/status.json", { cache: "no-store" });
1854
+ const r = await fetch(p.status, { cache: "no-store" });
1742
1855
  const d = await r.json();
1743
- const ind = (d.status && d.status.indicator) || "unknown";
1744
- const desc = (d.status && d.status.description) || "Status unknown";
1745
- el.className = "cstatus " + (ind === "none" ? "ok" : ind === "minor" ? "warn" : ind === "major" ? "orange" : ind === "critical" ? "bad" : "");
1746
- el.querySelector(".ctext").textContent = desc;
1747
- } catch {
1748
- el.className = "cstatus";
1749
- el.querySelector(".ctext").textContent = "Claude status unavailable";
1856
+ ind = (d.status && d.status.indicator) || "unknown";
1857
+ desc = (d.status && d.status.description) || "Status unknown";
1858
+ } catch { ind = "unknown"; }
1859
+ // Claude Code only: overlay the community incident signal. It elevates an otherwise
1860
+ // green/unknown pill to a warning; an official major/critical statuspage state wins.
1861
+ if (usageProvider === "claude" && ind !== "major" && ind !== "critical") {
1862
+ const n = await checkClaudeIncidents();
1863
+ if (n >= INCIDENT_MIN) {
1864
+ el.className = "cstatus orange";
1865
+ el.querySelector(".ctext").textContent = n + " recent 500/529 reports (30 min)";
1866
+ el.title = "Possible Anthropic API incident: " + n + " open claude-code issues reporting 500/529 errors in the last 30 minutes. Click to view.";
1867
+ el.style.cursor = "pointer";
1868
+ el.onclick = () => window.open(CLAUDE_ISSUES_VIEW, "_blank", "noopener");
1869
+ return;
1870
+ }
1750
1871
  }
1872
+ el.className = "cstatus " + (ind === "none" ? "ok" : ind === "minor" ? "warn" : ind === "major" ? "orange" : ind === "critical" ? "bad" : "");
1873
+ el.querySelector(".ctext").textContent = desc;
1751
1874
  }
1752
1875
  async function planSet(key, value) {
1753
1876
  try { await fetch("/api/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: Number(value) }) }); refreshAll(true); }
1754
1877
  catch { /* leave as-is */ }
1755
1878
  }
1756
1879
  function wireUsage() {
1880
+ // Provider + account selectors drive a client-side re-render from the cached report.
1881
+ const ps = $("uProvider");
1882
+ ps.innerHTML = Object.entries(USAGE_PROVIDERS).map(([k, v]) => '<option value="' + k + '">' + esc(v.label) + "</option>").join("");
1883
+ ps.value = usageProvider;
1884
+ ps.addEventListener("change", () => { usageProvider = ps.value; usageAccount = "__all__"; applyUsageView(); loadProviderStatus(); });
1885
+ $("uAccount").addEventListener("change", () => { usageAccount = $("uAccount").value; applyUsageView(); });
1757
1886
  const el = $("uWindows");
1758
1887
  el.addEventListener("click", (e) => {
1759
1888
  const b = e.target.closest("button[data-plan]"); if (!b) return;
@@ -1795,21 +1924,48 @@ export {
1795
1924
  '<span class="tag ' + (p.available ? "meas" : "") + '">' + esc(p.label) + (p.available ? "" : " - " + esc(p.note)) + "</span>").join("");
1796
1925
  }
1797
1926
 
1798
- function renderUsage(u) {
1799
- const panel = $("usagePanel"), hint = $("usageHint");
1800
- if (!u) { panel.style.display = "none"; hint.style.display = "block"; return; }
1801
- hint.style.display = "none";
1802
- panel.style.display = "block";
1803
- renderWindows(u);
1804
- $("uCost").textContent = fmtUsd(u.cost || 0);
1805
- $("uCacheMoney").textContent = fmtUsd(cacheMoney(u.cacheRead || 0));
1806
- $("uCacheRead").textContent = fmt(u.cacheRead || 0);
1807
- $("uInput").textContent = fmt(u.input || 0);
1808
- $("uOutput").textContent = fmt(u.output || 0);
1809
- $("uSessions").textContent = fmt(u.sessions || 0);
1810
- $("uSessionsFoot").textContent = u.pending ? "scanning sessions..." : fmt(u.scannedFiles || 0) + " files scanned";
1927
+ // The Claude account names that have usage, default first then alphabetical.
1928
+ function usageAccountNames() {
1929
+ const names = Object.keys((usageData && usageData.accounts) || {});
1930
+ return names.sort((a, b) => (a === "default" ? -1 : b === "default" ? 1 : a < b ? -1 : a > b ? 1 : 0));
1931
+ }
1932
+ function accountEmail(name) {
1933
+ const a = usageAccountsMeta.find((x) => x.name === name);
1934
+ return a && a.email ? a.email : "";
1935
+ }
1936
+ function accountLabel(name) {
1937
+ const display = name === "default" ? "Default" : name;
1938
+ const email = accountEmail(name);
1939
+ return email ? display + " · " + email : display;
1940
+ }
1941
+ // Rebuild the account <select> only when the account set/labels actually changed, so a
1942
+ // routine poll never resets an open dropdown or the current selection.
1943
+ function buildAccountOptions() {
1944
+ const sel = $("uAccount");
1945
+ const names = usageAccountNames();
1946
+ const sig = names.join("|") + "::" + usageAccountsMeta.map((a) => a.name + ":" + (a.email || "")).join(",");
1947
+ if (usageAccount !== "__all__" && !names.includes(usageAccount)) usageAccount = "__all__";
1948
+ if (sel.dataset.sig !== sig) {
1949
+ sel.dataset.sig = sig;
1950
+ sel.innerHTML = '<option value="__all__">All accounts</option>'
1951
+ + names.map((n) => '<option value="' + esc(n) + '">' + esc(accountLabel(n)) + "</option>").join("");
1952
+ }
1953
+ sel.value = usageAccount;
1954
+ }
1955
+
1956
+ // Fill the panel from one scope: the whole report (isAll) or one account's sub-report.
1957
+ // The Providers line and By Account table are only meaningful for the whole report.
1958
+ function renderUsageScoped(x, isAll, pending) {
1959
+ renderWindows(x);
1960
+ $("uCost").textContent = fmtUsd(x.cost || 0);
1961
+ $("uCacheMoney").textContent = fmtUsd(cacheMoney(x.cacheRead || 0));
1962
+ $("uCacheRead").textContent = fmt(x.cacheRead || 0);
1963
+ $("uInput").textContent = fmt(x.input || 0);
1964
+ $("uOutput").textContent = fmt(x.output || 0);
1965
+ $("uSessions").textContent = fmt(x.sessions || 0);
1966
+ $("uSessionsFoot").textContent = pending ? "scanning sessions..." : fmt(x.scannedFiles || 0) + " files scanned";
1811
1967
  // Current 5-hour block (computed locally from transcript timestamps).
1812
- const b = u.block, bw = $("uBlockWrap");
1968
+ const b = x.block, bw = $("uBlockWrap");
1813
1969
  if (b && b.startedAt) {
1814
1970
  bw.style.display = "block";
1815
1971
  const now = Date.now(), total = 5 * 3600 * 1000;
@@ -1824,11 +1980,57 @@ export {
1824
1980
  $("uBlockBurn").textContent = fmt(Math.round(b.burnRatePerMin || 0)) + " tok/min";
1825
1981
  $("uBlockProj").textContent = fmt(b.projectedTokens || 0) + " · " + fmtUsd(b.projectedCost || 0);
1826
1982
  } else { bw.style.display = "none"; }
1827
- renderProviders(u.providers);
1828
- renderUsageTable($("uByAccount"), u.byAccount, u.pending, "Account");
1829
- renderUsageTable($("uByModel"), u.byModel, u.pending, "Model");
1830
- renderUsageTable($("uByProject"), u.byProject, u.pending, "Project");
1831
- renderRecentSessions($("uRecent"), u.recentSessions, u.pending);
1983
+ $("uByAccountWrap").style.display = isAll ? "" : "none";
1984
+ if (isAll) { renderProviders(x.providers); renderUsageTable($("uByAccount"), x.byAccount, pending, "Account"); }
1985
+ else { $("uProviders").innerHTML = ""; }
1986
+ renderUsageTable($("uByModel"), x.byModel, pending, "Model");
1987
+ renderUsageTable($("uByProject"), x.byProject, pending, "Project");
1988
+ renderRecentSessions($("uRecent"), x.recentSessions, pending);
1989
+ }
1990
+
1991
+ // Adapt the header to the selected provider, then render its scope. Only Claude has a
1992
+ // measurable local store; the others show an honest "no local usage" panel instead.
1993
+ function applyUsageView() {
1994
+ const p = USAGE_PROVIDERS[usageProvider] || USAGE_PROVIDERS.claude;
1995
+ $("usageTitle").textContent = p.label + " Usage";
1996
+ const link = $("usageViewLink");
1997
+ link.href = p.viewHref; link.textContent = p.viewLabel + " ->";
1998
+ $("uAccountWrap").style.display = (usageProvider === "claude" && usageData) ? "" : "none";
1999
+ const panel = $("usagePanel"), hint = $("usageHint"), unavail = $("usageUnavail");
2000
+ if (usageProvider !== "claude") {
2001
+ panel.style.display = "none"; hint.style.display = "none"; unavail.style.display = "block";
2002
+ $("usageDesc").textContent = p.label + " token usage. enigma can only measure usage for tools that keep a local per-message log it can read.";
2003
+ const cov = ((usageData && usageData.providers) || []).find((x) => x.tool === usageProvider);
2004
+ const note = (cov && cov.note) || "no local token-usage store";
2005
+ $("unavailTitle").textContent = "No local usage data for " + p.label;
2006
+ $("unavailBody").innerHTML = "<b>" + esc(p.label) + "</b>: " + esc(note) + "."
2007
+ + " enigma measures token usage only for tools that keep a local per-message log it can read - today that is Claude Code."
2008
+ + " Switch the provider to <b>Claude Code</b> for measured usage, or open " + esc(p.label) + "'s own dashboard via the link above.";
2009
+ return;
2010
+ }
2011
+ $("usageDesc").innerHTML = CLAUDE_USAGE_DESC;
2012
+ unavail.style.display = "none";
2013
+ if (!usageData) { panel.style.display = "none"; hint.style.display = "block"; return; }
2014
+ hint.style.display = "none"; panel.style.display = "block";
2015
+ buildAccountOptions();
2016
+ const isAll = usageAccount === "__all__";
2017
+ const scoped = isAll ? usageData : ((usageData.accounts && usageData.accounts[usageAccount]) || usageData);
2018
+ renderUsageScoped(scoped, isAll, usageData.pending);
2019
+ }
2020
+
2021
+ // Entry point from the stats poll: cache the report, then render the active scope.
2022
+ function renderUsage(u) {
2023
+ usageData = u || null;
2024
+ applyUsageView();
2025
+ }
2026
+
2027
+ // Claude account identities (for the email labels), fetched once when the view opens.
2028
+ async function loadUsageAccounts() {
2029
+ try {
2030
+ const d = await (await fetch("/api/accounts", { cache: "no-store" })).json();
2031
+ usageAccountsMeta = (d.accounts || []).filter((a) => a.tool === "claude");
2032
+ if (usageProvider === "claude" && usageData) applyUsageView();
2033
+ } catch { /* labels fall back to account names */ }
1832
2034
  }
1833
2035
 
1834
2036
  function renderCache(c) {
@@ -2081,6 +2283,26 @@ export {
2081
2283
  // --- settings subpage (mirrors the TUI registry over /api/settings) ---
2082
2284
  function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
2083
2285
 
2286
+ // A self-dismissing notification so a failed action can't slip by unseen. Errors
2287
+ // linger longer than confirmations; both auto-clear so they never pile up.
2288
+ function toast(msg, kind) {
2289
+ let wrap = $("toasts");
2290
+ if (!wrap) { wrap = document.createElement("div"); wrap.id = "toasts"; document.body.appendChild(wrap); }
2291
+ const el = document.createElement("div");
2292
+ el.className = "toast " + (kind === "err" ? "err" : kind === "ok" ? "ok" : "");
2293
+ el.setAttribute("role", kind === "err" ? "alert" : "status");
2294
+ el.textContent = msg;
2295
+ wrap.appendChild(el);
2296
+ setTimeout(() => { el.classList.add("hide"); setTimeout(() => el.remove(), 320); }, kind === "err" ? 6000 : 3000);
2297
+ }
2298
+ // Set a status note's text and ok/err styling in one place. Errors are also toasted
2299
+ // (the inline note alone is easy to miss), so callers just call setNote(el, msg, "err").
2300
+ function setNote(el, msg, kind) {
2301
+ if (typeof el === "string") el = $(el);
2302
+ if (el) { el.textContent = msg; el.classList.toggle("ok", kind === "ok"); el.classList.toggle("err", kind === "err"); }
2303
+ if (kind === "err") toast(msg, "err");
2304
+ }
2305
+
2084
2306
  // Capitalize a choice value for display ("on-demand" -> "On demand"), keeping the raw
2085
2307
  // value as the option's value so the server still receives the exact token.
2086
2308
  function prettyChoice(c) { c = String(c).replace(/[-_]/g, " "); return c.charAt(0).toUpperCase() + c.slice(1); }
@@ -2092,11 +2314,17 @@ export {
2092
2314
  const back = document.createElement("div");
2093
2315
  back.className = "modal-back";
2094
2316
  let inner = "";
2095
- if (field && field.options) {
2317
+ if (field && field.iconOptions) {
2318
+ // Clickable app cards with logos; a native <select> can't render SVG options.
2319
+ inner = '<div class="modal-picker">' + field.iconOptions.map((o) =>
2320
+ '<button type="button" class="pick-card' + (o.value === field.value ? " sel" : "") + '" data-pick="' + esc(o.value) + '">'
2321
+ + (o.icon ? '<span class="pick-ic">' + o.icon + "</span>" : "") + "<span>" + esc(o.label || o.value) + "</span></button>").join("") + "</div>";
2322
+ } else if (field && field.options) {
2096
2323
  inner = '<select class="modal-input">' + field.options.map((o) =>
2097
2324
  '<option value="' + esc(o.value) + '"' + (o.value === field.value ? " selected" : "") + ">" + esc(o.label || o.value) + "</option>").join("") + "</select>";
2098
2325
  } else if (field) {
2099
- inner = '<input class="modal-input" type="text" value="' + esc(field.value || "") + '" placeholder="' + esc(field.placeholder || "") + '">';
2326
+ inner = '<input class="modal-input" type="text" value="' + esc(field.value || "") + '" placeholder="' + esc(field.placeholder || "") + '">'
2327
+ + (field.validate ? '<div class="modal-err" role="alert"></div>' : "");
2100
2328
  }
2101
2329
  back.innerHTML = '<div class="modal" role="dialog" aria-modal="true">'
2102
2330
  + '<div class="modal-title">' + esc(title) + "</div>"
@@ -2107,22 +2335,39 @@ export {
2107
2335
  document.body.appendChild(back);
2108
2336
  requestAnimationFrame(() => back.classList.add("show"));
2109
2337
  const inp = back.querySelector(".modal-input");
2338
+ const okBtn = back.querySelector(".modal-ok");
2339
+ const validate = field && field.validate, errEl = back.querySelector(".modal-err");
2340
+ let invalid = false;
2341
+ const runValidate = () => {
2342
+ if (!validate || !inp) return;
2343
+ const v = String(inp.value).trim(), msg = v ? validate(v) : null;
2344
+ invalid = !!msg || v === ""; // empty is incomplete (block submit) but shows no error text
2345
+ if (errEl) errEl.textContent = msg || "";
2346
+ okBtn.disabled = invalid;
2347
+ };
2110
2348
  let done = false;
2111
2349
  const close = (val) => { if (done) return; done = true; back.classList.remove("show"); setTimeout(() => back.remove(), 200); resolve(val); };
2112
- const ok = () => close(field ? (inp ? inp.value : true) : true);
2350
+ const ok = () => { if (invalid) return; close(field ? (inp ? inp.value : true) : true); };
2113
2351
  const cancel = () => close(field ? null : false);
2114
- back.querySelector(".modal-ok").addEventListener("click", ok);
2352
+ okBtn.addEventListener("click", ok);
2353
+ if (field && field.iconOptions) {
2354
+ okBtn.style.display = "none"; // a card click is the confirm
2355
+ back.querySelectorAll(".pick-card").forEach((c) => c.addEventListener("click", () => close(c.dataset.pick)));
2356
+ }
2357
+ if (inp && validate) { inp.addEventListener("input", runValidate); runValidate(); }
2115
2358
  back.querySelector(".modal-cancel").addEventListener("click", cancel);
2116
2359
  back.addEventListener("mousedown", (e) => { if (e.target === back) cancel(); });
2117
2360
  back.addEventListener("keydown", (e) => {
2118
2361
  if (e.key === "Escape") { e.preventDefault(); cancel(); }
2119
2362
  else if (e.key === "Enter" && (!inp || inp.tagName !== "TEXTAREA")) { e.preventDefault(); ok(); }
2120
2363
  });
2121
- if (inp) { inp.focus(); if (inp.select) inp.select(); } else back.querySelector(".modal-ok").focus();
2364
+ if (inp) { inp.focus(); if (inp.select) inp.select(); }
2365
+ else { const c = back.querySelector(".pick-card.sel") || back.querySelector(".pick-card") || okBtn; c.focus(); }
2122
2366
  });
2123
2367
  }
2124
- const askText = (title, value, message) => openModal({ title, message, field: { value: value || "" } }).then((v) => (v == null ? null : String(v).trim()));
2368
+ const askText = (title, value, message, validate) => openModal({ title, message, field: { value: value || "", validate } }).then((v) => (v == null ? null : String(v).trim()));
2125
2369
  const askChoice = (title, options, value, message) => openModal({ title, message, field: { options, value } });
2370
+ const askIconChoice = (title, iconOptions, value, message) => openModal({ title, message, field: { iconOptions, value } });
2126
2371
  const askConfirm = (title, message, danger) => openModal({ title, message, danger, okLabel: danger ? "Remove" : "OK" }).then(Boolean);
2127
2372
 
2128
2373
  // Turn "(experimental)" and " · measured/experimental" label suffixes into visual badges.
@@ -2169,7 +2414,7 @@ export {
2169
2414
  // render it as a switch (like a pure bool); 3+ values stay a select with pretty labels.
2170
2415
  const asSwitch = !s.choices || (s.choices.length === 2 && s.choices.indexOf(s.offChoice) !== -1);
2171
2416
  const ctl = !asSwitch
2172
- ? '<select class="choice" data-skey="' + esc(s.key) + '">'
2417
+ ? '<select class="choice" data-skey="' + esc(s.key) + '" data-prev="' + esc(s.choice) + '">'
2173
2418
  + s.choices.map((c) => '<option value="' + esc(c) + '"' + (c === s.choice ? " selected" : "") + ">" + esc(prettyChoice(c)) + "</option>").join("")
2174
2419
  + "</select>"
2175
2420
  : '<button type="button" class="toggle' + (s.value ? " on" : "") + '" data-skey="' + esc(s.key)
@@ -2191,32 +2436,44 @@ export {
2191
2436
  + '<div class="set-grid">' + c.settings.map(settingRow).join("") + "</div></div>").join("");
2192
2437
  }
2193
2438
 
2439
+ function applyBool(ctl, on) { ctl.classList.toggle("on", on); ctl.textContent = on ? "On" : "Off"; ctl.dataset.on = on ? 1 : 0; }
2440
+
2441
+ // Optimistic: flip the control immediately, then reconcile with the server. On any
2442
+ // failure - rejected write, unreachable server, OR a write that silently did not take
2443
+ // effect (returned value != requested) - roll the control back and surface the error.
2194
2444
  async function postSetting(key, value, ctl, noteEl) {
2195
- const note = noteEl || $("settingsNote");
2196
- note.textContent = "Saving " + key + "...";
2445
+ const el = noteEl || $("settingsNote");
2446
+ const isBool = ctl.dataset.kind === "bool";
2447
+ const want = isBool ? (value === true || value === 1) : String(value);
2448
+ const prevBool = ctl.dataset.on === "1";
2449
+ const prevChoice = ctl.dataset.prev != null ? ctl.dataset.prev : ctl.value; // <select> already changed natively
2450
+ const rollback = () => { if (isBool) applyBool(ctl, prevBool); else ctl.value = prevChoice; };
2451
+ if (isBool) applyBool(ctl, want);
2452
+ setNote(el, "Saving " + key + "...");
2197
2453
  try {
2198
2454
  const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value }) });
2199
2455
  const out = await res.json();
2200
- if (!out.ok) { note.textContent = "Could not change " + key + ": " + (out.error || "error"); return; }
2456
+ if (!out.ok) { rollback(); setNote(el, "Could not change " + key + ": " + (out.error || "error"), "err"); return; }
2201
2457
  const s = out.setting;
2202
- if (s && ctl.dataset.kind === "bool") { ctl.classList.toggle("on", s.value); ctl.textContent = s.value ? "On" : "Off"; ctl.dataset.on = s.value ? 1 : 0; }
2203
- else if (s) { ctl.value = s.choice; }
2204
- note.textContent = out.restartNote || ("Saved " + key + ".");
2205
- } catch { note.textContent = "Could not reach the server to change " + key + "."; }
2458
+ if (s && isBool) applyBool(ctl, s.value);
2459
+ else if (s) { ctl.value = s.choice; ctl.dataset.prev = s.choice; }
2460
+ if (s && (isBool ? s.value !== want : String(s.choice) !== want)) { setNote(el, key + " did not change (the write had no effect).", "err"); return; }
2461
+ setNote(el, out.restartNote || ("Saved " + key + "."), "ok");
2462
+ } catch { rollback(); setNote(el, "Could not reach the server to change " + key + ".", "err"); }
2206
2463
  }
2207
2464
 
2208
2465
  // List settings: add/remove one entry, then reload so the chips reflect disk truth.
2209
2466
  async function postListOp(key, op, item) {
2210
2467
  item = (item || "").trim();
2211
2468
  if (!item) return;
2212
- $("settingsNote").textContent = (op === "add" ? "Adding to " : "Removing from ") + key + "...";
2469
+ setNote("settingsNote", (op === "add" ? "Adding to " : "Removing from ") + key + "...");
2213
2470
  try {
2214
2471
  const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: { op, item } }) });
2215
2472
  const out = await res.json();
2216
- if (!out.ok) { $("settingsNote").textContent = "Could not change " + key + ": " + (out.error || "error"); return; }
2217
- $("settingsNote").textContent = "Saved " + key + ".";
2473
+ if (!out.ok) { setNote("settingsNote", "Could not change " + key + ": " + (out.error || "error"), "err"); return; }
2474
+ setNote("settingsNote", "Saved " + key + ".", "ok");
2218
2475
  await loadSettings();
2219
- } catch { $("settingsNote").textContent = "Could not reach the server to change " + key + "."; }
2476
+ } catch { setNote("settingsNote", "Could not reach the server to change " + key + ".", "err"); }
2220
2477
  }
2221
2478
 
2222
2479
  function wireSettings() {
@@ -2241,16 +2498,40 @@ export {
2241
2498
  const inp = e.target.closest("input.linput");
2242
2499
  if (inp) { e.preventDefault(); postListOp(inp.dataset.skey, "add", inp.value); inp.value = ""; }
2243
2500
  });
2501
+ const save = $("dashPortSave"), portInp = $("dashPortInput");
2502
+ if (save) save.addEventListener("click", saveDashPort);
2503
+ if (portInp) portInp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); saveDashPort(); } });
2244
2504
  }
2245
2505
 
2246
2506
  let settingsLoaded = false;
2247
2507
  async function loadSettings() {
2248
2508
  try {
2249
- const res = await fetch("/api/settings", { cache: "no-store" });
2250
- renderSettings((await res.json()).categories || []);
2509
+ const data = await (await fetch("/api/settings", { cache: "no-store" })).json();
2510
+ renderSettings(data.categories || []);
2511
+ renderDashPort(data);
2251
2512
  } catch { $("settingsBody").innerHTML = '<div class="empty" style="padding:24px 0">Settings unavailable.</div>'; }
2252
2513
  }
2253
2514
 
2515
+ // Numeric dashboard-port control (outside the boolean/choice registry, like token-price).
2516
+ function renderDashPort(data) {
2517
+ const inp = $("dashPortInput");
2518
+ if (!inp) return;
2519
+ inp.value = data && data.dashboardPort ? data.dashboardPort : "";
2520
+ const running = data && data.runningPort;
2521
+ $("dashPortHint").textContent = running ? ("currently running on port " + running + " - loopback only") : "loopback only; never network-facing";
2522
+ }
2523
+ async function saveDashPort() {
2524
+ const raw = $("dashPortInput").value.trim();
2525
+ const value = raw === "" ? 0 : Number(raw);
2526
+ if (!Number.isInteger(value) || value < 0 || value > 65535) { setNote("settingsNote", "Port must be 0 (auto) or 1-65535.", "err"); return; }
2527
+ setNote("settingsNote", "Saving dashboard port...");
2528
+ try {
2529
+ const out = await (await fetch("/api/dashboard-port", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value }) })).json();
2530
+ if (out.ok) setNote("settingsNote", out.restartNote || ("Saved dashboard port = " + (value || "auto") + "."), "ok");
2531
+ else setNote("settingsNote", "Could not save: " + (out.error || "error"), "err");
2532
+ } catch { setNote("settingsNote", "Could not reach the server to set the port.", "err"); }
2533
+ }
2534
+
2254
2535
  // --- "Enigma systems" overview (factual state, never invented savings) ---
2255
2536
  function pill(state, text) { return '<span class="pill ' + state + '">' + esc(text) + "</span>"; }
2256
2537
  function boolPill(b) { return b ? pill("on", "on") : pill("off", "off"); }
@@ -2309,19 +2590,19 @@ export {
2309
2590
  }
2310
2591
 
2311
2592
  async function runUpdate() {
2312
- $("updateNote").textContent = "Checking for updates...";
2593
+ setNote("updateNote", "Checking for updates...");
2313
2594
  try {
2314
2595
  const res = await fetch("/api/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
2315
2596
  const out = await res.json();
2316
- if (!out.ok) { $("updateNote").textContent = "Update failed: " + (out.error || "error"); return; }
2597
+ if (!out.ok) { setNote("updateNote", "Update failed: " + (out.error || "error"), "err"); return; }
2317
2598
  // Only reload when something actually changed; an up-to-date check just reports.
2318
2599
  if (out.changed) {
2319
- $("updateNote").textContent = (out.note || "Updated.") + " Refreshing the dashboard...";
2600
+ setNote("updateNote", (out.note || "Updated.") + " Refreshing the dashboard...", "ok");
2320
2601
  setTimeout(() => location.reload(), 1200);
2321
2602
  } else {
2322
- $("updateNote").textContent = out.note || "Already up to date.";
2603
+ setNote("updateNote", out.note || "Already up to date.", "ok");
2323
2604
  }
2324
- } catch { $("updateNote").textContent = "Could not reach the server to update."; }
2605
+ } catch { setNote("updateNote", "Could not reach the server to update.", "err"); }
2325
2606
  }
2326
2607
 
2327
2608
  // Detect & repair every tool's launch path; the server persists any off-PATH
@@ -2337,10 +2618,12 @@ export {
2337
2618
  $("updateNote").textContent = "";
2338
2619
  renderFixPath(out);
2339
2620
  loadSystems(true);
2621
+ if (!out.ok) toast("Some tools could not be fixed - see the details below.", "err");
2340
2622
  } catch {
2341
2623
  $("updateNote").textContent = "";
2342
2624
  $("fixPathResult").className = "fixres show";
2343
2625
  $("fixPathResult").innerHTML = '<div class="fixres-head bad">Could not reach the server to fix tool paths.</div>';
2626
+ toast("Could not reach the server to fix tool paths.", "err");
2344
2627
  } finally { if (btn) { btn.disabled = false; } }
2345
2628
  }
2346
2629
 
@@ -2462,24 +2745,24 @@ export {
2462
2745
  }
2463
2746
 
2464
2747
  async function postSkill(name, action, content) {
2465
- $("skillsNote").textContent = "Working...";
2748
+ setNote("skillsNote", "Working...");
2466
2749
  try {
2467
2750
  const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, action, content }) });
2468
2751
  const out = await res.json();
2469
- if (!out.ok) { $("skillsNote").textContent = "Could not " + action + " " + name + ": " + (out.error || "error"); return; }
2752
+ if (!out.ok) { setNote("skillsNote", "Could not " + action + " " + name + ": " + (out.error || "error"), "err"); return; }
2470
2753
  if (out.skills) renderSkills(out.skills);
2471
- $("skillsNote").textContent = out.note || ("Done: " + name);
2472
- } catch { $("skillsNote").textContent = "Could not reach the server to change " + name + "."; }
2754
+ setNote("skillsNote", out.note || ("Done: " + name), "ok");
2755
+ } catch { setNote("skillsNote", "Could not reach the server to change " + name + ".", "err"); }
2473
2756
  }
2474
2757
 
2475
2758
  // Inline SKILL.md editor (one skill at a time).
2476
2759
  let editing = null;
2477
2760
  async function openEditor(name, isEnigma) {
2478
- $("skillsNote").textContent = "Loading " + name + "...";
2761
+ setNote("skillsNote", "Loading " + name + "...");
2479
2762
  try {
2480
2763
  const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, action: "read" }) });
2481
2764
  const out = await res.json();
2482
- if (!out.ok) { $("skillsNote").textContent = "Could not open " + name + ": " + (out.error || "error"); return; }
2765
+ if (!out.ok) { setNote("skillsNote", "Could not open " + name + ": " + (out.error || "error"), "err"); return; }
2483
2766
  editing = name;
2484
2767
  $("seName").textContent = name;
2485
2768
  $("seWarn").textContent = isEnigma ? "managed by enigma - see Customized-skill updates below for whether edits survive an update" : "external skill";
@@ -2489,7 +2772,7 @@ export {
2489
2772
  $("skillsNote").textContent = "";
2490
2773
  window.scrollTo({ top: 0, behavior: "smooth" });
2491
2774
  $("seText").focus();
2492
- } catch { $("skillsNote").textContent = "Could not reach the server."; }
2775
+ } catch { setNote("skillsNote", "Could not reach the server.", "err"); }
2493
2776
  }
2494
2777
  function closeEditor() { editing = null; $("skillEditor").style.display = "none"; }
2495
2778
  // Render per-app save targets (one checkbox per app that holds the skill), only when
@@ -2509,18 +2792,18 @@ export {
2509
2792
  let content = $("seText").value;
2510
2793
  if (agEl.dataset.has === "1") {
2511
2794
  const agents = [...agEl.querySelectorAll("input[type=checkbox]:checked")].map((i) => i.dataset.agent);
2512
- if (!agents.length) { $("skillsNote").textContent = "Pick at least one app to save to."; return; }
2795
+ if (!agents.length) { setNote("skillsNote", "Pick at least one app to save to.", "err"); return; }
2513
2796
  content = { text: $("seText").value, agents }; // per-app save
2514
2797
  }
2515
- $("skillsNote").textContent = "Saving " + editing + "...";
2798
+ setNote("skillsNote", "Saving " + editing + "...");
2516
2799
  try {
2517
2800
  const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editing, action: "save", content }) });
2518
2801
  const out = await res.json();
2519
- if (!out.ok) { $("skillsNote").textContent = "Could not save " + editing + ": " + (out.error || "error"); return; }
2802
+ if (!out.ok) { setNote("skillsNote", "Could not save " + editing + ": " + (out.error || "error"), "err"); return; }
2520
2803
  if (out.skills) renderSkills(out.skills);
2521
- $("skillsNote").textContent = out.note || "Saved.";
2804
+ setNote("skillsNote", out.note || "Saved.", "ok");
2522
2805
  closeEditor();
2523
- } catch { $("skillsNote").textContent = "Could not reach the server."; }
2806
+ } catch { setNote("skillsNote", "Could not reach the server.", "err"); }
2524
2807
  }
2525
2808
 
2526
2809
  function wireSkills() {
@@ -2573,7 +2856,19 @@ export {
2573
2856
 
2574
2857
  // --- accounts & profiles subpage (mirrors the TUI hub over /api/accounts) ---
2575
2858
  let accountsLoaded = false;
2576
- let accTools = [];
2859
+ let accTools = [], accAccounts = [], accProfiles = [];
2860
+ // Real-time name validator mirroring the server (accounts.ts validateAccountName + the
2861
+ // duplicate checks), so the modal blocks an invalid or already-used name as the user types.
2862
+ // `taken` = names already in scope (per-tool for accounts, global for profiles); `current`
2863
+ // = the name being renamed (allowed to keep itself), or null when adding.
2864
+ function nameValidator(taken, current) {
2865
+ return (v) => {
2866
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(v)) return "Use letters, digits, '.', '_' or '-' (max 64).";
2867
+ if (v === "default") return "'default' is a reserved name.";
2868
+ if (v !== current && taken.indexOf(v) !== -1) return "That name is already in use.";
2869
+ return null;
2870
+ };
2871
+ }
2577
2872
  async function loadAccounts() {
2578
2873
  try { const r = await fetch("/api/accounts", { cache: "no-store" }); renderAccounts(await r.json()); }
2579
2874
  catch { $("accountsBody").innerHTML = '<div class="empty" style="padding:24px 0">Accounts unavailable.</div>'; }
@@ -2604,7 +2899,7 @@ export {
2604
2899
  const ic = agentSvg(t.name);
2605
2900
  const lbl = ic ? '<span class="aicon" title="' + esc(t.label) + '" aria-label="' + esc(t.label) + '">' + ic + "</span>" : esc(t.label);
2606
2901
  return '<label style="font-size:12px;color:var(--muted);display:inline-flex;align-items:center;gap:6px;margin:0 12px 6px 0">' + lbl
2607
- + ' <select class="choice" data-prof-tool="' + esc(t.name) + '" data-prof="' + esc(p.name) + '">' + opts + "</select></label>";
2902
+ + ' <select class="choice" data-prof-tool="' + esc(t.name) + '" data-prof="' + esc(p.name) + '" data-prev="' + esc(cur) + '">' + opts + "</select></label>";
2608
2903
  }).join("");
2609
2904
  const acts = '<div class="set-ctl" style="display:flex;gap:6px;align-items:center">'
2610
2905
  + (p.active ? "" : '<button type="button" class="toggle" data-prof-act="activate" data-name="' + esc(p.name) + '">Use</button>')
@@ -2616,67 +2911,74 @@ export {
2616
2911
  function renderAccounts(d) {
2617
2912
  d = d || {};
2618
2913
  const accounts = d.accounts || [], profiles = d.profiles || [], tools = d.tools || [];
2619
- accTools = tools;
2914
+ accTools = tools; accAccounts = accounts; accProfiles = profiles;
2620
2915
  $("accountsBody").innerHTML = accounts.length ? accounts.map(accountRow).join("") : '<div class="empty" style="padding:20px 0">No accounts.</div>';
2621
2916
  $("profilesBody").innerHTML = profiles.length ? profiles.map((p) => profileRow(p, tools, accounts)).join("")
2622
2917
  : '<div class="empty" style="padding:20px 0">No profiles yet. Add one to pin an account per tool.</div>';
2623
2918
  }
2624
- async function accAction(op, payload) {
2625
- $("accountsNote").textContent = "Working...";
2919
+ // `rollback` (optional) restores an optimistically-changed control (e.g. a profile
2920
+ // <select>) when the server rejects or is unreachable, so the UI never shows a value
2921
+ // that was not saved. A successful action re-renders from out.data (authoritative).
2922
+ async function accAction(op, payload, rollback) {
2923
+ setNote("accountsNote", "Working...");
2626
2924
  try {
2627
2925
  const r = await fetch("/api/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.assign({ op }, payload)) });
2628
2926
  const out = await r.json();
2629
- $("accountsNote").textContent = out.ok ? "Saved." : ("Error: " + (out.error || "failed"));
2630
2927
  if (out.data) renderAccounts(out.data);
2631
- } catch { $("accountsNote").textContent = "Could not reach the server."; }
2928
+ if (out.ok) { setNote("accountsNote", "Saved.", "ok"); return; }
2929
+ if (rollback && !out.data) rollback();
2930
+ setNote("accountsNote", "Error: " + (out.error || "failed"), "err");
2931
+ } catch { if (rollback) rollback(); setNote("accountsNote", "Could not reach the server.", "err"); }
2632
2932
  }
2633
2933
  function wireAccounts() {
2634
2934
  $("accAdd").addEventListener("click", async () => {
2635
2935
  const tool = accTools.length === 1 ? accTools[0].name
2636
- : await askChoice("Add account", accTools.map((t) => ({ value: t.name, label: t.label || t.name })), accTools[0] ? accTools[0].name : "", "Which tool is this account for?");
2936
+ : await askIconChoice("Add account", accTools.map((t) => ({ value: t.name, label: t.label || t.name, icon: agentSvg(t.name) })), accTools[0] ? accTools[0].name : "", "Which tool is this account for?");
2637
2937
  if (!tool) return;
2638
- const name = await askText("Add account", "", "New account name (e.g. work):");
2938
+ const taken = accAccounts.filter((a) => a.tool === tool).map((a) => a.name);
2939
+ const name = await askText("Add account", "", "New account name (e.g. work):", nameValidator(taken, null));
2639
2940
  if (name) accAction("account.add", { tool, name });
2640
2941
  });
2641
- $("profAdd").addEventListener("click", async () => { const name = await askText("Add profile", "", "New profile name:"); if (name) accAction("profile.add", { name }); });
2942
+ $("profAdd").addEventListener("click", async () => { const name = await askText("Add profile", "", "New profile name:", nameValidator(accProfiles.map((p) => p.name), null)); if (name) accAction("profile.add", { name }); });
2642
2943
  $("accountsBody").addEventListener("click", async (e) => {
2643
2944
  const b = e.target.closest("button[data-acc]"); if (!b) return;
2644
2945
  const op = b.dataset.acc, tool = b.dataset.tool, name = b.dataset.name;
2645
2946
  if (op === "activate") accAction("account.activate", { tool, name });
2646
2947
  else if (op === "remove") { if (await askConfirm("Remove account", "Remove account '" + name + "' and delete its local config dir?", true)) accAction("account.remove", { tool, name }); }
2647
- else if (op === "rename") { const nn = await askText("Rename account", name, "Rename '" + name + "' to:"); if (nn && nn !== name) accAction("account.rename", { tool, name, newName: nn }); }
2648
- else if (op === "login") { $("accountsNote").textContent = "To log in, run this in a terminal: enigma " + tool + " " + name; }
2948
+ else if (op === "rename") { const taken = accAccounts.filter((a) => a.tool === tool).map((a) => a.name); const nn = await askText("Rename account", name, "Rename '" + name + "' to:", nameValidator(taken, name)); if (nn && nn !== name) accAction("account.rename", { tool, name, newName: nn }); }
2949
+ else if (op === "login") { setNote("accountsNote", "To log in, run this in a terminal: enigma " + tool + " " + name); }
2649
2950
  });
2650
2951
  $("profilesBody").addEventListener("click", async (e) => {
2651
2952
  const b = e.target.closest("button[data-prof-act]"); if (!b) return;
2652
2953
  const op = b.dataset.profAct, name = b.dataset.name;
2653
2954
  if (op === "activate") accAction("profile.activate", { name });
2654
2955
  else if (op === "remove") { if (await askConfirm("Remove profile", "Remove profile '" + name + "'? (its accounts are kept)", true)) accAction("profile.remove", { name }); }
2655
- else if (op === "rename") { const nn = await askText("Rename profile", name, "Rename profile '" + name + "' to:"); if (nn && nn !== name) accAction("profile.rename", { name, newName: nn }); }
2956
+ else if (op === "rename") { const nn = await askText("Rename profile", name, "Rename profile '" + name + "' to:", nameValidator(accProfiles.map((p) => p.name), name)); if (nn && nn !== name) accAction("profile.rename", { name, newName: nn }); }
2656
2957
  });
2657
2958
  $("profilesBody").addEventListener("change", (e) => {
2658
2959
  const sel = e.target.closest("select[data-prof-tool]"); if (!sel) return;
2659
- accAction("profile.setAccount", { profile: sel.dataset.prof, tool: sel.dataset.profTool, account: sel.value || null });
2960
+ const prev = sel.dataset.prev != null ? sel.dataset.prev : "";
2961
+ accAction("profile.setAccount", { profile: sel.dataset.prof, tool: sel.dataset.profTool, account: sel.value || null }, () => { sel.value = prev; });
2660
2962
  });
2661
2963
  }
2662
2964
 
2663
2965
  // --- config import / export (the browser file picker is what "asks where") ---
2664
2966
  async function exportConfig() {
2665
- $("settingsNote").textContent = "Exporting...";
2967
+ setNote("settingsNote", "Exporting...");
2666
2968
  let text;
2667
2969
  try { text = await (await fetch("/api/config-export", { cache: "no-store" })).text(); }
2668
- catch { $("settingsNote").textContent = "Export failed."; return; }
2970
+ catch { setNote("settingsNote", "Export failed.", "err"); return; }
2669
2971
  const name = "enigma-config-" + new Date().toISOString().slice(0, 10) + ".json";
2670
2972
  if (window.showSaveFilePicker) {
2671
2973
  try {
2672
2974
  const h = await window.showSaveFilePicker({ suggestedName: name, types: [{ description: "JSON", accept: { "application/json": [".json"] } }] });
2673
2975
  const w = await h.createWritable(); await w.write(text); await w.close();
2674
- $("settingsNote").textContent = "Exported (no secrets included)."; return;
2675
- } catch (e) { if (e && e.name === "AbortError") { $("settingsNote").textContent = "Export cancelled."; return; } }
2976
+ setNote("settingsNote", "Exported (no secrets included).", "ok"); return;
2977
+ } catch (e) { if (e && e.name === "AbortError") { setNote("settingsNote", "Export cancelled."); return; } }
2676
2978
  }
2677
2979
  const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
2678
2980
  const a = document.createElement("a"); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url);
2679
- $("settingsNote").textContent = "Exported to your downloads (no secrets included).";
2981
+ setNote("settingsNote", "Exported to your downloads (no secrets included).", "ok");
2680
2982
  }
2681
2983
  async function importConfig() {
2682
2984
  let text = null;
@@ -2692,14 +2994,14 @@ export {
2692
2994
  });
2693
2995
  if (text === null) return;
2694
2996
  }
2695
- let body; try { body = JSON.parse(text); } catch { $("settingsNote").textContent = "That file is not valid JSON."; return; }
2696
- $("settingsNote").textContent = "Importing...";
2997
+ let body; try { body = JSON.parse(text); } catch { setNote("settingsNote", "That file is not valid JSON.", "err"); return; }
2998
+ setNote("settingsNote", "Importing...");
2697
2999
  try {
2698
3000
  const out = await (await fetch("/api/config-import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })).json();
2699
- if (!out.ok) { $("settingsNote").textContent = "Import failed: " + (out.error || "error"); return; }
2700
- $("settingsNote").textContent = "Imported: " + (out.applied || []).join(", ") + ((out.skipped && out.skipped.length) ? (" · skipped: " + out.skipped.join(", ")) : "");
3001
+ if (!out.ok) { setNote("settingsNote", "Import failed: " + (out.error || "error"), "err"); return; }
3002
+ setNote("settingsNote", "Imported: " + (out.applied || []).join(", ") + ((out.skipped && out.skipped.length) ? (" · skipped: " + out.skipped.join(", ")) : ""), "ok");
2701
3003
  settingsLoaded = false; loadSettings(); accountsLoaded = false;
2702
- } catch { $("settingsNote").textContent = "Could not reach the server to import."; }
3004
+ } catch { setNote("settingsNote", "Could not reach the server to import.", "err"); }
2703
3005
  }
2704
3006
 
2705
3007
  // --- hash routing between the subpages ---
@@ -2713,6 +3015,7 @@ export {
2713
3015
  if (v === "settings" && !settingsLoaded) { settingsLoaded = true; loadSettings(); }
2714
3016
  if (v === "skills" && !skillsLoaded) { skillsLoaded = true; loadSkills(); }
2715
3017
  if (v === "accounts" && !accountsLoaded) { accountsLoaded = true; loadAccounts(); }
3018
+ if (v === "usage") { loadProviderStatus(); if (!usageAccountsLoaded) { usageAccountsLoaded = true; loadUsageAccounts(); } }
2716
3019
  if (v === "resources") loadResources();
2717
3020
  // The chart was sized while its view may have been hidden; nudge it on return.
2718
3021
  if (v === "savings") { loadSystems(true); if (chart) { try { applyRange(); } catch { /* not ready */ } } }
@@ -2747,12 +3050,12 @@ export {
2747
3050
  }
2748
3051
  async function resAction(op, value, confirmMsg) {
2749
3052
  if (confirmMsg && !(await askConfirm("Confirm", confirmMsg, true))) return;
2750
- $("resNote").textContent = "Running...";
3053
+ setNote("resNote", "Running...");
2751
3054
  try {
2752
3055
  const out = await (await fetch("/api/resources", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op, value }) })).json();
2753
- $("resNote").textContent = out.message || (out.ok ? "Done." : "Failed.");
2754
3056
  if (out.status) renderResources(out.status);
2755
- } catch { $("resNote").textContent = "Could not reach the server."; }
3057
+ setNote("resNote", out.message || (out.ok ? "Done." : "Action failed."), out.ok ? "ok" : "err");
3058
+ } catch { setNote("resNote", "Could not reach the server.", "err"); }
2756
3059
  }
2757
3060
  function wireResources() {
2758
3061
  $("resRefresh").addEventListener("click", loadResources);
@@ -2775,12 +3078,12 @@ export {
2775
3078
 
2776
3079
  initChart();
2777
3080
  poll(true);
2778
- loadClaudeStatus();
3081
+ loadProviderStatus();
2779
3082
  checkDashboardUpdate();
2780
3083
  setInterval(() => refreshAll(false), POLL_MS);
2781
- setInterval(loadClaudeStatus, 60000);
3084
+ setInterval(loadProviderStatus, 60000);
2782
3085
  setInterval(checkDashboardUpdate, UPDATE_CHECK_MS);
2783
- document.addEventListener("visibilitychange", () => { if (!document.hidden) { refreshAll(true); loadClaudeStatus(); checkDashboardUpdate(); } });
3086
+ document.addEventListener("visibilitychange", () => { if (!document.hidden) { refreshAll(true); loadProviderStatus(); checkDashboardUpdate(); } });
2784
3087
  wireSettings();
2785
3088
  wireSkills();
2786
3089
  wireAccounts();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/dashboard",
3
- "version": "0.1.36",
3
+ "version": "0.1.42",
4
4
  "description": "Local browser dashboard UI for enigma: the static page and chart assets enigma serves on its loopback dashboard (savings, real tool usage, in-browser settings). Installed on demand by enigma-cli; not a runtime dependency.",
5
5
  "type": "module",
6
6
  "files": [