@intentius/behold 0.2.3 → 0.4.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 +425 -95
- 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 +4 -2
- package/web/app.js +471 -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,379 @@ 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
|
+
// #191: a k8s env shows the cluster it's bound to (`home → home-cloud`),
|
|
842
|
+
// so a wrong-cluster pick is visible before the read, not after.
|
|
843
|
+
const ctx = projectInfo && projectInfo.k8sContexts && projectInfo.k8sContexts[e];
|
|
844
|
+
host.appendChild(
|
|
845
|
+
panelOpt(ctx ? `${e} → ${ctx}` : e, view.env === e, () => {
|
|
846
|
+
view.env = e;
|
|
847
|
+
resetDialCaches();
|
|
848
|
+
renderStatusbar();
|
|
849
|
+
load();
|
|
850
|
+
}, ctx ? `Live overlay for ${e} — bound to kubeconfig context ${ctx}` : `Live overlay for ${e}`),
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
if (!environments.length) host.appendChild(panelMuted("no environments declared"));
|
|
854
|
+
if (stacks.length) {
|
|
855
|
+
host.appendChild(panelHeading("stack"));
|
|
856
|
+
for (const s of stacks) {
|
|
857
|
+
host.appendChild(
|
|
858
|
+
panelOpt(s, view.stack === s, () => {
|
|
859
|
+
view.stack = s;
|
|
860
|
+
resetDialCaches();
|
|
861
|
+
renderStatusbar();
|
|
862
|
+
load();
|
|
863
|
+
}),
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (tiers.length) {
|
|
868
|
+
host.appendChild(panelHeading("tier"));
|
|
869
|
+
for (const t of tiers) {
|
|
870
|
+
host.appendChild(
|
|
871
|
+
panelOpt(t, view.tier === t, () => {
|
|
872
|
+
view.tier = t;
|
|
873
|
+
resetDialCaches();
|
|
874
|
+
renderStatusbar();
|
|
875
|
+
load();
|
|
876
|
+
}),
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
if (targets.length) {
|
|
881
|
+
host.appendChild(panelHeading("target"));
|
|
882
|
+
const sel = document.createElement("select");
|
|
883
|
+
for (const t of targets) sel.add(new Option(t.endpoint, t.endpoint, false, view.target === t.endpoint));
|
|
667
884
|
sel.addEventListener("change", () => {
|
|
668
|
-
|
|
885
|
+
view.target = sel.value;
|
|
886
|
+
resetDialCaches();
|
|
669
887
|
renderStatusbar();
|
|
670
888
|
load();
|
|
671
889
|
});
|
|
672
|
-
|
|
673
|
-
|
|
890
|
+
host.appendChild(sel);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Model tab: how the current graph's nodes stand against reality — the drift
|
|
895
|
+
// overlay's managed/foreign/pending or the component DAG's applied/unapplied
|
|
896
|
+
// (deployed / in progress / rolled back / not deployed). Replaces the two old
|
|
897
|
+
// header legends, with live counts; component/attention rows click through to
|
|
898
|
+
// the node's inspect panel.
|
|
899
|
+
const DRIFT_STATUS_VAR = { good: "var(--managed)", warn: "var(--foreign)", accent: "var(--pending)", neutral: "var(--muted)", runtime: "var(--runtime)" };
|
|
900
|
+
const COMPONENT_STATUS_VAR = { good: "var(--managed)", accent: "var(--pending)", warn: "var(--degraded)", neutral: "var(--muted)" };
|
|
901
|
+
|
|
902
|
+
function panelDotRow(color, main, tag, onClick) {
|
|
903
|
+
const row = document.createElement("div");
|
|
904
|
+
row.className = onClick ? "node-row" : "count-row";
|
|
905
|
+
const dot = document.createElement("span");
|
|
906
|
+
dot.className = "dot";
|
|
907
|
+
dot.style.background = color;
|
|
908
|
+
const name = document.createElement("span");
|
|
909
|
+
name.className = "grow";
|
|
910
|
+
name.textContent = main;
|
|
911
|
+
name.title = main;
|
|
912
|
+
row.append(dot, name);
|
|
913
|
+
if (tag) {
|
|
914
|
+
const t = document.createElement("span");
|
|
915
|
+
t.className = "tag";
|
|
916
|
+
t.textContent = tag;
|
|
917
|
+
row.appendChild(t);
|
|
918
|
+
}
|
|
919
|
+
if (onClick) row.addEventListener("click", onClick);
|
|
920
|
+
return row;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Select a node from a panel row the same way a graph click would: highlight
|
|
924
|
+
// its card (when it's in the current SVG) and open its inspect panel.
|
|
925
|
+
function selectNode(id) {
|
|
926
|
+
const node = lastGraphIr && lastGraphIr.nodes.find((n) => n.id === id);
|
|
927
|
+
if (!node) return;
|
|
928
|
+
const host = document.getElementById("graph");
|
|
929
|
+
host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
|
|
930
|
+
const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
|
|
931
|
+
if (g) g.classList.add("sel");
|
|
932
|
+
inspect(node);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function renderPanelModel() {
|
|
936
|
+
const host = document.getElementById("tab-model");
|
|
937
|
+
if (!host) return;
|
|
938
|
+
host.innerHTML = "";
|
|
939
|
+
const ir = lastGraphIr;
|
|
940
|
+
const m = lastMeta;
|
|
941
|
+
if (!ir || !m) {
|
|
942
|
+
host.appendChild(panelMuted("no graph loaded yet"));
|
|
943
|
+
return;
|
|
674
944
|
}
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
945
|
+
const drift = m.mode === "overlay" || (m.mode === "logical" && !!m.env);
|
|
946
|
+
const componentStatus = m.mode === "component-status";
|
|
947
|
+
const count = (statuses) => {
|
|
948
|
+
const c = Object.fromEntries(Object.keys(statuses).map((k) => [k, 0]));
|
|
949
|
+
for (const n of ir.nodes) {
|
|
950
|
+
const s = n.attrs && n.attrs._status;
|
|
951
|
+
if (s in c) c[s]++;
|
|
952
|
+
}
|
|
953
|
+
return c;
|
|
954
|
+
};
|
|
955
|
+
if (componentStatus) {
|
|
956
|
+
host.appendChild(panelHeading(`live status · ${m.env}`));
|
|
957
|
+
const c = count(COMPONENT_STATUS_LABEL);
|
|
958
|
+
for (const [k, label] of Object.entries(COMPONENT_STATUS_LABEL)) {
|
|
959
|
+
host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[k], label, String(c[k])));
|
|
960
|
+
}
|
|
961
|
+
host.appendChild(panelHeading("components"));
|
|
962
|
+
for (const n of ir.nodes.filter((n) => n.kind === "Component")) {
|
|
963
|
+
const s = n.attrs && n.attrs._status;
|
|
964
|
+
host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[s] || "var(--muted)", n.id, APPLY_STATUS_TAG[s] || "", () => selectNode(n.id)));
|
|
965
|
+
}
|
|
966
|
+
} else if (drift) {
|
|
967
|
+
host.appendChild(panelHeading(`drift · ${m.env}`));
|
|
968
|
+
const c = count(STATUS_LABEL);
|
|
969
|
+
for (const [k, label] of Object.entries(STATUS_LABEL)) {
|
|
970
|
+
// The additive buckets (chant#1168 unobserved, chant#1180 runtime) stay
|
|
971
|
+
// hidden until a chant actually emits them — same as the old legend.
|
|
972
|
+
if ((k === "neutral" || k === "runtime") && !c[k]) continue;
|
|
973
|
+
host.appendChild(panelDotRow(DRIFT_STATUS_VAR[k], label, String(c[k])));
|
|
974
|
+
}
|
|
975
|
+
// The actionable nodes — foreign (adoptable) and pending (not applied yet).
|
|
976
|
+
const attention = ir.nodes.filter((n) => {
|
|
977
|
+
const s = n.attrs && n.attrs._status;
|
|
978
|
+
return s === "warn" || s === "accent";
|
|
979
|
+
});
|
|
980
|
+
if (attention.length) {
|
|
981
|
+
host.appendChild(panelHeading("needs attention"));
|
|
982
|
+
for (const n of attention.slice(0, 40)) {
|
|
983
|
+
const s = n.attrs._status;
|
|
984
|
+
host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, STATUS_LABEL[s], () => selectNode(n.id)));
|
|
985
|
+
}
|
|
986
|
+
if (attention.length > 40) host.appendChild(panelMuted(`+ ${attention.length - 40} more — click nodes in the graph`));
|
|
987
|
+
}
|
|
988
|
+
} else {
|
|
989
|
+
host.appendChild(panelHeading("model"));
|
|
990
|
+
host.appendChild(panelMuted(`declared source graph — ${ir.nodes.length} nodes, ${ir.edges.length} edges.`));
|
|
991
|
+
if (environments.length && !staticMode) {
|
|
992
|
+
host.appendChild(panelMuted("pick an environment on the Scope tab to see live status: applied / drift / health."));
|
|
993
|
+
host.appendChild(actButton("→ Scope", () => setPanelTab("scope")));
|
|
689
994
|
}
|
|
690
|
-
sel.dataset.built = want;
|
|
691
995
|
}
|
|
692
|
-
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function renderPanel() {
|
|
999
|
+
renderPanelView();
|
|
1000
|
+
renderPanelScope();
|
|
1001
|
+
renderPanelModel();
|
|
693
1002
|
}
|
|
694
1003
|
|
|
695
1004
|
function renderStatusbar() {
|
|
696
|
-
|
|
1005
|
+
renderPanel();
|
|
697
1006
|
const el = document.getElementById("statusbar");
|
|
698
1007
|
if (!el) return;
|
|
699
1008
|
// 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.
|
|
1009
|
+
// clicking opens the palette rather than doing nothing.
|
|
702
1010
|
if (!el.dataset.clickable) {
|
|
703
1011
|
el.dataset.clickable = "1";
|
|
704
1012
|
el.style.cursor = "pointer";
|
|
705
|
-
el.title = "env · stack · tier —
|
|
1013
|
+
el.title = "zoom · env · stack · tier — change on the panel, or ⌘K";
|
|
706
1014
|
el.addEventListener("click", () => openPalette());
|
|
707
1015
|
}
|
|
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)"];
|
|
1016
|
+
// Pure state — the strip echoes the axes whose controls live on the floating
|
|
1017
|
+
// panel and in ⌘K, so the current view stays legible with the panel collapsed.
|
|
1018
|
+
const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
|
|
714
1019
|
if (view.stack) parts.push(`stack: ${view.stack}`);
|
|
715
1020
|
if (axes.tier) parts.push(`tier: ${axes.tier}`);
|
|
716
1021
|
if (view.radial && !view.components && !view.logical) parts.push("radial");
|
|
@@ -733,6 +1038,10 @@ function renderStatusbar() {
|
|
|
733
1038
|
// itself.
|
|
734
1039
|
let lastNote = null;
|
|
735
1040
|
|
|
1041
|
+
// The `meta` of the last rendered graph — the panel's Model tab reads its
|
|
1042
|
+
// mode/env to pick the status vocabulary (drift vs component live status).
|
|
1043
|
+
let lastMeta = null;
|
|
1044
|
+
|
|
736
1045
|
// The deploy axes as currently displayed in the header (#59 unify, M2 #54
|
|
737
1046
|
// lenses) — seeded once from /api/project (server-derived from the process
|
|
738
1047
|
// env at launch; see deployAxes() in src/server.ts), then kept in sync with
|
|
@@ -1259,6 +1568,11 @@ function render(ir, svg, m) {
|
|
|
1259
1568
|
// #131: set before anything can early-return, so a level that stopped
|
|
1260
1569
|
// degrading stops explaining itself on the very next render.
|
|
1261
1570
|
lastNote = m.note || null;
|
|
1571
|
+
// The panel's Model tab reads both of these via renderStatusbar() →
|
|
1572
|
+
// renderPanel() below — set them first so it renders THIS graph, not the
|
|
1573
|
+
// previous one (recolorNodesByCategory also sets lastGraphIr; harmless).
|
|
1574
|
+
lastMeta = m;
|
|
1575
|
+
lastGraphIr = ir;
|
|
1262
1576
|
const overlay = m.mode === "overlay";
|
|
1263
1577
|
// Logical/architecture lens (#63): its own mode, but when an env is picked the
|
|
1264
1578
|
// projected nodes still carry the drift `_status`, so it reads as a drift view
|
|
@@ -1304,22 +1618,27 @@ function render(ir, svg, m) {
|
|
|
1304
1618
|
tail = ` · ${c.good} healthy · ${c.accent} in progress · ${c.warn} rollback/failed · ${c.neutral} not deployed`;
|
|
1305
1619
|
}
|
|
1306
1620
|
// Multi-estate (#31): note the composed project count; the graph draws one box per project.
|
|
1307
|
-
|
|
1621
|
+
// #186: the meta line lives in the panel's 272px footer now — the directory
|
|
1622
|
+
// basename reads better than an absolute path (full path in the tooltip).
|
|
1623
|
+
const scope = m.estate
|
|
1624
|
+
? `estate of ${m.estate} projects`
|
|
1625
|
+
: String(m.projectDir || "").replace(/\/+$/, "").split("/").pop() || m.projectDir;
|
|
1308
1626
|
// The deploy axes (#59 unify, M2 #54 lenses) — tier/target, kept in sync with
|
|
1309
1627
|
// what this response actually observed (falls back to the launch-time value
|
|
1310
1628
|
// from /api/project when a route doesn't echo them, e.g. /api/overlay).
|
|
1311
1629
|
if (m.tier !== undefined) axes.tier = m.tier;
|
|
1312
1630
|
if (m.target !== undefined) axes.target = m.target;
|
|
1313
1631
|
const axesTail = `${axes.tier ? " · tier " + axes.tier : ""}${axes.target ? " · target " + axes.target : ""}`;
|
|
1314
|
-
document.getElementById("meta")
|
|
1632
|
+
const metaEl = document.getElementById("meta");
|
|
1633
|
+
metaEl.title = m.projectDir || "";
|
|
1634
|
+
// #195: the browser tab names the loaded project — with the header gone
|
|
1635
|
+
// (#186) the title bar is free chrome, and it's what shows in cmd-tab /
|
|
1636
|
+
// tab-hover when several behold instances are up.
|
|
1637
|
+
document.title = `behold — ${scope}`;
|
|
1638
|
+
metaEl.textContent =
|
|
1315
1639
|
`${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).
|
|
1640
|
+
// Keep the persistent state strip + the floating panel in sync (the panel's
|
|
1641
|
+
// Model tab is what replaced the two old header legends).
|
|
1323
1642
|
renderStatusbar();
|
|
1324
1643
|
const g = document.getElementById("graph");
|
|
1325
1644
|
// Ghostty theming (#62): strip pinhole's baked-in `:root{--pin-*}` defaults from the
|
|
@@ -1542,6 +1861,9 @@ const PRECONDITION_TITLE = {
|
|
|
1542
1861
|
"not-installed": "This project isn't installed",
|
|
1543
1862
|
tier: "This tier needs credentials",
|
|
1544
1863
|
eval: "chant couldn't evaluate this project",
|
|
1864
|
+
// #193: behold was pointed at a directory that isn't a chant project at all
|
|
1865
|
+
// — the first screen must say so, not draw a blank graph.
|
|
1866
|
+
"no-project": "This isn't a chant project",
|
|
1545
1867
|
};
|
|
1546
1868
|
|
|
1547
1869
|
// A precondition failure — the lint gate, a not-installed/no-typegen project,
|
|
@@ -1573,8 +1895,6 @@ function renderPreconditionError(body) {
|
|
|
1573
1895
|
card.appendChild(remedy);
|
|
1574
1896
|
}
|
|
1575
1897
|
host.appendChild(card);
|
|
1576
|
-
document.getElementById("legend").style.display = "none";
|
|
1577
|
-
document.getElementById("component-legend").style.display = "none";
|
|
1578
1898
|
}
|
|
1579
1899
|
|
|
1580
1900
|
// Fetch the current view (source graph, or the picked env's live overlay).
|
|
@@ -1641,6 +1961,17 @@ async function load(opts = {}) {
|
|
|
1641
1961
|
}
|
|
1642
1962
|
throw new Error(body.error || res.statusText);
|
|
1643
1963
|
}
|
|
1964
|
+
// #182: an empty first components view → re-load at the resources zoom
|
|
1965
|
+
// instead of painting a blank pane. Checked before render() so the blank
|
|
1966
|
+
// graph never flashes; the loading scrim stays up across the second fetch
|
|
1967
|
+
// (showLoading is ref-counted).
|
|
1968
|
+
if (autoZoomFallback && view.components && !((body.ir && body.ir.nodes) || []).length) {
|
|
1969
|
+
autoZoomFallback = false;
|
|
1970
|
+
applyZoom("resources");
|
|
1971
|
+
renderStatusbar();
|
|
1972
|
+
return load(opts);
|
|
1973
|
+
}
|
|
1974
|
+
autoZoomFallback = false;
|
|
1644
1975
|
render(body.ir, body.svg, body.meta);
|
|
1645
1976
|
} catch (err) {
|
|
1646
1977
|
// A background settle poll must not blow away a good graph on a transient error.
|
|
@@ -1688,6 +2019,9 @@ let environments = [];
|
|
|
1688
2019
|
let tiers = [];
|
|
1689
2020
|
let targets = [];
|
|
1690
2021
|
let stacks = [];
|
|
2022
|
+
// #195: the last /api/project payload — the Scope tab's project section reads
|
|
2023
|
+
// projectDir/projectDirs/recents from it.
|
|
2024
|
+
let projectInfo = null;
|
|
1691
2025
|
|
|
1692
2026
|
// Fetch the project once, seed view/axes + the lens lists above, then do the
|
|
1693
2027
|
// first load. previously also built the header's env/zoom/radial/tier/target
|
|
@@ -1697,6 +2031,7 @@ async function initPickers() {
|
|
|
1697
2031
|
const info = await apiFetch("/api/project")
|
|
1698
2032
|
.then((r) => r.json())
|
|
1699
2033
|
.catch(() => ({ environments: [], currentEnv: null }));
|
|
2034
|
+
projectInfo = info;
|
|
1700
2035
|
view.env = info.currentEnv || null;
|
|
1701
2036
|
view.tier = info.tier || null;
|
|
1702
2037
|
view.target = (info.targets && info.targets[0] && info.targets[0].endpoint) || null;
|
|
@@ -1738,10 +2073,10 @@ events.addEventListener("changed", () => {
|
|
|
1738
2073
|
scheduleSettle();
|
|
1739
2074
|
});
|
|
1740
2075
|
|
|
1741
|
-
// Substrate readiness
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
// into
|
|
2076
|
+
// Substrate readiness (M5, #54): is each substrate the project needs actually
|
|
2077
|
+
// up? Poll /api/substrates and render rows on the panel's Substrates tab —
|
|
2078
|
+
// status dot + name + state, with the bring-up/reset/pipeline actions inline
|
|
2079
|
+
// again (#73 moved those into ⌘K; they remain there too, via lastSubstrates).
|
|
1745
2080
|
async function loadSubstrates() {
|
|
1746
2081
|
try {
|
|
1747
2082
|
const { substrates } = await apiFetch("/api/substrates").then((r) => r.json());
|
|
@@ -1759,27 +2094,40 @@ let lastSubstrates = [];
|
|
|
1759
2094
|
function renderSubstrates(subs) {
|
|
1760
2095
|
lastSubstrates = subs;
|
|
1761
2096
|
const host = document.getElementById("substrates");
|
|
2097
|
+
if (!host) return;
|
|
2098
|
+
// Rebuilt only when the data changes — this runs on a 5s poll, and wiping
|
|
2099
|
+
// identical rows would yank a button out from under the cursor.
|
|
2100
|
+
const sig = JSON.stringify(subs) + `|${staticMode}|${previewMode}`;
|
|
2101
|
+
if (host.dataset.sig === sig) return;
|
|
2102
|
+
host.dataset.sig = sig;
|
|
2103
|
+
host.innerHTML = "";
|
|
1762
2104
|
if (!subs.length) {
|
|
1763
|
-
host.
|
|
2105
|
+
host.appendChild(panelMuted("no substrates detected for this project"));
|
|
1764
2106
|
return;
|
|
1765
2107
|
}
|
|
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
2108
|
for (const s of subs) {
|
|
1773
|
-
const
|
|
1774
|
-
|
|
1775
|
-
|
|
2109
|
+
const row = document.createElement("div");
|
|
2110
|
+
row.className = `sub ${s.status}`;
|
|
2111
|
+
row.title = s.detail || "";
|
|
1776
2112
|
const dot = document.createElement("span");
|
|
1777
2113
|
dot.className = "dot";
|
|
1778
|
-
pill.appendChild(dot);
|
|
1779
2114
|
const name = document.createElement("span");
|
|
2115
|
+
name.className = "grow";
|
|
1780
2116
|
name.textContent = s.label;
|
|
1781
|
-
|
|
1782
|
-
|
|
2117
|
+
const status = document.createElement("span");
|
|
2118
|
+
status.className = "tag";
|
|
2119
|
+
status.textContent = s.status;
|
|
2120
|
+
row.append(dot, name, status);
|
|
2121
|
+
// Writes — none in a static export; the GitHub dispatch also respects the
|
|
2122
|
+
// preview lock, mirroring paletteCommands()'s gating exactly.
|
|
2123
|
+
if (!staticMode) {
|
|
2124
|
+
if (s.bringUp) row.appendChild(actButton("bring up", () => bringUpSubstrate(s)));
|
|
2125
|
+
if (s.name === "floci" && s.status === "up")
|
|
2126
|
+
row.appendChild(actButton("reset", () => resetLocal(), "Reset the local emulator — wipes every stack, reboots, redeploys clean"));
|
|
2127
|
+
if (s.name === "github" && !previewMode)
|
|
2128
|
+
row.appendChild(actButton("run", () => dispatchPipeline(), "Dispatch the GitHub Actions pipeline via your gh login"));
|
|
2129
|
+
}
|
|
2130
|
+
host.appendChild(row);
|
|
1783
2131
|
}
|
|
1784
2132
|
}
|
|
1785
2133
|
|
|
@@ -1811,6 +2159,24 @@ function bringUpSubstrate(s) {
|
|
|
1811
2159
|
.catch((e) => nowline("✗ bring up: " + e.message));
|
|
1812
2160
|
}
|
|
1813
2161
|
|
|
2162
|
+
// Dispatch a GitHub Actions run (#164) — through the operator's own `gh`
|
|
2163
|
+
// login. The server refuses honestly (no gh, unauthenticated, no matching
|
|
2164
|
+
// workflow_dispatch workflow) and the reason lands as a toast. Shared by the
|
|
2165
|
+
// Substrates tab's button and the ⌘K entry.
|
|
2166
|
+
function dispatchPipeline() {
|
|
2167
|
+
if (!window.confirm("Dispatch the GitHub Actions pipeline?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.")) return;
|
|
2168
|
+
fetch("/api/ci/dispatch", { method: "POST" })
|
|
2169
|
+
.then((r) => r.json())
|
|
2170
|
+
.then((j) => {
|
|
2171
|
+
if (j.error) {
|
|
2172
|
+
showToast(`✗ dispatch: ${j.error}`, false);
|
|
2173
|
+
nowline("✗ dispatch: " + j.error);
|
|
2174
|
+
} else {
|
|
2175
|
+
showToast(`▶ dispatched ${j.workflow} @ ${j.ref} (${j.jobs} jobs) — following on the dial`, true);
|
|
2176
|
+
}
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
|
|
1814
2180
|
loadSubstrates();
|
|
1815
2181
|
// Poll readiness so pills update as things come up on their own (Docker
|
|
1816
2182
|
// starting, a bring-up provisioning) without needing a `changed` event.
|
|
@@ -1857,7 +2223,7 @@ function showToast(msg, ok) {
|
|
|
1857
2223
|
if (!host) {
|
|
1858
2224
|
host = document.createElement("div");
|
|
1859
2225
|
host.id = "toasts";
|
|
1860
|
-
host.style.cssText = "position:fixed;top:
|
|
2226
|
+
host.style.cssText = "position:fixed;top:12px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:60;max-width:420px";
|
|
1861
2227
|
document.body.appendChild(host);
|
|
1862
2228
|
}
|
|
1863
2229
|
const t = document.createElement("div");
|
|
@@ -2065,7 +2431,7 @@ async function initActions() {
|
|
|
2065
2431
|
applyPicker = true;
|
|
2066
2432
|
loadComponentChoices().then(renderDial);
|
|
2067
2433
|
renderDial();
|
|
2068
|
-
|
|
2434
|
+
setPanelTab("deploy"); // the dial lives on the panel's Deploy tab
|
|
2069
2435
|
});
|
|
2070
2436
|
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
2437
|
bar.appendChild(deploy);
|
|
@@ -2180,10 +2546,10 @@ function exportSvg() {
|
|
|
2180
2546
|
// list from live state on every open, palRender() filtering + repainting,
|
|
2181
2547
|
// openPalette()/closePalette() toggling the `.on` class), retargeted at
|
|
2182
2548
|
// 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
|
-
//
|
|
2549
|
+
// (spicypath's own design rule, carried over): every control here also lives
|
|
2550
|
+
// on the floating panel now, and the current values stay visible in the
|
|
2551
|
+
// header (renderStatusbar(), #meta) — the palette is the fast surface, the
|
|
2552
|
+
// panel the discoverable one.
|
|
2187
2553
|
const palette = document.getElementById("palette");
|
|
2188
2554
|
const palInput = document.getElementById("pal-input");
|
|
2189
2555
|
const palList = document.getElementById("pal-list");
|
|
@@ -2201,6 +2567,11 @@ function paletteCommands() {
|
|
|
2201
2567
|
c.push(["Export: current graph as SVG", () => exportSvg()]);
|
|
2202
2568
|
const inspectCollapsed = document.getElementById("app").classList.contains("inspect-collapsed");
|
|
2203
2569
|
c.push([inspectCollapsed ? "Show inspect panel" : "Hide inspect panel", () => toggleInspect()]);
|
|
2570
|
+
// The floating control panel (panel.js): collapse/expand + jump to a tab.
|
|
2571
|
+
c.push([isPanelCollapsed() ? "Expand control panel" : "Collapse control panel", () => togglePanelCollapsed()]);
|
|
2572
|
+
for (const b of document.querySelectorAll("#panel-tabs button[data-tab]")) {
|
|
2573
|
+
c.push([`Panel: ${b.textContent}`, () => setPanelTab(b.dataset.tab)]);
|
|
2574
|
+
}
|
|
2204
2575
|
|
|
2205
2576
|
// Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
|
|
2206
2577
|
for (const [label, v] of ZOOM_OPTS) {
|
|
@@ -2262,22 +2633,7 @@ function paletteCommands() {
|
|
|
2262
2633
|
// (no gh, unauthenticated, no matching workflow_dispatch workflow) and
|
|
2263
2634
|
// the reason lands as a toast.
|
|
2264
2635
|
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
|
-
]);
|
|
2636
|
+
c.push(["Run pipeline: GitHub Actions", () => dispatchPipeline()]);
|
|
2281
2637
|
}
|
|
2282
2638
|
}
|
|
2283
2639
|
|