@enigmax/dashboard 0.1.38 → 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 +339 -99
  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,6 +349,22 @@
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; }
337
370
  .modal-ok:disabled { opacity: .45; cursor: not-allowed; }
@@ -463,14 +496,18 @@
463
496
  <section id="view-usage" style="display:none">
464
497
  <div class="page-head" style="display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap">
465
498
  <div style="flex:1;min-width:280px">
466
- <h1>Claude Usage</h1>
467
- <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>
468
501
  </div>
469
502
  <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px">
470
- <span id="claudeStatus" class="cstatus"><span class="cdot"></span><span class="ctext">Checking Claude status...</span></span>
471
- <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>
472
505
  </div>
473
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>
474
511
  <div class="panel" id="usagePanel" style="display:none">
475
512
  <h2 style="margin:0 0 4px">Usage limits <small>same windows Claude Code shows</small></h2>
476
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>
@@ -494,8 +531,10 @@
494
531
  </div>
495
532
  </div>
496
533
  <div id="uProviders" class="sub" style="margin:4px 0 0"></div>
497
- <h2 style="margin:18px 0 10px">By Account <small>per Claude login</small></h2>
498
- <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>
499
538
  <h2 style="margin:18px 0 10px">By Model</h2>
500
539
  <div id="uByModel"></div>
501
540
  <h2 style="margin:18px 0 10px">By Project</h2>
@@ -507,6 +546,10 @@
507
546
  <h2 style="margin-bottom:8px">Real tool usage <small>opt-in</small></h2>
508
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>
509
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>
510
553
  </section>
511
554
 
512
555
  <section id="view-accounts" style="display:none">
@@ -1678,6 +1721,22 @@ export {
1678
1721
  // Real tool-usage panel (Claude Code transcripts). null = the opt-in flag is off.
1679
1722
  function shortProject(p) { p = String(p || ""); return p.length > 44 ? "..." + p.slice(-41) : p; }
1680
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
+
1681
1740
  // --- Claude-style usage windows (current session / weekly all / weekly Sonnet) ---
1682
1741
  function fmtResetIn(ms) {
1683
1742
  const d = ms - Date.now();
@@ -1747,28 +1806,83 @@ export {
1747
1806
  + "</div>";
1748
1807
  }
1749
1808
 
1750
- // Claude operational status from the public statuspage (CORS-open, no auth). Maps the
1751
- // indicator to a colored dot; degrades quietly to "unavailable" if the fetch is blocked.
1752
- 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() {
1753
1846
  const el = $("claudeStatus");
1754
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";
1755
1853
  try {
1756
- 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" });
1757
1855
  const d = await r.json();
1758
- const ind = (d.status && d.status.indicator) || "unknown";
1759
- const desc = (d.status && d.status.description) || "Status unknown";
1760
- el.className = "cstatus " + (ind === "none" ? "ok" : ind === "minor" ? "warn" : ind === "major" ? "orange" : ind === "critical" ? "bad" : "");
1761
- el.querySelector(".ctext").textContent = desc;
1762
- } catch {
1763
- el.className = "cstatus";
1764
- 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
+ }
1765
1871
  }
1872
+ el.className = "cstatus " + (ind === "none" ? "ok" : ind === "minor" ? "warn" : ind === "major" ? "orange" : ind === "critical" ? "bad" : "");
1873
+ el.querySelector(".ctext").textContent = desc;
1766
1874
  }
1767
1875
  async function planSet(key, value) {
1768
1876
  try { await fetch("/api/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: Number(value) }) }); refreshAll(true); }
1769
1877
  catch { /* leave as-is */ }
1770
1878
  }
1771
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(); });
1772
1886
  const el = $("uWindows");
1773
1887
  el.addEventListener("click", (e) => {
1774
1888
  const b = e.target.closest("button[data-plan]"); if (!b) return;
@@ -1810,21 +1924,48 @@ export {
1810
1924
  '<span class="tag ' + (p.available ? "meas" : "") + '">' + esc(p.label) + (p.available ? "" : " - " + esc(p.note)) + "</span>").join("");
1811
1925
  }
1812
1926
 
1813
- function renderUsage(u) {
1814
- const panel = $("usagePanel"), hint = $("usageHint");
1815
- if (!u) { panel.style.display = "none"; hint.style.display = "block"; return; }
1816
- hint.style.display = "none";
1817
- panel.style.display = "block";
1818
- renderWindows(u);
1819
- $("uCost").textContent = fmtUsd(u.cost || 0);
1820
- $("uCacheMoney").textContent = fmtUsd(cacheMoney(u.cacheRead || 0));
1821
- $("uCacheRead").textContent = fmt(u.cacheRead || 0);
1822
- $("uInput").textContent = fmt(u.input || 0);
1823
- $("uOutput").textContent = fmt(u.output || 0);
1824
- $("uSessions").textContent = fmt(u.sessions || 0);
1825
- $("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";
1826
1967
  // Current 5-hour block (computed locally from transcript timestamps).
1827
- const b = u.block, bw = $("uBlockWrap");
1968
+ const b = x.block, bw = $("uBlockWrap");
1828
1969
  if (b && b.startedAt) {
1829
1970
  bw.style.display = "block";
1830
1971
  const now = Date.now(), total = 5 * 3600 * 1000;
@@ -1839,11 +1980,57 @@ export {
1839
1980
  $("uBlockBurn").textContent = fmt(Math.round(b.burnRatePerMin || 0)) + " tok/min";
1840
1981
  $("uBlockProj").textContent = fmt(b.projectedTokens || 0) + " · " + fmtUsd(b.projectedCost || 0);
1841
1982
  } else { bw.style.display = "none"; }
1842
- renderProviders(u.providers);
1843
- renderUsageTable($("uByAccount"), u.byAccount, u.pending, "Account");
1844
- renderUsageTable($("uByModel"), u.byModel, u.pending, "Model");
1845
- renderUsageTable($("uByProject"), u.byProject, u.pending, "Project");
1846
- 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 */ }
1847
2034
  }
1848
2035
 
1849
2036
  function renderCache(c) {
@@ -2096,6 +2283,26 @@ export {
2096
2283
  // --- settings subpage (mirrors the TUI registry over /api/settings) ---
2097
2284
  function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
2098
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
+
2099
2306
  // Capitalize a choice value for display ("on-demand" -> "On demand"), keeping the raw
2100
2307
  // value as the option's value so the server still receives the exact token.
2101
2308
  function prettyChoice(c) { c = String(c).replace(/[-_]/g, " "); return c.charAt(0).toUpperCase() + c.slice(1); }
@@ -2107,7 +2314,12 @@ export {
2107
2314
  const back = document.createElement("div");
2108
2315
  back.className = "modal-back";
2109
2316
  let inner = "";
2110
- 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) {
2111
2323
  inner = '<select class="modal-input">' + field.options.map((o) =>
2112
2324
  '<option value="' + esc(o.value) + '"' + (o.value === field.value ? " selected" : "") + ">" + esc(o.label || o.value) + "</option>").join("") + "</select>";
2113
2325
  } else if (field) {
@@ -2138,6 +2350,10 @@ export {
2138
2350
  const ok = () => { if (invalid) return; close(field ? (inp ? inp.value : true) : true); };
2139
2351
  const cancel = () => close(field ? null : false);
2140
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
+ }
2141
2357
  if (inp && validate) { inp.addEventListener("input", runValidate); runValidate(); }
2142
2358
  back.querySelector(".modal-cancel").addEventListener("click", cancel);
2143
2359
  back.addEventListener("mousedown", (e) => { if (e.target === back) cancel(); });
@@ -2145,11 +2361,13 @@ export {
2145
2361
  if (e.key === "Escape") { e.preventDefault(); cancel(); }
2146
2362
  else if (e.key === "Enter" && (!inp || inp.tagName !== "TEXTAREA")) { e.preventDefault(); ok(); }
2147
2363
  });
2148
- if (inp) { inp.focus(); if (inp.select) inp.select(); } else okBtn.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(); }
2149
2366
  });
2150
2367
  }
2151
2368
  const askText = (title, value, message, validate) => openModal({ title, message, field: { value: value || "", validate } }).then((v) => (v == null ? null : String(v).trim()));
2152
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 } });
2153
2371
  const askConfirm = (title, message, danger) => openModal({ title, message, danger, okLabel: danger ? "Remove" : "OK" }).then(Boolean);
2154
2372
 
2155
2373
  // Turn "(experimental)" and " · measured/experimental" label suffixes into visual badges.
@@ -2196,7 +2414,7 @@ export {
2196
2414
  // render it as a switch (like a pure bool); 3+ values stay a select with pretty labels.
2197
2415
  const asSwitch = !s.choices || (s.choices.length === 2 && s.choices.indexOf(s.offChoice) !== -1);
2198
2416
  const ctl = !asSwitch
2199
- ? '<select class="choice" data-skey="' + esc(s.key) + '">'
2417
+ ? '<select class="choice" data-skey="' + esc(s.key) + '" data-prev="' + esc(s.choice) + '">'
2200
2418
  + s.choices.map((c) => '<option value="' + esc(c) + '"' + (c === s.choice ? " selected" : "") + ">" + esc(prettyChoice(c)) + "</option>").join("")
2201
2419
  + "</select>"
2202
2420
  : '<button type="button" class="toggle' + (s.value ? " on" : "") + '" data-skey="' + esc(s.key)
@@ -2218,32 +2436,44 @@ export {
2218
2436
  + '<div class="set-grid">' + c.settings.map(settingRow).join("") + "</div></div>").join("");
2219
2437
  }
2220
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.
2221
2444
  async function postSetting(key, value, ctl, noteEl) {
2222
- const note = noteEl || $("settingsNote");
2223
- 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 + "...");
2224
2453
  try {
2225
2454
  const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value }) });
2226
2455
  const out = await res.json();
2227
- 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; }
2228
2457
  const s = out.setting;
2229
- 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; }
2230
- else if (s) { ctl.value = s.choice; }
2231
- note.textContent = out.restartNote || ("Saved " + key + ".");
2232
- } 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"); }
2233
2463
  }
2234
2464
 
2235
2465
  // List settings: add/remove one entry, then reload so the chips reflect disk truth.
2236
2466
  async function postListOp(key, op, item) {
2237
2467
  item = (item || "").trim();
2238
2468
  if (!item) return;
2239
- $("settingsNote").textContent = (op === "add" ? "Adding to " : "Removing from ") + key + "...";
2469
+ setNote("settingsNote", (op === "add" ? "Adding to " : "Removing from ") + key + "...");
2240
2470
  try {
2241
2471
  const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: { op, item } }) });
2242
2472
  const out = await res.json();
2243
- if (!out.ok) { $("settingsNote").textContent = "Could not change " + key + ": " + (out.error || "error"); return; }
2244
- $("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");
2245
2475
  await loadSettings();
2246
- } catch { $("settingsNote").textContent = "Could not reach the server to change " + key + "."; }
2476
+ } catch { setNote("settingsNote", "Could not reach the server to change " + key + ".", "err"); }
2247
2477
  }
2248
2478
 
2249
2479
  function wireSettings() {
@@ -2293,12 +2523,13 @@ export {
2293
2523
  async function saveDashPort() {
2294
2524
  const raw = $("dashPortInput").value.trim();
2295
2525
  const value = raw === "" ? 0 : Number(raw);
2296
- if (!Number.isInteger(value) || value < 0 || value > 65535) { $("settingsNote").textContent = "Port must be 0 (auto) or 1-65535."; return; }
2297
- $("settingsNote").textContent = "Saving dashboard port...";
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...");
2298
2528
  try {
2299
2529
  const out = await (await fetch("/api/dashboard-port", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value }) })).json();
2300
- $("settingsNote").textContent = out.ok ? (out.restartNote || ("Saved dashboard port = " + (value || "auto") + ".")) : ("Could not save: " + (out.error || "error"));
2301
- } catch { $("settingsNote").textContent = "Could not reach the server to set the port."; }
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"); }
2302
2533
  }
2303
2534
 
2304
2535
  // --- "Enigma systems" overview (factual state, never invented savings) ---
@@ -2359,19 +2590,19 @@ export {
2359
2590
  }
2360
2591
 
2361
2592
  async function runUpdate() {
2362
- $("updateNote").textContent = "Checking for updates...";
2593
+ setNote("updateNote", "Checking for updates...");
2363
2594
  try {
2364
2595
  const res = await fetch("/api/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
2365
2596
  const out = await res.json();
2366
- if (!out.ok) { $("updateNote").textContent = "Update failed: " + (out.error || "error"); return; }
2597
+ if (!out.ok) { setNote("updateNote", "Update failed: " + (out.error || "error"), "err"); return; }
2367
2598
  // Only reload when something actually changed; an up-to-date check just reports.
2368
2599
  if (out.changed) {
2369
- $("updateNote").textContent = (out.note || "Updated.") + " Refreshing the dashboard...";
2600
+ setNote("updateNote", (out.note || "Updated.") + " Refreshing the dashboard...", "ok");
2370
2601
  setTimeout(() => location.reload(), 1200);
2371
2602
  } else {
2372
- $("updateNote").textContent = out.note || "Already up to date.";
2603
+ setNote("updateNote", out.note || "Already up to date.", "ok");
2373
2604
  }
2374
- } catch { $("updateNote").textContent = "Could not reach the server to update."; }
2605
+ } catch { setNote("updateNote", "Could not reach the server to update.", "err"); }
2375
2606
  }
2376
2607
 
2377
2608
  // Detect & repair every tool's launch path; the server persists any off-PATH
@@ -2387,10 +2618,12 @@ export {
2387
2618
  $("updateNote").textContent = "";
2388
2619
  renderFixPath(out);
2389
2620
  loadSystems(true);
2621
+ if (!out.ok) toast("Some tools could not be fixed - see the details below.", "err");
2390
2622
  } catch {
2391
2623
  $("updateNote").textContent = "";
2392
2624
  $("fixPathResult").className = "fixres show";
2393
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");
2394
2627
  } finally { if (btn) { btn.disabled = false; } }
2395
2628
  }
2396
2629
 
@@ -2512,24 +2745,24 @@ export {
2512
2745
  }
2513
2746
 
2514
2747
  async function postSkill(name, action, content) {
2515
- $("skillsNote").textContent = "Working...";
2748
+ setNote("skillsNote", "Working...");
2516
2749
  try {
2517
2750
  const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, action, content }) });
2518
2751
  const out = await res.json();
2519
- 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; }
2520
2753
  if (out.skills) renderSkills(out.skills);
2521
- $("skillsNote").textContent = out.note || ("Done: " + name);
2522
- } 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"); }
2523
2756
  }
2524
2757
 
2525
2758
  // Inline SKILL.md editor (one skill at a time).
2526
2759
  let editing = null;
2527
2760
  async function openEditor(name, isEnigma) {
2528
- $("skillsNote").textContent = "Loading " + name + "...";
2761
+ setNote("skillsNote", "Loading " + name + "...");
2529
2762
  try {
2530
2763
  const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, action: "read" }) });
2531
2764
  const out = await res.json();
2532
- 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; }
2533
2766
  editing = name;
2534
2767
  $("seName").textContent = name;
2535
2768
  $("seWarn").textContent = isEnigma ? "managed by enigma - see Customized-skill updates below for whether edits survive an update" : "external skill";
@@ -2539,7 +2772,7 @@ export {
2539
2772
  $("skillsNote").textContent = "";
2540
2773
  window.scrollTo({ top: 0, behavior: "smooth" });
2541
2774
  $("seText").focus();
2542
- } catch { $("skillsNote").textContent = "Could not reach the server."; }
2775
+ } catch { setNote("skillsNote", "Could not reach the server.", "err"); }
2543
2776
  }
2544
2777
  function closeEditor() { editing = null; $("skillEditor").style.display = "none"; }
2545
2778
  // Render per-app save targets (one checkbox per app that holds the skill), only when
@@ -2559,18 +2792,18 @@ export {
2559
2792
  let content = $("seText").value;
2560
2793
  if (agEl.dataset.has === "1") {
2561
2794
  const agents = [...agEl.querySelectorAll("input[type=checkbox]:checked")].map((i) => i.dataset.agent);
2562
- 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; }
2563
2796
  content = { text: $("seText").value, agents }; // per-app save
2564
2797
  }
2565
- $("skillsNote").textContent = "Saving " + editing + "...";
2798
+ setNote("skillsNote", "Saving " + editing + "...");
2566
2799
  try {
2567
2800
  const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editing, action: "save", content }) });
2568
2801
  const out = await res.json();
2569
- 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; }
2570
2803
  if (out.skills) renderSkills(out.skills);
2571
- $("skillsNote").textContent = out.note || "Saved.";
2804
+ setNote("skillsNote", out.note || "Saved.", "ok");
2572
2805
  closeEditor();
2573
- } catch { $("skillsNote").textContent = "Could not reach the server."; }
2806
+ } catch { setNote("skillsNote", "Could not reach the server.", "err"); }
2574
2807
  }
2575
2808
 
2576
2809
  function wireSkills() {
@@ -2666,7 +2899,7 @@ export {
2666
2899
  const ic = agentSvg(t.name);
2667
2900
  const lbl = ic ? '<span class="aicon" title="' + esc(t.label) + '" aria-label="' + esc(t.label) + '">' + ic + "</span>" : esc(t.label);
2668
2901
  return '<label style="font-size:12px;color:var(--muted);display:inline-flex;align-items:center;gap:6px;margin:0 12px 6px 0">' + lbl
2669
- + ' <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>";
2670
2903
  }).join("");
2671
2904
  const acts = '<div class="set-ctl" style="display:flex;gap:6px;align-items:center">'
2672
2905
  + (p.active ? "" : '<button type="button" class="toggle" data-prof-act="activate" data-name="' + esc(p.name) + '">Use</button>')
@@ -2683,19 +2916,24 @@ export {
2683
2916
  $("profilesBody").innerHTML = profiles.length ? profiles.map((p) => profileRow(p, tools, accounts)).join("")
2684
2917
  : '<div class="empty" style="padding:20px 0">No profiles yet. Add one to pin an account per tool.</div>';
2685
2918
  }
2686
- async function accAction(op, payload) {
2687
- $("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...");
2688
2924
  try {
2689
2925
  const r = await fetch("/api/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.assign({ op }, payload)) });
2690
2926
  const out = await r.json();
2691
- $("accountsNote").textContent = out.ok ? "Saved." : ("Error: " + (out.error || "failed"));
2692
2927
  if (out.data) renderAccounts(out.data);
2693
- } 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"); }
2694
2932
  }
2695
2933
  function wireAccounts() {
2696
2934
  $("accAdd").addEventListener("click", async () => {
2697
2935
  const tool = accTools.length === 1 ? accTools[0].name
2698
- : 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?");
2699
2937
  if (!tool) return;
2700
2938
  const taken = accAccounts.filter((a) => a.tool === tool).map((a) => a.name);
2701
2939
  const name = await askText("Add account", "", "New account name (e.g. work):", nameValidator(taken, null));
@@ -2708,7 +2946,7 @@ export {
2708
2946
  if (op === "activate") accAction("account.activate", { tool, name });
2709
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 }); }
2710
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 }); }
2711
- else if (op === "login") { $("accountsNote").textContent = "To log in, run this in a terminal: enigma " + tool + " " + name; }
2949
+ else if (op === "login") { setNote("accountsNote", "To log in, run this in a terminal: enigma " + tool + " " + name); }
2712
2950
  });
2713
2951
  $("profilesBody").addEventListener("click", async (e) => {
2714
2952
  const b = e.target.closest("button[data-prof-act]"); if (!b) return;
@@ -2719,27 +2957,28 @@ export {
2719
2957
  });
2720
2958
  $("profilesBody").addEventListener("change", (e) => {
2721
2959
  const sel = e.target.closest("select[data-prof-tool]"); if (!sel) return;
2722
- 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; });
2723
2962
  });
2724
2963
  }
2725
2964
 
2726
2965
  // --- config import / export (the browser file picker is what "asks where") ---
2727
2966
  async function exportConfig() {
2728
- $("settingsNote").textContent = "Exporting...";
2967
+ setNote("settingsNote", "Exporting...");
2729
2968
  let text;
2730
2969
  try { text = await (await fetch("/api/config-export", { cache: "no-store" })).text(); }
2731
- catch { $("settingsNote").textContent = "Export failed."; return; }
2970
+ catch { setNote("settingsNote", "Export failed.", "err"); return; }
2732
2971
  const name = "enigma-config-" + new Date().toISOString().slice(0, 10) + ".json";
2733
2972
  if (window.showSaveFilePicker) {
2734
2973
  try {
2735
2974
  const h = await window.showSaveFilePicker({ suggestedName: name, types: [{ description: "JSON", accept: { "application/json": [".json"] } }] });
2736
2975
  const w = await h.createWritable(); await w.write(text); await w.close();
2737
- $("settingsNote").textContent = "Exported (no secrets included)."; return;
2738
- } 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; } }
2739
2978
  }
2740
2979
  const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
2741
2980
  const a = document.createElement("a"); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url);
2742
- $("settingsNote").textContent = "Exported to your downloads (no secrets included).";
2981
+ setNote("settingsNote", "Exported to your downloads (no secrets included).", "ok");
2743
2982
  }
2744
2983
  async function importConfig() {
2745
2984
  let text = null;
@@ -2755,14 +2994,14 @@ export {
2755
2994
  });
2756
2995
  if (text === null) return;
2757
2996
  }
2758
- let body; try { body = JSON.parse(text); } catch { $("settingsNote").textContent = "That file is not valid JSON."; return; }
2759
- $("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...");
2760
2999
  try {
2761
3000
  const out = await (await fetch("/api/config-import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })).json();
2762
- if (!out.ok) { $("settingsNote").textContent = "Import failed: " + (out.error || "error"); return; }
2763
- $("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");
2764
3003
  settingsLoaded = false; loadSettings(); accountsLoaded = false;
2765
- } catch { $("settingsNote").textContent = "Could not reach the server to import."; }
3004
+ } catch { setNote("settingsNote", "Could not reach the server to import.", "err"); }
2766
3005
  }
2767
3006
 
2768
3007
  // --- hash routing between the subpages ---
@@ -2776,6 +3015,7 @@ export {
2776
3015
  if (v === "settings" && !settingsLoaded) { settingsLoaded = true; loadSettings(); }
2777
3016
  if (v === "skills" && !skillsLoaded) { skillsLoaded = true; loadSkills(); }
2778
3017
  if (v === "accounts" && !accountsLoaded) { accountsLoaded = true; loadAccounts(); }
3018
+ if (v === "usage") { loadProviderStatus(); if (!usageAccountsLoaded) { usageAccountsLoaded = true; loadUsageAccounts(); } }
2779
3019
  if (v === "resources") loadResources();
2780
3020
  // The chart was sized while its view may have been hidden; nudge it on return.
2781
3021
  if (v === "savings") { loadSystems(true); if (chart) { try { applyRange(); } catch { /* not ready */ } } }
@@ -2810,12 +3050,12 @@ export {
2810
3050
  }
2811
3051
  async function resAction(op, value, confirmMsg) {
2812
3052
  if (confirmMsg && !(await askConfirm("Confirm", confirmMsg, true))) return;
2813
- $("resNote").textContent = "Running...";
3053
+ setNote("resNote", "Running...");
2814
3054
  try {
2815
3055
  const out = await (await fetch("/api/resources", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op, value }) })).json();
2816
- $("resNote").textContent = out.message || (out.ok ? "Done." : "Failed.");
2817
3056
  if (out.status) renderResources(out.status);
2818
- } 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"); }
2819
3059
  }
2820
3060
  function wireResources() {
2821
3061
  $("resRefresh").addEventListener("click", loadResources);
@@ -2838,12 +3078,12 @@ export {
2838
3078
 
2839
3079
  initChart();
2840
3080
  poll(true);
2841
- loadClaudeStatus();
3081
+ loadProviderStatus();
2842
3082
  checkDashboardUpdate();
2843
3083
  setInterval(() => refreshAll(false), POLL_MS);
2844
- setInterval(loadClaudeStatus, 60000);
3084
+ setInterval(loadProviderStatus, 60000);
2845
3085
  setInterval(checkDashboardUpdate, UPDATE_CHECK_MS);
2846
- document.addEventListener("visibilitychange", () => { if (!document.hidden) { refreshAll(true); loadClaudeStatus(); checkDashboardUpdate(); } });
3086
+ document.addEventListener("visibilitychange", () => { if (!document.hidden) { refreshAll(true); loadProviderStatus(); checkDashboardUpdate(); } });
2847
3087
  wireSettings();
2848
3088
  wireSkills();
2849
3089
  wireAccounts();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/dashboard",
3
- "version": "0.1.38",
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": [