@enigmax/dashboard 0.1.38 → 0.1.43
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 +365 -106
- 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; }
|
|
@@ -240,6 +252,8 @@
|
|
|
240
252
|
.aicons { display: inline-flex; gap: 5px; align-items: center; vertical-align: middle; margin-left: 6px; }
|
|
241
253
|
.aicon { display: inline-flex; align-items: center; color: var(--muted); }
|
|
242
254
|
.aicon svg { width: 14px; height: 14px; display: block; }
|
|
255
|
+
/* Brand icon + label, reused by the savings breakdowns and the recent-compressions table. */
|
|
256
|
+
.srctag { display: inline-flex; align-items: center; gap: 7px; }
|
|
243
257
|
.set-ic { vertical-align: middle; margin-right: 7px; }
|
|
244
258
|
/* List settings (guard block/allow globs, custom secret patterns): chips + add input. A list row spans the full grid width. */
|
|
245
259
|
.set-row.list { grid-column: 1 / -1; align-items: stretch; flex-direction: column; }
|
|
@@ -277,13 +291,18 @@
|
|
|
277
291
|
.fxtitle { font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
|
278
292
|
.fxmsg { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
279
293
|
.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-
|
|
294
|
+
/* Each system reads as its own lit chip-card (name left, state pill right) in a grid,
|
|
295
|
+
rather than a dense list of underlined rows - easier to scan at a glance. */
|
|
296
|
+
.sys-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: 10px; }
|
|
297
|
+
.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); }
|
|
298
|
+
.sys-item .k { color: var(--text); }
|
|
299
|
+
.sys-item .v { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; flex: 0 0 auto; }
|
|
300
|
+
/* On a surface2 card the surface2 "off" pill would vanish - keep it outlined instead. */
|
|
301
|
+
.sys-item .pill.off { background: transparent; }
|
|
283
302
|
/* Rows whose value is a list of chips span the full width and let the chips wrap with
|
|
284
303
|
even spacing, instead of cramming many tags into one compact half-column cell. */
|
|
285
304
|
.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; }
|
|
305
|
+
.sys-item.wide .v { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-start; }
|
|
287
306
|
.sys-item.wide .v .tag { margin-left: 0; }
|
|
288
307
|
.pill { font-size: 11px; border-radius: 6px; padding: 2px 9px; border: 1px solid var(--border); white-space: nowrap; }
|
|
289
308
|
.pill.on { color: #06210f; background: var(--good); border-color: var(--good); font-weight: 600; }
|
|
@@ -332,6 +351,22 @@
|
|
|
332
351
|
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 9px 12px; font-size: 13px; font-family: var(--mono);
|
|
333
352
|
}
|
|
334
353
|
.modal-input:focus { outline: none; border-color: var(--accent); }
|
|
354
|
+
/* Usage view provider/account pickers. */
|
|
355
|
+
.useg-pick { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
|
|
356
|
+
.useg-pick select { width: auto; min-width: 170px; }
|
|
357
|
+
/* Icon picker: clickable app cards (a native <select> can't render SVG in options). */
|
|
358
|
+
.modal-picker { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
|
|
359
|
+
.pick-card {
|
|
360
|
+
display: flex; align-items: center; gap: 11px; width: 100%; text-align: left; cursor: pointer;
|
|
361
|
+
background: var(--surface2); color: var(--text); border: 1px solid var(--border);
|
|
362
|
+
border-radius: var(--radius-sm); padding: 11px 13px; font-size: 13px; font-family: var(--mono);
|
|
363
|
+
transition: border-color .15s, background .15s, transform .1s;
|
|
364
|
+
}
|
|
365
|
+
.pick-card:hover { border-color: #2f3744; }
|
|
366
|
+
.pick-card:active { transform: scale(0.98); }
|
|
367
|
+
.pick-card.sel { border-color: var(--accent); }
|
|
368
|
+
.pick-card svg { width: 20px; height: 20px; display: block; flex: none; color: var(--muted); }
|
|
369
|
+
.pick-card.sel svg, .pick-card:hover svg { color: var(--text); }
|
|
335
370
|
.modal-acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
|
|
336
371
|
.modal-ok.danger { background: var(--accent2); border-color: var(--accent2); color: #2a0f08; }
|
|
337
372
|
.modal-ok:disabled { opacity: .45; cursor: not-allowed; }
|
|
@@ -463,14 +498,18 @@
|
|
|
463
498
|
<section id="view-usage" style="display:none">
|
|
464
499
|
<div class="page-head" style="display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap">
|
|
465
500
|
<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>
|
|
501
|
+
<h1 id="usageTitle">Claude Usage</h1>
|
|
502
|
+
<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
503
|
</div>
|
|
469
504
|
<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>
|
|
505
|
+
<span id="claudeStatus" class="cstatus"><span class="cdot"></span><span class="ctext">Checking status...</span></span>
|
|
506
|
+
<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
507
|
</div>
|
|
473
508
|
</div>
|
|
509
|
+
<div class="useg-controls" style="display:flex;gap:14px;flex-wrap:wrap;align-items:center;margin-bottom:16px">
|
|
510
|
+
<label class="useg-pick"><span>Provider</span><span id="uProviderIcon" class="aicon" aria-hidden="true"></span><select id="uProvider" class="modal-input"></select></label>
|
|
511
|
+
<label class="useg-pick" id="uAccountWrap"><span>Account</span><select id="uAccount" class="modal-input"></select></label>
|
|
512
|
+
</div>
|
|
474
513
|
<div class="panel" id="usagePanel" style="display:none">
|
|
475
514
|
<h2 style="margin:0 0 4px">Usage limits <small>same windows Claude Code shows</small></h2>
|
|
476
515
|
<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 +533,10 @@
|
|
|
494
533
|
</div>
|
|
495
534
|
</div>
|
|
496
535
|
<div id="uProviders" class="sub" style="margin:4px 0 0"></div>
|
|
497
|
-
<
|
|
498
|
-
|
|
536
|
+
<div id="uByAccountWrap">
|
|
537
|
+
<h2 style="margin:18px 0 10px">By Account <small>per Claude login</small></h2>
|
|
538
|
+
<div id="uByAccount"></div>
|
|
539
|
+
</div>
|
|
499
540
|
<h2 style="margin:18px 0 10px">By Model</h2>
|
|
500
541
|
<div id="uByModel"></div>
|
|
501
542
|
<h2 style="margin:18px 0 10px">By Project</h2>
|
|
@@ -507,6 +548,10 @@
|
|
|
507
548
|
<h2 style="margin-bottom:8px">Real tool usage <small>opt-in</small></h2>
|
|
508
549
|
<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
550
|
</div>
|
|
551
|
+
<div class="panel" id="usageUnavail" style="display:none">
|
|
552
|
+
<h2 id="unavailTitle" style="margin-bottom:8px">No local usage data</h2>
|
|
553
|
+
<div class="sub" id="unavailBody"></div>
|
|
554
|
+
</div>
|
|
510
555
|
</section>
|
|
511
556
|
|
|
512
557
|
<section id="view-accounts" style="display:none">
|
|
@@ -573,8 +618,8 @@
|
|
|
573
618
|
<div class="set-grid">
|
|
574
619
|
<div class="set-row">
|
|
575
620
|
<div class="set-meta"><div class="set-name">Preferred port</div><div class="set-hint" id="dashPortHint">loopback only; never network-facing</div></div>
|
|
576
|
-
<div class="set-ctl" style="
|
|
577
|
-
<input id="dashPortInput" type="number" min="0" max="65535" step="1" class="
|
|
621
|
+
<div class="set-ctl ladd" style="align-items:center">
|
|
622
|
+
<input id="dashPortInput" type="number" min="0" max="65535" step="1" class="linput" style="flex:none;width:120px" inputmode="numeric" />
|
|
578
623
|
<button id="dashPortSave" type="button" class="toggle on">Save</button>
|
|
579
624
|
</div>
|
|
580
625
|
</div>
|
|
@@ -1601,7 +1646,7 @@ export {
|
|
|
1601
1646
|
function prettyType(k) { return TYPE_LABELS[k] || String(k).replace(/\b\w/g, (c) => c.toUpperCase()); }
|
|
1602
1647
|
|
|
1603
1648
|
// One bar per key (source or type), sorted by tokens saved, with an optional $ column.
|
|
1604
|
-
function renderBreakdown(el, map, label, money) {
|
|
1649
|
+
function renderBreakdown(el, map, label, money, iconFn) {
|
|
1605
1650
|
const entries = Object.entries(map || {}).sort((a, b) => (b[1].tokensSaved || 0) - (a[1].tokensSaved || 0));
|
|
1606
1651
|
if (!entries.length) {
|
|
1607
1652
|
el.innerHTML = '<div class="empty" style="padding:20px 0">No data yet - this fills in as compressions are recorded.</div>';
|
|
@@ -1611,7 +1656,7 @@ export {
|
|
|
1611
1656
|
el.innerHTML = entries.map(([k, v]) => {
|
|
1612
1657
|
const saved = v.tokensSaved || 0, pct = Math.round(saved / max * 100);
|
|
1613
1658
|
const m = money ? '<span class="money">' + fmtUsd(moneyOf(k, saved)) + '</span>' : '';
|
|
1614
|
-
return '<div class="bar-row"><span class="name" style="width:140px">' + label(k)
|
|
1659
|
+
return '<div class="bar-row"><span class="name" style="width:140px">' + iconLabel(iconFn ? iconFn(k) : "", label(k))
|
|
1615
1660
|
+ '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--accent)"></div></div>'
|
|
1616
1661
|
+ '<span class="num">' + fmt(saved) + '</span>' + m
|
|
1617
1662
|
+ '<span class="foot" style="width:64px;text-align:right;margin:0">' + fmt(v.calls || 0) + ' calls</span></div>';
|
|
@@ -1633,8 +1678,9 @@ export {
|
|
|
1633
1678
|
+ '<th class="r">Before</th><th class="r">After</th><th class="r">Saved</th></tr></thead><tbody>'
|
|
1634
1679
|
+ rows.map((p) => {
|
|
1635
1680
|
const saved = savedOf(p), pct = p.b ? Math.round(saved / p.b * 100) : 0;
|
|
1681
|
+
const src = p.s || "unknown";
|
|
1636
1682
|
return '<tr><td>' + new Date(p.t).toLocaleString() + '</td><td>' + prettyType(p.c || "unknown")
|
|
1637
|
-
+ '</td><td>' + prettySource(
|
|
1683
|
+
+ '</td><td>' + iconLabel(sourceSvg(src), prettySource(src)) + '</td><td class="r">' + fmt(p.b || 0)
|
|
1638
1684
|
+ '</td><td class="r">' + fmt(p.a || 0) + '</td><td class="r good">' + fmt(saved) + " (" + pct + "%)</td></tr>";
|
|
1639
1685
|
}).join("") + "</tbody></table>";
|
|
1640
1686
|
}
|
|
@@ -1678,6 +1724,22 @@ export {
|
|
|
1678
1724
|
// Real tool-usage panel (Claude Code transcripts). null = the opt-in flag is off.
|
|
1679
1725
|
function shortProject(p) { p = String(p || ""); return p.length > 44 ? "..." + p.slice(-41) : p; }
|
|
1680
1726
|
|
|
1727
|
+
// --- provider / account scoping for the Usage view ---------------------------------
|
|
1728
|
+
// Only Claude Code keeps a local per-message usage log enigma can read; Codex/OpenCode
|
|
1729
|
+
// are honestly reported as having no readable store. The header (title, status pill,
|
|
1730
|
+
// "View in ..." link) adapts to the selected provider.
|
|
1731
|
+
const CLAUDE_USAGE_DESC = $("usageDesc").innerHTML; // preserve the rich Claude copy
|
|
1732
|
+
const USAGE_PROVIDERS = {
|
|
1733
|
+
claude: { label: "Claude Code", status: "https://status.claude.com/api/v2/status.json", viewHref: "https://claude.ai/settings/usage", viewLabel: "View in Claude" },
|
|
1734
|
+
codex: { label: "Codex", status: "https://status.openai.com/api/v2/status.json", viewHref: "https://platform.openai.com/usage", viewLabel: "View in OpenAI" },
|
|
1735
|
+
opencode: { label: "OpenCode", status: null, viewHref: "https://opencode.ai", viewLabel: "View OpenCode" },
|
|
1736
|
+
};
|
|
1737
|
+
let usageData = null; // the latest /api/stats usage report (null when opt-in is off)
|
|
1738
|
+
let usageProvider = "claude";
|
|
1739
|
+
let usageAccount = "__all__"; // "__all__" or a Claude account name
|
|
1740
|
+
let usageAccountsMeta = []; // claude accounts from /api/accounts, for email labels
|
|
1741
|
+
let usageAccountsLoaded = false;
|
|
1742
|
+
|
|
1681
1743
|
// --- Claude-style usage windows (current session / weekly all / weekly Sonnet) ---
|
|
1682
1744
|
function fmtResetIn(ms) {
|
|
1683
1745
|
const d = ms - Date.now();
|
|
@@ -1747,28 +1809,90 @@ export {
|
|
|
1747
1809
|
+ "</div>";
|
|
1748
1810
|
}
|
|
1749
1811
|
|
|
1750
|
-
//
|
|
1751
|
-
//
|
|
1752
|
-
|
|
1812
|
+
// Community incident signal for Claude Code: even when the official statuspage reads
|
|
1813
|
+
// green, a sudden cluster of freshly-opened "500/529 server error" issues on the
|
|
1814
|
+
// claude-code repo is an early outage indicator (the statuspage usually lags a live
|
|
1815
|
+
// incident by many minutes). Browser-side, unauthenticated GitHub API (CORS-open,
|
|
1816
|
+
// 60 req/hr per IP) - throttled to one query / 5 min to stay well under the limit.
|
|
1817
|
+
const CLAUDE_ISSUES_API = "https://api.github.com/repos/anthropics/claude-code/issues?state=open&sort=created&direction=desc&per_page=30";
|
|
1818
|
+
const CLAUDE_ISSUES_VIEW = "https://github.com/anthropics/claude-code/issues?q=is%3Aissue+is%3Aopen+500";
|
|
1819
|
+
// A title is a server-error report when it carries an HTTP 500/529 AND error wording
|
|
1820
|
+
// ("API Error: 500", "500 errors", "Internal Server Error (500)", "API Error 529 and 500"...).
|
|
1821
|
+
const SERVER_ERR_NUM_RE = /\b(500|529)\b/;
|
|
1822
|
+
const SERVER_ERR_WORD_RE = /error|internal server/i;
|
|
1823
|
+
const INCIDENT_WINDOW_MS = 30 * 60 * 1000; // only issues opened in the last 30 minutes
|
|
1824
|
+
const INCIDENT_MIN = 2; // need a small cluster, not a single one-off
|
|
1825
|
+
const INCIDENT_TTL_MS = 5 * 60 * 1000; // re-query GitHub at most every 5 minutes
|
|
1826
|
+
let claudeIncidents = { at: 0, count: 0 }; // last GitHub probe (cached count)
|
|
1827
|
+
async function checkClaudeIncidents() {
|
|
1828
|
+
if (Date.now() - claudeIncidents.at < INCIDENT_TTL_MS) return claudeIncidents.count;
|
|
1829
|
+
try {
|
|
1830
|
+
const r = await fetch(CLAUDE_ISSUES_API, { cache: "no-store", headers: { Accept: "application/vnd.github+json" } });
|
|
1831
|
+
if (!r.ok) throw 0;
|
|
1832
|
+
const list = await r.json();
|
|
1833
|
+
const cut = Date.now() - INCIDENT_WINDOW_MS;
|
|
1834
|
+
const count = (Array.isArray(list) ? list : []).filter((it) => {
|
|
1835
|
+
if (!it || it.pull_request) return false; // the issues endpoint also returns PRs
|
|
1836
|
+
if (new Date(it.created_at).getTime() < cut) return false;
|
|
1837
|
+
const t = it.title || "";
|
|
1838
|
+
return SERVER_ERR_NUM_RE.test(t) && SERVER_ERR_WORD_RE.test(t);
|
|
1839
|
+
}).length;
|
|
1840
|
+
claudeIncidents = { at: Date.now(), count };
|
|
1841
|
+
} catch { /* keep last known count; a probe failure must never break the pill */ }
|
|
1842
|
+
return claudeIncidents.count;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// Operational status for the selected provider, from its public statuspage (CORS-open,
|
|
1846
|
+
// no auth; same Atlassian Statuspage shape for Claude and OpenAI). Maps the indicator to
|
|
1847
|
+
// a colored dot; degrades quietly to "unavailable", and hides for providers with no page.
|
|
1848
|
+
async function loadProviderStatus() {
|
|
1753
1849
|
const el = $("claudeStatus");
|
|
1754
1850
|
if (!el || document.hidden) return;
|
|
1851
|
+
const p = USAGE_PROVIDERS[usageProvider] || USAGE_PROVIDERS.claude;
|
|
1852
|
+
if (!p.status) { el.style.display = "none"; return; }
|
|
1853
|
+
el.style.display = "";
|
|
1854
|
+
el.onclick = null; el.style.cursor = ""; el.title = ""; // clear any prior incident affordance
|
|
1855
|
+
let ind = "unknown", desc = p.label + " status unavailable";
|
|
1755
1856
|
try {
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1857
|
+
// Prefer the same-origin server proxy: the browser's direct cross-origin fetch is
|
|
1858
|
+
// unreliable in the field (blockers/MITM proxies/captive portals returned a stub
|
|
1859
|
+
// 200 -> a bogus "Status unknown"). Fall back to direct if the server predates it.
|
|
1860
|
+
let s = null;
|
|
1861
|
+
try {
|
|
1862
|
+
const rp = await fetch("/api/provider-status?provider=" + encodeURIComponent(usageProvider), { cache: "no-store" });
|
|
1863
|
+
if (rp.ok) { const dp = await rp.json(); if (!dp.error) s = dp; }
|
|
1864
|
+
} catch { /* fall through to the direct statuspage */ }
|
|
1865
|
+
if (!s) { const d = await (await fetch(p.status, { cache: "no-store" })).json(); s = d.status || {}; }
|
|
1866
|
+
ind = s.indicator || "unknown";
|
|
1867
|
+
desc = s.description || "Status unknown";
|
|
1868
|
+
} catch { ind = "unknown"; }
|
|
1869
|
+
// Claude Code only: overlay the community incident signal. It elevates an otherwise
|
|
1870
|
+
// green/unknown pill to a warning; an official major/critical statuspage state wins.
|
|
1871
|
+
if (usageProvider === "claude" && ind !== "major" && ind !== "critical") {
|
|
1872
|
+
const n = await checkClaudeIncidents();
|
|
1873
|
+
if (n >= INCIDENT_MIN) {
|
|
1874
|
+
el.className = "cstatus orange";
|
|
1875
|
+
el.querySelector(".ctext").textContent = n + " recent 500/529 reports (30 min)";
|
|
1876
|
+
el.title = "Possible Anthropic API incident: " + n + " open claude-code issues reporting 500/529 errors in the last 30 minutes. Click to view.";
|
|
1877
|
+
el.style.cursor = "pointer";
|
|
1878
|
+
el.onclick = () => window.open(CLAUDE_ISSUES_VIEW, "_blank", "noopener");
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1765
1881
|
}
|
|
1882
|
+
el.className = "cstatus " + (ind === "none" ? "ok" : ind === "minor" ? "warn" : ind === "major" ? "orange" : ind === "critical" ? "bad" : "");
|
|
1883
|
+
el.querySelector(".ctext").textContent = desc;
|
|
1766
1884
|
}
|
|
1767
1885
|
async function planSet(key, value) {
|
|
1768
1886
|
try { await fetch("/api/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: Number(value) }) }); refreshAll(true); }
|
|
1769
1887
|
catch { /* leave as-is */ }
|
|
1770
1888
|
}
|
|
1771
1889
|
function wireUsage() {
|
|
1890
|
+
// Provider + account selectors drive a client-side re-render from the cached report.
|
|
1891
|
+
const ps = $("uProvider");
|
|
1892
|
+
ps.innerHTML = Object.entries(USAGE_PROVIDERS).map(([k, v]) => '<option value="' + k + '">' + esc(v.label) + "</option>").join("");
|
|
1893
|
+
ps.value = usageProvider;
|
|
1894
|
+
ps.addEventListener("change", () => { usageProvider = ps.value; usageAccount = "__all__"; applyUsageView(); loadProviderStatus(); });
|
|
1895
|
+
$("uAccount").addEventListener("change", () => { usageAccount = $("uAccount").value; applyUsageView(); });
|
|
1772
1896
|
const el = $("uWindows");
|
|
1773
1897
|
el.addEventListener("click", (e) => {
|
|
1774
1898
|
const b = e.target.closest("button[data-plan]"); if (!b) return;
|
|
@@ -1810,21 +1934,48 @@ export {
|
|
|
1810
1934
|
'<span class="tag ' + (p.available ? "meas" : "") + '">' + esc(p.label) + (p.available ? "" : " - " + esc(p.note)) + "</span>").join("");
|
|
1811
1935
|
}
|
|
1812
1936
|
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1937
|
+
// The Claude account names that have usage, default first then alphabetical.
|
|
1938
|
+
function usageAccountNames() {
|
|
1939
|
+
const names = Object.keys((usageData && usageData.accounts) || {});
|
|
1940
|
+
return names.sort((a, b) => (a === "default" ? -1 : b === "default" ? 1 : a < b ? -1 : a > b ? 1 : 0));
|
|
1941
|
+
}
|
|
1942
|
+
function accountEmail(name) {
|
|
1943
|
+
const a = usageAccountsMeta.find((x) => x.name === name);
|
|
1944
|
+
return a && a.email ? a.email : "";
|
|
1945
|
+
}
|
|
1946
|
+
function accountLabel(name) {
|
|
1947
|
+
const display = name === "default" ? "Default" : name;
|
|
1948
|
+
const email = accountEmail(name);
|
|
1949
|
+
return email ? display + " · " + email : display;
|
|
1950
|
+
}
|
|
1951
|
+
// Rebuild the account <select> only when the account set/labels actually changed, so a
|
|
1952
|
+
// routine poll never resets an open dropdown or the current selection.
|
|
1953
|
+
function buildAccountOptions() {
|
|
1954
|
+
const sel = $("uAccount");
|
|
1955
|
+
const names = usageAccountNames();
|
|
1956
|
+
const sig = names.join("|") + "::" + usageAccountsMeta.map((a) => a.name + ":" + (a.email || "")).join(",");
|
|
1957
|
+
if (usageAccount !== "__all__" && !names.includes(usageAccount)) usageAccount = "__all__";
|
|
1958
|
+
if (sel.dataset.sig !== sig) {
|
|
1959
|
+
sel.dataset.sig = sig;
|
|
1960
|
+
sel.innerHTML = '<option value="__all__">All accounts</option>'
|
|
1961
|
+
+ names.map((n) => '<option value="' + esc(n) + '">' + esc(accountLabel(n)) + "</option>").join("");
|
|
1962
|
+
}
|
|
1963
|
+
sel.value = usageAccount;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// Fill the panel from one scope: the whole report (isAll) or one account's sub-report.
|
|
1967
|
+
// The Providers line and By Account table are only meaningful for the whole report.
|
|
1968
|
+
function renderUsageScoped(x, isAll, pending) {
|
|
1969
|
+
renderWindows(x);
|
|
1970
|
+
$("uCost").textContent = fmtUsd(x.cost || 0);
|
|
1971
|
+
$("uCacheMoney").textContent = fmtUsd(cacheMoney(x.cacheRead || 0));
|
|
1972
|
+
$("uCacheRead").textContent = fmt(x.cacheRead || 0);
|
|
1973
|
+
$("uInput").textContent = fmt(x.input || 0);
|
|
1974
|
+
$("uOutput").textContent = fmt(x.output || 0);
|
|
1975
|
+
$("uSessions").textContent = fmt(x.sessions || 0);
|
|
1976
|
+
$("uSessionsFoot").textContent = pending ? "scanning sessions..." : fmt(x.scannedFiles || 0) + " files scanned";
|
|
1826
1977
|
// Current 5-hour block (computed locally from transcript timestamps).
|
|
1827
|
-
const b =
|
|
1978
|
+
const b = x.block, bw = $("uBlockWrap");
|
|
1828
1979
|
if (b && b.startedAt) {
|
|
1829
1980
|
bw.style.display = "block";
|
|
1830
1981
|
const now = Date.now(), total = 5 * 3600 * 1000;
|
|
@@ -1839,11 +1990,58 @@ export {
|
|
|
1839
1990
|
$("uBlockBurn").textContent = fmt(Math.round(b.burnRatePerMin || 0)) + " tok/min";
|
|
1840
1991
|
$("uBlockProj").textContent = fmt(b.projectedTokens || 0) + " · " + fmtUsd(b.projectedCost || 0);
|
|
1841
1992
|
} else { bw.style.display = "none"; }
|
|
1842
|
-
|
|
1843
|
-
renderUsageTable($("uByAccount"),
|
|
1844
|
-
|
|
1845
|
-
renderUsageTable($("
|
|
1846
|
-
|
|
1993
|
+
$("uByAccountWrap").style.display = isAll ? "" : "none";
|
|
1994
|
+
if (isAll) { renderProviders(x.providers); renderUsageTable($("uByAccount"), x.byAccount, pending, "Account"); }
|
|
1995
|
+
else { $("uProviders").innerHTML = ""; }
|
|
1996
|
+
renderUsageTable($("uByModel"), x.byModel, pending, "Model");
|
|
1997
|
+
renderUsageTable($("uByProject"), x.byProject, pending, "Project");
|
|
1998
|
+
renderRecentSessions($("uRecent"), x.recentSessions, pending);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Adapt the header to the selected provider, then render its scope. Only Claude has a
|
|
2002
|
+
// measurable local store; the others show an honest "no local usage" panel instead.
|
|
2003
|
+
function applyUsageView() {
|
|
2004
|
+
const p = USAGE_PROVIDERS[usageProvider] || USAGE_PROVIDERS.claude;
|
|
2005
|
+
$("uProviderIcon").innerHTML = agentSvg(usageProvider);
|
|
2006
|
+
$("usageTitle").textContent = p.label + " Usage";
|
|
2007
|
+
const link = $("usageViewLink");
|
|
2008
|
+
link.href = p.viewHref; link.textContent = p.viewLabel + " ->";
|
|
2009
|
+
$("uAccountWrap").style.display = (usageProvider === "claude" && usageData) ? "" : "none";
|
|
2010
|
+
const panel = $("usagePanel"), hint = $("usageHint"), unavail = $("usageUnavail");
|
|
2011
|
+
if (usageProvider !== "claude") {
|
|
2012
|
+
panel.style.display = "none"; hint.style.display = "none"; unavail.style.display = "block";
|
|
2013
|
+
$("usageDesc").textContent = p.label + " token usage. enigma can only measure usage for tools that keep a local per-message log it can read.";
|
|
2014
|
+
const cov = ((usageData && usageData.providers) || []).find((x) => x.tool === usageProvider);
|
|
2015
|
+
const note = (cov && cov.note) || "no local token-usage store";
|
|
2016
|
+
$("unavailTitle").textContent = "No local usage data for " + p.label;
|
|
2017
|
+
$("unavailBody").innerHTML = "<b>" + esc(p.label) + "</b>: " + esc(note) + "."
|
|
2018
|
+
+ " enigma measures token usage only for tools that keep a local per-message log it can read - today that is Claude Code."
|
|
2019
|
+
+ " Switch the provider to <b>Claude Code</b> for measured usage, or open " + esc(p.label) + "'s own dashboard via the link above.";
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
$("usageDesc").innerHTML = CLAUDE_USAGE_DESC;
|
|
2023
|
+
unavail.style.display = "none";
|
|
2024
|
+
if (!usageData) { panel.style.display = "none"; hint.style.display = "block"; return; }
|
|
2025
|
+
hint.style.display = "none"; panel.style.display = "block";
|
|
2026
|
+
buildAccountOptions();
|
|
2027
|
+
const isAll = usageAccount === "__all__";
|
|
2028
|
+
const scoped = isAll ? usageData : ((usageData.accounts && usageData.accounts[usageAccount]) || usageData);
|
|
2029
|
+
renderUsageScoped(scoped, isAll, usageData.pending);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// Entry point from the stats poll: cache the report, then render the active scope.
|
|
2033
|
+
function renderUsage(u) {
|
|
2034
|
+
usageData = u || null;
|
|
2035
|
+
applyUsageView();
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// Claude account identities (for the email labels), fetched once when the view opens.
|
|
2039
|
+
async function loadUsageAccounts() {
|
|
2040
|
+
try {
|
|
2041
|
+
const d = await (await fetch("/api/accounts", { cache: "no-store" })).json();
|
|
2042
|
+
usageAccountsMeta = (d.accounts || []).filter((a) => a.tool === "claude");
|
|
2043
|
+
if (usageProvider === "claude" && usageData) applyUsageView();
|
|
2044
|
+
} catch { /* labels fall back to account names */ }
|
|
1847
2045
|
}
|
|
1848
2046
|
|
|
1849
2047
|
function renderCache(c) {
|
|
@@ -2014,7 +2212,7 @@ export {
|
|
|
2014
2212
|
$("removed").textContent = fmt(saved) + " (" + rate + "%)";
|
|
2015
2213
|
$("reduction").textContent = before ? (100 - rate) + "% of input sent" : "-";
|
|
2016
2214
|
$("ratio").textContent = after ? "~" + (before / after).toFixed(1) + ":1" : "-";
|
|
2017
|
-
renderBreakdown($("sources"), s.bySource, prettySource, true);
|
|
2215
|
+
renderBreakdown($("sources"), s.bySource, prettySource, true, sourceSvg);
|
|
2018
2216
|
renderBreakdown($("types"), s.byType, prettyType, false);
|
|
2019
2217
|
|
|
2020
2218
|
renderUsage(data.usage);
|
|
@@ -2096,6 +2294,26 @@ export {
|
|
|
2096
2294
|
// --- settings subpage (mirrors the TUI registry over /api/settings) ---
|
|
2097
2295
|
function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }
|
|
2098
2296
|
|
|
2297
|
+
// A self-dismissing notification so a failed action can't slip by unseen. Errors
|
|
2298
|
+
// linger longer than confirmations; both auto-clear so they never pile up.
|
|
2299
|
+
function toast(msg, kind) {
|
|
2300
|
+
let wrap = $("toasts");
|
|
2301
|
+
if (!wrap) { wrap = document.createElement("div"); wrap.id = "toasts"; document.body.appendChild(wrap); }
|
|
2302
|
+
const el = document.createElement("div");
|
|
2303
|
+
el.className = "toast " + (kind === "err" ? "err" : kind === "ok" ? "ok" : "");
|
|
2304
|
+
el.setAttribute("role", kind === "err" ? "alert" : "status");
|
|
2305
|
+
el.textContent = msg;
|
|
2306
|
+
wrap.appendChild(el);
|
|
2307
|
+
setTimeout(() => { el.classList.add("hide"); setTimeout(() => el.remove(), 320); }, kind === "err" ? 6000 : 3000);
|
|
2308
|
+
}
|
|
2309
|
+
// Set a status note's text and ok/err styling in one place. Errors are also toasted
|
|
2310
|
+
// (the inline note alone is easy to miss), so callers just call setNote(el, msg, "err").
|
|
2311
|
+
function setNote(el, msg, kind) {
|
|
2312
|
+
if (typeof el === "string") el = $(el);
|
|
2313
|
+
if (el) { el.textContent = msg; el.classList.toggle("ok", kind === "ok"); el.classList.toggle("err", kind === "err"); }
|
|
2314
|
+
if (kind === "err") toast(msg, "err");
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2099
2317
|
// Capitalize a choice value for display ("on-demand" -> "On demand"), keeping the raw
|
|
2100
2318
|
// value as the option's value so the server still receives the exact token.
|
|
2101
2319
|
function prettyChoice(c) { c = String(c).replace(/[-_]/g, " "); return c.charAt(0).toUpperCase() + c.slice(1); }
|
|
@@ -2107,7 +2325,12 @@ export {
|
|
|
2107
2325
|
const back = document.createElement("div");
|
|
2108
2326
|
back.className = "modal-back";
|
|
2109
2327
|
let inner = "";
|
|
2110
|
-
if (field && field.
|
|
2328
|
+
if (field && field.iconOptions) {
|
|
2329
|
+
// Clickable app cards with logos; a native <select> can't render SVG options.
|
|
2330
|
+
inner = '<div class="modal-picker">' + field.iconOptions.map((o) =>
|
|
2331
|
+
'<button type="button" class="pick-card' + (o.value === field.value ? " sel" : "") + '" data-pick="' + esc(o.value) + '">'
|
|
2332
|
+
+ (o.icon ? '<span class="pick-ic">' + o.icon + "</span>" : "") + "<span>" + esc(o.label || o.value) + "</span></button>").join("") + "</div>";
|
|
2333
|
+
} else if (field && field.options) {
|
|
2111
2334
|
inner = '<select class="modal-input">' + field.options.map((o) =>
|
|
2112
2335
|
'<option value="' + esc(o.value) + '"' + (o.value === field.value ? " selected" : "") + ">" + esc(o.label || o.value) + "</option>").join("") + "</select>";
|
|
2113
2336
|
} else if (field) {
|
|
@@ -2138,6 +2361,10 @@ export {
|
|
|
2138
2361
|
const ok = () => { if (invalid) return; close(field ? (inp ? inp.value : true) : true); };
|
|
2139
2362
|
const cancel = () => close(field ? null : false);
|
|
2140
2363
|
okBtn.addEventListener("click", ok);
|
|
2364
|
+
if (field && field.iconOptions) {
|
|
2365
|
+
okBtn.style.display = "none"; // a card click is the confirm
|
|
2366
|
+
back.querySelectorAll(".pick-card").forEach((c) => c.addEventListener("click", () => close(c.dataset.pick)));
|
|
2367
|
+
}
|
|
2141
2368
|
if (inp && validate) { inp.addEventListener("input", runValidate); runValidate(); }
|
|
2142
2369
|
back.querySelector(".modal-cancel").addEventListener("click", cancel);
|
|
2143
2370
|
back.addEventListener("mousedown", (e) => { if (e.target === back) cancel(); });
|
|
@@ -2145,11 +2372,13 @@ export {
|
|
|
2145
2372
|
if (e.key === "Escape") { e.preventDefault(); cancel(); }
|
|
2146
2373
|
else if (e.key === "Enter" && (!inp || inp.tagName !== "TEXTAREA")) { e.preventDefault(); ok(); }
|
|
2147
2374
|
});
|
|
2148
|
-
if (inp) { inp.focus(); if (inp.select) inp.select(); }
|
|
2375
|
+
if (inp) { inp.focus(); if (inp.select) inp.select(); }
|
|
2376
|
+
else { const c = back.querySelector(".pick-card.sel") || back.querySelector(".pick-card") || okBtn; c.focus(); }
|
|
2149
2377
|
});
|
|
2150
2378
|
}
|
|
2151
2379
|
const askText = (title, value, message, validate) => openModal({ title, message, field: { value: value || "", validate } }).then((v) => (v == null ? null : String(v).trim()));
|
|
2152
2380
|
const askChoice = (title, options, value, message) => openModal({ title, message, field: { options, value } });
|
|
2381
|
+
const askIconChoice = (title, iconOptions, value, message) => openModal({ title, message, field: { iconOptions, value } });
|
|
2153
2382
|
const askConfirm = (title, message, danger) => openModal({ title, message, danger, okLabel: danger ? "Remove" : "OK" }).then(Boolean);
|
|
2154
2383
|
|
|
2155
2384
|
// Turn "(experimental)" and " · measured/experimental" label suffixes into visual badges.
|
|
@@ -2196,7 +2425,7 @@ export {
|
|
|
2196
2425
|
// render it as a switch (like a pure bool); 3+ values stay a select with pretty labels.
|
|
2197
2426
|
const asSwitch = !s.choices || (s.choices.length === 2 && s.choices.indexOf(s.offChoice) !== -1);
|
|
2198
2427
|
const ctl = !asSwitch
|
|
2199
|
-
? '<select class="choice" data-skey="' + esc(s.key) + '">'
|
|
2428
|
+
? '<select class="choice" data-skey="' + esc(s.key) + '" data-prev="' + esc(s.choice) + '">'
|
|
2200
2429
|
+ s.choices.map((c) => '<option value="' + esc(c) + '"' + (c === s.choice ? " selected" : "") + ">" + esc(prettyChoice(c)) + "</option>").join("")
|
|
2201
2430
|
+ "</select>"
|
|
2202
2431
|
: '<button type="button" class="toggle' + (s.value ? " on" : "") + '" data-skey="' + esc(s.key)
|
|
@@ -2218,32 +2447,44 @@ export {
|
|
|
2218
2447
|
+ '<div class="set-grid">' + c.settings.map(settingRow).join("") + "</div></div>").join("");
|
|
2219
2448
|
}
|
|
2220
2449
|
|
|
2450
|
+
function applyBool(ctl, on) { ctl.classList.toggle("on", on); ctl.textContent = on ? "On" : "Off"; ctl.dataset.on = on ? 1 : 0; }
|
|
2451
|
+
|
|
2452
|
+
// Optimistic: flip the control immediately, then reconcile with the server. On any
|
|
2453
|
+
// failure - rejected write, unreachable server, OR a write that silently did not take
|
|
2454
|
+
// effect (returned value != requested) - roll the control back and surface the error.
|
|
2221
2455
|
async function postSetting(key, value, ctl, noteEl) {
|
|
2222
|
-
const
|
|
2223
|
-
|
|
2456
|
+
const el = noteEl || $("settingsNote");
|
|
2457
|
+
const isBool = ctl.dataset.kind === "bool";
|
|
2458
|
+
const want = isBool ? (value === true || value === 1) : String(value);
|
|
2459
|
+
const prevBool = ctl.dataset.on === "1";
|
|
2460
|
+
const prevChoice = ctl.dataset.prev != null ? ctl.dataset.prev : ctl.value; // <select> already changed natively
|
|
2461
|
+
const rollback = () => { if (isBool) applyBool(ctl, prevBool); else ctl.value = prevChoice; };
|
|
2462
|
+
if (isBool) applyBool(ctl, want);
|
|
2463
|
+
setNote(el, "Saving " + key + "...");
|
|
2224
2464
|
try {
|
|
2225
2465
|
const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value }) });
|
|
2226
2466
|
const out = await res.json();
|
|
2227
|
-
if (!out.ok) {
|
|
2467
|
+
if (!out.ok) { rollback(); setNote(el, "Could not change " + key + ": " + (out.error || "error"), "err"); return; }
|
|
2228
2468
|
const s = out.setting;
|
|
2229
|
-
if (s &&
|
|
2230
|
-
else if (s) { ctl.value = s.choice; }
|
|
2231
|
-
|
|
2232
|
-
|
|
2469
|
+
if (s && isBool) applyBool(ctl, s.value);
|
|
2470
|
+
else if (s) { ctl.value = s.choice; ctl.dataset.prev = s.choice; }
|
|
2471
|
+
if (s && (isBool ? s.value !== want : String(s.choice) !== want)) { setNote(el, key + " did not change (the write had no effect).", "err"); return; }
|
|
2472
|
+
setNote(el, out.restartNote || ("Saved " + key + "."), "ok");
|
|
2473
|
+
} catch { rollback(); setNote(el, "Could not reach the server to change " + key + ".", "err"); }
|
|
2233
2474
|
}
|
|
2234
2475
|
|
|
2235
2476
|
// List settings: add/remove one entry, then reload so the chips reflect disk truth.
|
|
2236
2477
|
async function postListOp(key, op, item) {
|
|
2237
2478
|
item = (item || "").trim();
|
|
2238
2479
|
if (!item) return;
|
|
2239
|
-
|
|
2480
|
+
setNote("settingsNote", (op === "add" ? "Adding to " : "Removing from ") + key + "...");
|
|
2240
2481
|
try {
|
|
2241
2482
|
const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: { op, item } }) });
|
|
2242
2483
|
const out = await res.json();
|
|
2243
|
-
if (!out.ok) {
|
|
2244
|
-
|
|
2484
|
+
if (!out.ok) { setNote("settingsNote", "Could not change " + key + ": " + (out.error || "error"), "err"); return; }
|
|
2485
|
+
setNote("settingsNote", "Saved " + key + ".", "ok");
|
|
2245
2486
|
await loadSettings();
|
|
2246
|
-
} catch {
|
|
2487
|
+
} catch { setNote("settingsNote", "Could not reach the server to change " + key + ".", "err"); }
|
|
2247
2488
|
}
|
|
2248
2489
|
|
|
2249
2490
|
function wireSettings() {
|
|
@@ -2293,12 +2534,13 @@ export {
|
|
|
2293
2534
|
async function saveDashPort() {
|
|
2294
2535
|
const raw = $("dashPortInput").value.trim();
|
|
2295
2536
|
const value = raw === "" ? 0 : Number(raw);
|
|
2296
|
-
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
|
2297
|
-
|
|
2537
|
+
if (!Number.isInteger(value) || value < 0 || value > 65535) { setNote("settingsNote", "Port must be 0 (auto) or 1-65535.", "err"); return; }
|
|
2538
|
+
setNote("settingsNote", "Saving dashboard port...");
|
|
2298
2539
|
try {
|
|
2299
2540
|
const out = await (await fetch("/api/dashboard-port", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value }) })).json();
|
|
2300
|
-
|
|
2301
|
-
|
|
2541
|
+
if (out.ok) setNote("settingsNote", out.restartNote || ("Saved dashboard port = " + (value || "auto") + "."), "ok");
|
|
2542
|
+
else setNote("settingsNote", "Could not save: " + (out.error || "error"), "err");
|
|
2543
|
+
} catch { setNote("settingsNote", "Could not reach the server to set the port.", "err"); }
|
|
2302
2544
|
}
|
|
2303
2545
|
|
|
2304
2546
|
// --- "Enigma systems" overview (factual state, never invented savings) ---
|
|
@@ -2359,19 +2601,19 @@ export {
|
|
|
2359
2601
|
}
|
|
2360
2602
|
|
|
2361
2603
|
async function runUpdate() {
|
|
2362
|
-
|
|
2604
|
+
setNote("updateNote", "Checking for updates...");
|
|
2363
2605
|
try {
|
|
2364
2606
|
const res = await fetch("/api/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
|
2365
2607
|
const out = await res.json();
|
|
2366
|
-
if (!out.ok) {
|
|
2608
|
+
if (!out.ok) { setNote("updateNote", "Update failed: " + (out.error || "error"), "err"); return; }
|
|
2367
2609
|
// Only reload when something actually changed; an up-to-date check just reports.
|
|
2368
2610
|
if (out.changed) {
|
|
2369
|
-
|
|
2611
|
+
setNote("updateNote", (out.note || "Updated.") + " Refreshing the dashboard...", "ok");
|
|
2370
2612
|
setTimeout(() => location.reload(), 1200);
|
|
2371
2613
|
} else {
|
|
2372
|
-
|
|
2614
|
+
setNote("updateNote", out.note || "Already up to date.", "ok");
|
|
2373
2615
|
}
|
|
2374
|
-
} catch {
|
|
2616
|
+
} catch { setNote("updateNote", "Could not reach the server to update.", "err"); }
|
|
2375
2617
|
}
|
|
2376
2618
|
|
|
2377
2619
|
// Detect & repair every tool's launch path; the server persists any off-PATH
|
|
@@ -2387,10 +2629,12 @@ export {
|
|
|
2387
2629
|
$("updateNote").textContent = "";
|
|
2388
2630
|
renderFixPath(out);
|
|
2389
2631
|
loadSystems(true);
|
|
2632
|
+
if (!out.ok) toast("Some tools could not be fixed - see the details below.", "err");
|
|
2390
2633
|
} catch {
|
|
2391
2634
|
$("updateNote").textContent = "";
|
|
2392
2635
|
$("fixPathResult").className = "fixres show";
|
|
2393
2636
|
$("fixPathResult").innerHTML = '<div class="fixres-head bad">Could not reach the server to fix tool paths.</div>';
|
|
2637
|
+
toast("Could not reach the server to fix tool paths.", "err");
|
|
2394
2638
|
} finally { if (btn) { btn.disabled = false; } }
|
|
2395
2639
|
}
|
|
2396
2640
|
|
|
@@ -2455,6 +2699,14 @@ export {
|
|
|
2455
2699
|
const AGENT_NAME_BY_LABEL = { "Claude Code": "claude", "OpenAI Codex": "codex", "OpenCode": "opencode" };
|
|
2456
2700
|
const agentSvg = (name) => AGENT_ICONS[name] || "";
|
|
2457
2701
|
|
|
2702
|
+
// Compression sources -> a brand icon. Reuses the app icons; the CLI source is enigma itself
|
|
2703
|
+
// (the monochrome cube mark, recoloured to currentColor so it sits uniformly in the tables).
|
|
2704
|
+
const ENIGMA_ICON = '<svg viewBox="0 0 2187.36 2500.37" fill="currentColor" aria-hidden="true"><path d="m2152.75 605.14c-2.56-1.46-5.13-2.93-8.06-4.76q-254.23-145.51-507.76-291.38-266.32-152.69-532.28-305.34l-6.23-3.66-6.59 2.56a202.53 202.53 0 0 0 -29.28 13.55q-231.73 135.63-463.1 270.89c-191.83 112.4-384.03 224.42-574.39 336.44-23.8 11-23.07 35.88-22.7 50.89 0 3.29.37 7 0 10.62q-.55 426.12-1.46 852.24l-.74 316.3c-1.46 19 7 35.15 21.24 41.74q330 187.24 660.05 375.23l389.15 221.54c8.79 5.49 17.57 8.42 25.63 8.42a29.12 29.12 0 0 0 18.3-6.23l564.47-325.13c160.35-92.26 320.7-184.88 479.94-276.4 17.94-8.05 28.56-26 26.36-42.83l1.83-1197.1c2.56-27.46-20.5-40.27-34.41-47.59m-1058.32 75.41 464.19 270.17-463.46 267.28-157.79-89.33q-155.94-88.41-311.9-176.45l469-271.64m527.52 233.57-504.1-293.24c-13.91-9.88-32.21-9.88-45.39-1.1l-510.73 295.77-463.83-262.12 323.62-189.26q336.62-197.15 673.6-393.55 309.15 178.47 618.68 355.47 188.91 108.74 377.8 217.09l-469.69 270.91m-1555.86-205.74 461.63 261.38 9.52 576.59c-1.1 17.93 7.69 34 21.6 40.27q153.19 88.38 307.19 176.45l197 113.12-.36 538.51-998.78-568.16zm525.7 297.62 471.88 266.88-.73 530.46-462.36-265.78zm534.85 870.56 197.32-113.12c101-58.21 202.45-116 304.22-174.63l2.93-1.83c5.49-3.29 12.81-7.69 17.93-15l2.93-4v-4.76c-1.09-127-1.09-256.63-.73-381.83 0-70.29.37-140.58 0-210.87l472.25-272.36-.73 464.56-1.1 678.36q-316.29 181.76-633 364.25l-362.79 209 .73-537.78m0-72.86.37-530.82 464.93-268v531.92q-232.29 132.89-465.3 266.87"/></svg>';
|
|
2705
|
+
const SOURCE_ICON_KEY = { "claude-code": "claude", "claude": "claude", "claude-desktop": "claude", "opencode": "opencode", "codex": "codex", "codex-cli": "codex" };
|
|
2706
|
+
function sourceSvg(k) { return k === "cli" ? ENIGMA_ICON : (AGENT_ICONS[SOURCE_ICON_KEY[k]] || ""); }
|
|
2707
|
+
// Brand icon + text in one inline tag; the icon is omitted when there is none (e.g. content types).
|
|
2708
|
+
function iconLabel(ic, text) { return '<span class="srctag">' + (ic ? '<span class="aicon">' + ic + '</span>' : '') + esc(text) + '</span>'; }
|
|
2709
|
+
|
|
2458
2710
|
function skillRow(s) {
|
|
2459
2711
|
const ver = s.version ? '<span class="tag">v' + esc(s.version) + "</span>" : "";
|
|
2460
2712
|
// Last-edited date (from skill.json), shown relative via the vendored <relative-time>.
|
|
@@ -2512,24 +2764,24 @@ export {
|
|
|
2512
2764
|
}
|
|
2513
2765
|
|
|
2514
2766
|
async function postSkill(name, action, content) {
|
|
2515
|
-
|
|
2767
|
+
setNote("skillsNote", "Working...");
|
|
2516
2768
|
try {
|
|
2517
2769
|
const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, action, content }) });
|
|
2518
2770
|
const out = await res.json();
|
|
2519
|
-
if (!out.ok) {
|
|
2771
|
+
if (!out.ok) { setNote("skillsNote", "Could not " + action + " " + name + ": " + (out.error || "error"), "err"); return; }
|
|
2520
2772
|
if (out.skills) renderSkills(out.skills);
|
|
2521
|
-
|
|
2522
|
-
} catch {
|
|
2773
|
+
setNote("skillsNote", out.note || ("Done: " + name), "ok");
|
|
2774
|
+
} catch { setNote("skillsNote", "Could not reach the server to change " + name + ".", "err"); }
|
|
2523
2775
|
}
|
|
2524
2776
|
|
|
2525
2777
|
// Inline SKILL.md editor (one skill at a time).
|
|
2526
2778
|
let editing = null;
|
|
2527
2779
|
async function openEditor(name, isEnigma) {
|
|
2528
|
-
|
|
2780
|
+
setNote("skillsNote", "Loading " + name + "...");
|
|
2529
2781
|
try {
|
|
2530
2782
|
const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, action: "read" }) });
|
|
2531
2783
|
const out = await res.json();
|
|
2532
|
-
if (!out.ok) {
|
|
2784
|
+
if (!out.ok) { setNote("skillsNote", "Could not open " + name + ": " + (out.error || "error"), "err"); return; }
|
|
2533
2785
|
editing = name;
|
|
2534
2786
|
$("seName").textContent = name;
|
|
2535
2787
|
$("seWarn").textContent = isEnigma ? "managed by enigma - see Customized-skill updates below for whether edits survive an update" : "external skill";
|
|
@@ -2539,7 +2791,7 @@ export {
|
|
|
2539
2791
|
$("skillsNote").textContent = "";
|
|
2540
2792
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
2541
2793
|
$("seText").focus();
|
|
2542
|
-
} catch {
|
|
2794
|
+
} catch { setNote("skillsNote", "Could not reach the server.", "err"); }
|
|
2543
2795
|
}
|
|
2544
2796
|
function closeEditor() { editing = null; $("skillEditor").style.display = "none"; }
|
|
2545
2797
|
// Render per-app save targets (one checkbox per app that holds the skill), only when
|
|
@@ -2559,18 +2811,18 @@ export {
|
|
|
2559
2811
|
let content = $("seText").value;
|
|
2560
2812
|
if (agEl.dataset.has === "1") {
|
|
2561
2813
|
const agents = [...agEl.querySelectorAll("input[type=checkbox]:checked")].map((i) => i.dataset.agent);
|
|
2562
|
-
if (!agents.length) {
|
|
2814
|
+
if (!agents.length) { setNote("skillsNote", "Pick at least one app to save to.", "err"); return; }
|
|
2563
2815
|
content = { text: $("seText").value, agents }; // per-app save
|
|
2564
2816
|
}
|
|
2565
|
-
|
|
2817
|
+
setNote("skillsNote", "Saving " + editing + "...");
|
|
2566
2818
|
try {
|
|
2567
2819
|
const res = await fetch("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editing, action: "save", content }) });
|
|
2568
2820
|
const out = await res.json();
|
|
2569
|
-
if (!out.ok) {
|
|
2821
|
+
if (!out.ok) { setNote("skillsNote", "Could not save " + editing + ": " + (out.error || "error"), "err"); return; }
|
|
2570
2822
|
if (out.skills) renderSkills(out.skills);
|
|
2571
|
-
|
|
2823
|
+
setNote("skillsNote", out.note || "Saved.", "ok");
|
|
2572
2824
|
closeEditor();
|
|
2573
|
-
} catch {
|
|
2825
|
+
} catch { setNote("skillsNote", "Could not reach the server.", "err"); }
|
|
2574
2826
|
}
|
|
2575
2827
|
|
|
2576
2828
|
function wireSkills() {
|
|
@@ -2666,7 +2918,7 @@ export {
|
|
|
2666
2918
|
const ic = agentSvg(t.name);
|
|
2667
2919
|
const lbl = ic ? '<span class="aicon" title="' + esc(t.label) + '" aria-label="' + esc(t.label) + '">' + ic + "</span>" : esc(t.label);
|
|
2668
2920
|
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>";
|
|
2921
|
+
+ ' <select class="choice" data-prof-tool="' + esc(t.name) + '" data-prof="' + esc(p.name) + '" data-prev="' + esc(cur) + '">' + opts + "</select></label>";
|
|
2670
2922
|
}).join("");
|
|
2671
2923
|
const acts = '<div class="set-ctl" style="display:flex;gap:6px;align-items:center">'
|
|
2672
2924
|
+ (p.active ? "" : '<button type="button" class="toggle" data-prof-act="activate" data-name="' + esc(p.name) + '">Use</button>')
|
|
@@ -2683,19 +2935,24 @@ export {
|
|
|
2683
2935
|
$("profilesBody").innerHTML = profiles.length ? profiles.map((p) => profileRow(p, tools, accounts)).join("")
|
|
2684
2936
|
: '<div class="empty" style="padding:20px 0">No profiles yet. Add one to pin an account per tool.</div>';
|
|
2685
2937
|
}
|
|
2686
|
-
|
|
2687
|
-
|
|
2938
|
+
// `rollback` (optional) restores an optimistically-changed control (e.g. a profile
|
|
2939
|
+
// <select>) when the server rejects or is unreachable, so the UI never shows a value
|
|
2940
|
+
// that was not saved. A successful action re-renders from out.data (authoritative).
|
|
2941
|
+
async function accAction(op, payload, rollback) {
|
|
2942
|
+
setNote("accountsNote", "Working...");
|
|
2688
2943
|
try {
|
|
2689
2944
|
const r = await fetch("/api/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.assign({ op }, payload)) });
|
|
2690
2945
|
const out = await r.json();
|
|
2691
|
-
$("accountsNote").textContent = out.ok ? "Saved." : ("Error: " + (out.error || "failed"));
|
|
2692
2946
|
if (out.data) renderAccounts(out.data);
|
|
2693
|
-
|
|
2947
|
+
if (out.ok) { setNote("accountsNote", "Saved.", "ok"); return; }
|
|
2948
|
+
if (rollback && !out.data) rollback();
|
|
2949
|
+
setNote("accountsNote", "Error: " + (out.error || "failed"), "err");
|
|
2950
|
+
} catch { if (rollback) rollback(); setNote("accountsNote", "Could not reach the server.", "err"); }
|
|
2694
2951
|
}
|
|
2695
2952
|
function wireAccounts() {
|
|
2696
2953
|
$("accAdd").addEventListener("click", async () => {
|
|
2697
2954
|
const tool = accTools.length === 1 ? accTools[0].name
|
|
2698
|
-
: await
|
|
2955
|
+
: 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
2956
|
if (!tool) return;
|
|
2700
2957
|
const taken = accAccounts.filter((a) => a.tool === tool).map((a) => a.name);
|
|
2701
2958
|
const name = await askText("Add account", "", "New account name (e.g. work):", nameValidator(taken, null));
|
|
@@ -2708,7 +2965,7 @@ export {
|
|
|
2708
2965
|
if (op === "activate") accAction("account.activate", { tool, name });
|
|
2709
2966
|
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
2967
|
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") {
|
|
2968
|
+
else if (op === "login") { setNote("accountsNote", "To log in, run this in a terminal: enigma " + tool + " " + name); }
|
|
2712
2969
|
});
|
|
2713
2970
|
$("profilesBody").addEventListener("click", async (e) => {
|
|
2714
2971
|
const b = e.target.closest("button[data-prof-act]"); if (!b) return;
|
|
@@ -2719,27 +2976,28 @@ export {
|
|
|
2719
2976
|
});
|
|
2720
2977
|
$("profilesBody").addEventListener("change", (e) => {
|
|
2721
2978
|
const sel = e.target.closest("select[data-prof-tool]"); if (!sel) return;
|
|
2722
|
-
|
|
2979
|
+
const prev = sel.dataset.prev != null ? sel.dataset.prev : "";
|
|
2980
|
+
accAction("profile.setAccount", { profile: sel.dataset.prof, tool: sel.dataset.profTool, account: sel.value || null }, () => { sel.value = prev; });
|
|
2723
2981
|
});
|
|
2724
2982
|
}
|
|
2725
2983
|
|
|
2726
2984
|
// --- config import / export (the browser file picker is what "asks where") ---
|
|
2727
2985
|
async function exportConfig() {
|
|
2728
|
-
|
|
2986
|
+
setNote("settingsNote", "Exporting...");
|
|
2729
2987
|
let text;
|
|
2730
2988
|
try { text = await (await fetch("/api/config-export", { cache: "no-store" })).text(); }
|
|
2731
|
-
catch {
|
|
2989
|
+
catch { setNote("settingsNote", "Export failed.", "err"); return; }
|
|
2732
2990
|
const name = "enigma-config-" + new Date().toISOString().slice(0, 10) + ".json";
|
|
2733
2991
|
if (window.showSaveFilePicker) {
|
|
2734
2992
|
try {
|
|
2735
2993
|
const h = await window.showSaveFilePicker({ suggestedName: name, types: [{ description: "JSON", accept: { "application/json": [".json"] } }] });
|
|
2736
2994
|
const w = await h.createWritable(); await w.write(text); await w.close();
|
|
2737
|
-
|
|
2738
|
-
} catch (e) { if (e && e.name === "AbortError") {
|
|
2995
|
+
setNote("settingsNote", "Exported (no secrets included).", "ok"); return;
|
|
2996
|
+
} catch (e) { if (e && e.name === "AbortError") { setNote("settingsNote", "Export cancelled."); return; } }
|
|
2739
2997
|
}
|
|
2740
2998
|
const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
|
2741
2999
|
const a = document.createElement("a"); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url);
|
|
2742
|
-
|
|
3000
|
+
setNote("settingsNote", "Exported to your downloads (no secrets included).", "ok");
|
|
2743
3001
|
}
|
|
2744
3002
|
async function importConfig() {
|
|
2745
3003
|
let text = null;
|
|
@@ -2755,14 +3013,14 @@ export {
|
|
|
2755
3013
|
});
|
|
2756
3014
|
if (text === null) return;
|
|
2757
3015
|
}
|
|
2758
|
-
let body; try { body = JSON.parse(text); } catch {
|
|
2759
|
-
|
|
3016
|
+
let body; try { body = JSON.parse(text); } catch { setNote("settingsNote", "That file is not valid JSON.", "err"); return; }
|
|
3017
|
+
setNote("settingsNote", "Importing...");
|
|
2760
3018
|
try {
|
|
2761
3019
|
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
|
-
|
|
3020
|
+
if (!out.ok) { setNote("settingsNote", "Import failed: " + (out.error || "error"), "err"); return; }
|
|
3021
|
+
setNote("settingsNote", "Imported: " + (out.applied || []).join(", ") + ((out.skipped && out.skipped.length) ? (" · skipped: " + out.skipped.join(", ")) : ""), "ok");
|
|
2764
3022
|
settingsLoaded = false; loadSettings(); accountsLoaded = false;
|
|
2765
|
-
} catch {
|
|
3023
|
+
} catch { setNote("settingsNote", "Could not reach the server to import.", "err"); }
|
|
2766
3024
|
}
|
|
2767
3025
|
|
|
2768
3026
|
// --- hash routing between the subpages ---
|
|
@@ -2776,6 +3034,7 @@ export {
|
|
|
2776
3034
|
if (v === "settings" && !settingsLoaded) { settingsLoaded = true; loadSettings(); }
|
|
2777
3035
|
if (v === "skills" && !skillsLoaded) { skillsLoaded = true; loadSkills(); }
|
|
2778
3036
|
if (v === "accounts" && !accountsLoaded) { accountsLoaded = true; loadAccounts(); }
|
|
3037
|
+
if (v === "usage") { loadProviderStatus(); if (!usageAccountsLoaded) { usageAccountsLoaded = true; loadUsageAccounts(); } }
|
|
2779
3038
|
if (v === "resources") loadResources();
|
|
2780
3039
|
// The chart was sized while its view may have been hidden; nudge it on return.
|
|
2781
3040
|
if (v === "savings") { loadSystems(true); if (chart) { try { applyRange(); } catch { /* not ready */ } } }
|
|
@@ -2810,12 +3069,12 @@ export {
|
|
|
2810
3069
|
}
|
|
2811
3070
|
async function resAction(op, value, confirmMsg) {
|
|
2812
3071
|
if (confirmMsg && !(await askConfirm("Confirm", confirmMsg, true))) return;
|
|
2813
|
-
|
|
3072
|
+
setNote("resNote", "Running...");
|
|
2814
3073
|
try {
|
|
2815
3074
|
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
3075
|
if (out.status) renderResources(out.status);
|
|
2818
|
-
|
|
3076
|
+
setNote("resNote", out.message || (out.ok ? "Done." : "Action failed."), out.ok ? "ok" : "err");
|
|
3077
|
+
} catch { setNote("resNote", "Could not reach the server.", "err"); }
|
|
2819
3078
|
}
|
|
2820
3079
|
function wireResources() {
|
|
2821
3080
|
$("resRefresh").addEventListener("click", loadResources);
|
|
@@ -2838,12 +3097,12 @@ export {
|
|
|
2838
3097
|
|
|
2839
3098
|
initChart();
|
|
2840
3099
|
poll(true);
|
|
2841
|
-
|
|
3100
|
+
loadProviderStatus();
|
|
2842
3101
|
checkDashboardUpdate();
|
|
2843
3102
|
setInterval(() => refreshAll(false), POLL_MS);
|
|
2844
|
-
setInterval(
|
|
3103
|
+
setInterval(loadProviderStatus, 60000);
|
|
2845
3104
|
setInterval(checkDashboardUpdate, UPDATE_CHECK_MS);
|
|
2846
|
-
document.addEventListener("visibilitychange", () => { if (!document.hidden) { refreshAll(true);
|
|
3105
|
+
document.addEventListener("visibilitychange", () => { if (!document.hidden) { refreshAll(true); loadProviderStatus(); checkDashboardUpdate(); } });
|
|
2847
3106
|
wireSettings();
|
|
2848
3107
|
wireSkills();
|
|
2849
3108
|
wireAccounts();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enigmax/dashboard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.43",
|
|
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": [
|