@enigmax/dashboard 0.1.6 → 0.1.7

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 CHANGED
@@ -130,6 +130,17 @@
130
130
  border-radius: 7px; padding: 5px 14px; font-size: 12px; cursor: pointer; font-family: inherit;
131
131
  }
132
132
  .btn-danger:hover { border-color: var(--accent2); }
133
+ /* List settings (guard block/allow globs, custom secret patterns): chips + add input. A list row spans the full grid width. */
134
+ .set-row.list { grid-column: 1 / -1; align-items: stretch; flex-direction: column; }
135
+ .lchips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
136
+ .lchip { display: inline-flex; align-items: center; gap: 4px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 3px 4px 3px 9px; font-size: 12px; color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
137
+ .lchip .lx { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 4px; font-family: inherit; }
138
+ .lchip .lx:hover { color: var(--accent2); }
139
+ .lnone { color: var(--muted); font-size: 12px; margin-top: 8px; font-style: italic; }
140
+ .ladd { display: flex; gap: 8px; margin-top: 8px; }
141
+ .linput { flex: 1; min-width: 0; background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-radius: 7px; padding: 5px 10px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
142
+ .laddb { background: var(--surface2); color: var(--accent); border: 1px solid var(--border); border-radius: 7px; padding: 5px 14px; font-size: 12px; cursor: pointer; font-family: inherit; }
143
+ .laddb:hover { border-color: var(--accent); }
133
144
  .sys-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px; }
134
145
  .sys-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--surface2); font-size: 13px; }
135
146
  .sys-item .k { color: var(--muted); }
@@ -148,6 +159,8 @@
148
159
  </div>
149
160
  <nav class="tabs">
150
161
  <a href="#/" data-view="savings" class="tab active">Savings</a>
162
+ <a href="#/usage" data-view="usage" class="tab">Usage</a>
163
+ <a href="#/accounts" data-view="accounts" class="tab">Accounts</a>
151
164
  <a href="#/skills" data-view="skills" class="tab">Skills</a>
152
165
  <a href="#/settings" data-view="settings" class="tab">Settings</a>
153
166
  </nav>
@@ -215,25 +228,6 @@
215
228
  <div id="chartEmpty" class="empty" style="display:none">No compression activity recorded yet. Run <code>enigma compress &lt;file&gt;</code> or enable the compression MCP (<code>enigma config compress on</code>).</div>
216
229
  </div>
217
230
 
218
- <div class="panel" id="usagePanel" style="display:none">
219
- <h2 style="margin-bottom:4px">Real Tool Usage <small id="usageSub">Claude Code transcripts</small></h2>
220
- <div class="sub" style="margin-bottom:14px">Measured token consumption and real prompt-cache savings, read-only from your own session logs. Deliberately not attributed to skills or output-style: a transcript has no counterfactual baseline, so any such number would be invented.</div>
221
- <div class="grid" style="margin-bottom:16px">
222
- <div class="card"><div class="label">Cache Saved</div><div id="uCacheMoney" class="value good">-</div><div class="foot">Est. from prompt caching</div></div>
223
- <div class="card"><div class="label">Cache Reads</div><div id="uCacheRead" class="value accent">-</div><div class="foot">Tokens served from cache</div></div>
224
- <div class="card"><div class="label">Input Tokens</div><div id="uInput" class="value">-</div><div class="foot">Consumed across sessions</div></div>
225
- <div class="card"><div class="label">Output Tokens</div><div id="uOutput" class="value">-</div><div class="foot">Generated by the agent</div></div>
226
- <div class="card"><div class="label">Sessions</div><div id="uSessions" class="value">-</div><div id="uSessionsFoot" class="foot">Transcripts scanned</div></div>
227
- </div>
228
- <h2 style="margin:0 0 10px">By Model</h2>
229
- <div id="uByModel"></div>
230
- </div>
231
-
232
- <div class="panel" id="usageHint" style="display:none">
233
- <h2 style="margin-bottom:8px">Real Tool Usage <small>opt-in</small></h2>
234
- <div class="sub">Show real token consumption and prompt-cache savings from your Claude Code sessions in <a href="#/settings">Settings</a> (Real tool-usage stats). enigma reads only your local session transcripts; nothing is sent anywhere.</div>
235
- </div>
236
-
237
231
  <div class="panel">
238
232
  <h2 style="margin-bottom:14px">Savings By Source <small>which app/CLI compressed</small></h2>
239
233
  <div id="sources"></div>
@@ -276,6 +270,61 @@
276
270
  </div>
277
271
  </main>
278
272
 
273
+ <section id="view-usage" style="display:none">
274
+ <div class="page-head">
275
+ <h1>Claude Usage</h1>
276
+ <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>
277
+ </div>
278
+ <div class="panel" id="usagePanel" style="display:none">
279
+ <div class="grid" style="margin-bottom:16px">
280
+ <div class="card"><div class="label">Est. Cost</div><div id="uCost" class="value accent">-</div><div class="foot">All sessions, per-model pricing</div></div>
281
+ <div class="card"><div class="label">Cache Saved</div><div id="uCacheMoney" class="value good">-</div><div class="foot">Est. from prompt caching</div></div>
282
+ <div class="card"><div class="label">Input Tokens</div><div id="uInput" class="value">-</div><div class="foot">Consumed across sessions</div></div>
283
+ <div class="card"><div class="label">Output Tokens</div><div id="uOutput" class="value">-</div><div class="foot">Generated by the agent</div></div>
284
+ <div class="card"><div class="label">Cache Reads</div><div id="uCacheRead" class="value accent">-</div><div class="foot">Tokens served from cache</div></div>
285
+ <div class="card"><div class="label">Sessions</div><div id="uSessions" class="value">-</div><div id="uSessionsFoot" class="foot">Transcripts scanned</div></div>
286
+ </div>
287
+ <div id="uBlockWrap" style="display:none;margin-bottom:6px">
288
+ <h2 style="margin-bottom:10px">Current 5-hour block <small id="uBlockState"></small></h2>
289
+ <div class="bar-row"><span class="name" style="width:120px">Window</span><div class="bar-track"><div id="uBlockBar" class="bar-fill" style="background:var(--accent)"></div></div><span id="uBlockTime" class="num">-</span></div>
290
+ <div class="stat-line" style="margin:12px 0 0">
291
+ <div>Tokens <b id="uBlockTokens">-</b></div>
292
+ <div>Cost <b id="uBlockCost">-</b></div>
293
+ <div>Burn rate <b id="uBlockBurn">-</b></div>
294
+ <div>Projected <b id="uBlockProj">-</b></div>
295
+ </div>
296
+ </div>
297
+ <h2 style="margin:18px 0 10px">By Model</h2>
298
+ <div id="uByModel"></div>
299
+ <h2 style="margin:18px 0 10px">By Project</h2>
300
+ <div id="uByProject"></div>
301
+ <h2 style="margin:18px 0 10px">Recent Sessions</h2>
302
+ <div class="tbl-wrap"><div id="uRecent"></div></div>
303
+ </div>
304
+ <div class="panel" id="usageHint" style="display:none">
305
+ <h2 style="margin-bottom:8px">Real tool usage <small>opt-in</small></h2>
306
+ <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>
307
+ </div>
308
+ </section>
309
+
310
+ <section id="view-accounts" style="display:none">
311
+ <div class="page-head" style="display:flex;align-items:flex-start;gap:16px">
312
+ <div style="flex:1">
313
+ <h1>Accounts &amp; Profiles</h1>
314
+ <p>Manage per-tool logins and profiles (a profile pins one account per tool and drives launches), the same as the terminal UI. Logging an account IN happens in a terminal - the browser shows the command to run.</p>
315
+ </div>
316
+ </div>
317
+ <div class="panel">
318
+ <div class="panel-head"><h2>Accounts</h2><button id="accAdd" type="button" class="toggle on">Add account</button></div>
319
+ <div id="accountsBody"><div class="empty" style="padding:24px 0">Loading accounts...</div></div>
320
+ </div>
321
+ <div class="panel">
322
+ <div class="panel-head"><h2>Profiles</h2><button id="profAdd" type="button" class="toggle on">Add profile</button></div>
323
+ <div id="profilesBody"><div class="empty" style="padding:24px 0">Loading profiles...</div></div>
324
+ </div>
325
+ <div id="accountsNote" class="set-note"></div>
326
+ </section>
327
+
279
328
  <section id="view-skills" style="display:none">
280
329
  <div class="page-head" style="display:flex;align-items:flex-start;gap:16px">
281
330
  <div style="flex:1">
@@ -299,11 +348,17 @@
299
348
  </section>
300
349
 
301
350
  <section id="view-settings" style="display:none">
302
- <div class="page-head">
303
- <h1>Settings</h1>
304
- <p>Everything you can configure with <code>enigma config</code> or the terminal UI, editable here. Changes apply at global scope and take effect immediately; toggles that change agent memory need an agent restart.</p>
305
- <div id="settingsNote" class="set-note"></div>
351
+ <div class="page-head" style="display:flex;align-items:flex-start;gap:16px">
352
+ <div style="flex:1">
353
+ <h1>Settings</h1>
354
+ <p>Everything you can configure with <code>enigma config</code> or the terminal UI, editable here. Changes apply at global scope and take effect immediately; toggles that change agent memory need an agent restart.</p>
355
+ </div>
356
+ <div style="display:flex;gap:8px;white-space:nowrap">
357
+ <button id="cfgExport" type="button" class="toggle">Export config</button>
358
+ <button id="cfgImport" type="button" class="toggle">Import config</button>
359
+ </div>
306
360
  </div>
361
+ <div id="settingsNote" class="set-note"></div>
307
362
  <div id="settingsBody"><div class="empty" style="padding:24px 0">Loading settings...</div></div>
308
363
  </section>
309
364
 
@@ -458,28 +513,58 @@
458
513
  }
459
514
 
460
515
  // Real tool-usage panel (Claude Code transcripts). null = the opt-in flag is off.
516
+ function shortProject(p) { p = String(p || ""); return p.length > 44 ? "..." + p.slice(-41) : p; }
517
+
518
+ // One token/cost breakdown table (By Model / By Project), sorted by cost then tokens.
519
+ function renderUsageTable(el, map, pending, label) {
520
+ const entries = Object.entries(map || {}).sort((a, b) => ((b[1].cost || 0) - (a[1].cost || 0)) || ((b[1].input + b[1].output) - (a[1].input + a[1].output)));
521
+ if (!entries.length) { el.innerHTML = '<div class="empty" style="padding:16px 0">' + (pending ? "Scanning..." : "No usage recorded yet.") + "</div>"; return; }
522
+ el.innerHTML = '<table class="tbl"><thead><tr><th>' + label + '</th><th class="r">Input</th><th class="r">Output</th>'
523
+ + '<th class="r">Cache reads</th><th class="r">Msgs</th><th class="r">Cost</th></tr></thead><tbody>'
524
+ + entries.map(([k, v]) => '<tr><td>' + esc(label === "Project" ? shortProject(k) : k) + '</td><td class="r">' + fmt(v.input) + '</td><td class="r">'
525
+ + fmt(v.output) + '</td><td class="r good">' + fmt(v.cacheRead) + '</td><td class="r">' + fmt(v.messages) + '</td><td class="r">' + fmtUsd(v.cost || 0) + "</td></tr>").join("")
526
+ + "</tbody></table>";
527
+ }
528
+
529
+ function renderRecentSessions(el, rows, pending) {
530
+ rows = rows || [];
531
+ if (!rows.length) { el.innerHTML = '<div class="empty" style="padding:16px 0">' + (pending ? "Scanning..." : "No sessions yet.") + "</div>"; return; }
532
+ el.innerHTML = '<table class="tbl"><thead><tr><th>Last active</th><th>Project</th><th>Model</th><th class="r">Input</th><th class="r">Output</th><th class="r">Cost</th></tr></thead><tbody>'
533
+ + rows.map((s) => '<tr><td>' + (s.lastActive ? new Date(s.lastActive).toLocaleString() : "-") + '</td><td>' + esc(shortProject(s.project)) + '</td><td>' + esc(s.model || "-")
534
+ + '</td><td class="r">' + fmt(s.input) + '</td><td class="r">' + fmt(s.output) + '</td><td class="r">' + fmtUsd(s.cost || 0) + "</td></tr>").join("")
535
+ + "</tbody></table>";
536
+ }
537
+
461
538
  function renderUsage(u) {
462
539
  const panel = $("usagePanel"), hint = $("usageHint");
463
540
  if (!u) { panel.style.display = "none"; hint.style.display = "block"; return; }
464
541
  hint.style.display = "none";
465
542
  panel.style.display = "block";
543
+ $("uCost").textContent = fmtUsd(u.cost || 0);
466
544
  $("uCacheMoney").textContent = fmtUsd(cacheMoney(u.cacheRead || 0));
467
545
  $("uCacheRead").textContent = fmt(u.cacheRead || 0);
468
546
  $("uInput").textContent = fmt(u.input || 0);
469
547
  $("uOutput").textContent = fmt(u.output || 0);
470
548
  $("uSessions").textContent = fmt(u.sessions || 0);
471
549
  $("uSessionsFoot").textContent = u.pending ? "scanning sessions..." : fmt(u.scannedFiles || 0) + " files scanned";
472
- const entries = Object.entries(u.byModel || {}).sort((a, b) => ((b[1].input + b[1].output) - (a[1].input + a[1].output)));
473
- const el = $("uByModel");
474
- if (!entries.length) {
475
- el.innerHTML = '<div class="empty" style="padding:20px 0">' + (u.pending ? "Scanning session transcripts..." : "No usage recorded yet.") + "</div>";
476
- return;
477
- }
478
- el.innerHTML = '<table class="tbl"><thead><tr><th>Model</th><th class="r">Input</th><th class="r">Output</th>'
479
- + '<th class="r">Cache reads</th><th class="r">Messages</th></tr></thead><tbody>'
480
- + entries.map(([m, v]) => '<tr><td>' + m + '</td><td class="r">' + fmt(v.input) + '</td><td class="r">'
481
- + fmt(v.output) + '</td><td class="r good">' + fmt(v.cacheRead) + '</td><td class="r">' + fmt(v.messages) + "</td></tr>").join("")
482
- + "</tbody></table>";
550
+ // Current 5-hour block (computed locally from transcript timestamps).
551
+ const b = u.block, bw = $("uBlockWrap");
552
+ if (b && b.startedAt) {
553
+ bw.style.display = "block";
554
+ const now = Date.now(), total = 5 * 3600 * 1000;
555
+ const pct = Math.max(0, Math.min(100, Math.round((now - b.startedAt) / total * 100)));
556
+ $("uBlockBar").style.width = pct + "%";
557
+ $("uBlockState").textContent = b.active ? "active" : "window elapsed";
558
+ const remMin = Math.max(0, Math.round((b.endsAt - now) / 60000));
559
+ $("uBlockTime").textContent = b.active ? (remMin + " min left") : "ended";
560
+ $("uBlockTokens").textContent = fmt(b.tokens || 0);
561
+ $("uBlockCost").textContent = fmtUsd(b.cost || 0);
562
+ $("uBlockBurn").textContent = fmt(Math.round(b.burnRatePerMin || 0)) + " tok/min";
563
+ $("uBlockProj").textContent = fmt(b.projectedTokens || 0) + " · " + fmtUsd(b.projectedCost || 0);
564
+ } else { bw.style.display = "none"; }
565
+ renderUsageTable($("uByModel"), u.byModel, u.pending, "Model");
566
+ renderUsageTable($("uByProject"), u.byProject, u.pending, "Project");
567
+ renderRecentSessions($("uRecent"), u.recentSessions, u.pending);
483
568
  }
484
569
 
485
570
  function renderCache(c) {
@@ -681,6 +766,19 @@
681
766
  }
682
767
 
683
768
  function settingRow(s) {
769
+ if (s.kind === "list") {
770
+ const items = s.items || [];
771
+ const chips = items.length
772
+ ? '<div class="lchips">' + items.map((it) => '<span class="lchip">' + esc(it)
773
+ + '<button type="button" class="lx" title="remove" data-skey="' + esc(s.key) + '" data-item="' + esc(it) + '">x</button></span>').join("") + "</div>"
774
+ : '<div class="lnone">none - using the built-in defaults</div>';
775
+ const adder = '<div class="ladd"><input type="text" class="linput" data-skey="' + esc(s.key)
776
+ + '" placeholder="' + esc(s.itemHint || "add an entry...") + '">'
777
+ + '<button type="button" class="laddb" data-skey="' + esc(s.key) + '">Add</button></div>';
778
+ return '<div class="set-row list"><div class="set-meta"><div class="set-name">' + labelWithBadges(s.label) + "</div>"
779
+ + '<div class="set-hint">' + esc(s.hint) + (s.globalOnly ? " · global" : "") + "</div>"
780
+ + chips + adder + "</div></div>";
781
+ }
684
782
  const ctl = s.choices
685
783
  ? '<select class="choice" data-skey="' + esc(s.key) + '">'
686
784
  + s.choices.map((c) => '<option value="' + esc(c) + '"' + (c === s.choice ? " selected" : "") + ">" + esc(c) + "</option>").join("")
@@ -715,16 +813,42 @@
715
813
  } catch { $("settingsNote").textContent = "Could not reach the server to change " + key + "."; }
716
814
  }
717
815
 
816
+ // List settings: add/remove one entry, then reload so the chips reflect disk truth.
817
+ async function postListOp(key, op, item) {
818
+ item = (item || "").trim();
819
+ if (!item) return;
820
+ $("settingsNote").textContent = (op === "add" ? "Adding to " : "Removing from ") + key + "...";
821
+ try {
822
+ const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: { op, item } }) });
823
+ const out = await res.json();
824
+ if (!out.ok) { $("settingsNote").textContent = "Could not change " + key + ": " + (out.error || "error"); return; }
825
+ $("settingsNote").textContent = "Saved " + key + ".";
826
+ await loadSettings();
827
+ } catch { $("settingsNote").textContent = "Could not reach the server to change " + key + "."; }
828
+ }
829
+
718
830
  function wireSettings() {
719
831
  const el = $("settingsBody");
720
832
  el.addEventListener("click", (e) => {
721
833
  const b = e.target.closest("button.toggle");
722
- if (b) postSetting(b.dataset.skey, b.dataset.on !== "1", b);
834
+ if (b) { postSetting(b.dataset.skey, b.dataset.on !== "1", b); return; }
835
+ const rm = e.target.closest("button.lx");
836
+ if (rm) { postListOp(rm.dataset.skey, "remove", rm.dataset.item); return; }
837
+ const add = e.target.closest("button.laddb");
838
+ if (add) {
839
+ const inp = el.querySelector('input.linput[data-skey="' + (window.CSS && CSS.escape ? CSS.escape(add.dataset.skey) : add.dataset.skey) + '"]');
840
+ if (inp) { postListOp(add.dataset.skey, "add", inp.value); inp.value = ""; }
841
+ }
723
842
  });
724
843
  el.addEventListener("change", (e) => {
725
844
  const sel = e.target.closest("select.choice");
726
845
  if (sel) postSetting(sel.dataset.skey, sel.value, sel);
727
846
  });
847
+ el.addEventListener("keydown", (e) => {
848
+ if (e.key !== "Enter") return;
849
+ const inp = e.target.closest("input.linput");
850
+ if (inp) { e.preventDefault(); postListOp(inp.dataset.skey, "add", inp.value); inp.value = ""; }
851
+ });
728
852
  }
729
853
 
730
854
  let settingsLoaded = false;
@@ -754,12 +878,30 @@
754
878
  ? item("Proxy measured · real", '<span class="tag">' + fmt(ps.calls) + " calls</span><span class=\"tag\">"
755
879
  + fmt(ps.input) + " in</span><span class=\"tag\">" + fmt(ps.output) + " out</span><span class=\"tag\">" + fmt(ps.cacheRead) + " cache</span>")
756
880
  : "";
881
+ const proxyLast = (ps.lastRequestAt || 0) > 0
882
+ ? item("Proxy last request · real", '<span class="tag">' + esc(fmtDuration((Date.now() - ps.lastRequestAt) / 1000) + " ago")
883
+ + '</span><span class="tag">' + esc(new Date(ps.lastRequestAt).toLocaleString()) + "</span>")
884
+ : "";
885
+ // Prompt secret guard: configured state + what it has actually blocked (measured).
886
+ const guardState = s.promptSecretGuard
887
+ ? '<span class="pill on">on</span><span class="tag">' + esc(s.promptSecretMode || "redact") + "</span>"
888
+ : pill("off", "off");
889
+ const promptGuard = item("Prompt secret guard · experimental", guardState);
890
+ const blockedTotal = (ps.redacted || 0) + (ps.rejected || 0);
891
+ const promptBlocked = blockedTotal > 0
892
+ ? item("Prompt secrets blocked · real", '<span class="tag">' + fmt(ps.redacted || 0) + " redacted</span><span class=\"tag\">"
893
+ + fmt(ps.rejected || 0) + " rejected</span>"
894
+ + ((ps.lastBlockedAt || 0) > 0 ? '<span class="tag">' + esc(fmtDuration((Date.now() - ps.lastBlockedAt) / 1000) + " ago") + "</span>" : ""))
895
+ : "";
757
896
  const guard = (sec.guardProtects || []).map((p) => '<span class="tag">' + esc(p) + "</span>").join("");
758
897
  el.innerHTML =
759
898
  item("Context compression (MCP) · measured", boolPill(s.compress))
760
899
  + item("Real tool-usage stats · measured", boolPill(s.usageStats))
761
- + item("Claude Code measuring proxy · experimental", boolPill(s.proxy))
900
+ + item("Claude Code proxy · experimental", boolPill(s.proxy))
762
901
  + proxyMeasured
902
+ + proxyLast
903
+ + promptGuard
904
+ + promptBlocked
763
905
  + item("Token-efficient output", levelPill(s.outputStyle))
764
906
  + item("Minimal code", levelPill(s.minimalCode))
765
907
  + item("Parallel sub-agents", boolPill(s.parallelSubagents))
@@ -892,16 +1034,141 @@
892
1034
  } catch { $("skillsBody").innerHTML = '<div class="empty" style="padding:24px 0">Skills unavailable.</div>'; }
893
1035
  }
894
1036
 
895
- // --- hash routing between the Savings, Skills and Settings subpages ---
1037
+ // --- accounts & profiles subpage (mirrors the TUI hub over /api/accounts) ---
1038
+ let accountsLoaded = false;
1039
+ let accTools = [];
1040
+ async function loadAccounts() {
1041
+ try { const r = await fetch("/api/accounts", { cache: "no-store" }); renderAccounts(await r.json()); }
1042
+ catch { $("accountsBody").innerHTML = '<div class="empty" style="padding:24px 0">Accounts unavailable.</div>'; }
1043
+ }
1044
+ function accountRow(a) {
1045
+ const badges = (a.active ? '<span class="tag meas">active</span>' : "")
1046
+ + (a.loggedIn ? '<span class="tag">' + esc(a.email || "logged in") + "</span>" : '<span class="tag warn">not logged in</span>');
1047
+ const btn = (act, label, cls) => '<button type="button" class="' + (cls || "toggle") + '" data-acc="' + act + '" data-tool="' + esc(a.tool) + '" data-name="' + esc(a.name) + '">' + label + "</button>";
1048
+ const acts = '<div class="set-ctl" style="display:flex;gap:6px;flex-wrap:wrap">'
1049
+ + (a.active ? "" : btn("activate", "Use"))
1050
+ + (a.loggedIn ? "" : btn("login", "Log in..."))
1051
+ + (a.removable ? btn("rename", "Rename") + btn("remove", "Remove", "btn-danger") : "")
1052
+ + "</div>";
1053
+ return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(a.name) + ' <span class="tag ext">' + esc(a.toolLabel) + "</span> " + badges
1054
+ + '</div><div class="set-hint">' + esc(a.dir) + "</div></div>" + acts + "</div>";
1055
+ }
1056
+ function profileRow(p, tools, accounts) {
1057
+ const maps = tools.map((t) => {
1058
+ const cur = p.accounts[t.name] || "";
1059
+ const opts = ['<option value="">(none)</option>'].concat(accounts.filter((a) => a.tool === t.name)
1060
+ .map((a) => '<option value="' + esc(a.name) + '"' + (a.name === cur ? " selected" : "") + ">" + esc(a.name) + "</option>")).join("");
1061
+ return '<label style="font-size:12px;color:var(--muted);display:inline-flex;align-items:center;gap:6px;margin:0 12px 6px 0">' + esc(t.label)
1062
+ + ' <select class="choice" data-prof-tool="' + esc(t.name) + '" data-prof="' + esc(p.name) + '">' + opts + "</select></label>";
1063
+ }).join("");
1064
+ const acts = '<div class="set-ctl" style="display:flex;gap:6px">'
1065
+ + (p.active ? "" : '<button type="button" class="toggle" data-prof-act="activate" data-name="' + esc(p.name) + '">Use</button>')
1066
+ + '<button type="button" class="toggle" data-prof-act="rename" data-name="' + esc(p.name) + '">Rename</button>'
1067
+ + '<button type="button" class="btn-danger" data-prof-act="remove" data-name="' + esc(p.name) + '">Remove</button></div>';
1068
+ return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(p.name) + (p.active ? ' <span class="tag meas">active</span>' : "")
1069
+ + '</div><div class="set-hint" style="margin-top:6px">' + maps + "</div></div>" + acts + "</div>";
1070
+ }
1071
+ function renderAccounts(d) {
1072
+ d = d || {};
1073
+ const accounts = d.accounts || [], profiles = d.profiles || [], tools = d.tools || [];
1074
+ accTools = tools;
1075
+ $("accountsBody").innerHTML = accounts.length ? accounts.map(accountRow).join("") : '<div class="empty" style="padding:20px 0">No accounts.</div>';
1076
+ $("profilesBody").innerHTML = profiles.length ? profiles.map((p) => profileRow(p, tools, accounts)).join("")
1077
+ : '<div class="empty" style="padding:20px 0">No profiles yet. Add one to pin an account per tool.</div>';
1078
+ }
1079
+ async function accAction(op, payload) {
1080
+ $("accountsNote").textContent = "Working...";
1081
+ try {
1082
+ const r = await fetch("/api/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.assign({ op }, payload)) });
1083
+ const out = await r.json();
1084
+ $("accountsNote").textContent = out.ok ? "Saved." : ("Error: " + (out.error || "failed"));
1085
+ if (out.data) renderAccounts(out.data);
1086
+ } catch { $("accountsNote").textContent = "Could not reach the server."; }
1087
+ }
1088
+ function wireAccounts() {
1089
+ $("accAdd").addEventListener("click", () => {
1090
+ const tool = accTools.length === 1 ? accTools[0].name : prompt("Which tool? (" + accTools.map((t) => t.name).join(" / ") + ")", accTools[0] ? accTools[0].name : "");
1091
+ if (!tool) return;
1092
+ const name = prompt("New account name (e.g. work):", "");
1093
+ if (name) accAction("account.add", { tool, name });
1094
+ });
1095
+ $("profAdd").addEventListener("click", () => { const name = prompt("New profile name:", ""); if (name) accAction("profile.add", { name }); });
1096
+ $("accountsBody").addEventListener("click", (e) => {
1097
+ const b = e.target.closest("button[data-acc]"); if (!b) return;
1098
+ const op = b.dataset.acc, tool = b.dataset.tool, name = b.dataset.name;
1099
+ if (op === "activate") accAction("account.activate", { tool, name });
1100
+ else if (op === "remove") { if (confirm("Remove account '" + name + "' and delete its local config dir?")) accAction("account.remove", { tool, name }); }
1101
+ else if (op === "rename") { const nn = prompt("Rename '" + name + "' to:", name); if (nn && nn !== name) accAction("account.rename", { tool, name, newName: nn }); }
1102
+ else if (op === "login") { $("accountsNote").textContent = "To log in, run this in a terminal: enigma " + tool + " " + name; }
1103
+ });
1104
+ $("profilesBody").addEventListener("click", (e) => {
1105
+ const b = e.target.closest("button[data-prof-act]"); if (!b) return;
1106
+ const op = b.dataset.profAct, name = b.dataset.name;
1107
+ if (op === "activate") accAction("profile.activate", { name });
1108
+ else if (op === "remove") { if (confirm("Remove profile '" + name + "'? (its accounts are kept)")) accAction("profile.remove", { name }); }
1109
+ else if (op === "rename") { const nn = prompt("Rename profile '" + name + "' to:", name); if (nn && nn !== name) accAction("profile.rename", { name, newName: nn }); }
1110
+ });
1111
+ $("profilesBody").addEventListener("change", (e) => {
1112
+ const sel = e.target.closest("select[data-prof-tool]"); if (!sel) return;
1113
+ accAction("profile.setAccount", { profile: sel.dataset.prof, tool: sel.dataset.profTool, account: sel.value || null });
1114
+ });
1115
+ }
1116
+
1117
+ // --- config import / export (the browser file picker is what "asks where") ---
1118
+ async function exportConfig() {
1119
+ $("settingsNote").textContent = "Exporting...";
1120
+ let text;
1121
+ try { text = await (await fetch("/api/config-export", { cache: "no-store" })).text(); }
1122
+ catch { $("settingsNote").textContent = "Export failed."; return; }
1123
+ const name = "enigma-config-" + new Date().toISOString().slice(0, 10) + ".json";
1124
+ if (window.showSaveFilePicker) {
1125
+ try {
1126
+ const h = await window.showSaveFilePicker({ suggestedName: name, types: [{ description: "JSON", accept: { "application/json": [".json"] } }] });
1127
+ const w = await h.createWritable(); await w.write(text); await w.close();
1128
+ $("settingsNote").textContent = "Exported (no secrets included)."; return;
1129
+ } catch (e) { if (e && e.name === "AbortError") { $("settingsNote").textContent = "Export cancelled."; return; } }
1130
+ }
1131
+ const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
1132
+ const a = document.createElement("a"); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url);
1133
+ $("settingsNote").textContent = "Exported to your downloads (no secrets included).";
1134
+ }
1135
+ async function importConfig() {
1136
+ let text = null;
1137
+ if (window.showOpenFilePicker) {
1138
+ try { const [h] = await window.showOpenFilePicker({ types: [{ description: "JSON", accept: { "application/json": [".json"] } }] }); text = await (await h.getFile()).text(); }
1139
+ catch (e) { if (e && e.name === "AbortError") return; }
1140
+ }
1141
+ if (text === null) {
1142
+ text = await new Promise((res) => {
1143
+ const inp = document.createElement("input"); inp.type = "file"; inp.accept = "application/json,.json";
1144
+ inp.onchange = () => { const f = inp.files[0]; if (!f) { res(null); return; } const rd = new FileReader(); rd.onload = () => res(String(rd.result)); rd.readAsText(f); };
1145
+ inp.click();
1146
+ });
1147
+ if (text === null) return;
1148
+ }
1149
+ let body; try { body = JSON.parse(text); } catch { $("settingsNote").textContent = "That file is not valid JSON."; return; }
1150
+ $("settingsNote").textContent = "Importing...";
1151
+ try {
1152
+ const out = await (await fetch("/api/config-import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })).json();
1153
+ if (!out.ok) { $("settingsNote").textContent = "Import failed: " + (out.error || "error"); return; }
1154
+ $("settingsNote").textContent = "Imported: " + (out.applied || []).join(", ") + ((out.skipped && out.skipped.length) ? (" · skipped: " + out.skipped.join(", ")) : "");
1155
+ settingsLoaded = false; loadSettings(); accountsLoaded = false;
1156
+ } catch { $("settingsNote").textContent = "Could not reach the server to import."; }
1157
+ }
1158
+
1159
+ // --- hash routing between the subpages ---
896
1160
  function route() {
897
1161
  const h = (location.hash || "").replace(/^#\/?/, "");
898
- const v = (h === "settings" || h === "skills") ? h : "savings";
1162
+ const v = ["usage", "accounts", "settings", "skills"].includes(h) ? h : "savings";
899
1163
  $("view-savings").style.display = v === "savings" ? "" : "none";
1164
+ $("view-usage").style.display = v === "usage" ? "" : "none";
1165
+ $("view-accounts").style.display = v === "accounts" ? "" : "none";
900
1166
  $("view-skills").style.display = v === "skills" ? "" : "none";
901
1167
  $("view-settings").style.display = v === "settings" ? "" : "none";
902
1168
  document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.view === v));
903
1169
  if (v === "settings" && !settingsLoaded) { settingsLoaded = true; loadSettings(); }
904
1170
  if (v === "skills" && !skillsLoaded) { skillsLoaded = true; loadSkills(); }
1171
+ if (v === "accounts" && !accountsLoaded) { accountsLoaded = true; loadAccounts(); }
905
1172
  // The chart was sized while its view may have been hidden; nudge it on return.
906
1173
  if (v === "savings") { loadSystems(true); if (chart) { try { applyRange(); } catch { /* not ready */ } } }
907
1174
  }
@@ -912,6 +1179,9 @@
912
1179
  document.addEventListener("visibilitychange", () => { if (!document.hidden) refreshAll(true); });
913
1180
  wireSettings();
914
1181
  wireSkills();
1182
+ wireAccounts();
1183
+ $("cfgExport").addEventListener("click", exportConfig);
1184
+ $("cfgImport").addEventListener("click", importConfig);
915
1185
  $("updateBtn").addEventListener("click", runUpdate);
916
1186
  $("refreshBtn").addEventListener("click", () => refreshAll(true));
917
1187
  window.addEventListener("hashchange", route);