@monster-orange/den 0.2.8 → 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/CHANGELOG.md +1731 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +730 -6
- package/make/den.mk +19 -4
- package/make/journeys.mk +2 -1
- package/make/local_dev.mk +6 -13
- package/make/secrets-1password.mk +18 -0
- package/make/test.mk +1 -1
- package/package.json +12 -2
- package/schemas/den.plugin.v1.json +156 -0
- package/schemas/den.theme.v1.json +269 -0
- package/scripts/auth-configure.ts +16 -8
- package/scripts/heal-config.ts +0 -5
- package/scripts/setup-1password.ts +41 -19
- package/scripts/setup-secrets.ts +67 -101
- package/src/pillars/api/global-setup.ts +1 -1
- package/src/pillars/journeys/sandbox.ts +23 -18
- package/src/pillars/journeys/tools/browser-session.ts +2 -2
- package/src/scripts/setup-default-workspace.ts +2 -2
- package/src/shared/agents/default-pack/be-dev.md +20 -0
- package/src/shared/agents/default-pack/devops.md +14 -0
- package/src/shared/agents/default-pack/fe-dev.md +20 -0
- package/src/shared/agents/default-pack/orchestrator.md +16 -0
- package/src/shared/agents/default-pack/planner.md +17 -0
- package/src/shared/agents/default-pack/reviewer.md +14 -0
- package/src/shared/agents/default-pack/ux-designer.md +15 -0
- package/src/shared/agents/den-claude.ts +173 -0
- package/src/shared/agents/generic.ts +52 -0
- package/src/shared/agents/personas.ts +218 -0
- package/src/shared/agents/records.ts +118 -0
- package/src/shared/agents/runtime.ts +192 -0
- package/src/shared/agents/runtimes.ts +47 -0
- package/src/shared/auth/headless-login.ts +1 -1
- package/src/shared/auth/sudo-vault.ts +93 -31
- package/src/shared/auth/token-providers/index.ts +7 -3
- package/src/shared/brand.ts +7 -0
- package/src/shared/config-error.ts +33 -3
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +257 -5
- package/src/shared/edge/config.ts +147 -21
- package/src/shared/edge/exec.ts +31 -0
- package/src/shared/edge/included.ts +169 -0
- package/src/shared/edge/render.ts +69 -14
- package/src/shared/edge/runtime.ts +128 -121
- package/src/shared/edge/status.ts +68 -8
- package/src/shared/edge/tls.ts +328 -0
- package/src/shared/edge/types.ts +25 -9
- package/src/shared/engine.ts +23 -0
- package/src/shared/environment-manager/default-workspace.ts +16 -1
- package/src/shared/environment-manager/health.ts +73 -38
- package/src/shared/environment-manager/scope.ts +168 -0
- package/src/shared/environment-manager/service-runner.ts +789 -43
- package/src/shared/environment-manager/up-targets.ts +132 -0
- package/src/shared/environment-manager/workspaces.ts +19 -0
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +78 -59
- package/src/shared/org.ts +53 -5
- package/src/shared/plugins/manifest.ts +406 -0
- package/src/shared/plugins/store.ts +358 -0
- package/src/shared/process/legacy-adopt.ts +285 -0
- package/src/shared/process/process-group.ts +202 -0
- package/src/shared/process/spawn-streaming.ts +7 -0
- package/src/shared/secret-env.ts +70 -0
- package/src/shared/secrets/doppler.ts +1 -1
- package/src/shared/secrets/index.ts +279 -50
- package/src/shared/secrets/none.ts +56 -0
- package/src/shared/secrets/strategies.ts +202 -0
- package/src/shared/secrets/types.ts +1 -1
- package/src/shared/sessions/client.ts +277 -0
- package/src/shared/sessions/den-pty.ts +97 -0
- package/src/shared/sessions/env.ts +79 -0
- package/src/shared/sessions/host.ts +667 -0
- package/src/shared/sessions/names.ts +85 -0
- package/src/shared/sessions/paths.ts +27 -0
- package/src/shared/sessions/protocol.ts +72 -0
- package/src/shared/sessions/pty.ts +141 -0
- package/src/shared/sessions/ring-buffer.ts +160 -0
- package/src/shared/sessions/types.ts +81 -0
- package/src/shared/smoke/git-remote.ts +40 -0
- package/src/shared/stacks/manifest-commands.ts +1 -1
- package/src/shared/stacks/namespace.ts +178 -0
- package/src/shared/stacks/owners.ts +303 -0
- package/src/shared/stacks/render.ts +8 -3
- package/src/shared/stacks/runtime.ts +100 -16
- package/src/shared/stacks/status.ts +63 -12
- package/src/shared/strategies/registry.ts +89 -0
- package/src/shared/theme/contract.ts +538 -0
- package/src/shared/types/index.ts +12 -3
- package/src/ui/cli/agents-command.ts +812 -0
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +260 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/env-probe.ts +266 -0
- package/src/ui/cli/flag-parser.ts +18 -5
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +580 -60
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/sessions-command.ts +718 -0
- package/src/ui/cli/stack-command.ts +63 -2
- package/src/ui/cli/test-runner.ts +11 -9
- package/src/ui/cli/theme-command.ts +136 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +145 -20
- package/src/ui/mcp/server.ts +27 -2
- package/src/ui/mcp/tools.ts +94 -12
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/audit.ts +35 -0
- package/src/ui/server/daemon-reaper.ts +29 -1
- package/src/ui/server/engine-stamp.ts +83 -0
- package/src/ui/server/github-service.ts +23 -44
- package/src/ui/server/graph-page.ts +20 -0
- package/src/ui/server/graph-prefs.ts +44 -1
- package/src/ui/server/http-errors.ts +293 -0
- package/src/ui/server/http-helpers.ts +22 -3
- package/src/ui/server/index.ts +539 -51
- package/src/ui/server/public/appearance.js +531 -0
- package/src/ui/server/public/edge-page.js +248 -0
- package/src/ui/server/public/edge.html +251 -0
- package/src/ui/server/public/environments.html +65 -83
- package/src/ui/server/public/error-page.js +247 -0
- package/src/ui/server/public/error.html +260 -0
- package/src/ui/server/public/graph-model.js +332 -14
- package/src/ui/server/public/graph.css +141 -47
- package/src/ui/server/public/graph.js +223 -44
- package/src/ui/server/public/header.js +396 -600
- package/src/ui/server/public/health-fit.js +248 -0
- package/src/ui/server/public/health-rollup.js +128 -0
- package/src/ui/server/public/i18n/es.js +287 -0
- package/src/ui/server/public/i18n/ja.js +276 -0
- package/src/ui/server/public/index.html +139 -438
- package/src/ui/server/public/local-dev.html +3468 -4044
- package/src/ui/server/public/logs.html +71 -208
- package/src/ui/server/public/panel.js +14 -2
- package/src/ui/server/public/release.html +175 -389
- package/src/ui/server/public/services.html +596 -770
- package/src/ui/server/public/settings-page.js +759 -0
- package/src/ui/server/public/settings-registry.js +521 -0
- package/src/ui/server/public/settings.html +590 -0
- package/src/ui/server/public/shell.js +3869 -0
- package/src/ui/server/public/topbar.js +1413 -0
- package/src/ui/server/public/version-label.js +12 -0
- package/src/ui/server/public/workspace-picker.js +74 -34
- package/src/ui/server/readonly-routes.ts +37 -3
- package/src/ui/server/services-list.ts +94 -17
- package/src/ui/server/settings-distribution.ts +74 -0
- package/src/ui/server/shell-prefs.ts +192 -0
- package/src/ui/server/theme-routes.ts +292 -0
- package/src/ui/server/theme-runtime.ts +407 -0
- package/src/ui/server/ui-assets.ts +16 -1
- package/src/ui/server/ui-language.ts +50 -0
- package/src/ui/server/workspace-routes.ts +48 -18
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- package/themes/den/mark.svg +6 -0
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The top bar's chips and overlays (0.3.0-dev.20): shell.js draws the bar,
|
|
3
|
+
* this module drives it. header.js imports it on every page.
|
|
4
|
+
*
|
|
5
|
+
* ⌘K search (R5) #den-search, ⌘K or Ctrl+K: the command palette (R17)
|
|
6
|
+
* status (R6) #den-status: "All N healthy" / "ok/N healthy · k down",
|
|
7
|
+
* from the same rollup as the rail's Services badge; it
|
|
8
|
+
* opens the status drawer (R15)
|
|
9
|
+
* Runs (R7) #den-runs: a pulsing dot and "N running" while a run
|
|
10
|
+
* streams. It shows and hides the runs dock (R16,
|
|
11
|
+
* shell.js's denShell.runs, dev.30), where every page's
|
|
12
|
+
* output lands; an empty dock links to the page that ran
|
|
13
|
+
* the last run
|
|
14
|
+
* lock (R8) #den-lock: 🔒 Locked / 🔓 Unlocked, the admin-password
|
|
15
|
+
* form in a popover (Local Dev's sudo banner, moved)
|
|
16
|
+
*
|
|
17
|
+
* Exports for pages (through header.js): requireUnlock() (Local Dev's lock
|
|
18
|
+
* rule: true when unlocked, else the lock popover opens), isUnlocked(),
|
|
19
|
+
* onLockChange(fn), setServiceRunner(fn) (the page that runs Start and
|
|
20
|
+
* Restart itself), takePendingAction().
|
|
21
|
+
*
|
|
22
|
+
* The pure parts (statusSummary, statusChip, problemCards, drawerGroups,
|
|
23
|
+
* checkLine, rankCommands, runOf) take plain data, so tests need no
|
|
24
|
+
* browser. Opening the palette, the drawer or a popover only reads: no
|
|
25
|
+
* route, container, daemon or server process changes. Start and Restart
|
|
26
|
+
* are the explicit actions, through the page's own runner.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// ── pure parts ──────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The status chip's numbers from buildRow's row (health-fit.js), or null
|
|
33
|
+
* before the first complete cycle. The rules are the rail badge's
|
|
34
|
+
* (shellHealth): a service counts as down only once its repo's checks have
|
|
35
|
+
* all answered (`down` is the rollup's), a pending check is never down, an
|
|
36
|
+
* unchecked one is never a problem. `n` is the checked services.
|
|
37
|
+
*/
|
|
38
|
+
export function statusSummary(row) {
|
|
39
|
+
if (!row || !Array.isArray(row.flat))
|
|
40
|
+
return {
|
|
41
|
+
state: "checking",
|
|
42
|
+
n: 0,
|
|
43
|
+
ok: 0,
|
|
44
|
+
down: 0,
|
|
45
|
+
unchecked: 0,
|
|
46
|
+
pending: 0,
|
|
47
|
+
awaiting: 0,
|
|
48
|
+
};
|
|
49
|
+
let n = 0;
|
|
50
|
+
let ok = 0;
|
|
51
|
+
let down = 0;
|
|
52
|
+
let unchecked = 0;
|
|
53
|
+
let pending = 0;
|
|
54
|
+
let awaiting = 0;
|
|
55
|
+
for (const r of row.flat) {
|
|
56
|
+
if (r.rollup.awaiting) {
|
|
57
|
+
awaiting++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
down += r.rollup.down.length;
|
|
61
|
+
for (const i of r.items) {
|
|
62
|
+
if (i.awaiting) continue;
|
|
63
|
+
if (i.ok === true) {
|
|
64
|
+
n++;
|
|
65
|
+
ok++;
|
|
66
|
+
} else if (i.ok === false) n++;
|
|
67
|
+
else if (i.pending) pending++;
|
|
68
|
+
else unchecked++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const state = !row.flat.length
|
|
72
|
+
? "none"
|
|
73
|
+
: n === 0
|
|
74
|
+
? awaiting || pending
|
|
75
|
+
? "checking"
|
|
76
|
+
: "unchecked"
|
|
77
|
+
: down > 0
|
|
78
|
+
? "bad"
|
|
79
|
+
: "ok";
|
|
80
|
+
return { state, n, ok, down, unchecked, pending, awaiting };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What the chip shows for a summary: its dot, `bad` (the border tint),
|
|
85
|
+
* `lead` (the part that hides below 860px), `text` and a title that says
|
|
86
|
+
* what was and wasn't checked. `t` is the shell's message lookup.
|
|
87
|
+
*/
|
|
88
|
+
export function statusChip(sum, t = tr) {
|
|
89
|
+
const extra = [
|
|
90
|
+
sum.unchecked ? t("status.unchecked", { n: sum.unchecked }) : "",
|
|
91
|
+
sum.pending ? t("status.pending", { n: sum.pending }) : "",
|
|
92
|
+
sum.awaiting ? t("status.awaiting", { n: sum.awaiting }) : "",
|
|
93
|
+
].filter(Boolean);
|
|
94
|
+
const title = [t("status.title", { n: sum.n }), ...extra].join(" · ");
|
|
95
|
+
if (sum.state === "checking")
|
|
96
|
+
return {
|
|
97
|
+
dot: "off",
|
|
98
|
+
bad: false,
|
|
99
|
+
lead: "",
|
|
100
|
+
text: t("status.checking"),
|
|
101
|
+
title: t("status.checking"),
|
|
102
|
+
busy: true,
|
|
103
|
+
};
|
|
104
|
+
if (sum.state === "none")
|
|
105
|
+
return {
|
|
106
|
+
dot: "off",
|
|
107
|
+
bad: false,
|
|
108
|
+
lead: "",
|
|
109
|
+
text: t("status.none"),
|
|
110
|
+
title: t("status.none"),
|
|
111
|
+
busy: false,
|
|
112
|
+
};
|
|
113
|
+
if (sum.state === "unchecked")
|
|
114
|
+
return {
|
|
115
|
+
dot: "off",
|
|
116
|
+
bad: false,
|
|
117
|
+
lead: "",
|
|
118
|
+
text: t("status.unchecked", { n: sum.unchecked }),
|
|
119
|
+
title,
|
|
120
|
+
busy: false,
|
|
121
|
+
};
|
|
122
|
+
if (sum.state === "bad")
|
|
123
|
+
return {
|
|
124
|
+
dot: "bad",
|
|
125
|
+
bad: true,
|
|
126
|
+
lead: `${t("status.some", { ok: sum.ok, n: sum.n })} · `,
|
|
127
|
+
text: t("status.down", { n: sum.down }),
|
|
128
|
+
title,
|
|
129
|
+
busy: false,
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
dot: "",
|
|
133
|
+
bad: false,
|
|
134
|
+
lead: "",
|
|
135
|
+
text: t("status.all", { n: sum.n }),
|
|
136
|
+
title,
|
|
137
|
+
busy: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The shell's catalog (shell.js MESSAGES, R19) does every lookup in a page.
|
|
143
|
+
* These are the en strings of this module's pure exports, for where there
|
|
144
|
+
* is no shell (a unit test, a script): the same text as shell.js's en
|
|
145
|
+
* (i18n.test.ts holds them equal).
|
|
146
|
+
*/
|
|
147
|
+
export const EN_FALLBACK = {
|
|
148
|
+
"status.checking": "Checking…",
|
|
149
|
+
"status.none": "No services",
|
|
150
|
+
"status.unchecked": "{n} not checked",
|
|
151
|
+
"status.all": "All {n} healthy",
|
|
152
|
+
"status.some": "{ok}/{n} healthy",
|
|
153
|
+
"status.down": "{n} down",
|
|
154
|
+
"status.title": "{n} checked",
|
|
155
|
+
"status.pending": "{n} pending",
|
|
156
|
+
"status.awaiting": "{n} still checking",
|
|
157
|
+
"status.unknown": "Status unknown",
|
|
158
|
+
"status.unknownTip":
|
|
159
|
+
"Den's server isn't responding, so service health is unknown",
|
|
160
|
+
"chk.awaiting": "still checking",
|
|
161
|
+
"chk.pending": "{what} — pending",
|
|
162
|
+
"chk.check": "check",
|
|
163
|
+
"chk.unchecked": "not checked",
|
|
164
|
+
"chk.healthy": "healthy",
|
|
165
|
+
"chk.down": "down",
|
|
166
|
+
"chk.pendingWord": "pending",
|
|
167
|
+
"chk.checking": "checking…",
|
|
168
|
+
ms: "{n} ms",
|
|
169
|
+
"fix.failedAt": "{line} · failed at {at}",
|
|
170
|
+
"fix.start": "Start",
|
|
171
|
+
"fix.startX": "Start {s}",
|
|
172
|
+
"fix.logs": "Logs",
|
|
173
|
+
"fix.logsX": "Logs of {s}",
|
|
174
|
+
"row.name": "{id}, {state}",
|
|
175
|
+
"row.nameMs": "{id}, {state}, {ms}",
|
|
176
|
+
};
|
|
177
|
+
/** t: the shell's, else the en fallback. */
|
|
178
|
+
function tr(key, vars) {
|
|
179
|
+
const s = typeof window !== "undefined" ? window.denShell : null;
|
|
180
|
+
if (s?.t) return s.t(key, vars);
|
|
181
|
+
return String(EN_FALLBACK[key] ?? key).replace(/\{(\w+)\}/g, (m, k) =>
|
|
182
|
+
vars && vars[k] != null ? String(vars[k]) : m,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* What a check said, for a tooltip or a card: "http://shop/healthz → 200",
|
|
188
|
+
* "http://shop/healthz — connect ECONNREFUSED", "pending", "not checked".
|
|
189
|
+
*/
|
|
190
|
+
export function checkLine(i) {
|
|
191
|
+
const what = i.url ?? i.check ?? null;
|
|
192
|
+
if (i.awaiting) return tr("chk.awaiting");
|
|
193
|
+
if (i.ok == null)
|
|
194
|
+
return i.pending
|
|
195
|
+
? tr("chk.pending", { what: what ?? tr("chk.check") })
|
|
196
|
+
: tr("chk.unchecked");
|
|
197
|
+
if (!what) return i.ok ? tr("chk.healthy") : (i.error ?? tr("chk.down"));
|
|
198
|
+
if (i.error) return `${what} — ${i.error}`;
|
|
199
|
+
return i.status != null ? `${what} → ${i.status}` : what;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The drawer's "Needs attention": one card per down service, problem
|
|
204
|
+
* repos first (row.flat's order), only repos whose checks have all
|
|
205
|
+
* answered.
|
|
206
|
+
*/
|
|
207
|
+
export function problemCards(row) {
|
|
208
|
+
if (!row || !row.flat) return [];
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const r of row.flat) {
|
|
211
|
+
if (r.rollup.awaiting || !r.rollup.down.length) continue;
|
|
212
|
+
for (const i of r.items)
|
|
213
|
+
if (i.ok === false && !i.awaiting)
|
|
214
|
+
out.push({
|
|
215
|
+
id: `${r.repo}/${i.service}`,
|
|
216
|
+
repo: r.repo,
|
|
217
|
+
service: i.service,
|
|
218
|
+
entry: i.service === r.rollup.entry,
|
|
219
|
+
line: checkLine(i),
|
|
220
|
+
workspaces: i.workspaces ?? [],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* What a problem card says and offers (R15), the one source for the status
|
|
228
|
+
* drawer and the Workspaces table's problem rows (R24d): its text (what the
|
|
229
|
+
* check said, when it failed) and its fix buttons, each with an accessible
|
|
230
|
+
* name that names the service. `at` is the check's time, or "".
|
|
231
|
+
*/
|
|
232
|
+
export function problemFix(c, at = "") {
|
|
233
|
+
return {
|
|
234
|
+
text: at ? tr("fix.failedAt", { line: c.line, at }) : c.line,
|
|
235
|
+
actions: [
|
|
236
|
+
{
|
|
237
|
+
act: "start",
|
|
238
|
+
label: tr("fix.start"),
|
|
239
|
+
name: tr("fix.startX", { s: c.id }),
|
|
240
|
+
primary: true,
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
act: "logs",
|
|
244
|
+
label: tr("fix.logs"),
|
|
245
|
+
name: tr("fix.logsX", { s: c.id }),
|
|
246
|
+
primary: false,
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A service's state in the drawer: ok, down, pending, unchecked or awaiting. */
|
|
253
|
+
function serviceState(r, i) {
|
|
254
|
+
if (i.awaiting || r.rollup.awaiting) return "awaiting";
|
|
255
|
+
if (i.ok === true) return "ok";
|
|
256
|
+
if (i.ok === false) return "down";
|
|
257
|
+
return i.pending ? "pending" : "unchecked";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The drawer's "Everything": one group per application box (or per repo
|
|
262
|
+
* outside one), each "ok/N" over its checked services and every service
|
|
263
|
+
* with its state, ms and what its check said. `groups` is header.js's
|
|
264
|
+
* groupReposByApplication output (`[{ app, repos: [name] }]`).
|
|
265
|
+
*/
|
|
266
|
+
export function drawerGroups(row, groups) {
|
|
267
|
+
if (!row || !row.flat) return [];
|
|
268
|
+
const byRepo = new Map(row.flat.map((r) => [r.repo, r]));
|
|
269
|
+
const units = [];
|
|
270
|
+
for (const g of groups && groups.length
|
|
271
|
+
? groups
|
|
272
|
+
: [{ app: null, repos: row.flat.map((r) => r.repo) }]) {
|
|
273
|
+
if (g.app) units.push({ title: g.app.title || g.app.name, repos: g.repos });
|
|
274
|
+
else for (const repo of g.repos) units.push({ title: repo, repos: [repo] });
|
|
275
|
+
}
|
|
276
|
+
return units
|
|
277
|
+
.map((u) => {
|
|
278
|
+
const services = [];
|
|
279
|
+
for (const repo of u.repos) {
|
|
280
|
+
const r = byRepo.get(repo);
|
|
281
|
+
if (!r) continue;
|
|
282
|
+
for (const i of r.items) {
|
|
283
|
+
if (i.awaiting) continue;
|
|
284
|
+
services.push({
|
|
285
|
+
id: `${r.repo}/${i.service}`,
|
|
286
|
+
repo: r.repo,
|
|
287
|
+
service: i.service,
|
|
288
|
+
state: serviceState(r, i),
|
|
289
|
+
ms: typeof i.ms === "number" ? i.ms : null,
|
|
290
|
+
line: checkLine(i),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (r.rollup.awaiting && !r.items.some((i) => !i.awaiting))
|
|
294
|
+
services.push({
|
|
295
|
+
id: r.repo,
|
|
296
|
+
repo: r.repo,
|
|
297
|
+
service: null,
|
|
298
|
+
state: "awaiting",
|
|
299
|
+
ms: null,
|
|
300
|
+
line: "still checking",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const checked = services.filter(
|
|
304
|
+
(s) => s.state === "ok" || s.state === "down",
|
|
305
|
+
).length;
|
|
306
|
+
const ok = services.filter((s) => s.state === "ok").length;
|
|
307
|
+
return { title: u.title, ok, checked, services };
|
|
308
|
+
})
|
|
309
|
+
.filter((u) => u.services.length);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The palette's ranking: every word of the query must match a row's label,
|
|
314
|
+
* keywords or kind; a label that starts with it ranks first, then a word
|
|
315
|
+
* in the label, then anywhere in it, then keywords and kind, then a fuzzy
|
|
316
|
+
* match (the letters in order). Ties keep the list's order. An empty
|
|
317
|
+
* query lists the rows not marked `hideEmpty`, up to `emptyLimit`.
|
|
318
|
+
*/
|
|
319
|
+
export function rankCommands(items, query, opts = {}) {
|
|
320
|
+
const { limit = 40, emptyLimit = 14 } = opts;
|
|
321
|
+
const q = String(query ?? "")
|
|
322
|
+
.trim()
|
|
323
|
+
.toLowerCase();
|
|
324
|
+
if (!q)
|
|
325
|
+
return items
|
|
326
|
+
.filter((x) => !x.hideEmpty)
|
|
327
|
+
.slice(0, emptyLimit)
|
|
328
|
+
.map((x) => ({ ...x, score: 0 }));
|
|
329
|
+
const words = q.split(/\s+/).filter(Boolean);
|
|
330
|
+
const scored = [];
|
|
331
|
+
items.forEach((x, idx) => {
|
|
332
|
+
const label = String(x.label ?? "").toLowerCase();
|
|
333
|
+
const keys = (x.keywords ?? []).map((k) => String(k).toLowerCase());
|
|
334
|
+
const kind = String(x.kindLabel ?? x.kind ?? "").toLowerCase();
|
|
335
|
+
let total = 0;
|
|
336
|
+
for (const w of words) {
|
|
337
|
+
const s = wordScore(w, label, keys, kind);
|
|
338
|
+
if (!s) return;
|
|
339
|
+
total += s;
|
|
340
|
+
}
|
|
341
|
+
scored.push({ ...x, score: total, idx });
|
|
342
|
+
});
|
|
343
|
+
scored.sort((a, b) => b.score - a.score || a.idx - b.idx);
|
|
344
|
+
return scored.slice(0, limit).map(({ idx, ...x }) => x);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function wordScore(w, label, keys, kind) {
|
|
348
|
+
if (label.startsWith(w)) return 100;
|
|
349
|
+
if (new RegExp(`(^|[\\s/:._-])${escRe(w)}`).test(label)) return 80;
|
|
350
|
+
if (label.includes(w)) return 60;
|
|
351
|
+
if (keys.some((k) => k.startsWith(w))) return 45;
|
|
352
|
+
if (keys.some((k) => k.includes(w))) return 40;
|
|
353
|
+
if (kind.startsWith(w)) return 30;
|
|
354
|
+
const f = fuzzy(w, label);
|
|
355
|
+
return f ? 10 + f : 0;
|
|
356
|
+
}
|
|
357
|
+
function escRe(s) {
|
|
358
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
359
|
+
}
|
|
360
|
+
/** The letters of `w` in order in `s`: 1..9, tighter is higher; 0 if not. */
|
|
361
|
+
function fuzzy(w, s) {
|
|
362
|
+
let at = -1;
|
|
363
|
+
let first = -1;
|
|
364
|
+
for (const ch of w) {
|
|
365
|
+
at = s.indexOf(ch, at + 1);
|
|
366
|
+
if (at < 0) return 0;
|
|
367
|
+
if (first < 0) first = at;
|
|
368
|
+
}
|
|
369
|
+
const span = at - first + 1;
|
|
370
|
+
return Math.max(1, 9 - Math.floor((span - w.length) / 2));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The run endpoints: a stream to one of these is a run (R7). */
|
|
374
|
+
const RUN_PATHS = [
|
|
375
|
+
"/api/service/exec",
|
|
376
|
+
"/api/workspace/exec",
|
|
377
|
+
"/api/workspace/sync",
|
|
378
|
+
"/api/run",
|
|
379
|
+
"/api/release/deploy",
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* A run's label from its stream URL ("restart shop/web", "sync billing",
|
|
384
|
+
* "tests", "deploy"), or null when the URL is not a run (a log tail, the
|
|
385
|
+
* health stream). `base` resolves a relative URL.
|
|
386
|
+
*/
|
|
387
|
+
export function runOf(url, base = "http://localhost/") {
|
|
388
|
+
let u;
|
|
389
|
+
try {
|
|
390
|
+
u = new URL(String(url), base);
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
if (!RUN_PATHS.includes(u.pathname)) return null;
|
|
395
|
+
const q = u.searchParams;
|
|
396
|
+
if (u.pathname === "/api/service/exec")
|
|
397
|
+
return `${q.get("cmd") ?? "run"} ${q.get("repo") ?? "?"}/${q.get("service") ?? "?"}`;
|
|
398
|
+
if (u.pathname === "/api/workspace/exec") {
|
|
399
|
+
const what =
|
|
400
|
+
q.get("addresses") || q.get("seed") || q.get("workspace") || "workspace";
|
|
401
|
+
return `${q.get("cmd") ?? "run"} ${what}`;
|
|
402
|
+
}
|
|
403
|
+
if (u.pathname === "/api/workspace/sync")
|
|
404
|
+
return `sync ${q.get("repo") ?? "workspace"}`;
|
|
405
|
+
if (u.pathname === "/api/run") return `tests ${q.get("suite") ?? ""}`.trim();
|
|
406
|
+
return "deploy";
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── the UI ──────────────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
const LS_LAST_RUN = "__DEN_CLI__-last-run";
|
|
412
|
+
const LS_LOCK = "__DEN_CLI__-lock";
|
|
413
|
+
const SS_PENDING = "__DEN_CLI__-pending-action";
|
|
414
|
+
const KIND = ["page", "service", "app", "action", "setting"];
|
|
415
|
+
const kindLabel = (k) =>
|
|
416
|
+
KIND.includes(k)
|
|
417
|
+
? t(`kind.${k}`)
|
|
418
|
+
: String(k).charAt(0).toUpperCase() + String(k).slice(1);
|
|
419
|
+
|
|
420
|
+
const sh = () => (typeof window !== "undefined" ? window.denShell : null);
|
|
421
|
+
/** The locale for dates and times: the browser's for English, else the page's. */
|
|
422
|
+
function uiLocale() {
|
|
423
|
+
const l = sh()?.lang?.();
|
|
424
|
+
return l && l !== "en" ? l : undefined;
|
|
425
|
+
}
|
|
426
|
+
const t = tr;
|
|
427
|
+
const $ = (id) => document.getElementById(id);
|
|
428
|
+
function esc(v) {
|
|
429
|
+
return String(v ?? "").replace(
|
|
430
|
+
/[&<>"']/g,
|
|
431
|
+
(c) =>
|
|
432
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
433
|
+
c
|
|
434
|
+
],
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
function toast(msg) {
|
|
438
|
+
sh()?.toast?.(msg);
|
|
439
|
+
}
|
|
440
|
+
function go(href) {
|
|
441
|
+
window.location.assign(href);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** What paintStatus last saw: the row, the application groups, the env. */
|
|
445
|
+
const health = { row: null, groups: [], env: "local", at: null };
|
|
446
|
+
let hooks = {};
|
|
447
|
+
let inited = false;
|
|
448
|
+
|
|
449
|
+
/** One overlay at a time: { kind, close(refocus) }. */
|
|
450
|
+
let overlay = null;
|
|
451
|
+
function closeOverlay(refocus = true) {
|
|
452
|
+
if (!overlay) return;
|
|
453
|
+
const o = overlay;
|
|
454
|
+
overlay = null;
|
|
455
|
+
o.close(refocus);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Tab and Shift+Tab stay inside `box` (a modal). */
|
|
459
|
+
function trapTab(e, box) {
|
|
460
|
+
const stops = [
|
|
461
|
+
...box.querySelectorAll(
|
|
462
|
+
"button, a[href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
|
|
463
|
+
),
|
|
464
|
+
].filter((el) => !el.disabled && !el.hidden && el.getClientRects().length);
|
|
465
|
+
if (!stops.length) return;
|
|
466
|
+
const first = stops[0];
|
|
467
|
+
const last = stops[stops.length - 1];
|
|
468
|
+
const at = document.activeElement;
|
|
469
|
+
if (!stops.includes(at)) {
|
|
470
|
+
e.preventDefault();
|
|
471
|
+
first.focus();
|
|
472
|
+
} else if (e.shiftKey && at === first) {
|
|
473
|
+
e.preventDefault();
|
|
474
|
+
last.focus();
|
|
475
|
+
} else if (!e.shiftKey && at === last) {
|
|
476
|
+
e.preventDefault();
|
|
477
|
+
first.focus();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── status chip (R6) ────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Health data from header.js's poll, painted through its gate (a cold load
|
|
485
|
+
* paints once, complete: no partial red, no flash). `row` is buildRow's,
|
|
486
|
+
* `groups` the application grouping, `env` the env checked.
|
|
487
|
+
*/
|
|
488
|
+
export function paintStatus({ row, groups = [], env = "local" }) {
|
|
489
|
+
health.row = row;
|
|
490
|
+
health.groups = groups;
|
|
491
|
+
health.env = env;
|
|
492
|
+
health.at = new Date();
|
|
493
|
+
paintChip();
|
|
494
|
+
if (overlay?.kind === "drawer") renderDrawerBody();
|
|
495
|
+
if (overlay?.kind === "palette") overlay.redraw?.();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** The chip while a re-check runs (an env switch): neutral, never red. */
|
|
499
|
+
export function paintChecking() {
|
|
500
|
+
health.row = null;
|
|
501
|
+
paintChip();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** R26d: the server isn't answering, so nothing about health is known. */
|
|
505
|
+
let serverDown = false;
|
|
506
|
+
|
|
507
|
+
function paintChip() {
|
|
508
|
+
const chip = $("den-status");
|
|
509
|
+
if (!chip) return;
|
|
510
|
+
if (serverDown) {
|
|
511
|
+
chip.classList.remove("bad");
|
|
512
|
+
chip.setAttribute("aria-busy", "false");
|
|
513
|
+
chip.title = t("status.unknownTip");
|
|
514
|
+
chip.innerHTML =
|
|
515
|
+
`<span class="den-dot off" aria-hidden="true"></span>` +
|
|
516
|
+
`<span class="den-n">${esc(t("status.unknown"))}</span>`;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const c = statusChip(statusSummary(health.row), t);
|
|
520
|
+
chip.classList.toggle("bad", c.bad);
|
|
521
|
+
chip.setAttribute("aria-busy", c.busy ? "true" : "false");
|
|
522
|
+
chip.title = c.title;
|
|
523
|
+
chip.innerHTML =
|
|
524
|
+
`<span class="den-dot ${c.dot}" aria-hidden="true"></span>` +
|
|
525
|
+
`<span class="den-n">${c.lead ? `<span class="den-lbl-l">${esc(c.lead)}</span>` : ""}${esc(c.text)}</span>`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── status drawer (R15) ─────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
function openDrawer() {
|
|
531
|
+
if (overlay?.kind === "drawer") return;
|
|
532
|
+
closeOverlay(false);
|
|
533
|
+
sh()?.closeMenu?.();
|
|
534
|
+
const chip = $("den-status");
|
|
535
|
+
const back = chip;
|
|
536
|
+
const scrim = document.createElement("div");
|
|
537
|
+
scrim.className = "den-scrim";
|
|
538
|
+
const d = document.createElement("aside");
|
|
539
|
+
d.className = "den-drawer";
|
|
540
|
+
d.id = "den-status-drawer";
|
|
541
|
+
d.setAttribute("role", "dialog");
|
|
542
|
+
d.setAttribute("aria-modal", "true");
|
|
543
|
+
d.setAttribute("aria-labelledby", "den-drawer-title");
|
|
544
|
+
d.innerHTML =
|
|
545
|
+
`<div class="den-dh"><h2 id="den-drawer-title">${esc(t("drawer.title"))}</h2>` +
|
|
546
|
+
`<button type="button" class="den-ib" data-refresh title="${esc(t("drawer.refresh"))}" aria-label="${esc(t("drawer.refresh"))}">⟳</button>` +
|
|
547
|
+
`<button type="button" class="den-ib" data-close aria-label="${esc(t("drawer.close"))}">✕</button></div>` +
|
|
548
|
+
'<div class="den-db"></div>';
|
|
549
|
+
document.body.append(scrim, d);
|
|
550
|
+
chip?.setAttribute("aria-expanded", "true");
|
|
551
|
+
chip?.setAttribute("aria-controls", d.id);
|
|
552
|
+
const onKey = (e) => {
|
|
553
|
+
if (e.key === "Escape") {
|
|
554
|
+
e.preventDefault();
|
|
555
|
+
e.stopPropagation();
|
|
556
|
+
closeOverlay(true);
|
|
557
|
+
} else if (e.key === "Tab") trapTab(e, d);
|
|
558
|
+
};
|
|
559
|
+
d.addEventListener("keydown", onKey);
|
|
560
|
+
scrim.addEventListener("click", () => closeOverlay(true));
|
|
561
|
+
d.addEventListener("click", onDrawerClick);
|
|
562
|
+
const onPins = () => renderDrawerBody();
|
|
563
|
+
window.addEventListener("den:pins", onPins);
|
|
564
|
+
overlay = {
|
|
565
|
+
kind: "drawer",
|
|
566
|
+
close(refocus) {
|
|
567
|
+
window.removeEventListener("den:pins", onPins);
|
|
568
|
+
scrim.remove();
|
|
569
|
+
d.remove();
|
|
570
|
+
chip?.setAttribute("aria-expanded", "false");
|
|
571
|
+
if (refocus && back?.isConnected) back.focus();
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
renderDrawerBody();
|
|
575
|
+
d.querySelector("[data-close]").focus();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function dotClass(state) {
|
|
579
|
+
return state === "ok"
|
|
580
|
+
? ""
|
|
581
|
+
: state === "down"
|
|
582
|
+
? "bad"
|
|
583
|
+
: state === "pending" || state === "awaiting"
|
|
584
|
+
? "pending"
|
|
585
|
+
: "off";
|
|
586
|
+
}
|
|
587
|
+
function msText(s) {
|
|
588
|
+
if (s.state === "ok")
|
|
589
|
+
return s.ms != null ? tr("ms", { n: s.ms }) : tr("chk.healthy");
|
|
590
|
+
if (s.state === "down") return tr("chk.down");
|
|
591
|
+
if (s.state === "pending") return tr("chk.pendingWord");
|
|
592
|
+
if (s.state === "awaiting") return tr("chk.checking");
|
|
593
|
+
return tr("chk.unchecked");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* A drawer row's accessible name: service, state and latency. What the
|
|
598
|
+
* check ran (a URL, or a whole shell command) is its description, not its
|
|
599
|
+
* name (SA's dev.20 run).
|
|
600
|
+
*/
|
|
601
|
+
export function rowName(s) {
|
|
602
|
+
const word = s.state === "ok" ? tr("chk.healthy") : msText(s);
|
|
603
|
+
return s.state === "ok" && s.ms != null
|
|
604
|
+
? tr("row.nameMs", { id: s.id, state: word, ms: tr("ms", { n: s.ms }) })
|
|
605
|
+
: tr("row.name", { id: s.id, state: word });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function renderDrawerBody() {
|
|
609
|
+
const d = $("den-status-drawer");
|
|
610
|
+
const db = d?.querySelector(".den-db");
|
|
611
|
+
if (!db) return;
|
|
612
|
+
const keep = d.contains(document.activeElement)
|
|
613
|
+
? document.activeElement.dataset?.k
|
|
614
|
+
: null;
|
|
615
|
+
const row = health.row;
|
|
616
|
+
const pinned = (id) => !!sh()?.isPinned?.(id);
|
|
617
|
+
const at = health.at
|
|
618
|
+
? // English keeps the browser's own clock (12 or 24 hours); another
|
|
619
|
+
// language's page reads its times in that language.
|
|
620
|
+
new Intl.DateTimeFormat(uiLocale(), {
|
|
621
|
+
hour: "2-digit",
|
|
622
|
+
minute: "2-digit",
|
|
623
|
+
}).format(health.at)
|
|
624
|
+
: "";
|
|
625
|
+
let html;
|
|
626
|
+
if (!row) {
|
|
627
|
+
html = `<p class="den-none" role="status">${esc(t("status.checking"))}</p>`;
|
|
628
|
+
} else {
|
|
629
|
+
const cards = problemCards(row);
|
|
630
|
+
const groups = drawerGroups(row, health.groups);
|
|
631
|
+
html =
|
|
632
|
+
`<section aria-labelledby="den-dr-p"><h2 class="den-sec" id="den-dr-p">${esc(t("drawer.attention"))}</h2>` +
|
|
633
|
+
(cards.length
|
|
634
|
+
? '<div class="den-cards">' +
|
|
635
|
+
cards
|
|
636
|
+
.map((c) => {
|
|
637
|
+
const fix = problemFix(c, at);
|
|
638
|
+
return (
|
|
639
|
+
'<div class="den-prob-card">' +
|
|
640
|
+
`<div class="den-t"><span class="den-dot bad" aria-hidden="true"></span>${esc(c.service)} <span class="den-mono">${esc(c.repo)}</span></div>` +
|
|
641
|
+
`<div class="den-s">${esc(fix.text)}${c.workspaces.length > 1 ? ` · ${esc(c.workspaces.join(", "))}` : ""}</div>` +
|
|
642
|
+
'<div class="den-a">' +
|
|
643
|
+
fix.actions
|
|
644
|
+
.map(
|
|
645
|
+
(a) =>
|
|
646
|
+
`<button type="button" class="den-btn sm${a.primary ? " primary" : ""}" data-k="${a.act}:${esc(c.id)}" data-${a.act}="${esc(c.id)}" aria-label="${esc(a.name)}">${esc(a.label)}</button>`,
|
|
647
|
+
)
|
|
648
|
+
.join("") +
|
|
649
|
+
`<button type="button" class="den-btn sm" data-k="pin:${esc(c.id)}" data-pin="${esc(c.id)}" aria-pressed="${pinned(c.id)}" aria-label="${esc(t(pinned(c.id) ? "act.unpinX" : "act.pinX", { s: c.id }))}">${esc(t(pinned(c.id) ? "act.unpin" : "act.pin"))}</button>` +
|
|
650
|
+
"</div></div>"
|
|
651
|
+
);
|
|
652
|
+
})
|
|
653
|
+
.join("") +
|
|
654
|
+
"</div>"
|
|
655
|
+
: `<p class="den-none">${esc(t("drawer.nothing"))}</p>`) +
|
|
656
|
+
"</section>" +
|
|
657
|
+
`<section aria-labelledby="den-dr-e"><h2 class="den-sec" id="den-dr-e">${esc(t("drawer.everything"))}</h2><div class="den-grps">` +
|
|
658
|
+
groups
|
|
659
|
+
.map(
|
|
660
|
+
(g) =>
|
|
661
|
+
'<div class="den-grp">' +
|
|
662
|
+
`<h3><span>${esc(g.title)}</span><span title="${esc(t("drawer.okTip", { ok: g.ok, n: g.checked }))}">${g.ok}/${g.checked}</span></h3>` +
|
|
663
|
+
'<div class="den-svl">' +
|
|
664
|
+
g.services
|
|
665
|
+
.map(
|
|
666
|
+
(s) =>
|
|
667
|
+
`<button type="button" data-k="svc:${esc(s.id)}" data-svc="${esc(s.repo)}" title="${esc(s.line)}" aria-label="${esc(rowName(s))}" aria-description="${esc(s.line)}">` +
|
|
668
|
+
`<span class="den-dot ${dotClass(s.state)}" aria-hidden="true"></span>` +
|
|
669
|
+
`<span class="den-mono">${esc(s.id)}</span>` +
|
|
670
|
+
`<span class="den-ms">${esc(msText(s))}</span></button>`,
|
|
671
|
+
)
|
|
672
|
+
.join("") +
|
|
673
|
+
"</div></div>",
|
|
674
|
+
)
|
|
675
|
+
.join("") +
|
|
676
|
+
"</div></section>";
|
|
677
|
+
}
|
|
678
|
+
db.innerHTML = html;
|
|
679
|
+
if (keep) db.querySelector(`[data-k="${CSS.escape(keep)}"]`)?.focus();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function svcOf(id) {
|
|
683
|
+
for (const r of health.row?.flat ?? [])
|
|
684
|
+
for (const i of r.items)
|
|
685
|
+
if (`${r.repo}/${i.service}` === id)
|
|
686
|
+
return {
|
|
687
|
+
repo: r.repo,
|
|
688
|
+
service: i.service,
|
|
689
|
+
address: id,
|
|
690
|
+
workspaces: i.workspaces ?? [],
|
|
691
|
+
};
|
|
692
|
+
const [repo, service] = String(id).split("/");
|
|
693
|
+
return { repo, service, address: id, workspaces: [] };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function onDrawerClick(e) {
|
|
697
|
+
const b = e.target.closest?.("button");
|
|
698
|
+
if (!b) return;
|
|
699
|
+
if (b.hasAttribute("data-close")) return closeOverlay(true);
|
|
700
|
+
if (b.hasAttribute("data-refresh")) {
|
|
701
|
+
hooks.refreshHealth?.(hooks.healthEnv?.() ?? health.env, { force: true });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (b.dataset.start) {
|
|
705
|
+
closeOverlay(false);
|
|
706
|
+
return runServiceAction("up", svcOf(b.dataset.start));
|
|
707
|
+
}
|
|
708
|
+
if (b.dataset.logs) {
|
|
709
|
+
closeOverlay(false);
|
|
710
|
+
return openLogs(svcOf(b.dataset.logs));
|
|
711
|
+
}
|
|
712
|
+
if (b.dataset.pin) {
|
|
713
|
+
const id = b.dataset.pin;
|
|
714
|
+
if (sh()?.isPinned?.(id)) sh()?.unpin?.(id);
|
|
715
|
+
else sh()?.pin?.(id);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (b.dataset.svc) {
|
|
719
|
+
closeOverlay(false);
|
|
720
|
+
go(`/services#repo-${encodeURIComponent(b.dataset.svc)}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ── service actions: Start, Restart, Logs ───────────────────────────────────
|
|
725
|
+
|
|
726
|
+
let serviceRunner = null;
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* The page that runs a service command itself (Local Dev: its runCmd, with
|
|
730
|
+
* its lock rule and its output pane) registers `fn(cmd, svc)`; `svc` is
|
|
731
|
+
* `{ repo, service, address, workspaces }`.
|
|
732
|
+
*/
|
|
733
|
+
export function setServiceRunner(fn) {
|
|
734
|
+
serviceRunner = typeof fn === "function" ? fn : null;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Start (`up`) or Restart from the drawer or the palette: an explicit
|
|
739
|
+
* action. The page's runner does it where there is one. Elsewhere the lock
|
|
740
|
+
* rule applies first (requireUnlock), then the action is handed to Local
|
|
741
|
+
* Dev, where the command runs with its output (a run stops when its page
|
|
742
|
+
* closes, so it can't run on a page that is about to be left): the page
|
|
743
|
+
* stores it for this tab (sessionStorage, not a link anyone could open)
|
|
744
|
+
* and Local Dev takes it once (takePendingAction).
|
|
745
|
+
*/
|
|
746
|
+
export function runServiceAction(cmd, svc) {
|
|
747
|
+
if (serviceRunner) return serviceRunner(cmd, svc);
|
|
748
|
+
if (!requireUnlock()) return;
|
|
749
|
+
try {
|
|
750
|
+
sessionStorage.setItem(
|
|
751
|
+
SS_PENDING,
|
|
752
|
+
JSON.stringify({ cmd, address: svc.address, at: Date.now() }),
|
|
753
|
+
);
|
|
754
|
+
} catch {
|
|
755
|
+
toast(t("toast.openWs"));
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
go("/local-dev");
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** The action another page handed to this one (once, if under a minute old). */
|
|
762
|
+
export function takePendingAction() {
|
|
763
|
+
try {
|
|
764
|
+
const raw = sessionStorage.getItem(SS_PENDING);
|
|
765
|
+
sessionStorage.removeItem(SS_PENDING);
|
|
766
|
+
const a = raw ? JSON.parse(raw) : null;
|
|
767
|
+
if (!a || !a.cmd || !a.address || Date.now() - a.at > 60_000) return null;
|
|
768
|
+
if (!["up", "restart", "down"].includes(a.cmd)) return null;
|
|
769
|
+
return { cmd: a.cmd, address: a.address };
|
|
770
|
+
} catch {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function openLogs(svc) {
|
|
776
|
+
hooks.storeOpenLog?.(svc.repo, svc.service, hooks.healthEnv?.() ?? "local");
|
|
777
|
+
go(`/logs?open=${encodeURIComponent(`${svc.repo}/${svc.service}`)}`);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ── command palette (R17) ───────────────────────────────────────────────────
|
|
781
|
+
|
|
782
|
+
/** Every palette row: pages, apps, services, their actions, then commands. */
|
|
783
|
+
function paletteItems() {
|
|
784
|
+
const s = sh();
|
|
785
|
+
const items = [];
|
|
786
|
+
const st = s?.getState?.() ?? {};
|
|
787
|
+
for (const n of s?.nav?.() ?? []) {
|
|
788
|
+
if (n.when && !n.when(st)) continue;
|
|
789
|
+
const label = s.t(n.label);
|
|
790
|
+
items.push({
|
|
791
|
+
key: `page:${n.id}`,
|
|
792
|
+
kind: "page",
|
|
793
|
+
label,
|
|
794
|
+
keywords: [n.id, n.group, ...(n.keywords ?? [])],
|
|
795
|
+
run: () => go(n.href),
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
const settings = document.querySelector('.den-rail [data-nav="settings"]');
|
|
799
|
+
if (settings)
|
|
800
|
+
items.push({
|
|
801
|
+
key: "page:settings",
|
|
802
|
+
kind: "page",
|
|
803
|
+
label: settings.getAttribute("title") || t("n.settings"),
|
|
804
|
+
keywords: ["settings", "preferences"],
|
|
805
|
+
run: () => go(settings.getAttribute("href")),
|
|
806
|
+
});
|
|
807
|
+
const row = health.row;
|
|
808
|
+
for (const g of health.groups ?? [])
|
|
809
|
+
if (g.app)
|
|
810
|
+
items.push({
|
|
811
|
+
key: `app:${g.app.name}`,
|
|
812
|
+
kind: "app",
|
|
813
|
+
label: g.app.title || g.app.name,
|
|
814
|
+
sub: t("pal.repos", { n: g.repos.length }),
|
|
815
|
+
keywords: [g.app.name, ...g.repos],
|
|
816
|
+
run: () => go(`/services#repo-${encodeURIComponent(g.repos[0])}`),
|
|
817
|
+
});
|
|
818
|
+
const services = [];
|
|
819
|
+
for (const r of row?.flat ?? [])
|
|
820
|
+
for (const i of r.items)
|
|
821
|
+
if (!i.awaiting) services.push({ r, i, id: `${r.repo}/${i.service}` });
|
|
822
|
+
for (const { r, i, id } of services)
|
|
823
|
+
items.push({
|
|
824
|
+
key: `svc:${id}`,
|
|
825
|
+
kind: "service",
|
|
826
|
+
label: id,
|
|
827
|
+
dot: dotClass(serviceState(r, i)),
|
|
828
|
+
keywords: [r.repo, i.service],
|
|
829
|
+
run: () => go(`/services#repo-${encodeURIComponent(r.repo)}`),
|
|
830
|
+
});
|
|
831
|
+
for (const { id } of services) {
|
|
832
|
+
items.push({
|
|
833
|
+
key: `restart:${id}`,
|
|
834
|
+
kind: "action",
|
|
835
|
+
label: t("pal.restart", { s: id }),
|
|
836
|
+
keywords: ["restart"],
|
|
837
|
+
hideEmpty: true,
|
|
838
|
+
run: () => runServiceAction("restart", svcOf(id)),
|
|
839
|
+
});
|
|
840
|
+
items.push({
|
|
841
|
+
key: `logs:${id}`,
|
|
842
|
+
kind: "action",
|
|
843
|
+
label: t("fix.logsX", { s: id }),
|
|
844
|
+
keywords: ["logs", "tail"],
|
|
845
|
+
hideEmpty: true,
|
|
846
|
+
run: () => openLogs(svcOf(id)),
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
for (const c of s?.commands?.() ?? [])
|
|
850
|
+
items.push({
|
|
851
|
+
key: `cmd:${c.id}`,
|
|
852
|
+
kind: c.kind,
|
|
853
|
+
label: c.label,
|
|
854
|
+
keywords: c.keywords,
|
|
855
|
+
run: c.run,
|
|
856
|
+
});
|
|
857
|
+
return items.map((x) => ({ ...x, kindLabel: kindLabel(x.kind) }));
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function openPalette() {
|
|
861
|
+
if (overlay?.kind === "palette") {
|
|
862
|
+
$("den-pal-q")?.focus();
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const back =
|
|
866
|
+
document.activeElement && document.activeElement !== document.body
|
|
867
|
+
? document.activeElement
|
|
868
|
+
: $("den-search");
|
|
869
|
+
closeOverlay(false);
|
|
870
|
+
sh()?.closeMenu?.();
|
|
871
|
+
const ph = t("search.ph");
|
|
872
|
+
const wrap = document.createElement("div");
|
|
873
|
+
wrap.className = "den-pal-wrap";
|
|
874
|
+
wrap.innerHTML =
|
|
875
|
+
`<div class="den-pal" role="dialog" aria-modal="true" aria-label="${esc(ph)}">` +
|
|
876
|
+
`<input id="den-pal-q" role="combobox" aria-expanded="true" aria-controls="den-pal-list" aria-autocomplete="list" aria-label="${esc(ph)}" placeholder="${esc(ph)}" autocomplete="off" spellcheck="false">` +
|
|
877
|
+
`<ul id="den-pal-list" role="listbox" aria-label="${esc(t("pal.results"))}"></ul>` +
|
|
878
|
+
`<div class="den-foot" aria-hidden="true"><span>${esc(t("pal.move"))}</span><span>${esc(t("pal.open"))}</span><span>${esc(t("pal.close"))}</span></div>` +
|
|
879
|
+
'<span class="den-sr" role="status" aria-live="polite" id="den-pal-count"></span></div>';
|
|
880
|
+
document.body.appendChild(wrap);
|
|
881
|
+
const input = $("den-pal-q");
|
|
882
|
+
const ul = $("den-pal-list");
|
|
883
|
+
let sel = 0;
|
|
884
|
+
let list = [];
|
|
885
|
+
const draw = () => {
|
|
886
|
+
list = rankCommands(paletteItems(), input.value);
|
|
887
|
+
sel = Math.min(sel, Math.max(0, list.length - 1));
|
|
888
|
+
ul.innerHTML = list.length
|
|
889
|
+
? list
|
|
890
|
+
.map(
|
|
891
|
+
(x, i) =>
|
|
892
|
+
`<li role="option" id="den-pal-o-${i}" data-i="${i}" aria-selected="${i === sel}">` +
|
|
893
|
+
(x.dot != null
|
|
894
|
+
? `<span class="den-dot ${x.dot}" aria-hidden="true"></span>`
|
|
895
|
+
: "") +
|
|
896
|
+
`<span class="den-pal-l">${esc(x.label)}</span>` +
|
|
897
|
+
(x.sub ? `<span class="den-sub">${esc(x.sub)}</span>` : "") +
|
|
898
|
+
`<span class="den-kind">${esc(x.kindLabel)}</span></li>`,
|
|
899
|
+
)
|
|
900
|
+
.join("")
|
|
901
|
+
: `<li class="den-pal-empty" role="presentation">${esc(t("pal.none"))}</li>`;
|
|
902
|
+
if (list.length)
|
|
903
|
+
input.setAttribute("aria-activedescendant", `den-pal-o-${sel}`);
|
|
904
|
+
else input.removeAttribute("aria-activedescendant");
|
|
905
|
+
$("den-pal-count").textContent = t("pal.count", { n: list.length });
|
|
906
|
+
ul.querySelector('[aria-selected="true"]')?.scrollIntoView?.({
|
|
907
|
+
block: "nearest",
|
|
908
|
+
});
|
|
909
|
+
};
|
|
910
|
+
const pick = (x) => {
|
|
911
|
+
if (!x) return;
|
|
912
|
+
// Focus goes back first; then the row runs (it may navigate).
|
|
913
|
+
closeOverlay(true);
|
|
914
|
+
x.run();
|
|
915
|
+
};
|
|
916
|
+
input.addEventListener("input", () => {
|
|
917
|
+
sel = 0;
|
|
918
|
+
draw();
|
|
919
|
+
});
|
|
920
|
+
input.addEventListener("keydown", (e) => {
|
|
921
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
922
|
+
e.preventDefault();
|
|
923
|
+
if (!list.length) return;
|
|
924
|
+
sel =
|
|
925
|
+
e.key === "ArrowDown"
|
|
926
|
+
? (sel + 1) % list.length
|
|
927
|
+
: (sel - 1 + list.length) % list.length;
|
|
928
|
+
draw();
|
|
929
|
+
} else if (e.key === "Enter") {
|
|
930
|
+
e.preventDefault();
|
|
931
|
+
pick(list[sel]);
|
|
932
|
+
} else if (e.key === "Escape") {
|
|
933
|
+
e.preventDefault();
|
|
934
|
+
e.stopPropagation();
|
|
935
|
+
closeOverlay(true);
|
|
936
|
+
} else if (e.key === "Tab") e.preventDefault();
|
|
937
|
+
});
|
|
938
|
+
ul.addEventListener("mousedown", (e) => e.preventDefault());
|
|
939
|
+
ul.addEventListener("click", (e) => {
|
|
940
|
+
const li = e.target.closest?.("li[data-i]");
|
|
941
|
+
if (li) pick(list[+li.dataset.i]);
|
|
942
|
+
});
|
|
943
|
+
wrap.addEventListener("mousedown", (e) => {
|
|
944
|
+
if (e.target === wrap) closeOverlay(true);
|
|
945
|
+
});
|
|
946
|
+
$("den-search")?.setAttribute("aria-expanded", "true");
|
|
947
|
+
overlay = {
|
|
948
|
+
kind: "palette",
|
|
949
|
+
redraw: draw,
|
|
950
|
+
close(refocus) {
|
|
951
|
+
wrap.remove();
|
|
952
|
+
$("den-search")?.setAttribute("aria-expanded", "false");
|
|
953
|
+
if (refocus && back?.isConnected) back.focus();
|
|
954
|
+
},
|
|
955
|
+
};
|
|
956
|
+
draw();
|
|
957
|
+
input.focus();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ── Runs chip (R7) ──────────────────────────────────────────────────────────
|
|
961
|
+
|
|
962
|
+
const running = new Map();
|
|
963
|
+
let runSeq = 0;
|
|
964
|
+
|
|
965
|
+
function lastRun() {
|
|
966
|
+
try {
|
|
967
|
+
return JSON.parse(sessionStorage.getItem(LS_LAST_RUN) || "null");
|
|
968
|
+
} catch {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function runStarted(label) {
|
|
974
|
+
const id = ++runSeq;
|
|
975
|
+
running.set(id, label);
|
|
976
|
+
try {
|
|
977
|
+
sessionStorage.setItem(
|
|
978
|
+
LS_LAST_RUN,
|
|
979
|
+
JSON.stringify({
|
|
980
|
+
label,
|
|
981
|
+
page: location.pathname + location.search,
|
|
982
|
+
at: Date.now(),
|
|
983
|
+
}),
|
|
984
|
+
);
|
|
985
|
+
} catch {
|
|
986
|
+
/* a convenience only */
|
|
987
|
+
}
|
|
988
|
+
paintRuns();
|
|
989
|
+
return id;
|
|
990
|
+
}
|
|
991
|
+
function runEnded(id) {
|
|
992
|
+
if (running.delete(id)) paintRuns();
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* The chip's count: the runs dock's running tabs (R16) or the run streams
|
|
997
|
+
* open on the page, whichever is more (a page registers each run in the dock;
|
|
998
|
+
* a workspace-wide command is one stream feeding several tabs).
|
|
999
|
+
*/
|
|
1000
|
+
function paintRuns() {
|
|
1001
|
+
const chip = $("den-runs");
|
|
1002
|
+
if (!chip) return;
|
|
1003
|
+
const dock = (sh()?.runs?.list?.() ?? []).filter((r) => r.running);
|
|
1004
|
+
const labels =
|
|
1005
|
+
dock.length >= running.size
|
|
1006
|
+
? dock.map((r) => r.label)
|
|
1007
|
+
: [...running.values()];
|
|
1008
|
+
const n = labels.length;
|
|
1009
|
+
const last = lastRun();
|
|
1010
|
+
chip.title = n
|
|
1011
|
+
? labels.join(", ")
|
|
1012
|
+
: last
|
|
1013
|
+
? t("runs.last", { s: last.label })
|
|
1014
|
+
: t("runs.title");
|
|
1015
|
+
chip.setAttribute("aria-label", n ? t("runs.aria", { n }) : t("runs.title"));
|
|
1016
|
+
chip.innerHTML =
|
|
1017
|
+
`<span class="den-dot ${n ? "run" : "off"}" aria-hidden="true"></span>` +
|
|
1018
|
+
`<span class="den-n den-lbl-l">${esc(n ? t("runs.n", { n }) : t("runs.title"))}</span>`;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* The Runs chip shows and hides the runs dock (R16); returns whether it is
|
|
1023
|
+
* open now. Without the dock (no shell), a toast says where output goes.
|
|
1024
|
+
*/
|
|
1025
|
+
export function showRuns() {
|
|
1026
|
+
const runs = sh()?.runs;
|
|
1027
|
+
if (runs?.toggle) return runs.toggle() ? "open" : "closed";
|
|
1028
|
+
toast(t("runs.toast"));
|
|
1029
|
+
return "none";
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* A run is an open stream to a run endpoint (RUN_PATHS): EventSource is
|
|
1034
|
+
* wrapped once, before any page script opens one (pages import header.js
|
|
1035
|
+
* first), and counts each run until the page closes it, the server says
|
|
1036
|
+
* "done", or the connection ends.
|
|
1037
|
+
*/
|
|
1038
|
+
function trackRuns() {
|
|
1039
|
+
const Native = window.EventSource;
|
|
1040
|
+
if (!Native || Native.__denRuns) return;
|
|
1041
|
+
class DenEventSource extends Native {
|
|
1042
|
+
constructor(url, init) {
|
|
1043
|
+
super(url, init);
|
|
1044
|
+
const label = runOf(url, location.href);
|
|
1045
|
+
if (!label) return;
|
|
1046
|
+
const id = runStarted(label);
|
|
1047
|
+
const end = () => runEnded(id);
|
|
1048
|
+
this.__denEnd = end;
|
|
1049
|
+
this.addEventListener("message", (e) => {
|
|
1050
|
+
try {
|
|
1051
|
+
if (JSON.parse(e.data)?.type === "done") end();
|
|
1052
|
+
} catch {
|
|
1053
|
+
/* not JSON: not the end */
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
this.addEventListener("error", () => {
|
|
1057
|
+
if (this.readyState === 2) end();
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
close() {
|
|
1061
|
+
this.__denEnd?.();
|
|
1062
|
+
super.close();
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
DenEventSource.__denRuns = true;
|
|
1066
|
+
window.EventSource = DenEventSource;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// ── lock chip (R8) ──────────────────────────────────────────────────────────
|
|
1070
|
+
|
|
1071
|
+
const lock = {
|
|
1072
|
+
known: false,
|
|
1073
|
+
unlocked: false,
|
|
1074
|
+
passwordless: false,
|
|
1075
|
+
vault: {
|
|
1076
|
+
backend: "none",
|
|
1077
|
+
label: "",
|
|
1078
|
+
backendAvailable: false,
|
|
1079
|
+
hasStored: false,
|
|
1080
|
+
},
|
|
1081
|
+
subs: new Set(),
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
export function isUnlocked() {
|
|
1085
|
+
return lock.unlocked;
|
|
1086
|
+
}
|
|
1087
|
+
/** fn(unlocked) on every change. */
|
|
1088
|
+
export function onLockChange(fn) {
|
|
1089
|
+
lock.subs.add(fn);
|
|
1090
|
+
return () => lock.subs.delete(fn);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Local Dev's lock rule, for every page: true when unlocked; otherwise the
|
|
1095
|
+
* lock popover opens on its password field and the action doesn't run.
|
|
1096
|
+
*/
|
|
1097
|
+
export function requireUnlock() {
|
|
1098
|
+
if (lock.unlocked) return true;
|
|
1099
|
+
openLock();
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function setUnlocked(unlocked, passwordless = false) {
|
|
1104
|
+
const changed = lock.unlocked !== unlocked || !lock.known;
|
|
1105
|
+
lock.known = true;
|
|
1106
|
+
lock.unlocked = unlocked;
|
|
1107
|
+
lock.passwordless = passwordless;
|
|
1108
|
+
try {
|
|
1109
|
+
sessionStorage.setItem(LS_LOCK, unlocked ? "1" : "0");
|
|
1110
|
+
} catch {
|
|
1111
|
+
/* a convenience only */
|
|
1112
|
+
}
|
|
1113
|
+
paintLock();
|
|
1114
|
+
if (overlay?.kind === "lock") overlay.redraw?.();
|
|
1115
|
+
if (changed) for (const fn of lock.subs) fn(unlocked);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function paintLock() {
|
|
1119
|
+
const chip = $("den-lock");
|
|
1120
|
+
if (!chip) return;
|
|
1121
|
+
chip.title = lock.unlocked
|
|
1122
|
+
? lock.passwordless
|
|
1123
|
+
? t("lock.nopw")
|
|
1124
|
+
: t("lock.session")
|
|
1125
|
+
: t("lock.title");
|
|
1126
|
+
chip.innerHTML =
|
|
1127
|
+
`<span class="den-lock-ic" aria-hidden="true">${lock.unlocked ? "🔓" : "🔒"}</span>` +
|
|
1128
|
+
`<span class="den-lbl-l">${esc(lock.unlocked ? t("lock.unlocked") : t("lock.locked"))}</span>`;
|
|
1129
|
+
chip.setAttribute(
|
|
1130
|
+
"aria-label",
|
|
1131
|
+
lock.unlocked ? t("lock.unlocked") : t("lock.locked"),
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async function getJson(url, fallback, init) {
|
|
1136
|
+
try {
|
|
1137
|
+
const r = await fetch(url, init);
|
|
1138
|
+
return r.ok ? await r.json() : fallback;
|
|
1139
|
+
} catch {
|
|
1140
|
+
return fallback;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Local Dev's bootstrap, moved: the in-memory status first (fast), then,
|
|
1146
|
+
* when locked, the vault's stored password in the background (op can be
|
|
1147
|
+
* slow: Touch ID, a cold session) so nothing waits on it.
|
|
1148
|
+
*/
|
|
1149
|
+
/**
|
|
1150
|
+
* What the secrets backend can do here (`backend`, `backendAvailable`,
|
|
1151
|
+
* `reason`): asked once a page, then kept. Not configured or unavailable
|
|
1152
|
+
* means no save option and no auto-load.
|
|
1153
|
+
*/
|
|
1154
|
+
let vaultAsked = null;
|
|
1155
|
+
function checkVault() {
|
|
1156
|
+
vaultAsked ??= getJson("/api/sudo-password/vault-status", {
|
|
1157
|
+
backend: "none",
|
|
1158
|
+
label: "",
|
|
1159
|
+
backendAvailable: false,
|
|
1160
|
+
hasStored: false,
|
|
1161
|
+
}).then((v) => {
|
|
1162
|
+
lock.vault = v;
|
|
1163
|
+
// Only the save option changes: what's typed stays.
|
|
1164
|
+
const label = document.querySelector("#den-lock-pop .den-lock-save");
|
|
1165
|
+
const save = saveLabel();
|
|
1166
|
+
if (label) {
|
|
1167
|
+
label.hidden = !save;
|
|
1168
|
+
label.querySelector("span").textContent = save ?? "";
|
|
1169
|
+
}
|
|
1170
|
+
const cfg = document.querySelector("#den-lock-pop .den-lock-cfg");
|
|
1171
|
+
if (cfg) {
|
|
1172
|
+
const note = notConfiguredNote();
|
|
1173
|
+
cfg.hidden = !note;
|
|
1174
|
+
cfg.textContent = note ?? "";
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
return vaultAsked;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async function initLock() {
|
|
1181
|
+
try {
|
|
1182
|
+
if (sessionStorage.getItem(LS_LOCK) === "1") {
|
|
1183
|
+
lock.unlocked = true;
|
|
1184
|
+
paintLock();
|
|
1185
|
+
}
|
|
1186
|
+
} catch {
|
|
1187
|
+
/* none */
|
|
1188
|
+
}
|
|
1189
|
+
const st = await getJson("/api/sudo-password/status", {
|
|
1190
|
+
set: false,
|
|
1191
|
+
passwordless: false,
|
|
1192
|
+
});
|
|
1193
|
+
if (st.set || st.passwordless) {
|
|
1194
|
+
setUnlocked(true, !!st.passwordless && !st.set);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
setUnlocked(false);
|
|
1198
|
+
// The vault (op) only where Local Dev always asked it, on load; any
|
|
1199
|
+
// other page asks when the popover opens, so a page view never wakes op.
|
|
1200
|
+
if (location.pathname !== "/local-dev") return;
|
|
1201
|
+
await checkVault();
|
|
1202
|
+
if (!lock.vault.backendAvailable || !lock.vault.hasStored || lock.unlocked)
|
|
1203
|
+
return;
|
|
1204
|
+
const loaded = await getJson(
|
|
1205
|
+
"/api/sudo-password/load-from-vault",
|
|
1206
|
+
{ ok: false },
|
|
1207
|
+
{ method: "POST" },
|
|
1208
|
+
);
|
|
1209
|
+
if (loaded.ok && !lock.unlocked) setUnlocked(true);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Why there is no save option, when the sudo ref is not configured: the
|
|
1214
|
+
* server's reason, which names the distribution's real config file
|
|
1215
|
+
* (`configFile`, e.g. config/acme.yml), never a placeholder.
|
|
1216
|
+
*/
|
|
1217
|
+
function notConfiguredNote() {
|
|
1218
|
+
const v = lock.vault;
|
|
1219
|
+
if (!v || v.configured !== false || !v.configFile) return null;
|
|
1220
|
+
return t("lock.cfg", { file: v.configFile });
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function saveLabel() {
|
|
1224
|
+
if (!lock.vault.backendAvailable) return null;
|
|
1225
|
+
return lock.vault.hasStored
|
|
1226
|
+
? t("lock.update")
|
|
1227
|
+
: t("lock.saveTo", { s: lock.vault.label || lock.vault.backend });
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function lockFormHtml() {
|
|
1231
|
+
const save = saveLabel();
|
|
1232
|
+
const note = notConfiguredNote();
|
|
1233
|
+
return (
|
|
1234
|
+
`<form class="den-lock-form"><b id="den-lock-t">${esc(t("lock.title"))}</b>` +
|
|
1235
|
+
`<input type="password" id="den-lock-pw" placeholder="${esc(t("lock.pw"))}" aria-label="${esc(t("lock.pw"))}" autocomplete="current-password">` +
|
|
1236
|
+
`<label class="den-lock-save"${save ? "" : " hidden"}><input type="checkbox" id="den-lock-vault"> <span>${esc(save ?? "")}</span></label>` +
|
|
1237
|
+
'<span class="den-lock-err" id="den-lock-err" role="alert" hidden></span>' +
|
|
1238
|
+
`<span class="den-note den-lock-cfg"${note ? "" : " hidden"}>${esc(note ?? "")}</span>` +
|
|
1239
|
+
`<button class="den-btn primary" type="submit" id="den-lock-go">${esc(t("lock.go"))}</button>` +
|
|
1240
|
+
`<span class="den-note">${esc(t("lock.note"))}</span></form>`
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function openLock() {
|
|
1245
|
+
const chip = $("den-lock");
|
|
1246
|
+
if (!chip) return;
|
|
1247
|
+
if (overlay?.kind === "lock") {
|
|
1248
|
+
overlay.el.querySelector("input,button")?.focus();
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
closeOverlay(false);
|
|
1252
|
+
sh()?.closeMenu?.();
|
|
1253
|
+
if (!lock.unlocked) void checkVault();
|
|
1254
|
+
const pop = document.createElement("div");
|
|
1255
|
+
pop.className = "den-pop den-lock-pop";
|
|
1256
|
+
pop.id = "den-lock-pop";
|
|
1257
|
+
pop.setAttribute("role", "dialog");
|
|
1258
|
+
pop.setAttribute("aria-label", t("lock.title"));
|
|
1259
|
+
let forceForm = false;
|
|
1260
|
+
const draw = () => {
|
|
1261
|
+
if (!lock.unlocked || forceForm) pop.innerHTML = lockFormHtml();
|
|
1262
|
+
else
|
|
1263
|
+
pop.innerHTML =
|
|
1264
|
+
`<div class="den-ml">${esc(t("lock.unlocked"))}</div>` +
|
|
1265
|
+
`<div class="den-lock-desc">${esc(lock.passwordless ? `${t("lock.nopw")}.` : t("lock.sessionDesc"))}</div>` +
|
|
1266
|
+
(lock.passwordless
|
|
1267
|
+
? ""
|
|
1268
|
+
: `<button type="button" class="den-mi" data-reenter>${esc(t("lock.again"))}</button>`);
|
|
1269
|
+
pop.querySelector("form")?.addEventListener("submit", onSubmit);
|
|
1270
|
+
pop.querySelector("[data-reenter]")?.addEventListener("click", () => {
|
|
1271
|
+
forceForm = true;
|
|
1272
|
+
draw();
|
|
1273
|
+
pop.querySelector("input")?.focus();
|
|
1274
|
+
});
|
|
1275
|
+
};
|
|
1276
|
+
const onSubmit = async (e) => {
|
|
1277
|
+
e.preventDefault();
|
|
1278
|
+
const input = $("den-lock-pw");
|
|
1279
|
+
const btn = $("den-lock-go");
|
|
1280
|
+
const err = $("den-lock-err");
|
|
1281
|
+
const saveCb = $("den-lock-vault");
|
|
1282
|
+
const pw = input.value;
|
|
1283
|
+
if (!pw) return;
|
|
1284
|
+
btn.textContent = t("lock.checking");
|
|
1285
|
+
btn.disabled = true;
|
|
1286
|
+
err.hidden = true;
|
|
1287
|
+
const saveToVault = !!(lock.vault.backendAvailable && saveCb?.checked);
|
|
1288
|
+
try {
|
|
1289
|
+
const r = await fetch("/api/sudo-password", {
|
|
1290
|
+
method: "POST",
|
|
1291
|
+
headers: { "Content-Type": "application/json" },
|
|
1292
|
+
body: JSON.stringify({ password: pw, saveToVault }),
|
|
1293
|
+
});
|
|
1294
|
+
const data = await r.json();
|
|
1295
|
+
if (r.ok) {
|
|
1296
|
+
input.value = "";
|
|
1297
|
+
if (data.savedToVault) lock.vault.hasStored = true;
|
|
1298
|
+
forceForm = false;
|
|
1299
|
+
setUnlocked(true);
|
|
1300
|
+
closeOverlay(true);
|
|
1301
|
+
toast(
|
|
1302
|
+
saveToVault && data.vaultError
|
|
1303
|
+
? t("lock.saveFailed", {
|
|
1304
|
+
s: lock.vault.label || lock.vault.backend,
|
|
1305
|
+
err: data.vaultError,
|
|
1306
|
+
})
|
|
1307
|
+
: t("lock.session"),
|
|
1308
|
+
);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
// A wrong password is said in the page's language; another refusal
|
|
1312
|
+
// is the server's own message.
|
|
1313
|
+
err.textContent =
|
|
1314
|
+
r.status === 401 || !data.error ? t("lock.wrong") : data.error;
|
|
1315
|
+
err.hidden = false;
|
|
1316
|
+
input.value = "";
|
|
1317
|
+
input.focus();
|
|
1318
|
+
} catch {
|
|
1319
|
+
err.textContent = t("lock.reqFailed");
|
|
1320
|
+
err.hidden = false;
|
|
1321
|
+
} finally {
|
|
1322
|
+
if (btn.isConnected) {
|
|
1323
|
+
btn.textContent = t("lock.go");
|
|
1324
|
+
btn.disabled = false;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
draw();
|
|
1329
|
+
document.body.appendChild(pop);
|
|
1330
|
+
const r = chip.getBoundingClientRect();
|
|
1331
|
+
pop.style.top = `${r.bottom + 6}px`;
|
|
1332
|
+
pop.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - pop.offsetWidth - 8))}px`;
|
|
1333
|
+
chip.setAttribute("aria-expanded", "true");
|
|
1334
|
+
chip.setAttribute("aria-controls", pop.id);
|
|
1335
|
+
const onDown = (e) => {
|
|
1336
|
+
if (!pop.contains(e.target) && !chip.contains(e.target))
|
|
1337
|
+
closeOverlay(false);
|
|
1338
|
+
};
|
|
1339
|
+
pop.addEventListener("keydown", (e) => {
|
|
1340
|
+
if (e.key === "Escape") {
|
|
1341
|
+
e.preventDefault();
|
|
1342
|
+
e.stopPropagation();
|
|
1343
|
+
closeOverlay(true);
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
document.addEventListener("mousedown", onDown, true);
|
|
1347
|
+
overlay = {
|
|
1348
|
+
kind: "lock",
|
|
1349
|
+
el: pop,
|
|
1350
|
+
redraw: draw,
|
|
1351
|
+
close(refocus) {
|
|
1352
|
+
document.removeEventListener("mousedown", onDown, true);
|
|
1353
|
+
pop.remove();
|
|
1354
|
+
chip.setAttribute("aria-expanded", "false");
|
|
1355
|
+
if (refocus && chip.isConnected) chip.focus();
|
|
1356
|
+
},
|
|
1357
|
+
};
|
|
1358
|
+
(
|
|
1359
|
+
pop.querySelector("input:not([type='checkbox'])") ||
|
|
1360
|
+
pop.querySelector("button") ||
|
|
1361
|
+
pop
|
|
1362
|
+
).focus();
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// ── wiring ──────────────────────────────────────────────────────────────────
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Wire the bar (once). `opts` are header.js's: refreshHealth(env, opts),
|
|
1369
|
+
* healthEnv() and storeOpenLog(repo, service, env).
|
|
1370
|
+
*/
|
|
1371
|
+
export function initTopbar(opts = {}) {
|
|
1372
|
+
hooks = { ...hooks, ...opts };
|
|
1373
|
+
if (inited || typeof document === "undefined") return;
|
|
1374
|
+
inited = true;
|
|
1375
|
+
trackRuns();
|
|
1376
|
+
// R26d: while the server doesn't answer, the chip reads unknown (never a
|
|
1377
|
+
// stale green); it repaints from the last health once it is back.
|
|
1378
|
+
serverDown = sh()?.serverUp ? !sh().serverUp() : false;
|
|
1379
|
+
window.addEventListener("den:server", (e) => {
|
|
1380
|
+
serverDown = e.detail?.up === false;
|
|
1381
|
+
paintChip();
|
|
1382
|
+
});
|
|
1383
|
+
paintChip();
|
|
1384
|
+
paintRuns();
|
|
1385
|
+
sh()?.runs?.onChange?.(paintRuns);
|
|
1386
|
+
paintLock();
|
|
1387
|
+
$("den-search")?.addEventListener("click", openPalette);
|
|
1388
|
+
$("den-status")?.addEventListener("click", () =>
|
|
1389
|
+
overlay?.kind === "drawer" ? closeOverlay(true) : openDrawer(),
|
|
1390
|
+
);
|
|
1391
|
+
$("den-runs")?.addEventListener("click", () => showRuns());
|
|
1392
|
+
$("den-lock")?.addEventListener("click", () =>
|
|
1393
|
+
overlay?.kind === "lock" ? closeOverlay(true) : openLock(),
|
|
1394
|
+
);
|
|
1395
|
+
document.addEventListener("keydown", (e) => {
|
|
1396
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "k") {
|
|
1397
|
+
e.preventDefault();
|
|
1398
|
+
openPalette();
|
|
1399
|
+
} else if (e.key === "Escape" && overlay && !e.defaultPrevented) {
|
|
1400
|
+
e.preventDefault();
|
|
1401
|
+
closeOverlay(true);
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
void initLock();
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/** For tests and plugins: open an overlay by name. */
|
|
1408
|
+
export const overlays = {
|
|
1409
|
+
palette: () => openPalette(),
|
|
1410
|
+
drawer: () => openDrawer(),
|
|
1411
|
+
lock: () => openLock(),
|
|
1412
|
+
close: () => closeOverlay(true),
|
|
1413
|
+
};
|