@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.
- package/assets/index.html +339 -99
- 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
|
-
|
|
281
|
-
|
|
282
|
-
.sys-
|
|
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
|
|
471
|
-
<a href="https://claude.ai/settings/usage" target="_blank" rel="noopener noreferrer" class="toggle" style="white-space:nowrap">View in Claude -></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 -></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 <tokens></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
|
-
<
|
|
498
|
-
|
|
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
|
-
//
|
|
1751
|
-
//
|
|
1752
|
-
|
|
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(
|
|
1854
|
+
const r = await fetch(p.status, { cache: "no-store" });
|
|
1757
1855
|
const d = await r.json();
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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 =
|
|
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
|
-
|
|
1843
|
-
renderUsageTable($("uByAccount"),
|
|
1844
|
-
|
|
1845
|
-
renderUsageTable($("
|
|
1846
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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.
|
|
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(); }
|
|
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
|
|
2223
|
-
|
|
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) {
|
|
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 &&
|
|
2230
|
-
else if (s) { ctl.value = s.choice; }
|
|
2231
|
-
|
|
2232
|
-
|
|
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
|
-
|
|
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) {
|
|
2244
|
-
|
|
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 {
|
|
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) {
|
|
2297
|
-
|
|
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
|
-
|
|
2301
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
2600
|
+
setNote("updateNote", (out.note || "Updated.") + " Refreshing the dashboard...", "ok");
|
|
2370
2601
|
setTimeout(() => location.reload(), 1200);
|
|
2371
2602
|
} else {
|
|
2372
|
-
|
|
2603
|
+
setNote("updateNote", out.note || "Already up to date.", "ok");
|
|
2373
2604
|
}
|
|
2374
|
-
} catch {
|
|
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
|
-
|
|
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) {
|
|
2752
|
+
if (!out.ok) { setNote("skillsNote", "Could not " + action + " " + name + ": " + (out.error || "error"), "err"); return; }
|
|
2520
2753
|
if (out.skills) renderSkills(out.skills);
|
|
2521
|
-
|
|
2522
|
-
} catch {
|
|
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
|
-
|
|
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) {
|
|
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 {
|
|
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) {
|
|
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
|
-
|
|
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) {
|
|
2802
|
+
if (!out.ok) { setNote("skillsNote", "Could not save " + editing + ": " + (out.error || "error"), "err"); return; }
|
|
2570
2803
|
if (out.skills) renderSkills(out.skills);
|
|
2571
|
-
|
|
2804
|
+
setNote("skillsNote", out.note || "Saved.", "ok");
|
|
2572
2805
|
closeEditor();
|
|
2573
|
-
} catch {
|
|
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
|
-
|
|
2687
|
-
|
|
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
|
-
|
|
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
|
|
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") {
|
|
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
|
-
|
|
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
|
-
|
|
2967
|
+
setNote("settingsNote", "Exporting...");
|
|
2729
2968
|
let text;
|
|
2730
2969
|
try { text = await (await fetch("/api/config-export", { cache: "no-store" })).text(); }
|
|
2731
|
-
catch {
|
|
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
|
-
|
|
2738
|
-
} catch (e) { if (e && e.name === "AbortError") {
|
|
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
|
-
|
|
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 {
|
|
2759
|
-
|
|
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) {
|
|
2763
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3081
|
+
loadProviderStatus();
|
|
2842
3082
|
checkDashboardUpdate();
|
|
2843
3083
|
setInterval(() => refreshAll(false), POLL_MS);
|
|
2844
|
-
setInterval(
|
|
3084
|
+
setInterval(loadProviderStatus, 60000);
|
|
2845
3085
|
setInterval(checkDashboardUpdate, UPDATE_CHECK_MS);
|
|
2846
|
-
document.addEventListener("visibilitychange", () => { if (!document.hidden) { refreshAll(true);
|
|
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.
|
|
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": [
|