@enigmax/dashboard 0.1.44 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/assets/index.html +163 -12
  2. package/package.json +1 -1
package/assets/index.html CHANGED
@@ -397,6 +397,7 @@
397
397
  <a href="#/usage" data-view="usage" class="tab">Usage</a>
398
398
  <a href="#/accounts" data-view="accounts" class="tab">Accounts</a>
399
399
  <a href="#/skills" data-view="skills" class="tab">Skills</a>
400
+ <a href="#/projects" data-view="projects" class="tab">Projects</a>
400
401
  <a href="#/settings" data-view="settings" class="tab">Settings</a>
401
402
  <a href="#/resources" data-view="resources" class="tab">Resources</a>
402
403
  </nav>
@@ -663,6 +664,23 @@
663
664
  <div class="panel"><div class="set-cat-h">Processes <small>by memory</small></div><div id="resProcs"><div class="empty" style="padding:12px 0">Loading...</div></div></div>
664
665
  </section>
665
666
 
667
+ <section id="view-projects" style="display:none">
668
+ <div class="page-head" style="display:flex;align-items:flex-start;gap:16px">
669
+ <div style="flex:1">
670
+ <h1>Projects</h1>
671
+ <p>Manage enigma per project folder: its deployed skills, project-local config, git hooks and the AI quality gate. Add a project by path; changes apply only to that project. Global config stays in <a href="#/settings">Settings</a>.</p>
672
+ </div>
673
+ <button id="projRefresh" type="button" class="iconbtn" title="Refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
674
+ </div>
675
+ <div id="projNote" class="set-note"></div>
676
+ <div class="panel">
677
+ <div class="set-cat-h">Add a project</div>
678
+ <div class="ladd"><input id="projPath" class="linput" type="text" placeholder="absolute path to a project folder"><input id="projLabel" class="linput" type="text" style="flex:none;width:160px" placeholder="label (optional)"><button id="projAdd" type="button" class="toggle on">Add</button></div>
679
+ </div>
680
+ <div class="panel"><div class="set-cat-h">Projects</div><div id="projList"><div class="empty" style="padding:12px 0">Loading...</div></div></div>
681
+ <div id="projDetail" class="panel" style="display:none"></div>
682
+ </section>
683
+
666
684
  <footer>
667
685
  Polling pauses automatically when this tab is hidden, and the server caches each snapshot - so an open dashboard costs almost nothing.
668
686
  Data source: <code>~/.enigma/ccr/stats.json</code> + <code>history.jsonl</code> - reset it with <code>enigma compress --clear</code>. Enigma <span id="ver"></span>
@@ -2745,8 +2763,12 @@ export {
2745
2763
  codex: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>',
2746
2764
  opencode: '<svg viewBox="0 0 240 300" fill="currentColor" aria-hidden="true"><path d="M180 240H60V120H180V240Z" opacity=".55"/><path fill-rule="evenodd" d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z"/></svg>',
2747
2765
  };
2748
- // App label -> internal name, so the "where deployed" tag (which carries labels) maps to icons.
2749
- const AGENT_NAME_BY_LABEL = { "Claude Code": "claude", "OpenAI Codex": "codex", "OpenCode": "opencode" };
2766
+ // App label -> internal name. Normalized (substring match) so every label variant maps
2767
+ // to an icon: "Claude Code", "Codex"/"OpenAI Codex", "opencode"/"OpenCode".
2768
+ const agentName = (label) => {
2769
+ const s = String(label || "").toLowerCase();
2770
+ return s.includes("claude") ? "claude" : s.includes("opencode") ? "opencode" : s.includes("codex") ? "codex" : "";
2771
+ };
2750
2772
  const agentSvg = (name) => AGENT_ICONS[name] || "";
2751
2773
 
2752
2774
  // Compression sources -> a brand icon. Reuses the app icons; the CLI source is enigma itself
@@ -2762,14 +2784,18 @@ export {
2762
2784
  // Last-edited date (from skill.json), shown relative via the vendored <relative-time>.
2763
2785
  const edited = s.updated ? '<span class="tag" title="Last edited ' + esc(new Date(s.updated).toLocaleString()) + '">edited '
2764
2786
  + rtime(new Date(s.updated).getTime(), 'format="relative"', new Date(s.updated).toLocaleDateString()) + "</span>" : "";
2765
- // Show one app icon per agent that holds the skill (name in the tooltip), not the names.
2766
- const where = s.agents && s.agents.length
2767
- ? '<span class="aicons">' + s.agents.map((l) => {
2768
- const ic = agentSvg(AGENT_NAME_BY_LABEL[l]);
2769
- return ic ? '<span class="aicon" title="' + esc(l) + '" aria-label="' + esc(l) + '">' + ic + "</span>"
2770
- : '<span class="tag">' + esc(l) + "</span>";
2771
- }).join("") + "</span>"
2772
- : (s.discarded ? '<span class="tag">disabled</span>' : '<span class="tag">not deployed</span>');
2787
+ // Per-app deployment is shown by the on/off chips below (one per CLI) for an enabled
2788
+ // enigma skill, so the static "where deployed" icons would be redundant there - only
2789
+ // show them when there are no chips (external or disabled skills).
2790
+ const hasChips = s.source === "enigma" && !s.discarded && s.agentStates && s.agentStates.length;
2791
+ const where = hasChips ? ""
2792
+ : s.agents && s.agents.length
2793
+ ? '<span class="aicons">' + s.agents.map((l) => {
2794
+ const ic = agentSvg(agentName(l));
2795
+ return ic ? '<span class="aicon" title="' + esc(l) + '" aria-label="' + esc(l) + '">' + ic + "</span>"
2796
+ : '<span class="tag">' + esc(l) + "</span>";
2797
+ }).join("") + "</span>"
2798
+ : (s.discarded ? '<span class="tag">disabled</span>' : '<span class="tag">not deployed</span>');
2773
2799
  const ext = s.source === "external" ? '<span class="tag ext">external</span>' : "";
2774
2800
  const upd = s.source !== "enigma" ? ""
2775
2801
  : s.update === "update" ? '<span class="tag warn">update available</span>'
@@ -2789,7 +2815,7 @@ export {
2789
2815
  // Per-app on/off chips (Claude Code, Codex, OpenCode...). Only for an enabled enigma
2790
2816
  // skill: when the whole skill is disabled the per-agent state is moot. data-off is the
2791
2817
  // value to SET on click (turn off if currently on, on if currently off).
2792
- const agentChips = (s.source === "enigma" && !s.discarded && s.agentStates && s.agentStates.length)
2818
+ const agentChips = hasChips
2793
2819
  ? '<div class="set-agents">' + s.agentStates.map((a) =>
2794
2820
  '<button type="button" class="achip' + (a.deployed ? " on" : "") + '" data-action="agent-toggle" data-name="' + esc(s.name)
2795
2821
  + '" data-agent="' + esc(a.name) + '" data-off="' + (a.deployed ? 1 : 0) + '" aria-label="' + esc(a.label) + '" title="'
@@ -3075,7 +3101,7 @@ export {
3075
3101
 
3076
3102
  // --- hash routing between the subpages ---
3077
3103
  // Single source of truth for the subpage ids (savings is the default/fallback).
3078
- const VIEWS = ["savings", "usage", "accounts", "skills", "settings", "resources"];
3104
+ const VIEWS = ["savings", "usage", "accounts", "skills", "projects", "settings", "resources"];
3079
3105
  function route() {
3080
3106
  const h = (location.hash || "").replace(/^#\/?/, "");
3081
3107
  const v = VIEWS.includes(h) ? h : "savings";
@@ -3086,6 +3112,7 @@ export {
3086
3112
  if (v === "accounts" && !accountsLoaded) { accountsLoaded = true; loadAccounts(); }
3087
3113
  if (v === "usage") { loadProviderStatus(); if (!usageAccountsLoaded) { usageAccountsLoaded = true; loadUsageAccounts(); } }
3088
3114
  if (v === "resources") loadResources();
3115
+ if (v === "projects") loadProjects();
3089
3116
  // The chart was sized while its view may have been hidden; nudge it on return.
3090
3117
  if (v === "savings") { loadSystems(true); if (chart) { try { applyRange(); } catch { /* not ready */ } } }
3091
3118
  }
@@ -3145,6 +3172,129 @@ export {
3145
3172
  });
3146
3173
  }
3147
3174
 
3175
+ // --- projects subpage (per-project skills / config / hooks / gate, origin-guarded) ---
3176
+ let projOpen = null;
3177
+ function renderProjectList(projects) {
3178
+ const el = $("projList");
3179
+ if (!projects || !projects.length) { el.innerHTML = '<div class="empty" style="padding:12px 0">No projects yet. Add one above.</div>'; return; }
3180
+ el.innerHTML = projects.map((p) => {
3181
+ const badges = [];
3182
+ if (!p.exists) badges.push('<span class="tag">missing</span>');
3183
+ if (p.isGitRepo) badges.push('<span class="tag">git</span>');
3184
+ badges.push('<span class="tag">' + p.skills + " skill" + (p.skills === 1 ? "" : "s") + "</span>");
3185
+ if (p.hooks) badges.push('<span class="tag">hooks</span>');
3186
+ if (p.gate) badges.push('<span class="tag">gate</span>');
3187
+ return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(p.label) + " " + badges.join(" ") + '</div><div class="set-hint">' + esc(p.path) + "</div></div>"
3188
+ + '<div class="set-ctl ladd" style="align-items:center"><button type="button" class="toggle on" data-proj-open="' + esc(p.path) + '">Manage</button>'
3189
+ + '<button type="button" class="toggle" data-proj-remove="' + esc(p.path) + '">Remove</button></div></div>';
3190
+ }).join("");
3191
+ }
3192
+ function projConfigRow(c) {
3193
+ const asSwitch = !c.choices || (c.choices.length === 2 && c.choices.indexOf(c.offChoice) !== -1);
3194
+ const ctl = !asSwitch
3195
+ ? '<select class="choice" data-proj-cfg="' + esc(c.key) + '">' + c.choices.map((x) => '<option value="' + esc(x) + '"' + (x === c.choice ? " selected" : "") + ">" + esc(prettyChoice(x)) + "</option>").join("") + "</select>"
3196
+ : '<button type="button" class="toggle' + (c.value ? " on" : "") + '" data-proj-cfg-bool="' + esc(c.key) + '" data-on="' + (c.value ? 1 : 0) + '">' + (c.value ? "On" : "Off") + "</button>";
3197
+ const reset = c.overridden ? ' <button type="button" class="iconbtn" data-proj-cfg-reset="' + esc(c.key) + '" title="Inherit global">reset</button>' : "";
3198
+ const badge = c.overridden ? ' <span class="tag">project</span>' : ' <span class="tag">inherited</span>';
3199
+ return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(c.label) + badge + '</div><div class="set-hint">' + esc(c.hint) + "</div></div>"
3200
+ + '<div class="set-ctl ladd" style="align-items:center">' + ctl + reset + "</div></div>";
3201
+ }
3202
+ function renderProjectDetail(d) {
3203
+ const el = $("projDetail");
3204
+ if (!d || d.error) { el.style.display = "none"; projOpen = null; if (d && d.error) setNote("projNote", d.error, "err"); return; }
3205
+ projOpen = d.path;
3206
+ const deployed = new Set();
3207
+ (d.agents || []).forEach((a) => (a.deployed || []).forEach((s) => deployed.add(s)));
3208
+ const agentsLine = (d.agents || []).map((a) => esc(a.label) + (a.installed ? "" : " (not installed)")).join(", ") || "no agents detected";
3209
+ const cfg = (d.config || []).map(projConfigRow).join("");
3210
+ const skills = (d.available || []).map((s) => {
3211
+ const on = deployed.has(s.name);
3212
+ return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(s.name) + '</div><div class="set-hint">' + esc(s.description || "") + "</div></div>"
3213
+ + '<div class="set-ctl"><button type="button" class="toggle' + (on ? " on" : "") + '" data-proj-skill="' + esc(s.name) + '" data-on="' + (on ? 1 : 0) + '">' + (on ? "On" : "Off") + "</button></div></div>";
3214
+ }).join("");
3215
+ const hooks = d.hooks ? '<span class="tag">installed</span>' : (d.isGitRepo ? '<button type="button" class="toggle on" data-proj-act="hooks">Install git hooks</button>' : '<span class="tag">not a git repo</span>');
3216
+ const gate = d.gate
3217
+ ? '<span class="tag">initialized</span> <button type="button" class="toggle" data-proj-act="gate-eject">Eject</button>'
3218
+ : '<button type="button" class="toggle on" data-proj-act="gate-init">Initialize gate</button>';
3219
+ el.style.display = "";
3220
+ el.innerHTML =
3221
+ '<div class="panel-head" style="display:flex;justify-content:space-between;align-items:center"><h2>' + esc(d.label) + '</h2><button type="button" class="iconbtn" data-proj-close title="Close" aria-label="Close">x</button></div>'
3222
+ + '<div class="set-hint" style="margin-bottom:10px">' + esc(d.path) + " &middot; " + agentsLine + "</div>"
3223
+ + '<div class="set-cat-h">Setup</div><div class="set-grid">'
3224
+ + '<div class="set-row"><div class="set-meta"><div class="set-name">Git hooks</div><div class="set-hint">commit secret guard for this repo</div></div><div class="set-ctl">' + hooks + "</div></div>"
3225
+ + '<div class="set-row"><div class="set-meta"><div class="set-name">AI quality gate</div><div class="set-hint">validate changes through the gate pipeline before push (needs the gate feature enabled)</div></div><div class="set-ctl">' + gate + "</div></div></div>"
3226
+ + '<div class="set-cat-h" style="margin-top:14px">Project config</div><div class="set-grid">' + (cfg || '<div class="empty" style="padding:8px 0">No project settings.</div>') + "</div>"
3227
+ + '<div class="set-cat-h" style="margin-top:14px">Skills</div><div class="set-grid">' + (skills || '<div class="empty" style="padding:8px 0">No skills.</div>') + "</div>";
3228
+ }
3229
+ async function loadProjects() {
3230
+ try { renderProjectList(((await (await fetch("/api/projects", { cache: "no-store" })).json()).projects) || []); }
3231
+ catch { $("projList").innerHTML = '<div class="empty" style="padding:12px 0">Projects unavailable.</div>'; }
3232
+ }
3233
+ async function openProject(path) {
3234
+ setNote("projNote", "Loading...");
3235
+ try {
3236
+ const d = await (await fetch("/api/projects/detail?path=" + encodeURIComponent(path), { cache: "no-store" })).json();
3237
+ renderProjectDetail(d);
3238
+ if (!d.error) setNote("projNote", "");
3239
+ } catch { setNote("projNote", "Could not load the project.", "err"); }
3240
+ }
3241
+ async function projAction(payload, confirmMsg) {
3242
+ if (!projOpen) return;
3243
+ if (confirmMsg && !(await askConfirm("Confirm", confirmMsg, true))) return;
3244
+ setNote("projNote", "Working...");
3245
+ try {
3246
+ const out = await (await fetch("/api/projects/action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: projOpen, ...payload }) })).json();
3247
+ if (out.detail) renderProjectDetail(out.detail);
3248
+ setNote("projNote", out.note || (out.ok ? "Done." : (out.error || "Action failed.")), out.ok ? "ok" : "err");
3249
+ loadProjects();
3250
+ } catch { setNote("projNote", "Could not reach the server.", "err"); }
3251
+ }
3252
+ async function addProjectUI() {
3253
+ const path = $("projPath").value.trim();
3254
+ if (!path) { setNote("projNote", "Enter a path.", "err"); return; }
3255
+ const label = $("projLabel").value.trim();
3256
+ try {
3257
+ const out = await (await fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op: "add", path, label }) })).json();
3258
+ if (out.ok) { $("projPath").value = ""; $("projLabel").value = ""; setNote("projNote", "Added.", "ok"); renderProjectList(out.projects); }
3259
+ else setNote("projNote", out.error || "Could not add.", "err");
3260
+ } catch { setNote("projNote", "Could not reach the server.", "err"); }
3261
+ }
3262
+ function wireProjects() {
3263
+ $("projAdd").addEventListener("click", addProjectUI);
3264
+ $("projRefresh").addEventListener("click", loadProjects);
3265
+ $("projPath").addEventListener("keydown", (e) => { if (e.key === "Enter") addProjectUI(); });
3266
+ $("projList").addEventListener("click", async (e) => {
3267
+ const open = e.target.closest("[data-proj-open]"), rem = e.target.closest("[data-proj-remove]");
3268
+ if (open) { openProject(open.dataset.projOpen); return; }
3269
+ if (rem) {
3270
+ const path = rem.dataset.projRemove;
3271
+ if (!(await askConfirm("Remove project", "Stop managing " + path + "? (its files are not touched)", false))) return;
3272
+ try { const out = await (await fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op: "remove", path }) })).json(); renderProjectList(out.projects || []); if (projOpen === path) { $("projDetail").style.display = "none"; projOpen = null; } }
3273
+ catch { setNote("projNote", "Could not reach the server.", "err"); }
3274
+ }
3275
+ });
3276
+ $("projDetail").addEventListener("click", (e) => {
3277
+ if (e.target.closest("[data-proj-close]")) { $("projDetail").style.display = "none"; projOpen = null; return; }
3278
+ const skill = e.target.closest("[data-proj-skill]");
3279
+ if (skill) { projAction({ op: "skill", name: skill.dataset.projSkill, on: skill.dataset.on !== "1" }); return; }
3280
+ const cfgBool = e.target.closest("[data-proj-cfg-bool]");
3281
+ if (cfgBool) { projAction({ op: "config-set", key: cfgBool.dataset.projCfgBool, value: cfgBool.dataset.on !== "1" }); return; }
3282
+ const reset = e.target.closest("[data-proj-cfg-reset]");
3283
+ if (reset) { projAction({ op: "config-unset", key: reset.dataset.projCfgReset }); return; }
3284
+ const act = e.target.closest("[data-proj-act]");
3285
+ if (act) {
3286
+ const a = act.dataset.projAct;
3287
+ if (a === "hooks") projAction({ op: "hooks" }, "Install enigma git hooks in this repo?");
3288
+ else if (a === "gate-init") projAction({ op: "gate", gateOp: "init" }, "Initialize the AI quality gate for this project?");
3289
+ else if (a === "gate-eject") projAction({ op: "gate", gateOp: "eject" }, "Eject the gate from this project?");
3290
+ }
3291
+ });
3292
+ $("projDetail").addEventListener("change", (e) => {
3293
+ const sel = e.target.closest("select[data-proj-cfg]");
3294
+ if (sel) projAction({ op: "config-set", key: sel.dataset.projCfg, value: sel.value });
3295
+ });
3296
+ }
3297
+
3148
3298
  initChart();
3149
3299
  poll(true);
3150
3300
  loadProviderStatus();
@@ -3158,6 +3308,7 @@ export {
3158
3308
  wireAccounts();
3159
3309
  wireUsage();
3160
3310
  wireResources();
3311
+ wireProjects();
3161
3312
  $("cfgExport").addEventListener("click", exportConfig);
3162
3313
  $("cfgImport").addEventListener("click", importConfig);
3163
3314
  $("updateBtn").addEventListener("click", runUpdate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/dashboard",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
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": [