@intentius/behold 0.2.2 → 0.3.0
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/AGENTS.md +64 -0
- package/README.md +25 -1
- package/dist/cli.js +298 -88
- package/example-writes/README.md +88 -0
- package/example-writes/chant.config.ts +12 -0
- package/example-writes/ops/apply.op.ts +7 -0
- package/example-writes/ops/floci.op.ts +20 -0
- package/example-writes/ops/reconcile.op.ts +6 -0
- package/example-writes/package-lock.json +1249 -0
- package/example-writes/package.json +14 -0
- package/example-writes/src/bucket.ts +54 -0
- package/example-writes/tsconfig.json +1 -0
- package/package.json +6 -3
- package/web/app.js +468 -115
- package/web/index.html +143 -91
- package/web/panel.js +163 -0
package/web/app.js
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
// the inspect panel, and (later) the lanes + delegated actions.
|
|
5
5
|
|
|
6
6
|
// Ghostty colour themes (#62): apply the persisted/default theme's tokens as CSS vars
|
|
7
|
-
// before first paint (so the whole graph + chrome recolour from one source)
|
|
8
|
-
//
|
|
7
|
+
// before first paint (so the whole graph + chrome recolour from one source). Then the
|
|
8
|
+
// floating control panel's chrome (panel.js — drag/snap/collapse/tabs, persisted
|
|
9
|
+
// position), and the theme picker into the panel's View-tab slot (a stable element
|
|
10
|
+
// renderPanelView never rewrites, so the select mounts once and survives re-renders).
|
|
9
11
|
import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens } from "./theme.js";
|
|
12
|
+
import { initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
|
|
10
13
|
initTheme();
|
|
11
|
-
|
|
14
|
+
initPanel();
|
|
15
|
+
mountThemePicker(document.getElementById("panel-theme"));
|
|
12
16
|
|
|
13
17
|
// Colour node fills by category/kind using the theme's FULL palette (spicypath-style, so the
|
|
14
18
|
// graph shows the theme's many colours), while pinhole's drift stays on the bar/stroke. Node
|
|
@@ -588,6 +592,14 @@ function wire(ir) {
|
|
|
588
592
|
// the `changed` SSE re-pull and a palette lens change go through the same path.
|
|
589
593
|
const view = { env: null, detail: 2, components: true, logical: false, runtime: false, tier: null, target: null, stack: null, radial: false };
|
|
590
594
|
|
|
595
|
+
// #182: `components` is the boot default, but a project that declares no
|
|
596
|
+
// components renders it as ZERO nodes — the first screen was a blank graph
|
|
597
|
+
// pane with only a statusbar note explaining. True exactly once, consumed by
|
|
598
|
+
// load(): an empty first components fetch falls back to the resources zoom
|
|
599
|
+
// before anything is painted. An explicit later pick of "components" (panel
|
|
600
|
+
// or ⌘K) still shows the honest empty view + the server's note.
|
|
601
|
+
let autoZoomFallback = true;
|
|
602
|
+
|
|
591
603
|
// v0.1.0 preview lock (set from /api/project in initActions): hides the git/PR
|
|
592
604
|
// write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local
|
|
593
605
|
// deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on.
|
|
@@ -631,86 +643,376 @@ function applyZoom(z) {
|
|
|
631
643
|
if (z !== "components" && z !== "logical") view.detail = ZOOM_DETAIL[z] ?? 2;
|
|
632
644
|
}
|
|
633
645
|
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
if (
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
646
|
+
// --- Floating control panel content ---------------------------------------
|
|
647
|
+
// panel.js owns the chrome (drag, snap to edges/corners, collapse, tabs);
|
|
648
|
+
// these functions own what's IN the tabs, re-rendered from renderStatusbar()
|
|
649
|
+
// — which already runs on every load/lens change — so the panel always
|
|
650
|
+
// reflects current state. The Substrates and Deploy tabs are populated
|
|
651
|
+
// separately (renderSubstrates / initActions / renderDial): their host
|
|
652
|
+
// elements simply live inside the panel now. Every control here keeps its ⌘K
|
|
653
|
+
// twin (paletteCommands()) — the panel is the discoverable surface, the
|
|
654
|
+
// palette the fast one.
|
|
655
|
+
function panelHeading(text) {
|
|
656
|
+
const h = document.createElement("h3");
|
|
657
|
+
h.textContent = text;
|
|
658
|
+
return h;
|
|
659
|
+
}
|
|
660
|
+
function panelMuted(text) {
|
|
661
|
+
const p = document.createElement("p");
|
|
662
|
+
p.className = "panel-muted";
|
|
663
|
+
p.textContent = text;
|
|
664
|
+
return p;
|
|
665
|
+
}
|
|
666
|
+
function panelOpt(label, active, onClick, title) {
|
|
667
|
+
const b = button(label, "opt" + (active ? " active" : ""), onClick);
|
|
668
|
+
if (title) b.title = title;
|
|
669
|
+
return b;
|
|
670
|
+
}
|
|
671
|
+
function actButton(label, onClick, title) {
|
|
672
|
+
const b = button(label, "act", onClick);
|
|
673
|
+
if (title) b.title = title;
|
|
674
|
+
return b;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// View tab: the zoom stops (the one granularity axis, coarse → fine), the
|
|
678
|
+
// radial toggle (entity zooms only), and the graph tools. The theme picker
|
|
679
|
+
// mounts once into #panel-theme at boot and is never rewritten here.
|
|
680
|
+
function renderPanelView() {
|
|
681
|
+
const zoom = document.getElementById("panel-zoom");
|
|
682
|
+
if (!zoom) return;
|
|
683
|
+
zoom.innerHTML = "";
|
|
684
|
+
zoom.appendChild(panelHeading("zoom"));
|
|
685
|
+
const current = zoomValue();
|
|
686
|
+
// runtime is only meaningful with an env: it descends below the declaration
|
|
687
|
+
// boundary to owner-referenced children, which exist in a cluster and never
|
|
688
|
+
// in your source.
|
|
689
|
+
for (const [label, v] of ZOOM_OPTS.filter(([, z]) => z !== "runtime" || view.env)) {
|
|
690
|
+
zoom.appendChild(
|
|
691
|
+
panelOpt(label.replace(/^zoom: /, ""), v === current, () => {
|
|
692
|
+
applyZoom(v);
|
|
693
|
+
renderStatusbar();
|
|
694
|
+
load();
|
|
695
|
+
}),
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
// Radial toggle — entity zooms only, same gate the ⌘K entry has
|
|
699
|
+
// (components/logical both lay themselves out: waves / nested arch boxes).
|
|
700
|
+
if (!view.components && !view.logical) {
|
|
701
|
+
zoom.appendChild(panelHeading("layout"));
|
|
702
|
+
zoom.appendChild(
|
|
703
|
+
panelOpt("radial", view.radial, () => {
|
|
704
|
+
view.radial = !view.radial;
|
|
705
|
+
load();
|
|
706
|
+
}, "Curl the wide DAG onto concentric rings"),
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
const tools = document.getElementById("panel-viewtools");
|
|
710
|
+
tools.innerHTML = "";
|
|
711
|
+
tools.appendChild(panelHeading("graph"));
|
|
712
|
+
const row = document.createElement("div");
|
|
713
|
+
row.className = "prow";
|
|
714
|
+
row.appendChild(actButton("⤢ fit", () => fitGraph(), "Reset zoom/pan to fit. Pinch or ⌘/Ctrl+scroll zooms at the cursor; drag pans."));
|
|
715
|
+
row.appendChild(actButton("↓ SVG", () => exportSvg(), "Export the current graph as a standalone SVG file"));
|
|
716
|
+
const collapsed = document.getElementById("app").classList.contains("inspect-collapsed");
|
|
717
|
+
row.appendChild(
|
|
718
|
+
actButton(collapsed ? "show inspect" : "hide inspect", () => {
|
|
719
|
+
toggleInspect();
|
|
720
|
+
renderPanelView();
|
|
721
|
+
}),
|
|
722
|
+
);
|
|
723
|
+
const lanes = document.createElement("a");
|
|
724
|
+
lanes.href = "/lanes";
|
|
725
|
+
lanes.textContent = "lanes →";
|
|
726
|
+
lanes.title = "The time-lanes view — captured frames of this graph over time";
|
|
727
|
+
lanes.style.cssText = "color:var(--pending);text-decoration:none;font-size:12px";
|
|
728
|
+
row.appendChild(lanes);
|
|
729
|
+
tools.appendChild(row);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Scope tab: which world the graph reads — env (source vs live overlay),
|
|
733
|
+
// stack (#76), tier and target (M2 #54). The same lenses ⌘K offers, but
|
|
734
|
+
// visible: this tab is the answer to "what can I even do in behold?".
|
|
735
|
+
// #195: filesystem basename, for showing a project by name with the full
|
|
736
|
+
// path demoted to a tooltip / muted line.
|
|
737
|
+
function pathBasename(p) {
|
|
738
|
+
return String(p || "").replace(/\/+$/, "").split("/").pop() || String(p || "");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// #195: pop the OS file manager (Finder on macOS) at a project directory —
|
|
742
|
+
// the server allowlists to served + recent projects.
|
|
743
|
+
async function revealProject(dir) {
|
|
744
|
+
try {
|
|
745
|
+
const r = await fetch("/api/project/reveal", {
|
|
746
|
+
method: "POST",
|
|
747
|
+
headers: { "content-type": "application/json" },
|
|
748
|
+
body: JSON.stringify(dir ? { dir } : {}),
|
|
749
|
+
});
|
|
750
|
+
const j = await r.json();
|
|
751
|
+
if (j.error) showToast("✗ reveal: " + j.error, false);
|
|
752
|
+
} catch (e) {
|
|
753
|
+
showToast("✗ reveal: " + e.message, false);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// #195: switch the served project. On success the page reloads — every
|
|
758
|
+
// client-side list and cache is project-scoped, so a clean boot is the honest
|
|
759
|
+
// way to re-seed all of it.
|
|
760
|
+
async function switchProject(dir) {
|
|
761
|
+
showLoading(`switching to ${pathBasename(dir)}…`);
|
|
762
|
+
try {
|
|
763
|
+
const r = await fetch("/api/project/open", {
|
|
764
|
+
method: "POST",
|
|
765
|
+
headers: { "content-type": "application/json" },
|
|
766
|
+
body: JSON.stringify({ dir }),
|
|
767
|
+
});
|
|
768
|
+
const j = await r.json();
|
|
769
|
+
if (!r.ok || j.error) {
|
|
770
|
+
hideLoading();
|
|
771
|
+
showToast("✗ switch: " + (j.error || r.statusText), false);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
location.reload();
|
|
775
|
+
} catch (e) {
|
|
776
|
+
hideLoading();
|
|
777
|
+
showToast("✗ switch: " + e.message, false);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function renderPanelScope() {
|
|
782
|
+
const host = document.getElementById("tab-scope");
|
|
783
|
+
if (!host) return;
|
|
784
|
+
host.innerHTML = "";
|
|
785
|
+
// #195: which project is loaded — the first thing this tab answers. Name
|
|
786
|
+
// bold, full path as a muted line, every estate member listed when several
|
|
787
|
+
// projects are composed, and a reveal button to pop the folder in the OS
|
|
788
|
+
// file manager.
|
|
789
|
+
host.appendChild(panelHeading("project"));
|
|
790
|
+
const info = projectInfo || {};
|
|
791
|
+
const estateDirs = info.projectDirs && info.projectDirs.length > 1 ? info.projectDirs : info.projectDir ? [info.projectDir] : [];
|
|
792
|
+
for (const dir of estateDirs) {
|
|
793
|
+
const row = document.createElement("div");
|
|
794
|
+
row.className = "prow";
|
|
795
|
+
const name = document.createElement("span");
|
|
796
|
+
name.className = "grow";
|
|
797
|
+
name.style.fontWeight = "600";
|
|
798
|
+
name.textContent = pathBasename(dir) + (estateDirs.length > 1 && dir === info.projectDir ? " · primary" : "");
|
|
799
|
+
name.title = dir;
|
|
800
|
+
row.appendChild(name);
|
|
801
|
+
if (!staticMode) row.appendChild(actButton("⌖ reveal", () => revealProject(dir), "Open this project's folder in your file manager"));
|
|
802
|
+
host.appendChild(row);
|
|
803
|
+
host.appendChild(panelMuted(dir));
|
|
804
|
+
}
|
|
805
|
+
if (!estateDirs.length) host.appendChild(panelMuted("no project loaded yet"));
|
|
806
|
+
// #195: switching — recents first (server-persisted, validated), then a
|
|
807
|
+
// free path input. Locked in preview mode (the demo's contract) and
|
|
808
|
+
// meaningless in a static export.
|
|
809
|
+
if (!staticMode && !previewMode) {
|
|
810
|
+
host.appendChild(panelHeading("switch project"));
|
|
811
|
+
const recents = (info.recents || []).filter((d) => d !== info.projectDir);
|
|
812
|
+
for (const d of recents.slice(0, 8)) {
|
|
813
|
+
host.appendChild(panelOpt(pathBasename(d), false, () => switchProject(d), d));
|
|
814
|
+
}
|
|
815
|
+
if (!recents.length) host.appendChild(panelMuted("projects you open appear here"));
|
|
816
|
+
const row = document.createElement("div");
|
|
817
|
+
row.className = "prow";
|
|
818
|
+
const input = document.createElement("input");
|
|
819
|
+
input.className = "panel-input";
|
|
820
|
+
input.placeholder = "/path/to/chant-project";
|
|
821
|
+
input.title = "Absolute path to a chant project (a directory with a chant.config.ts)";
|
|
822
|
+
const go = actButton("open →", () => {
|
|
823
|
+
if (input.value.trim()) switchProject(input.value.trim());
|
|
824
|
+
});
|
|
825
|
+
input.addEventListener("keydown", (e) => {
|
|
826
|
+
if (e.key === "Enter") go.click();
|
|
827
|
+
});
|
|
828
|
+
row.append(input, go);
|
|
829
|
+
host.appendChild(row);
|
|
830
|
+
}
|
|
831
|
+
host.appendChild(panelHeading("environment"));
|
|
832
|
+
host.appendChild(
|
|
833
|
+
panelOpt("(source)", !view.env, () => {
|
|
834
|
+
view.env = null;
|
|
835
|
+
resetDialCaches();
|
|
836
|
+
renderStatusbar();
|
|
837
|
+
load();
|
|
838
|
+
}, "The declared source graph — no live overlay"),
|
|
839
|
+
);
|
|
840
|
+
for (const e of environments) {
|
|
841
|
+
host.appendChild(
|
|
842
|
+
panelOpt(e, view.env === e, () => {
|
|
843
|
+
view.env = e;
|
|
844
|
+
resetDialCaches();
|
|
845
|
+
renderStatusbar();
|
|
846
|
+
load();
|
|
847
|
+
}, `Live overlay for ${e}`),
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
if (!environments.length) host.appendChild(panelMuted("no environments declared"));
|
|
851
|
+
if (stacks.length) {
|
|
852
|
+
host.appendChild(panelHeading("stack"));
|
|
853
|
+
for (const s of stacks) {
|
|
854
|
+
host.appendChild(
|
|
855
|
+
panelOpt(s, view.stack === s, () => {
|
|
856
|
+
view.stack = s;
|
|
857
|
+
resetDialCaches();
|
|
858
|
+
renderStatusbar();
|
|
859
|
+
load();
|
|
860
|
+
}),
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (tiers.length) {
|
|
865
|
+
host.appendChild(panelHeading("tier"));
|
|
866
|
+
for (const t of tiers) {
|
|
867
|
+
host.appendChild(
|
|
868
|
+
panelOpt(t, view.tier === t, () => {
|
|
869
|
+
view.tier = t;
|
|
870
|
+
resetDialCaches();
|
|
871
|
+
renderStatusbar();
|
|
872
|
+
load();
|
|
873
|
+
}),
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (targets.length) {
|
|
878
|
+
host.appendChild(panelHeading("target"));
|
|
879
|
+
const sel = document.createElement("select");
|
|
880
|
+
for (const t of targets) sel.add(new Option(t.endpoint, t.endpoint, false, view.target === t.endpoint));
|
|
667
881
|
sel.addEventListener("change", () => {
|
|
668
|
-
|
|
882
|
+
view.target = sel.value;
|
|
883
|
+
resetDialCaches();
|
|
669
884
|
renderStatusbar();
|
|
670
885
|
load();
|
|
671
886
|
});
|
|
672
|
-
|
|
673
|
-
|
|
887
|
+
host.appendChild(sel);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Model tab: how the current graph's nodes stand against reality — the drift
|
|
892
|
+
// overlay's managed/foreign/pending or the component DAG's applied/unapplied
|
|
893
|
+
// (deployed / in progress / rolled back / not deployed). Replaces the two old
|
|
894
|
+
// header legends, with live counts; component/attention rows click through to
|
|
895
|
+
// the node's inspect panel.
|
|
896
|
+
const DRIFT_STATUS_VAR = { good: "var(--managed)", warn: "var(--foreign)", accent: "var(--pending)", neutral: "var(--muted)", runtime: "var(--runtime)" };
|
|
897
|
+
const COMPONENT_STATUS_VAR = { good: "var(--managed)", accent: "var(--pending)", warn: "var(--degraded)", neutral: "var(--muted)" };
|
|
898
|
+
|
|
899
|
+
function panelDotRow(color, main, tag, onClick) {
|
|
900
|
+
const row = document.createElement("div");
|
|
901
|
+
row.className = onClick ? "node-row" : "count-row";
|
|
902
|
+
const dot = document.createElement("span");
|
|
903
|
+
dot.className = "dot";
|
|
904
|
+
dot.style.background = color;
|
|
905
|
+
const name = document.createElement("span");
|
|
906
|
+
name.className = "grow";
|
|
907
|
+
name.textContent = main;
|
|
908
|
+
name.title = main;
|
|
909
|
+
row.append(dot, name);
|
|
910
|
+
if (tag) {
|
|
911
|
+
const t = document.createElement("span");
|
|
912
|
+
t.className = "tag";
|
|
913
|
+
t.textContent = tag;
|
|
914
|
+
row.appendChild(t);
|
|
915
|
+
}
|
|
916
|
+
if (onClick) row.addEventListener("click", onClick);
|
|
917
|
+
return row;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Select a node from a panel row the same way a graph click would: highlight
|
|
921
|
+
// its card (when it's in the current SVG) and open its inspect panel.
|
|
922
|
+
function selectNode(id) {
|
|
923
|
+
const node = lastGraphIr && lastGraphIr.nodes.find((n) => n.id === id);
|
|
924
|
+
if (!node) return;
|
|
925
|
+
const host = document.getElementById("graph");
|
|
926
|
+
host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
|
|
927
|
+
const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
|
|
928
|
+
if (g) g.classList.add("sel");
|
|
929
|
+
inspect(node);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function renderPanelModel() {
|
|
933
|
+
const host = document.getElementById("tab-model");
|
|
934
|
+
if (!host) return;
|
|
935
|
+
host.innerHTML = "";
|
|
936
|
+
const ir = lastGraphIr;
|
|
937
|
+
const m = lastMeta;
|
|
938
|
+
if (!ir || !m) {
|
|
939
|
+
host.appendChild(panelMuted("no graph loaded yet"));
|
|
940
|
+
return;
|
|
674
941
|
}
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
942
|
+
const drift = m.mode === "overlay" || (m.mode === "logical" && !!m.env);
|
|
943
|
+
const componentStatus = m.mode === "component-status";
|
|
944
|
+
const count = (statuses) => {
|
|
945
|
+
const c = Object.fromEntries(Object.keys(statuses).map((k) => [k, 0]));
|
|
946
|
+
for (const n of ir.nodes) {
|
|
947
|
+
const s = n.attrs && n.attrs._status;
|
|
948
|
+
if (s in c) c[s]++;
|
|
949
|
+
}
|
|
950
|
+
return c;
|
|
951
|
+
};
|
|
952
|
+
if (componentStatus) {
|
|
953
|
+
host.appendChild(panelHeading(`live status · ${m.env}`));
|
|
954
|
+
const c = count(COMPONENT_STATUS_LABEL);
|
|
955
|
+
for (const [k, label] of Object.entries(COMPONENT_STATUS_LABEL)) {
|
|
956
|
+
host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[k], label, String(c[k])));
|
|
957
|
+
}
|
|
958
|
+
host.appendChild(panelHeading("components"));
|
|
959
|
+
for (const n of ir.nodes.filter((n) => n.kind === "Component")) {
|
|
960
|
+
const s = n.attrs && n.attrs._status;
|
|
961
|
+
host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[s] || "var(--muted)", n.id, APPLY_STATUS_TAG[s] || "", () => selectNode(n.id)));
|
|
962
|
+
}
|
|
963
|
+
} else if (drift) {
|
|
964
|
+
host.appendChild(panelHeading(`drift · ${m.env}`));
|
|
965
|
+
const c = count(STATUS_LABEL);
|
|
966
|
+
for (const [k, label] of Object.entries(STATUS_LABEL)) {
|
|
967
|
+
// The additive buckets (chant#1168 unobserved, chant#1180 runtime) stay
|
|
968
|
+
// hidden until a chant actually emits them — same as the old legend.
|
|
969
|
+
if ((k === "neutral" || k === "runtime") && !c[k]) continue;
|
|
970
|
+
host.appendChild(panelDotRow(DRIFT_STATUS_VAR[k], label, String(c[k])));
|
|
971
|
+
}
|
|
972
|
+
// The actionable nodes — foreign (adoptable) and pending (not applied yet).
|
|
973
|
+
const attention = ir.nodes.filter((n) => {
|
|
974
|
+
const s = n.attrs && n.attrs._status;
|
|
975
|
+
return s === "warn" || s === "accent";
|
|
976
|
+
});
|
|
977
|
+
if (attention.length) {
|
|
978
|
+
host.appendChild(panelHeading("needs attention"));
|
|
979
|
+
for (const n of attention.slice(0, 40)) {
|
|
980
|
+
const s = n.attrs._status;
|
|
981
|
+
host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, STATUS_LABEL[s], () => selectNode(n.id)));
|
|
982
|
+
}
|
|
983
|
+
if (attention.length > 40) host.appendChild(panelMuted(`+ ${attention.length - 40} more — click nodes in the graph`));
|
|
984
|
+
}
|
|
985
|
+
} else {
|
|
986
|
+
host.appendChild(panelHeading("model"));
|
|
987
|
+
host.appendChild(panelMuted(`declared source graph — ${ir.nodes.length} nodes, ${ir.edges.length} edges.`));
|
|
988
|
+
if (environments.length && !staticMode) {
|
|
989
|
+
host.appendChild(panelMuted("pick an environment on the Scope tab to see live status: applied / drift / health."));
|
|
990
|
+
host.appendChild(actButton("→ Scope", () => setPanelTab("scope")));
|
|
689
991
|
}
|
|
690
|
-
sel.dataset.built = want;
|
|
691
992
|
}
|
|
692
|
-
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function renderPanel() {
|
|
996
|
+
renderPanelView();
|
|
997
|
+
renderPanelScope();
|
|
998
|
+
renderPanelModel();
|
|
693
999
|
}
|
|
694
1000
|
|
|
695
1001
|
function renderStatusbar() {
|
|
696
|
-
|
|
1002
|
+
renderPanel();
|
|
697
1003
|
const el = document.getElementById("statusbar");
|
|
698
1004
|
if (!el) return;
|
|
699
1005
|
// The strip looks clickable whether or not it is, so make it act like it:
|
|
700
|
-
// clicking opens the palette rather than doing nothing.
|
|
701
|
-
// live only there, and this is the only affordance pointing at them.
|
|
1006
|
+
// clicking opens the palette rather than doing nothing.
|
|
702
1007
|
if (!el.dataset.clickable) {
|
|
703
1008
|
el.dataset.clickable = "1";
|
|
704
1009
|
el.style.cursor = "pointer";
|
|
705
|
-
el.title = "env · stack · tier —
|
|
1010
|
+
el.title = "zoom · env · stack · tier — change on the panel, or ⌘K";
|
|
706
1011
|
el.addEventListener("click", () => openPalette());
|
|
707
1012
|
}
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
|
|
711
|
-
// work — which is exactly how it was reported. The strip keeps the axes that
|
|
712
|
-
// still have no on-screen control.
|
|
713
|
-
const parts = [view.env ? `env: ${view.env}` : "env: (source)"];
|
|
1013
|
+
// Pure state — the strip echoes the axes whose controls live on the floating
|
|
1014
|
+
// panel and in ⌘K, so the current view stays legible with the panel collapsed.
|
|
1015
|
+
const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
|
|
714
1016
|
if (view.stack) parts.push(`stack: ${view.stack}`);
|
|
715
1017
|
if (axes.tier) parts.push(`tier: ${axes.tier}`);
|
|
716
1018
|
if (view.radial && !view.components && !view.logical) parts.push("radial");
|
|
@@ -733,6 +1035,10 @@ function renderStatusbar() {
|
|
|
733
1035
|
// itself.
|
|
734
1036
|
let lastNote = null;
|
|
735
1037
|
|
|
1038
|
+
// The `meta` of the last rendered graph — the panel's Model tab reads its
|
|
1039
|
+
// mode/env to pick the status vocabulary (drift vs component live status).
|
|
1040
|
+
let lastMeta = null;
|
|
1041
|
+
|
|
736
1042
|
// The deploy axes as currently displayed in the header (#59 unify, M2 #54
|
|
737
1043
|
// lenses) — seeded once from /api/project (server-derived from the process
|
|
738
1044
|
// env at launch; see deployAxes() in src/server.ts), then kept in sync with
|
|
@@ -1259,6 +1565,11 @@ function render(ir, svg, m) {
|
|
|
1259
1565
|
// #131: set before anything can early-return, so a level that stopped
|
|
1260
1566
|
// degrading stops explaining itself on the very next render.
|
|
1261
1567
|
lastNote = m.note || null;
|
|
1568
|
+
// The panel's Model tab reads both of these via renderStatusbar() →
|
|
1569
|
+
// renderPanel() below — set them first so it renders THIS graph, not the
|
|
1570
|
+
// previous one (recolorNodesByCategory also sets lastGraphIr; harmless).
|
|
1571
|
+
lastMeta = m;
|
|
1572
|
+
lastGraphIr = ir;
|
|
1262
1573
|
const overlay = m.mode === "overlay";
|
|
1263
1574
|
// Logical/architecture lens (#63): its own mode, but when an env is picked the
|
|
1264
1575
|
// projected nodes still carry the drift `_status`, so it reads as a drift view
|
|
@@ -1304,22 +1615,27 @@ function render(ir, svg, m) {
|
|
|
1304
1615
|
tail = ` · ${c.good} healthy · ${c.accent} in progress · ${c.warn} rollback/failed · ${c.neutral} not deployed`;
|
|
1305
1616
|
}
|
|
1306
1617
|
// Multi-estate (#31): note the composed project count; the graph draws one box per project.
|
|
1307
|
-
|
|
1618
|
+
// #186: the meta line lives in the panel's 272px footer now — the directory
|
|
1619
|
+
// basename reads better than an absolute path (full path in the tooltip).
|
|
1620
|
+
const scope = m.estate
|
|
1621
|
+
? `estate of ${m.estate} projects`
|
|
1622
|
+
: String(m.projectDir || "").replace(/\/+$/, "").split("/").pop() || m.projectDir;
|
|
1308
1623
|
// The deploy axes (#59 unify, M2 #54 lenses) — tier/target, kept in sync with
|
|
1309
1624
|
// what this response actually observed (falls back to the launch-time value
|
|
1310
1625
|
// from /api/project when a route doesn't echo them, e.g. /api/overlay).
|
|
1311
1626
|
if (m.tier !== undefined) axes.tier = m.tier;
|
|
1312
1627
|
if (m.target !== undefined) axes.target = m.target;
|
|
1313
1628
|
const axesTail = `${axes.tier ? " · tier " + axes.tier : ""}${axes.target ? " · target " + axes.target : ""}`;
|
|
1314
|
-
document.getElementById("meta")
|
|
1629
|
+
const metaEl = document.getElementById("meta");
|
|
1630
|
+
metaEl.title = m.projectDir || "";
|
|
1631
|
+
// #195: the browser tab names the loaded project — with the header gone
|
|
1632
|
+
// (#186) the title bar is free chrome, and it's what shows in cmd-tab /
|
|
1633
|
+
// tab-hover when several behold instances are up.
|
|
1634
|
+
document.title = `behold — ${scope}`;
|
|
1635
|
+
metaEl.textContent =
|
|
1315
1636
|
`${scope}${m.env ? " · env " + m.env : ""}${axesTail}${overlay ? " · overlay" : ""}${logical ? " · logical" : ""}${m.components ? " · components" : ""}${componentStatus ? " · live status" : ""} · ${ir.nodes.length} nodes${tail}`;
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
// Keep the persistent state strip in sync — zoom/env/tier/radial are picked
|
|
1319
|
-
// via the ⌘K palette now (#73), but stay visible here regardless (radial
|
|
1320
|
-
// only applies to the entity zooms; renderStatusbar() drops it for
|
|
1321
|
-
// components/logical, both of which lay themselves out: waves / nested
|
|
1322
|
-
// architecture boxes).
|
|
1637
|
+
// Keep the persistent state strip + the floating panel in sync (the panel's
|
|
1638
|
+
// Model tab is what replaced the two old header legends).
|
|
1323
1639
|
renderStatusbar();
|
|
1324
1640
|
const g = document.getElementById("graph");
|
|
1325
1641
|
// Ghostty theming (#62): strip pinhole's baked-in `:root{--pin-*}` defaults from the
|
|
@@ -1542,6 +1858,9 @@ const PRECONDITION_TITLE = {
|
|
|
1542
1858
|
"not-installed": "This project isn't installed",
|
|
1543
1859
|
tier: "This tier needs credentials",
|
|
1544
1860
|
eval: "chant couldn't evaluate this project",
|
|
1861
|
+
// #193: behold was pointed at a directory that isn't a chant project at all
|
|
1862
|
+
// — the first screen must say so, not draw a blank graph.
|
|
1863
|
+
"no-project": "This isn't a chant project",
|
|
1545
1864
|
};
|
|
1546
1865
|
|
|
1547
1866
|
// A precondition failure — the lint gate, a not-installed/no-typegen project,
|
|
@@ -1573,8 +1892,6 @@ function renderPreconditionError(body) {
|
|
|
1573
1892
|
card.appendChild(remedy);
|
|
1574
1893
|
}
|
|
1575
1894
|
host.appendChild(card);
|
|
1576
|
-
document.getElementById("legend").style.display = "none";
|
|
1577
|
-
document.getElementById("component-legend").style.display = "none";
|
|
1578
1895
|
}
|
|
1579
1896
|
|
|
1580
1897
|
// Fetch the current view (source graph, or the picked env's live overlay).
|
|
@@ -1641,6 +1958,17 @@ async function load(opts = {}) {
|
|
|
1641
1958
|
}
|
|
1642
1959
|
throw new Error(body.error || res.statusText);
|
|
1643
1960
|
}
|
|
1961
|
+
// #182: an empty first components view → re-load at the resources zoom
|
|
1962
|
+
// instead of painting a blank pane. Checked before render() so the blank
|
|
1963
|
+
// graph never flashes; the loading scrim stays up across the second fetch
|
|
1964
|
+
// (showLoading is ref-counted).
|
|
1965
|
+
if (autoZoomFallback && view.components && !((body.ir && body.ir.nodes) || []).length) {
|
|
1966
|
+
autoZoomFallback = false;
|
|
1967
|
+
applyZoom("resources");
|
|
1968
|
+
renderStatusbar();
|
|
1969
|
+
return load(opts);
|
|
1970
|
+
}
|
|
1971
|
+
autoZoomFallback = false;
|
|
1644
1972
|
render(body.ir, body.svg, body.meta);
|
|
1645
1973
|
} catch (err) {
|
|
1646
1974
|
// A background settle poll must not blow away a good graph on a transient error.
|
|
@@ -1688,6 +2016,9 @@ let environments = [];
|
|
|
1688
2016
|
let tiers = [];
|
|
1689
2017
|
let targets = [];
|
|
1690
2018
|
let stacks = [];
|
|
2019
|
+
// #195: the last /api/project payload — the Scope tab's project section reads
|
|
2020
|
+
// projectDir/projectDirs/recents from it.
|
|
2021
|
+
let projectInfo = null;
|
|
1691
2022
|
|
|
1692
2023
|
// Fetch the project once, seed view/axes + the lens lists above, then do the
|
|
1693
2024
|
// first load. previously also built the header's env/zoom/radial/tier/target
|
|
@@ -1697,6 +2028,7 @@ async function initPickers() {
|
|
|
1697
2028
|
const info = await apiFetch("/api/project")
|
|
1698
2029
|
.then((r) => r.json())
|
|
1699
2030
|
.catch(() => ({ environments: [], currentEnv: null }));
|
|
2031
|
+
projectInfo = info;
|
|
1700
2032
|
view.env = info.currentEnv || null;
|
|
1701
2033
|
view.tier = info.tier || null;
|
|
1702
2034
|
view.target = (info.targets && info.targets[0] && info.targets[0].endpoint) || null;
|
|
@@ -1738,10 +2070,10 @@ events.addEventListener("changed", () => {
|
|
|
1738
2070
|
scheduleSettle();
|
|
1739
2071
|
});
|
|
1740
2072
|
|
|
1741
|
-
// Substrate readiness
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
// into
|
|
2073
|
+
// Substrate readiness (M5, #54): is each substrate the project needs actually
|
|
2074
|
+
// up? Poll /api/substrates and render rows on the panel's Substrates tab —
|
|
2075
|
+
// status dot + name + state, with the bring-up/reset/pipeline actions inline
|
|
2076
|
+
// again (#73 moved those into ⌘K; they remain there too, via lastSubstrates).
|
|
1745
2077
|
async function loadSubstrates() {
|
|
1746
2078
|
try {
|
|
1747
2079
|
const { substrates } = await apiFetch("/api/substrates").then((r) => r.json());
|
|
@@ -1759,27 +2091,40 @@ let lastSubstrates = [];
|
|
|
1759
2091
|
function renderSubstrates(subs) {
|
|
1760
2092
|
lastSubstrates = subs;
|
|
1761
2093
|
const host = document.getElementById("substrates");
|
|
2094
|
+
if (!host) return;
|
|
2095
|
+
// Rebuilt only when the data changes — this runs on a 5s poll, and wiping
|
|
2096
|
+
// identical rows would yank a button out from under the cursor.
|
|
2097
|
+
const sig = JSON.stringify(subs) + `|${staticMode}|${previewMode}`;
|
|
2098
|
+
if (host.dataset.sig === sig) return;
|
|
2099
|
+
host.dataset.sig = sig;
|
|
2100
|
+
host.innerHTML = "";
|
|
1762
2101
|
if (!subs.length) {
|
|
1763
|
-
host.
|
|
2102
|
+
host.appendChild(panelMuted("no substrates detected for this project"));
|
|
1764
2103
|
return;
|
|
1765
2104
|
}
|
|
1766
|
-
host.style.display = "flex";
|
|
1767
|
-
host.innerHTML = "";
|
|
1768
|
-
const lbl = document.createElement("span");
|
|
1769
|
-
lbl.className = "label";
|
|
1770
|
-
lbl.textContent = "substrates:";
|
|
1771
|
-
host.appendChild(lbl);
|
|
1772
2105
|
for (const s of subs) {
|
|
1773
|
-
const
|
|
1774
|
-
|
|
1775
|
-
|
|
2106
|
+
const row = document.createElement("div");
|
|
2107
|
+
row.className = `sub ${s.status}`;
|
|
2108
|
+
row.title = s.detail || "";
|
|
1776
2109
|
const dot = document.createElement("span");
|
|
1777
2110
|
dot.className = "dot";
|
|
1778
|
-
pill.appendChild(dot);
|
|
1779
2111
|
const name = document.createElement("span");
|
|
2112
|
+
name.className = "grow";
|
|
1780
2113
|
name.textContent = s.label;
|
|
1781
|
-
|
|
1782
|
-
|
|
2114
|
+
const status = document.createElement("span");
|
|
2115
|
+
status.className = "tag";
|
|
2116
|
+
status.textContent = s.status;
|
|
2117
|
+
row.append(dot, name, status);
|
|
2118
|
+
// Writes — none in a static export; the GitHub dispatch also respects the
|
|
2119
|
+
// preview lock, mirroring paletteCommands()'s gating exactly.
|
|
2120
|
+
if (!staticMode) {
|
|
2121
|
+
if (s.bringUp) row.appendChild(actButton("bring up", () => bringUpSubstrate(s)));
|
|
2122
|
+
if (s.name === "floci" && s.status === "up")
|
|
2123
|
+
row.appendChild(actButton("reset", () => resetLocal(), "Reset the local emulator — wipes every stack, reboots, redeploys clean"));
|
|
2124
|
+
if (s.name === "github" && !previewMode)
|
|
2125
|
+
row.appendChild(actButton("run", () => dispatchPipeline(), "Dispatch the GitHub Actions pipeline via your gh login"));
|
|
2126
|
+
}
|
|
2127
|
+
host.appendChild(row);
|
|
1783
2128
|
}
|
|
1784
2129
|
}
|
|
1785
2130
|
|
|
@@ -1811,6 +2156,24 @@ function bringUpSubstrate(s) {
|
|
|
1811
2156
|
.catch((e) => nowline("✗ bring up: " + e.message));
|
|
1812
2157
|
}
|
|
1813
2158
|
|
|
2159
|
+
// Dispatch a GitHub Actions run (#164) — through the operator's own `gh`
|
|
2160
|
+
// login. The server refuses honestly (no gh, unauthenticated, no matching
|
|
2161
|
+
// workflow_dispatch workflow) and the reason lands as a toast. Shared by the
|
|
2162
|
+
// Substrates tab's button and the ⌘K entry.
|
|
2163
|
+
function dispatchPipeline() {
|
|
2164
|
+
if (!window.confirm("Dispatch the GitHub Actions pipeline?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.")) return;
|
|
2165
|
+
fetch("/api/ci/dispatch", { method: "POST" })
|
|
2166
|
+
.then((r) => r.json())
|
|
2167
|
+
.then((j) => {
|
|
2168
|
+
if (j.error) {
|
|
2169
|
+
showToast(`✗ dispatch: ${j.error}`, false);
|
|
2170
|
+
nowline("✗ dispatch: " + j.error);
|
|
2171
|
+
} else {
|
|
2172
|
+
showToast(`▶ dispatched ${j.workflow} @ ${j.ref} (${j.jobs} jobs) — following on the dial`, true);
|
|
2173
|
+
}
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
|
|
1814
2177
|
loadSubstrates();
|
|
1815
2178
|
// Poll readiness so pills update as things come up on their own (Docker
|
|
1816
2179
|
// starting, a bring-up provisioning) without needing a `changed` event.
|
|
@@ -1857,7 +2220,7 @@ function showToast(msg, ok) {
|
|
|
1857
2220
|
if (!host) {
|
|
1858
2221
|
host = document.createElement("div");
|
|
1859
2222
|
host.id = "toasts";
|
|
1860
|
-
host.style.cssText = "position:fixed;top:
|
|
2223
|
+
host.style.cssText = "position:fixed;top:12px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:60;max-width:420px";
|
|
1861
2224
|
document.body.appendChild(host);
|
|
1862
2225
|
}
|
|
1863
2226
|
const t = document.createElement("div");
|
|
@@ -2065,7 +2428,7 @@ async function initActions() {
|
|
|
2065
2428
|
applyPicker = true;
|
|
2066
2429
|
loadComponentChoices().then(renderDial);
|
|
2067
2430
|
renderDial();
|
|
2068
|
-
|
|
2431
|
+
setPanelTab("deploy"); // the dial lives on the panel's Deploy tab
|
|
2069
2432
|
});
|
|
2070
2433
|
deploy.title = `chant run <component|all> --components --env ${view.env || opsInitialEnv} --progress-json — opens the component picker on the dial. behold triggers, chant executes.`;
|
|
2071
2434
|
bar.appendChild(deploy);
|
|
@@ -2180,10 +2543,10 @@ function exportSvg() {
|
|
|
2180
2543
|
// list from live state on every open, palRender() filtering + repainting,
|
|
2181
2544
|
// openPalette()/closePalette() toggling the `.on` class), retargeted at
|
|
2182
2545
|
// behold's own handlers instead of spicypath's. "Hide controls, never state"
|
|
2183
|
-
// (spicypath's own design rule, carried over): every
|
|
2184
|
-
// the
|
|
2185
|
-
//
|
|
2186
|
-
//
|
|
2546
|
+
// (spicypath's own design rule, carried over): every control here also lives
|
|
2547
|
+
// on the floating panel now, and the current values stay visible in the
|
|
2548
|
+
// header (renderStatusbar(), #meta) — the palette is the fast surface, the
|
|
2549
|
+
// panel the discoverable one.
|
|
2187
2550
|
const palette = document.getElementById("palette");
|
|
2188
2551
|
const palInput = document.getElementById("pal-input");
|
|
2189
2552
|
const palList = document.getElementById("pal-list");
|
|
@@ -2201,6 +2564,11 @@ function paletteCommands() {
|
|
|
2201
2564
|
c.push(["Export: current graph as SVG", () => exportSvg()]);
|
|
2202
2565
|
const inspectCollapsed = document.getElementById("app").classList.contains("inspect-collapsed");
|
|
2203
2566
|
c.push([inspectCollapsed ? "Show inspect panel" : "Hide inspect panel", () => toggleInspect()]);
|
|
2567
|
+
// The floating control panel (panel.js): collapse/expand + jump to a tab.
|
|
2568
|
+
c.push([isPanelCollapsed() ? "Expand control panel" : "Collapse control panel", () => togglePanelCollapsed()]);
|
|
2569
|
+
for (const b of document.querySelectorAll("#panel-tabs button[data-tab]")) {
|
|
2570
|
+
c.push([`Panel: ${b.textContent}`, () => setPanelTab(b.dataset.tab)]);
|
|
2571
|
+
}
|
|
2204
2572
|
|
|
2205
2573
|
// Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
|
|
2206
2574
|
for (const [label, v] of ZOOM_OPTS) {
|
|
@@ -2262,22 +2630,7 @@ function paletteCommands() {
|
|
|
2262
2630
|
// (no gh, unauthenticated, no matching workflow_dispatch workflow) and
|
|
2263
2631
|
// the reason lands as a toast.
|
|
2264
2632
|
if (s.name === "github" && !previewMode) {
|
|
2265
|
-
c.push([
|
|
2266
|
-
"Run pipeline: GitHub Actions",
|
|
2267
|
-
() => {
|
|
2268
|
-
if (!window.confirm("Dispatch the GitHub Actions pipeline?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.")) return;
|
|
2269
|
-
fetch("/api/ci/dispatch", { method: "POST" })
|
|
2270
|
-
.then((r) => r.json())
|
|
2271
|
-
.then((j) => {
|
|
2272
|
-
if (j.error) {
|
|
2273
|
-
showToast(`✗ dispatch: ${j.error}`, false);
|
|
2274
|
-
nowline("✗ dispatch: " + j.error);
|
|
2275
|
-
} else {
|
|
2276
|
-
showToast(`▶ dispatched ${j.workflow} @ ${j.ref} (${j.jobs} jobs) — following on the dial`, true);
|
|
2277
|
-
}
|
|
2278
|
-
});
|
|
2279
|
-
},
|
|
2280
|
-
]);
|
|
2633
|
+
c.push(["Run pipeline: GitHub Actions", () => dispatchPipeline()]);
|
|
2281
2634
|
}
|
|
2282
2635
|
}
|
|
2283
2636
|
|