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