|
@@ -188,6 +188,28 @@
|
|
|
word-break: break-word; color: var(--fg);
|
|
word-break: break-word; color: var(--fg);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /* --- raw content windows --------------------------------------------------- */
|
|
|
|
|
+ .rawwin .win-body { display: flex; flex-direction: column; padding: 0; }
|
|
|
|
|
+ .rawbar {
|
|
|
|
|
+ display: flex; gap: 8px; align-items: center; padding: 8px 10px;
|
|
|
|
|
+ border-bottom: 1px solid var(--border); flex-shrink: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .rawbar select {
|
|
|
|
|
+ background: var(--btn); color: var(--fg); border: 1px solid var(--border);
|
|
|
|
|
+ border-radius: 6px; padding: 2px 6px; font-size: 12px; max-width: 320px; height: 26px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .rawbar .abtn { height: 26px; }
|
|
|
|
|
+ .rawnote { font-size: 12px; color: var(--muted); flex: 1; }
|
|
|
|
|
+
|
|
|
|
|
+ /* --- polish: skeleton + attention ------------------------------------------ */
|
|
|
|
|
+ .skeleton { height: 14px; border-radius: 6px; margin: 10px 0;
|
|
|
|
|
+ background: linear-gradient(90deg, var(--btn) 25%, var(--btn-hover) 50%, var(--btn) 75%);
|
|
|
|
|
+ background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
|
|
|
|
+ @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
|
|
|
|
+ .cled.exited { background: var(--led-off); box-shadow: 0 0 8px 2px rgba(248,81,73,.5); animation: pulse 1.2s infinite; }
|
|
|
|
|
+ .abtn:disabled { animation: pulse 1.2s infinite; }
|
|
|
|
|
+ .job.jjob .jdot { animation: pulse 1.2s infinite; }
|
|
|
|
|
+
|
|
|
/* --- confirm dialog ------------------------------------------------------- */
|
|
/* --- confirm dialog ------------------------------------------------------- */
|
|
|
.dlg-back[hidden] { display: none; }
|
|
.dlg-back[hidden] { display: none; }
|
|
|
.dlg-back {
|
|
.dlg-back {
|
|
@@ -214,7 +236,7 @@
|
|
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
|
@keyframes winIn { from { opacity: 0; transform: translateY(-4px) scale(.985); } to { opacity: 1; transform: none; } }
|
|
@keyframes winIn { from { opacity: 0; transform: translateY(-4px) scale(.985); } to { opacity: 1; transform: none; } }
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
- *, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg, .win { animation: none !important; }
|
|
|
|
|
|
|
+ *, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg, .win, .skeleton, .cled.exited, .abtn:disabled { animation: none !important; }
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|
|
|
</head>
|
|
</head>
|
|
@@ -346,6 +368,7 @@ function renderDock() {
|
|
|
}
|
|
}
|
|
|
for (const w of logWins.values()) {
|
|
for (const w of logWins.values()) {
|
|
|
if (w.win.style.display === "none") chip("– logs: " + w.node, () => { w.win.style.display = ""; front(w.win); renderDock(); });
|
|
if (w.win.style.display === "none") chip("– logs: " + w.node, () => { w.win.style.display = ""; front(w.win); renderDock(); });
|
|
|
|
|
+ if (!rawWin) chip("+ raw config / recipe / effective", openRaw);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -413,7 +436,8 @@ function renderCluster(c, prev, animate) {
|
|
|
for (const x of n.containers) {
|
|
for (const x of n.containers) {
|
|
|
const crow = document.createElement("div");
|
|
const crow = document.createElement("div");
|
|
|
crow.className = "crow";
|
|
crow.className = "crow";
|
|
|
- crow.innerHTML = `<span class="cled"></span><span class="cname">${esc(x.name)}</span><span>${esc(x.recipe)} · ${esc(x.status)}</span>`;
|
|
|
|
|
|
|
+ const dead = /exited|dead/i.test(x.status);
|
|
|
|
|
+ crow.innerHTML = `<span class="cled${dead ? " exited" : ""}"></span><span class="cname">${esc(x.name)}</span><span>${esc(x.recipe)} · ${esc(x.status)}</span>`;
|
|
|
box.appendChild(crow);
|
|
box.appendChild(crow);
|
|
|
}
|
|
}
|
|
|
el.appendChild(box);
|
|
el.appendChild(box);
|
|
@@ -692,6 +716,63 @@ function detachLog(attachId, hide = true) {
|
|
|
if (hide && w.win && w.win.isConnected) { w.win.style.display = "none"; renderDock(); }
|
|
if (hide && w.win && w.win.isConnected) { w.win.style.display = "none"; renderDock(); }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// --- raw content windows ---------------------------------------------------------
|
|
|
|
|
+// One window, a kind selector: config / effective / recipe:<name>. Content is
|
|
|
|
|
+// verbatim from the server (effective is pretty JSON); copy grabs the text.
|
|
|
|
|
+let rawWin = null;
|
|
|
|
|
+async function openRaw() {
|
|
|
|
|
+ if (rawWin) { front(rawWin); return; }
|
|
|
|
|
+ const handle = makeWin({
|
|
|
|
|
+ title: "raw", x: 20, y: 20, w: Math.min(680, innerWidth - 40), h: Math.min(460, innerHeight * 0.65),
|
|
|
|
|
+ bodyHtml: `<div class="rawbar">
|
|
|
|
|
+ <select></select>
|
|
|
|
|
+ <button class="abtn rawcopy">copy</button>
|
|
|
|
|
+ <span class="rawnote"></span>
|
|
|
|
|
+ </div><pre class="logpre rawpre"></pre>`,
|
|
|
|
|
+ extraCls: "rawwin",
|
|
|
|
|
+ removeOnClose: true,
|
|
|
|
|
+ onClosed: () => { rawWin = null; },
|
|
|
|
|
+ });
|
|
|
|
|
+ rawWin = handle.win;
|
|
|
|
|
+ const sel = handle.body.querySelector("select");
|
|
|
|
|
+ const pre = handle.body.querySelector(".rawpre");
|
|
|
|
|
+ const note = handle.body.querySelector(".rawnote");
|
|
|
|
|
+ handle.body.querySelector(".rawcopy").onclick = async () => {
|
|
|
|
|
+ try { await navigator.clipboard.writeText(pre.textContent); } catch {}
|
|
|
|
|
+ };
|
|
|
|
|
+ const fill = () => {
|
|
|
|
|
+ if (!state.recipes.length) return;
|
|
|
|
|
+ const cur = sel.value;
|
|
|
|
|
+ sel.innerHTML = "";
|
|
|
|
|
+ for (const kind of ["config", "effective", "recipe:", ...state.recipes.map((r) => "recipe:" + r.name)]) {
|
|
|
|
|
+ const o = document.createElement("option");
|
|
|
|
|
+ o.value = kind === "recipe:" ? "" : kind;
|
|
|
|
|
+ o.textContent = kind === "effective" ? "effective (merged config)" : kind;
|
|
|
|
|
+ if (kind === "recipe:") { o.disabled = true; o.textContent = "— recipes —"; }
|
|
|
|
|
+ sel.appendChild(o);
|
|
|
|
|
+ }
|
|
|
|
|
+ sel.value = cur || "config";
|
|
|
|
|
+ load();
|
|
|
|
|
+ };
|
|
|
|
|
+ async function load() {
|
|
|
|
|
+ const kind = sel.value;
|
|
|
|
|
+ note.textContent = "";
|
|
|
|
|
+ if (!kind) return;
|
|
|
|
|
+ const path = kind === "config" ? "/api/raw/config" : kind === "effective" ? "/api/raw/effective" : "/api/raw/recipe/" + encodeURIComponent(kind.slice("recipe:".length));
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(path);
|
|
|
|
|
+ const text = await res.text();
|
|
|
|
|
+ if (!res.ok) { note.textContent = "error: " + text; return; }
|
|
|
|
|
+ if (kind === "effective") {
|
|
|
|
|
+ try { pre.textContent = JSON.stringify(JSON.parse(text), null, 2); } catch { pre.textContent = text; }
|
|
|
|
|
+ } else pre.textContent = text;
|
|
|
|
|
+ } catch (e) { note.textContent = "error: " + e.message; }
|
|
|
|
|
+ }
|
|
|
|
|
+ sel.onchange = load;
|
|
|
|
|
+ if (state.recipes.length) fill();
|
|
|
|
|
+ else state.onRecipesReady = fill; // recipes may arrive after the first open
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// --- actions -----------------------------------------------------------------------
|
|
// --- actions -----------------------------------------------------------------------
|
|
|
const cur = () => state.snap;
|
|
const cur = () => state.snap;
|
|
|
function recipeOf(cluster) {
|
|
function recipeOf(cluster) {
|
|
@@ -836,7 +917,7 @@ function initManagedWindows() {
|
|
|
wins.state = makeWin({
|
|
wins.state = makeWin({
|
|
|
id: "win-state", title: "Cluster state", x: 12, y: 12,
|
|
id: "win-state", title: "Cluster state", x: 12, y: 12,
|
|
|
w: Math.max(430, Math.floor(W * 0.52)), h: Math.max(320, Math.floor(H * 0.78)),
|
|
w: Math.max(430, Math.floor(W * 0.52)), h: Math.max(320, Math.floor(H * 0.78)),
|
|
|
- bodyHtml: `<div id="clusters"></div>`,
|
|
|
|
|
|
|
+ bodyHtml: `<div id="clusters"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div>`,
|
|
|
});
|
|
});
|
|
|
wins.jobs = makeWin({
|
|
wins.jobs = makeWin({
|
|
|
id: "win-jobs", title: "Jobs", x: Math.max(450, Math.floor(W * 0.52) + 30), y: 12,
|
|
id: "win-jobs", title: "Jobs", x: Math.max(450, Math.floor(W * 0.52) + 30), y: 12,
|
|
@@ -867,6 +948,7 @@ async function boot() {
|
|
|
const d = await res.json();
|
|
const d = await res.json();
|
|
|
state.recipes = (d.recipes || []).sort((a, b) => a.name.localeCompare(b.name));
|
|
state.recipes = (d.recipes || []).sort((a, b) => a.name.localeCompare(b.name));
|
|
|
if (state.snap) onState(state.snap); // re-render with recipes
|
|
if (state.snap) onState(state.snap); // re-render with recipes
|
|
|
|
|
+ if (state.onRecipesReady) { const f = state.onRecipesReady; delete state.onRecipesReady; f(); }
|
|
|
}
|
|
}
|
|
|
} catch {}
|
|
} catch {}
|
|
|
connectSSE();
|
|
connectSSE();
|