@monster-orange/den 0.4.17 → 0.4.18
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/CHANGELOG.md +43 -0
- package/README.md +7 -7
- package/docs/distributions.md +39 -1
- package/package.json +1 -1
- package/src/ui/cli/agent-ops.ts +43 -0
- package/src/ui/server/agent-routes.ts +9 -0
- package/src/ui/server/public/agents-page.js +23 -0
- package/src/ui/server/public/agents.html +13 -0
- package/src/ui/server/public/graph.css +22 -0
- package/src/ui/server/public/graph.js +25 -1
- package/src/ui/server/public/header.js +7 -0
- package/src/ui/server/public/i18n/es.js +35 -0
- package/src/ui/server/public/i18n/ja.js +35 -0
- package/src/ui/server/public/local-dev.html +11 -2
- package/src/ui/server/public/log-source.js +26 -0
- package/src/ui/server/public/logs.html +7 -20
- package/src/ui/server/public/services.html +18 -3
- package/src/ui/server/public/shell.js +208 -25
- package/src/ui/server/public/svc-panel.js +672 -0
- package/src/ui/server/ui-assets.ts +4 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The service panel (0.4.18, QA-0.4.14 §C): one quick panel for a service,
|
|
3
|
+
* opened from its pinned row in the rail (its name, or the collapsed rail's
|
|
4
|
+
* dot), the Graph card's ⋯ and the Services card's name. It never
|
|
5
|
+
* navigates: it opens beside its opener in the top layer (the shell's
|
|
6
|
+
* topLayer), and at phone width (under 640 px) it is a bottom sheet. Esc
|
|
7
|
+
* or a click outside closes it, and focus goes back to the opener.
|
|
8
|
+
*
|
|
9
|
+
* What it shows, top to bottom: the name, workspace, health dot, state
|
|
10
|
+
* ("up 2h", "down: …", "starting…") and URL; Visit ↗ (primary), Start or
|
|
11
|
+
* Stop, Restart and Logs; the repo's last test run with Run tests; the
|
|
12
|
+
* agents working in the repo, each with Open, and Start agent here; then
|
|
13
|
+
* Open in Services, Open in Graph and Unpin (Pin when it isn't pinned).
|
|
14
|
+
*
|
|
15
|
+
* Every action calls what the pages call: Start, Stop and Restart are
|
|
16
|
+
* /api/service/exec (up, down, restart), as the Services page's buttons,
|
|
17
|
+
* and Logs reads where the Logs page reads (log-source.js: taillog for a
|
|
18
|
+
* local service), each a tab in the runs dock; Run tests is
|
|
19
|
+
* /api/run (the repo's suites), as the Tests page. It stays live from the
|
|
20
|
+
* same feeds the pages use: the header's health broadcast, the daemons
|
|
21
|
+
* list, the run history (GET /api/runs/last) and the sessions list (GET
|
|
22
|
+
* /api/sessions), asked again every few seconds while it is open.
|
|
23
|
+
*
|
|
24
|
+
* A pin is a repo ("shop", its primary service) or a service's address
|
|
25
|
+
* ("shop/db"). The rail's Visit ↗ reads each pin's URL from here
|
|
26
|
+
* (denShell state `pinUrls`).
|
|
27
|
+
*/
|
|
28
|
+
import { sudoRun } from "./topbar.js";
|
|
29
|
+
import { logStreamUrl } from "./log-source.js";
|
|
30
|
+
|
|
31
|
+
const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
|
|
32
|
+
const esc = (s) =>
|
|
33
|
+
String(s ?? "").replace(
|
|
34
|
+
/[&<>"']/g,
|
|
35
|
+
(c) =>
|
|
36
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
37
|
+
c
|
|
38
|
+
],
|
|
39
|
+
);
|
|
40
|
+
const REFRESH_MS = 5_000;
|
|
41
|
+
/**
|
|
42
|
+
* "starting…" gives up after this long, as the server's own ready watch
|
|
43
|
+
* does (lifecycle-exec.ts, readyTimeoutMs).
|
|
44
|
+
*/
|
|
45
|
+
const START_TIMEOUT_MS = 120_000;
|
|
46
|
+
/** A down check this soon after Start is the service still coming up. */
|
|
47
|
+
const START_GRACE_MS = 10_000;
|
|
48
|
+
/** Restart: an up check counts after a down one, or after this long. */
|
|
49
|
+
const RESTART_MIN_MS = 5_000;
|
|
50
|
+
/** Phone width: the panel is a bottom sheet. */
|
|
51
|
+
const SHEET = "(max-width: 639px)";
|
|
52
|
+
|
|
53
|
+
const S = {
|
|
54
|
+
deps: {},
|
|
55
|
+
/** The header's last health results, by `repo/service`. */
|
|
56
|
+
results: new Map(),
|
|
57
|
+
/** When each repo's service started (the daemons list). */
|
|
58
|
+
upSince: new Map(),
|
|
59
|
+
lastRuns: {},
|
|
60
|
+
sessions: [],
|
|
61
|
+
/**
|
|
62
|
+
* Pin ids starting from here (Start or Restart): id → { at, cmd, sawDown,
|
|
63
|
+
* timer }. "starting…" follows the health feed, not the exec stream: a
|
|
64
|
+
* dev server's `up` never ends.
|
|
65
|
+
*/
|
|
66
|
+
busy: new Map(),
|
|
67
|
+
/** Pin ids with tests in flight from here. */
|
|
68
|
+
testing: new Set(),
|
|
69
|
+
/** The open panel: { id, el, opener, scrim, timer }. */
|
|
70
|
+
open: null,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Start or Restart from here: "starting…" until health says, or the timeout. */
|
|
74
|
+
function startBusy(id, cmd) {
|
|
75
|
+
endBusy(id);
|
|
76
|
+
S.busy.set(id, {
|
|
77
|
+
at: Date.now(),
|
|
78
|
+
cmd,
|
|
79
|
+
sawDown: false,
|
|
80
|
+
timer: setTimeout(() => {
|
|
81
|
+
endBusy(id);
|
|
82
|
+
sync();
|
|
83
|
+
}, START_TIMEOUT_MS),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function endBusy(id) {
|
|
87
|
+
const b = S.busy.get(id);
|
|
88
|
+
if (!b) return;
|
|
89
|
+
clearTimeout(b.timer);
|
|
90
|
+
S.busy.delete(id);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A health update for the pins starting from here: up ends "starting…"
|
|
94
|
+
* (after Restart, once the old instance went down or after a moment);
|
|
95
|
+
* down ends it once the service has had a moment to come up.
|
|
96
|
+
*/
|
|
97
|
+
function settleBusy() {
|
|
98
|
+
for (const [id, b] of [...S.busy]) {
|
|
99
|
+
const s = serviceOf(id);
|
|
100
|
+
const age = Date.now() - b.at;
|
|
101
|
+
if (s.ok === false) {
|
|
102
|
+
b.sawDown = true;
|
|
103
|
+
if (age >= START_GRACE_MS) endBusy(id);
|
|
104
|
+
} else if (
|
|
105
|
+
s.ok === true &&
|
|
106
|
+
(b.cmd !== "restart" || b.sawDown || age >= RESTART_MIN_MS)
|
|
107
|
+
)
|
|
108
|
+
endBusy(id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The primary service of a repo from the health results (entrypoint first). */
|
|
113
|
+
function primaryOf(repo) {
|
|
114
|
+
let best = null;
|
|
115
|
+
let rank = -1;
|
|
116
|
+
for (const r of S.results.values()) {
|
|
117
|
+
if (r.repo !== repo) continue;
|
|
118
|
+
const k = r.isEntrypoint ? 2 : r.service === r.repo ? 1 : 0;
|
|
119
|
+
if (k > rank) {
|
|
120
|
+
best = r;
|
|
121
|
+
rank = k;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return best;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A pin's service: its health result, or what its id says. */
|
|
128
|
+
export function serviceOf(id) {
|
|
129
|
+
const [repo, sub] = String(id).split("/");
|
|
130
|
+
const r = sub ? S.results.get(`${repo}/${sub}`) : primaryOf(repo);
|
|
131
|
+
return {
|
|
132
|
+
id: String(id),
|
|
133
|
+
repo,
|
|
134
|
+
service: sub || r?.service || repo,
|
|
135
|
+
url: r?.openUrl || null,
|
|
136
|
+
ok: r ? r.ok : null,
|
|
137
|
+
error: r?.error || (r?.status ? `HTTP ${r.status}` : ""),
|
|
138
|
+
workspaces: r?.workspaces ?? [],
|
|
139
|
+
repoPath: r?.repoPath || "",
|
|
140
|
+
known: !!r,
|
|
141
|
+
ms:
|
|
142
|
+
typeof r?.ms === "number"
|
|
143
|
+
? r.ms
|
|
144
|
+
: typeof r?.durationMs === "number"
|
|
145
|
+
? r.durationMs
|
|
146
|
+
: null,
|
|
147
|
+
upSince: S.upSince.get(repo),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Its state: { cls, text } ("up 2h", "down: …", "starting…", "stopped"). */
|
|
152
|
+
export function stateOf(s) {
|
|
153
|
+
if (S.busy.has(s.id)) return { cls: "run", text: t("svp.starting") };
|
|
154
|
+
if (s.ok === false)
|
|
155
|
+
return {
|
|
156
|
+
cls: "bad",
|
|
157
|
+
text: s.error ? t("svp.down", { c: s.error }) : t("svp.downNone"),
|
|
158
|
+
};
|
|
159
|
+
if (s.ok === true || s.upSince !== undefined)
|
|
160
|
+
return { cls: s.ok === true ? "" : "off", text: upText(s.upSince) };
|
|
161
|
+
return { cls: "off", text: t("svp.stopped") };
|
|
162
|
+
}
|
|
163
|
+
function upText(at) {
|
|
164
|
+
if (at === undefined) return t("svp.upNow");
|
|
165
|
+
const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
166
|
+
const d =
|
|
167
|
+
sec < 60
|
|
168
|
+
? `${sec}s`
|
|
169
|
+
: sec < 3600
|
|
170
|
+
? `${Math.floor(sec / 60)}m`
|
|
171
|
+
: sec < 86400
|
|
172
|
+
? `${Math.floor(sec / 3600)}h`
|
|
173
|
+
: `${Math.floor(sec / 86400)}d`;
|
|
174
|
+
return t("svp.up", { d });
|
|
175
|
+
}
|
|
176
|
+
/** Visit works unless the service is down (or isn't running). */
|
|
177
|
+
export function visitable(s) {
|
|
178
|
+
return !!s.url && s.ok !== false && !S.busy.has(s.id);
|
|
179
|
+
}
|
|
180
|
+
const running = (s) =>
|
|
181
|
+
S.busy.has(s.id) || s.ok === true || s.upSince !== undefined;
|
|
182
|
+
|
|
183
|
+
/** The agents working in its repo: in its workspace, or in a folder inside the repo. */
|
|
184
|
+
function agentsOf(s) {
|
|
185
|
+
return S.sessions.filter(
|
|
186
|
+
(a) =>
|
|
187
|
+
a.kind === "agent" &&
|
|
188
|
+
((s.repoPath &&
|
|
189
|
+
a.cwd &&
|
|
190
|
+
(a.cwd === s.repoPath || a.cwd.startsWith(`${s.repoPath}/`))) ||
|
|
191
|
+
(s.workspaces.includes(a.workspace) &&
|
|
192
|
+
a.cwd &&
|
|
193
|
+
a.cwd.split("/").includes(s.repo))),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const agentName = (a) => a.displayName || a.agent || a.label || a.id;
|
|
197
|
+
const agentTone = (a) =>
|
|
198
|
+
a.waiting
|
|
199
|
+
? "warn"
|
|
200
|
+
: a.state === "running"
|
|
201
|
+
? ""
|
|
202
|
+
: a.state === "stopping"
|
|
203
|
+
? "run"
|
|
204
|
+
: "off";
|
|
205
|
+
|
|
206
|
+
function testsHtml(s) {
|
|
207
|
+
const r = S.lastRuns[s.repo];
|
|
208
|
+
if (S.testing.has(s.id) || r?.running)
|
|
209
|
+
return `<span class="den-dot run" aria-hidden="true"></span><span>${esc(t("svp.testsRun"))}</span>`;
|
|
210
|
+
if (!r) return `<span class="den-svp-none">${esc(t("svp.testsNone"))}</span>`;
|
|
211
|
+
const at = new Intl.DateTimeFormat(undefined, {
|
|
212
|
+
hour: "2-digit",
|
|
213
|
+
minute: "2-digit",
|
|
214
|
+
}).format(new Date(r.endedAt ?? r.startedAt));
|
|
215
|
+
const ok = r.exitCode === 0;
|
|
216
|
+
return `<span class="den-svp-${ok ? "ok" : "bad"}">${esc(t(ok ? "svp.testsOk" : "svp.testsBad", { t: at }))}</span>`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A Visit button (the rail row's ↗, or the panel's primary). */
|
|
220
|
+
export function visitHtml(s, cls, label) {
|
|
221
|
+
if (!s.url) return "";
|
|
222
|
+
const on = visitable(s);
|
|
223
|
+
return (
|
|
224
|
+
`<button type="button" class="${cls}" data-visit="${esc(s.id)}" data-url="${esc(s.url)}"` +
|
|
225
|
+
` aria-label="${esc(t("act.visitX", { s: s.id }))}" title="${esc(on ? s.url : t("svp.notRunning"))}"` +
|
|
226
|
+
`${on ? "" : ' aria-disabled="true"'}>${label}</button>`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function panelHtml(s) {
|
|
231
|
+
const st = stateOf(s);
|
|
232
|
+
const ags = agentsOf(s);
|
|
233
|
+
const pinned = window.denShell?.isPinned?.(s.id);
|
|
234
|
+
const testing = S.testing.has(s.id) || S.lastRuns[s.repo]?.running;
|
|
235
|
+
return (
|
|
236
|
+
`<div class="den-svp-h"><div class="den-svp-t"><span class="den-dot ${st.cls}" aria-hidden="true"></span>` +
|
|
237
|
+
`<b title="${esc(s.id)}">${esc(s.id)}</b>` +
|
|
238
|
+
`<button type="button" class="den-ib den-svp-x" data-svx="close" aria-label="${esc(t("svp.close"))}" title="${esc(t("svp.close"))}">✕</button></div>` +
|
|
239
|
+
`<div class="den-svp-meta"><span class="den-wsp">${esc(s.workspaces.join(", ") || "—")}</span>` +
|
|
240
|
+
`<span class="den-svp-st ${st.cls}">${esc(st.text)}</span>` +
|
|
241
|
+
(s.ok === true && s.ms != null
|
|
242
|
+
? `<span class="den-svp-ms">${esc(window.denShell?.formatNumber?.(s.ms) ?? s.ms)} ms</span>`
|
|
243
|
+
: "") +
|
|
244
|
+
`</div>` +
|
|
245
|
+
`<div class="den-svp-url">${esc(s.url || t("svp.noUrl"))}</div></div>` +
|
|
246
|
+
`<div class="den-svp-acts">${visitHtml(s, "den-btn primary", `${esc(t("act.visit"))} ↗`)}` +
|
|
247
|
+
(running(s)
|
|
248
|
+
? `<button type="button" class="den-btn" data-svx="stop">${esc(t("svp.stop"))}</button>`
|
|
249
|
+
: `<button type="button" class="den-btn" data-svx="start">${esc(t("svp.start"))}</button>`) +
|
|
250
|
+
`<button type="button" class="den-btn" data-svx="restart"${S.busy.has(s.id) ? " disabled" : ""}>${esc(t("svp.restart"))}</button>` +
|
|
251
|
+
`<button type="button" class="den-btn" data-svx="logs">${esc(t("svp.logs"))}</button></div>` +
|
|
252
|
+
`<div class="den-svp-sec"><div class="den-svp-k">${esc(t("svp.tests"))}</div>` +
|
|
253
|
+
`<div class="den-svp-line">${testsHtml(s)}<button type="button" class="den-btn sm den-svp-sp" data-svx="tests"${testing ? " disabled" : ""}>${esc(t("svp.runTests"))}</button></div></div>` +
|
|
254
|
+
`<div class="den-svp-sec"><div class="den-svp-k">${esc(t("svp.agents"))}${ags.length ? ` · ${ags.length}` : ""}</div>` +
|
|
255
|
+
(ags.length
|
|
256
|
+
? ags
|
|
257
|
+
.map(
|
|
258
|
+
(a) =>
|
|
259
|
+
`<div class="den-svp-ag"><span class="den-dot ${agentTone(a)}" aria-hidden="true"></span>` +
|
|
260
|
+
`<span class="den-svp-nm" title="${esc(a.persona || "")}">${esc(agentName(a))}</span>` +
|
|
261
|
+
`<span class="den-svp-as">${a.waiting ? `<span class="den-wbdg">${esc(t("ag.waiting"))}</span>` : esc(a.state || "")}</span>` +
|
|
262
|
+
`<button type="button" class="den-btn sm" data-svx="ag" data-id="${esc(a.id)}" aria-label="${esc(t("svp.openAg", { s: agentName(a) }))}">${esc(t("svp.open"))}</button></div>`,
|
|
263
|
+
)
|
|
264
|
+
.join("")
|
|
265
|
+
: `<div class="den-svp-help">${esc(t("svp.noAgents"))}</div>`) +
|
|
266
|
+
`<div><button type="button" class="den-btn sm" data-svx="startag">+ ${esc(t("svp.startHere"))}</button></div></div>` +
|
|
267
|
+
`<div class="den-svp-foot"><button type="button" class="den-lnk" data-svx="services">${esc(t("svp.openSvc"))}</button>` +
|
|
268
|
+
`<button type="button" class="den-lnk" data-svx="graph">${esc(t("svp.openGraph"))}</button><span class="den-svp-grow"></span>` +
|
|
269
|
+
`<button type="button" class="den-lnk" data-svx="${pinned ? "unpin" : "pin"}">${esc(t(pinned ? "act.unpin" : "act.pin"))}</button></div>`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── open, place, keep live, close ─────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/** The rail row a pin opens from, when it is in the rail. */
|
|
276
|
+
function railRow(id) {
|
|
277
|
+
return document.querySelector(
|
|
278
|
+
`.den-rail [data-svpanel="${CSS.escape(String(id))}"]`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function place() {
|
|
283
|
+
const o = S.open;
|
|
284
|
+
if (!o) return;
|
|
285
|
+
const sheet = window.matchMedia?.(SHEET).matches;
|
|
286
|
+
o.el.classList.toggle("den-sheet", !!sheet);
|
|
287
|
+
if (sheet && !o.scrim) {
|
|
288
|
+
o.scrim = document.createElement("div");
|
|
289
|
+
o.scrim.className = "den-scrim den-svp-scrim";
|
|
290
|
+
o.scrim.addEventListener("click", () => closePanel(false));
|
|
291
|
+
document.body.append(o.scrim);
|
|
292
|
+
} else if (!sheet && o.scrim) {
|
|
293
|
+
o.scrim.remove();
|
|
294
|
+
o.scrim = null;
|
|
295
|
+
}
|
|
296
|
+
const top = window.denShell?.topLayer;
|
|
297
|
+
if (!top) return;
|
|
298
|
+
// The page drew its opener again (a Graph or Services re-render).
|
|
299
|
+
if (!o.opener.isConnected)
|
|
300
|
+
o.opener =
|
|
301
|
+
document.querySelector(
|
|
302
|
+
`[data-svpanel-open="${CSS.escape(String(o.id))}"]`,
|
|
303
|
+
) || o.opener;
|
|
304
|
+
const rail = document.querySelector(".den-rail");
|
|
305
|
+
const fromRail = o.fromRail && rail;
|
|
306
|
+
if (sheet) top.show(o.el, document.body, { sheet: true });
|
|
307
|
+
else if (fromRail)
|
|
308
|
+
// Beside the rail, level with its row (the mock: rail's right + 8, row's top − 6).
|
|
309
|
+
top.show(o.el, rail, {
|
|
310
|
+
side: "right",
|
|
311
|
+
gap: 8,
|
|
312
|
+
dy: -6,
|
|
313
|
+
row: () => railRow(o.id)?.closest(".den-pin-row") || null,
|
|
314
|
+
});
|
|
315
|
+
else
|
|
316
|
+
top.show(o.el, o.opener.isConnected ? o.opener : document.body, {
|
|
317
|
+
gap: 6,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Repaint the open panel from the feeds, keeping focus and scroll. */
|
|
322
|
+
function sync() {
|
|
323
|
+
publishUrls();
|
|
324
|
+
const o = S.open;
|
|
325
|
+
if (!o) return;
|
|
326
|
+
const a = document.activeElement;
|
|
327
|
+
const mine = a && o.el.contains(a) && a !== o.el;
|
|
328
|
+
const key = mine
|
|
329
|
+
? a.dataset.svx
|
|
330
|
+
? `[data-svx="${a.dataset.svx}"]${a.dataset.id ? `[data-id="${CSS.escape(a.dataset.id)}"]` : ""}`
|
|
331
|
+
: a.dataset.visit
|
|
332
|
+
? "[data-visit]"
|
|
333
|
+
: null
|
|
334
|
+
: null;
|
|
335
|
+
const html = panelHtml(serviceOf(o.id));
|
|
336
|
+
if (html !== o.html) {
|
|
337
|
+
const sc = o.el.scrollTop;
|
|
338
|
+
o.el.innerHTML = html;
|
|
339
|
+
o.html = html;
|
|
340
|
+
o.el.scrollTop = sc;
|
|
341
|
+
if (mine) {
|
|
342
|
+
const swap = {
|
|
343
|
+
'[data-svx="start"]': '[data-svx="stop"]',
|
|
344
|
+
'[data-svx="stop"]': '[data-svx="start"]',
|
|
345
|
+
};
|
|
346
|
+
const f =
|
|
347
|
+
key &&
|
|
348
|
+
(o.el.querySelector(key) || o.el.querySelector(swap[key] || "x"));
|
|
349
|
+
(f && !f.disabled ? f : o.el).focus();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
place();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function refresh() {
|
|
356
|
+
const get = (u, d) =>
|
|
357
|
+
fetch(u, { headers: { accept: "application/json" } })
|
|
358
|
+
.then((r) => (r.ok ? r.json() : d))
|
|
359
|
+
.catch(() => d);
|
|
360
|
+
const [last, daemons, sess] = await Promise.all([
|
|
361
|
+
get("/api/runs/last", null),
|
|
362
|
+
get("/api/service/daemons", []),
|
|
363
|
+
get("/api/sessions", null),
|
|
364
|
+
]);
|
|
365
|
+
S.lastRuns = last?.repos ?? S.lastRuns;
|
|
366
|
+
if (Array.isArray(daemons)) {
|
|
367
|
+
S.upSince = new Map();
|
|
368
|
+
for (const d of daemons) {
|
|
369
|
+
const at = Date.parse(d.startedAt);
|
|
370
|
+
if (!d.repoName || !Number.isFinite(at)) continue;
|
|
371
|
+
const cur = S.upSince.get(d.repoName);
|
|
372
|
+
if (cur === undefined || at < cur) S.upSince.set(d.repoName, at);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (Array.isArray(sess?.sessions)) S.sessions = sess.sessions;
|
|
376
|
+
sync();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Open the panel for a pin id (or a service's address) beside `opener`.
|
|
381
|
+
* The same panel whoever opens it; opening it again from the same opener
|
|
382
|
+
* closes it.
|
|
383
|
+
*/
|
|
384
|
+
export function openPanel(id, opener, opts = {}) {
|
|
385
|
+
if (!id || !opener) return;
|
|
386
|
+
// Again from the same opener (a rail row is drawn again often): closes.
|
|
387
|
+
if (
|
|
388
|
+
S.open?.id === id &&
|
|
389
|
+
(S.open.opener === opener || (opts.fromRail && S.open.fromRail))
|
|
390
|
+
) {
|
|
391
|
+
closePanel(false);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
closePanel(false);
|
|
395
|
+
const el = document.createElement("div");
|
|
396
|
+
el.className = "den-pop den-svp";
|
|
397
|
+
el.tabIndex = -1;
|
|
398
|
+
el.setAttribute("role", "dialog");
|
|
399
|
+
el.setAttribute("aria-label", t("svp.label", { s: id }));
|
|
400
|
+
el.dataset.svp = id;
|
|
401
|
+
document.body.append(el);
|
|
402
|
+
S.open = {
|
|
403
|
+
id,
|
|
404
|
+
el,
|
|
405
|
+
opener,
|
|
406
|
+
fromRail: !!opts.fromRail,
|
|
407
|
+
scrim: null,
|
|
408
|
+
html: "",
|
|
409
|
+
timer: setInterval(() => void refresh(), REFRESH_MS),
|
|
410
|
+
};
|
|
411
|
+
opener.setAttribute?.("aria-expanded", "true");
|
|
412
|
+
window.denShell?.setState?.({ svpOpen: id });
|
|
413
|
+
sync();
|
|
414
|
+
(el.querySelector(".den-svp-acts .den-btn:not([disabled])") || el).focus();
|
|
415
|
+
void refresh();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function closePanel(refocus) {
|
|
419
|
+
const o = S.open;
|
|
420
|
+
if (!o) return;
|
|
421
|
+
S.open = null;
|
|
422
|
+
clearInterval(o.timer);
|
|
423
|
+
window.denShell?.topLayer?.hide(o.el);
|
|
424
|
+
o.el.remove();
|
|
425
|
+
o.scrim?.remove();
|
|
426
|
+
window.denShell?.setState?.({ svpOpen: null });
|
|
427
|
+
o.opener.setAttribute?.("aria-expanded", "false");
|
|
428
|
+
if (!refocus) return;
|
|
429
|
+
const back = o.fromRail ? railRow(o.id) : o.opener;
|
|
430
|
+
(back && back.isConnected ? back : railRow(o.id))?.focus();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function panelOpen() {
|
|
434
|
+
return S.open ? S.open.id : null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── actions ───────────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
const env = () => S.deps.currentEnv?.() ?? "local";
|
|
440
|
+
const runs = () => window.denShell?.runs;
|
|
441
|
+
|
|
442
|
+
/** Visit: the service's URL in a new tab, or why not. */
|
|
443
|
+
export function visit(btn) {
|
|
444
|
+
if (btn.getAttribute("aria-disabled") === "true") {
|
|
445
|
+
window.denShell?.toast?.(t("svp.notRunning"));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const url = btn.dataset.url;
|
|
449
|
+
if (!url || !/^https?:\/\//i.test(url)) return;
|
|
450
|
+
window.open(url, "_blank", "noopener");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** A stream into a runs-dock tab: Start, Stop, Restart (exec), Logs, tests. */
|
|
454
|
+
function stream(url, key, label, opts, done) {
|
|
455
|
+
const r = runs();
|
|
456
|
+
const id = r?.start(key, label, { doneLine: false, ...opts }) ?? null;
|
|
457
|
+
const out = (text, cls) => (id ? r.append(id, text, cls) : undefined);
|
|
458
|
+
const es = new EventSource(url);
|
|
459
|
+
const sudo = sudoRun();
|
|
460
|
+
es.onmessage = (e) => {
|
|
461
|
+
let msg;
|
|
462
|
+
try {
|
|
463
|
+
msg = JSON.parse(e.data);
|
|
464
|
+
} catch {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
sudo?.see(msg);
|
|
468
|
+
if (msg.type === "cmd") out(msg.text, "cmd");
|
|
469
|
+
else if (msg.type === "line") out(msg.text, "");
|
|
470
|
+
else if (msg.type === "error") out(msg.text, "err");
|
|
471
|
+
// A detached start is up (the server's ready watch); its stream stays
|
|
472
|
+
// open for the log.
|
|
473
|
+
else if (msg.type === "ready") opts.ready?.();
|
|
474
|
+
else if (msg.type === "done") {
|
|
475
|
+
const ok = msg.exitCode === 0;
|
|
476
|
+
es.close();
|
|
477
|
+
if (opts.running === false) r?.set(id, { running: false });
|
|
478
|
+
else r?.finish(id, { ok });
|
|
479
|
+
done?.(ok);
|
|
480
|
+
if (opts.again) void sudo?.done(opts.again);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
es.onerror = () => {
|
|
484
|
+
out("Connection closed", "err");
|
|
485
|
+
es.close();
|
|
486
|
+
if (opts.running === false) r?.set(id, { running: false });
|
|
487
|
+
else r?.finish(id, { ok: false });
|
|
488
|
+
done?.(false);
|
|
489
|
+
};
|
|
490
|
+
return es;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function exec(s, cmd) {
|
|
494
|
+
const params = new URLSearchParams({
|
|
495
|
+
repo: s.repo,
|
|
496
|
+
service: s.service,
|
|
497
|
+
cmd,
|
|
498
|
+
env: env(),
|
|
499
|
+
});
|
|
500
|
+
if (s.workspaces[0]) params.set("workspace", s.workspaces[0]);
|
|
501
|
+
if (cmd !== "down") startBusy(s.id, cmd);
|
|
502
|
+
if (cmd === "down") window.denShell?.toast?.(t("svp.stopping", { s: s.id }));
|
|
503
|
+
sync();
|
|
504
|
+
const settled = () => {
|
|
505
|
+
endBusy(s.id);
|
|
506
|
+
S.deps.refreshHealth?.(env(), { force: true });
|
|
507
|
+
void refresh();
|
|
508
|
+
};
|
|
509
|
+
stream(
|
|
510
|
+
`/api/service/exec?${params}`,
|
|
511
|
+
`${s.repo}/${s.service}`,
|
|
512
|
+
`${cmd} ${s.repo}/${s.service}`,
|
|
513
|
+
{ again: () => exec(s, cmd), ready: settled },
|
|
514
|
+
settled,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function logs(s) {
|
|
519
|
+
stream(
|
|
520
|
+
logStreamUrl(s.repo, s.service, env()),
|
|
521
|
+
`${s.repo}/${s.service}`,
|
|
522
|
+
`logs ${s.repo}/${s.service}`,
|
|
523
|
+
{ running: false, open: true },
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function runTests(s) {
|
|
528
|
+
const params = new URLSearchParams({
|
|
529
|
+
pillar: "suites",
|
|
530
|
+
module: s.repo,
|
|
531
|
+
env: env(),
|
|
532
|
+
});
|
|
533
|
+
S.testing.add(s.id);
|
|
534
|
+
sync();
|
|
535
|
+
stream(
|
|
536
|
+
`/api/run?${params}`,
|
|
537
|
+
`tests ${s.repo}`,
|
|
538
|
+
`tests ${s.repo}`,
|
|
539
|
+
{},
|
|
540
|
+
(ok) => {
|
|
541
|
+
S.testing.delete(s.id);
|
|
542
|
+
window.denShell?.toast?.(
|
|
543
|
+
t(ok ? "svp.testsDone" : "svp.testsFailed", { s: s.repo }),
|
|
544
|
+
);
|
|
545
|
+
void refresh();
|
|
546
|
+
},
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function go(href) {
|
|
551
|
+
closePanel(false);
|
|
552
|
+
window.location.assign(href);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function act(k, btn) {
|
|
556
|
+
const o = S.open;
|
|
557
|
+
if (!o) return;
|
|
558
|
+
const s = serviceOf(o.id);
|
|
559
|
+
if (k === "close") closePanel(true);
|
|
560
|
+
else if (k === "start") exec(s, "up");
|
|
561
|
+
else if (k === "stop") exec(s, "down");
|
|
562
|
+
else if (k === "restart") exec(s, "restart");
|
|
563
|
+
else if (k === "logs") logs(s);
|
|
564
|
+
else if (k === "tests") runTests(s);
|
|
565
|
+
else if (k === "ag") go(`/agents?open=${encodeURIComponent(btn.dataset.id)}`);
|
|
566
|
+
else if (k === "startag") {
|
|
567
|
+
const q = new URLSearchParams({ start: "1" });
|
|
568
|
+
const ws = s.workspaces[0];
|
|
569
|
+
if (ws) q.set("ws", ws);
|
|
570
|
+
if (s.repoPath) q.set("folder", s.repoPath);
|
|
571
|
+
go(`/agents?${q}`);
|
|
572
|
+
} else if (k === "services")
|
|
573
|
+
go(`/services#repo-${encodeURIComponent(s.repo)}`);
|
|
574
|
+
else if (k === "graph") go(`/graph#focus=${encodeURIComponent(s.id)}`);
|
|
575
|
+
else if (k === "unpin") {
|
|
576
|
+
const fromRail = o.fromRail;
|
|
577
|
+
closePanel(false);
|
|
578
|
+
window.denShell?.unpin?.(s.id);
|
|
579
|
+
if (fromRail) document.querySelector(".den-rail .den-ni")?.focus();
|
|
580
|
+
} else if (k === "pin") {
|
|
581
|
+
window.denShell?.pin?.(s.id);
|
|
582
|
+
sync();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ── wiring ────────────────────────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Every pin's URL, and whether its Visit is off (down, or starting), for
|
|
590
|
+
* the rail's Visit ↗: the live state the panel shows, so the ↗ greys out
|
|
591
|
+
* on the same health update as the panel's dot (whatever the View).
|
|
592
|
+
*/
|
|
593
|
+
function publishUrls() {
|
|
594
|
+
const sh = window.denShell;
|
|
595
|
+
if (!sh?.setState) return;
|
|
596
|
+
const urls = {};
|
|
597
|
+
const off = {};
|
|
598
|
+
for (const id of sh.getState?.().pins ?? []) {
|
|
599
|
+
const s = serviceOf(id);
|
|
600
|
+
if (!s.url) continue;
|
|
601
|
+
urls[id] = s.url;
|
|
602
|
+
off[id] = !visitable(s);
|
|
603
|
+
}
|
|
604
|
+
const st = sh.getState?.() ?? {};
|
|
605
|
+
if (
|
|
606
|
+
JSON.stringify(st.pinUrls ?? {}) !== JSON.stringify(urls) ||
|
|
607
|
+
JSON.stringify(st.pinOff ?? {}) !== JSON.stringify(off)
|
|
608
|
+
)
|
|
609
|
+
sh.setState({ pinUrls: urls, pinOff: off });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
let inited = false;
|
|
613
|
+
/** Once a page: header.js passes what it owns (env, health refresh, sudo). */
|
|
614
|
+
export function initServicePanel(deps = {}) {
|
|
615
|
+
if (inited || typeof document === "undefined") return;
|
|
616
|
+
inited = true;
|
|
617
|
+
S.deps = deps;
|
|
618
|
+
document.addEventListener("__DEN_CLI__:health-update", (e) => {
|
|
619
|
+
for (const r of Array.isArray(e.detail) ? e.detail : [])
|
|
620
|
+
if (r?.repo && r.service) S.results.set(`${r.repo}/${r.service}`, r);
|
|
621
|
+
settleBusy();
|
|
622
|
+
sync();
|
|
623
|
+
});
|
|
624
|
+
window.addEventListener("den:pins", () => sync());
|
|
625
|
+
document.addEventListener(
|
|
626
|
+
"click",
|
|
627
|
+
(e) => {
|
|
628
|
+
const o = S.open;
|
|
629
|
+
const tgt = e.target;
|
|
630
|
+
// The panel's Visit here; the rail's ↗ is shell.js's (the same visit()).
|
|
631
|
+
const v = tgt.closest?.("[data-visit]");
|
|
632
|
+
if (v) {
|
|
633
|
+
if (o?.el.contains(v)) {
|
|
634
|
+
e.preventDefault();
|
|
635
|
+
visit(v);
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const opener = tgt.closest?.("[data-svpanel-open]");
|
|
640
|
+
if (opener) {
|
|
641
|
+
e.preventDefault();
|
|
642
|
+
e.stopPropagation();
|
|
643
|
+
openPanel(opener.dataset.svpanelOpen, opener);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (!o) return;
|
|
647
|
+
const b = tgt.closest?.("[data-svx]");
|
|
648
|
+
if (b && o.el.contains(b)) {
|
|
649
|
+
if (!b.disabled) act(b.dataset.svx, b);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (o.el.contains(tgt) || o.opener.contains?.(tgt)) return;
|
|
653
|
+
if (o.fromRail && tgt.closest?.("[data-svpanel]")) return;
|
|
654
|
+
closePanel(false);
|
|
655
|
+
},
|
|
656
|
+
true,
|
|
657
|
+
);
|
|
658
|
+
document.addEventListener("keydown", (e) => {
|
|
659
|
+
if (e.key === "Escape" && S.open && !e.defaultPrevented) {
|
|
660
|
+
e.preventDefault();
|
|
661
|
+
closePanel(true);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
window.addEventListener("resize", () => place());
|
|
665
|
+
const sh = window.denShell;
|
|
666
|
+
if (sh?.setServicePanel)
|
|
667
|
+
sh.setServicePanel({ open: openPanel, close: closePanel, visit });
|
|
668
|
+
else
|
|
669
|
+
(window.denShellQueue = window.denShellQueue || []).push((x) =>
|
|
670
|
+
x.setServicePanel?.({ open: openPanel, close: closePanel, visit }),
|
|
671
|
+
);
|
|
672
|
+
}
|
|
@@ -48,6 +48,10 @@ export const UI_MODULES: readonly string[] = [
|
|
|
48
48
|
"/i18n/es.js",
|
|
49
49
|
// 0.4.17: one org and workspace rule for Environments and Releases.
|
|
50
50
|
"/view-scope.js",
|
|
51
|
+
// 0.4.18: the service panel (rail pins, Graph ⋯, Services names).
|
|
52
|
+
"/svc-panel.js",
|
|
53
|
+
// 0.4.18: where a service's live log comes from (Logs page and panel).
|
|
54
|
+
"/log-source.js",
|
|
51
55
|
];
|
|
52
56
|
|
|
53
57
|
/** URL path → file inside node_modules. */
|