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