@monster-orange/den 0.2.4 → 0.2.6
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 +195 -0
- package/docs/distributions.md +158 -16
- package/package.json +1 -1
- package/src/pillars/security/discover.ts +1 -1
- package/src/pillars/smoke/setup-global.ts +2 -1
- package/src/scripts/configure-aws-profiles.ts +2 -1
- package/src/scripts/register-repos.ts +4 -3
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +229 -87
- package/src/shared/environment-manager/health.ts +235 -35
- package/src/shared/manifest/placement.ts +1 -1
- package/src/shared/manifest/schema.ts +47 -0
- package/src/shared/manifest/service-links.ts +1 -1
- package/src/shared/manifest/validate-manifest.ts +1 -1
- package/src/shared/plural.ts +13 -0
- package/src/shared/service-url.ts +2 -5
- package/src/shared/stacks/compose-health.ts +242 -0
- package/src/shared/stacks/runtime.ts +73 -24
- package/src/shared/stacks/status.ts +3 -2
- package/src/shared/types/index.ts +13 -1
- package/src/ui/cli/create.ts +2 -1
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +86 -41
- package/src/ui/cli/mcp-connect.ts +36 -5
- package/src/ui/cli/server-identity.ts +233 -0
- package/src/ui/cli/service-command.ts +136 -12
- package/src/ui/cli/spinner.ts +48 -0
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +101 -32
- package/src/ui/mcp/api.ts +94 -1
- package/src/ui/mcp/server.ts +6 -1
- package/src/ui/mcp/tools.ts +6 -2
- package/src/ui/server/health-row.ts +6 -3
- package/src/ui/server/index.ts +120 -40
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/ui-assets.ts +1 -0
- package/src/ui/server/workspace-routes.ts +7 -0
package/src/ui/server/index.ts
CHANGED
|
@@ -10,8 +10,13 @@
|
|
|
10
10
|
* GET /api/run?pillar=X&... — SSE stream: runs tests, emits output line-by-line
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
|
|
13
|
+
import {
|
|
14
|
+
composeHealthTarget,
|
|
15
|
+
containerCheckLabel,
|
|
16
|
+
type ComposeHealthTarget,
|
|
17
|
+
type ContainerHealthTarget,
|
|
18
|
+
} from "../../shared/stacks/compose-health.js";
|
|
19
|
+
import { healthCheckFields, type HealthCheckType } from "./health-row.js";
|
|
15
20
|
import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
|
|
16
21
|
import * as http from "http";
|
|
17
22
|
import * as fs from "fs";
|
|
@@ -59,8 +64,11 @@ import {
|
|
|
59
64
|
isSafeServiceName,
|
|
60
65
|
} from "./log-history.js";
|
|
61
66
|
import {
|
|
67
|
+
containerTarget,
|
|
68
|
+
pingContainerCheck,
|
|
62
69
|
pingHttp,
|
|
63
|
-
|
|
70
|
+
pingShellCheck,
|
|
71
|
+
TimeoutStreaks,
|
|
64
72
|
prewarmHealthTls,
|
|
65
73
|
} from "../../shared/environment-manager/health.js";
|
|
66
74
|
import {
|
|
@@ -167,7 +175,10 @@ interface HealthResult {
|
|
|
167
175
|
repoPath: string;
|
|
168
176
|
mode: "dev-managed" | "managed";
|
|
169
177
|
isEntrypoint: boolean;
|
|
178
|
+
/** true up; false a completed check failed (down); null not known. */
|
|
170
179
|
ok: boolean | null;
|
|
180
|
+
/** With ok null: a check ran but has not answered (pending, not down). */
|
|
181
|
+
pending?: boolean;
|
|
171
182
|
status?: number;
|
|
172
183
|
error?: string;
|
|
173
184
|
/** Where the app opens for this env (open-in-new-tab), or null. */
|
|
@@ -209,9 +220,23 @@ function viewerRewrite(
|
|
|
209
220
|
}
|
|
210
221
|
|
|
211
222
|
const HEALTH_CACHE_TTL_MS = 8_000;
|
|
223
|
+
/** Consecutive timed-out health cycles per check (see TimeoutStreaks). */
|
|
224
|
+
const healthTimeouts = new TimeoutStreaks();
|
|
212
225
|
const healthCache = new Map<string, { results: HealthResult[]; at: number }>();
|
|
213
|
-
/**
|
|
214
|
-
|
|
226
|
+
/**
|
|
227
|
+
* The full-env health run in flight per cache key. Concurrent polls and
|
|
228
|
+
* streams join it (a stream replays what already landed, then follows), so
|
|
229
|
+
* each cycle runs every check once: a second concurrent run doubled the
|
|
230
|
+
* shell checks' processes and pushed them past their budget under load.
|
|
231
|
+
*/
|
|
232
|
+
const healthInFlight = new Map<
|
|
233
|
+
string,
|
|
234
|
+
{
|
|
235
|
+
run: Promise<HealthResult[]>;
|
|
236
|
+
landed: HealthResult[];
|
|
237
|
+
listeners: Set<(r: HealthResult) => void>;
|
|
238
|
+
}
|
|
239
|
+
>();
|
|
215
240
|
/** /api/services builds, shared by concurrent polls and reused briefly. */
|
|
216
241
|
/** The startup sweep of stale daemon records (see below the routes). */
|
|
217
242
|
let startupPrune: Promise<void> = Promise.resolve();
|
|
@@ -905,7 +930,7 @@ async function handleRequest(
|
|
|
905
930
|
repo: string;
|
|
906
931
|
service: string;
|
|
907
932
|
label: string;
|
|
908
|
-
checkType:
|
|
933
|
+
checkType: HealthCheckType;
|
|
909
934
|
target: string;
|
|
910
935
|
repoPath: string;
|
|
911
936
|
mode: "dev-managed" | "managed";
|
|
@@ -918,6 +943,10 @@ async function handleRequest(
|
|
|
918
943
|
org: string;
|
|
919
944
|
/** Codes that count as up for a remote target.url probe. */
|
|
920
945
|
expect?: Expect;
|
|
946
|
+
/** pingShell options (Den's own `stack health` can report unknown). */
|
|
947
|
+
compose: ComposeHealthTarget | null;
|
|
948
|
+
/** A `type: container` check: answered from the shared listing. */
|
|
949
|
+
container: ContainerHealthTarget | null;
|
|
921
950
|
meta: Pick<HealthResult, "runs" | "target">;
|
|
922
951
|
}> = [];
|
|
923
952
|
let edgeDomain: string | null = null;
|
|
@@ -950,6 +979,8 @@ async function handleRequest(
|
|
|
950
979
|
openUrl: null,
|
|
951
980
|
links: [],
|
|
952
981
|
org: orgOf(resolved.repoName, catalog),
|
|
982
|
+
compose: null,
|
|
983
|
+
container: null,
|
|
953
984
|
meta: { runs: "none", target: null },
|
|
954
985
|
});
|
|
955
986
|
continue;
|
|
@@ -957,9 +988,9 @@ async function handleRequest(
|
|
|
957
988
|
const svcCfg = manifest.services?.[resolved.subsystem];
|
|
958
989
|
// A compose-run repo with no declared check gets the stack's own health
|
|
959
990
|
// (container state + Docker healthchecks), as `den status` does.
|
|
960
|
-
const
|
|
991
|
+
const compose =
|
|
961
992
|
env === "local"
|
|
962
|
-
?
|
|
993
|
+
? composeHealthTarget(
|
|
963
994
|
manifest,
|
|
964
995
|
resolved.subsystem,
|
|
965
996
|
resolved.repoName,
|
|
@@ -972,7 +1003,7 @@ async function handleRequest(
|
|
|
972
1003
|
manifest,
|
|
973
1004
|
resolved.subsystem,
|
|
974
1005
|
env,
|
|
975
|
-
|
|
1006
|
+
compose?.cmd ?? null,
|
|
976
1007
|
);
|
|
977
1008
|
const healthCfg = plan.check;
|
|
978
1009
|
const remote = plan.placement.runs === "remote";
|
|
@@ -996,14 +1027,16 @@ async function handleRequest(
|
|
|
996
1027
|
service: resolved.subsystem,
|
|
997
1028
|
label: plan.label ?? healthCfg?.label ?? resolved.subsystem,
|
|
998
1029
|
checkType: healthCfg
|
|
999
|
-
? healthCfg.type === "shell"
|
|
1000
|
-
?
|
|
1030
|
+
? healthCfg.type === "shell" || healthCfg.type === "container"
|
|
1031
|
+
? healthCfg.type
|
|
1001
1032
|
: "http"
|
|
1002
1033
|
: "none",
|
|
1003
1034
|
target: healthCfg
|
|
1004
1035
|
? healthCfg.type === "shell"
|
|
1005
1036
|
? healthCfg.cmd
|
|
1006
|
-
: healthCfg.
|
|
1037
|
+
: healthCfg.type === "container"
|
|
1038
|
+
? containerCheckLabel(containerTarget(healthCfg))
|
|
1039
|
+
: healthCfg.url
|
|
1007
1040
|
: "",
|
|
1008
1041
|
repoPath: resolved.repoPath,
|
|
1009
1042
|
mode: resolved.mode,
|
|
@@ -1024,13 +1057,15 @@ async function handleRequest(
|
|
|
1024
1057
|
subsystem: resolved.subsystem,
|
|
1025
1058
|
repoName: resolved.repoName,
|
|
1026
1059
|
env,
|
|
1027
|
-
healthUrl:
|
|
1028
|
-
healthCfg && healthCfg.type !== "shell" ? healthCfg.url : null,
|
|
1060
|
+
healthUrl: healthCfg && "url" in healthCfg ? healthCfg.url : null,
|
|
1029
1061
|
edgeDomain,
|
|
1030
1062
|
}),
|
|
1031
1063
|
links: linksFor(manifest, resolved.subsystem, env),
|
|
1032
1064
|
org: orgOf(resolved.repoName, catalog),
|
|
1033
1065
|
...(plan.expect ? { expect: plan.expect } : {}),
|
|
1066
|
+
compose,
|
|
1067
|
+
container:
|
|
1068
|
+
healthCfg?.type === "container" ? containerTarget(healthCfg) : null,
|
|
1034
1069
|
meta: { runs: plan.placement.runs, target: plan.placement.target },
|
|
1035
1070
|
});
|
|
1036
1071
|
}
|
|
@@ -1060,19 +1095,28 @@ async function handleRequest(
|
|
|
1060
1095
|
conventional,
|
|
1061
1096
|
meta,
|
|
1062
1097
|
expect,
|
|
1098
|
+
compose,
|
|
1099
|
+
container,
|
|
1063
1100
|
}) => {
|
|
1064
1101
|
if (checkType !== "none") {
|
|
1065
1102
|
const started = Date.now();
|
|
1066
1103
|
const ping =
|
|
1067
1104
|
checkType === "shell"
|
|
1068
|
-
? await
|
|
1069
|
-
: await pingHttp(
|
|
1105
|
+
? await pingShellCheck(
|
|
1070
1106
|
target,
|
|
1071
|
-
env
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1107
|
+
awsProfileEnv(env),
|
|
1108
|
+
repoPath,
|
|
1109
|
+
compose,
|
|
1110
|
+
)
|
|
1111
|
+
: checkType === "container" && container
|
|
1112
|
+
? await pingContainerCheck(container)
|
|
1113
|
+
: await pingHttp(
|
|
1114
|
+
target,
|
|
1115
|
+
env === "local" && meta.runs !== "remote"
|
|
1116
|
+
? HEALTH_CHECK_TIMEOUT_MS
|
|
1117
|
+
: HOSTED_HEALTH_CHECK_TIMEOUT_MS,
|
|
1118
|
+
expect,
|
|
1119
|
+
);
|
|
1076
1120
|
return {
|
|
1077
1121
|
repo,
|
|
1078
1122
|
service,
|
|
@@ -1083,7 +1127,11 @@ async function handleRequest(
|
|
|
1083
1127
|
isEntrypoint,
|
|
1084
1128
|
...meta,
|
|
1085
1129
|
ms: Date.now() - started,
|
|
1086
|
-
|
|
1130
|
+
// A first timeout is pending; a repeat on the next cycle is down.
|
|
1131
|
+
...healthTimeouts.judge(
|
|
1132
|
+
`${env}|${repo}|${service}|${checkType}|${target}`,
|
|
1133
|
+
ping,
|
|
1134
|
+
),
|
|
1087
1135
|
};
|
|
1088
1136
|
}
|
|
1089
1137
|
// No declared health check: try the conventional probe when eligible.
|
|
@@ -1136,6 +1184,37 @@ async function handleRequest(
|
|
|
1136
1184
|
return results;
|
|
1137
1185
|
}
|
|
1138
1186
|
|
|
1187
|
+
/** Join the full-env health run in flight for (env, workspace), or start one. */
|
|
1188
|
+
function sharedHealthRun(env: Environment, workspace: string | null) {
|
|
1189
|
+
const key = healthCacheKey(env, workspace);
|
|
1190
|
+
const inFlight = healthInFlight.get(key);
|
|
1191
|
+
if (inFlight) return inFlight;
|
|
1192
|
+
const landed: HealthResult[] = [];
|
|
1193
|
+
const listeners = new Set<(r: HealthResult) => void>();
|
|
1194
|
+
const run = computeHealth(
|
|
1195
|
+
env,
|
|
1196
|
+
null,
|
|
1197
|
+
(r) => {
|
|
1198
|
+
landed.push(r);
|
|
1199
|
+
for (const l of listeners)
|
|
1200
|
+
try {
|
|
1201
|
+
l(r);
|
|
1202
|
+
} catch {
|
|
1203
|
+
/* a gone client must not stop the run */
|
|
1204
|
+
}
|
|
1205
|
+
},
|
|
1206
|
+
workspace,
|
|
1207
|
+
)
|
|
1208
|
+
.then((results) => {
|
|
1209
|
+
healthCache.set(key, { results, at: Date.now() });
|
|
1210
|
+
return results;
|
|
1211
|
+
})
|
|
1212
|
+
.finally(() => healthInFlight.delete(key));
|
|
1213
|
+
const shared = { run, landed, listeners };
|
|
1214
|
+
healthInFlight.set(key, shared);
|
|
1215
|
+
return shared;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1139
1218
|
// Full-env health, cached. Every page navigation used to re-run all 30 checks
|
|
1140
1219
|
// from scratch — and the slowest are shell probes that shell out to the aws
|
|
1141
1220
|
// CLI (~0.45s each) — so the dots took ~0.6s to resolve on EVERY switch. A
|
|
@@ -1162,17 +1241,10 @@ async function handleRequest(
|
|
|
1162
1241
|
if (hit && Date.now() - hit.at < HEALTH_CACHE_TTL_MS) {
|
|
1163
1242
|
return sendJson(res, hit.results.map(forViewer));
|
|
1164
1243
|
}
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
healthCache.set(cacheKey, { results, at: Date.now() });
|
|
1170
|
-
return results;
|
|
1171
|
-
})
|
|
1172
|
-
.finally(() => healthInFlight.delete(cacheKey));
|
|
1173
|
-
healthInFlight.set(cacheKey, run);
|
|
1174
|
-
}
|
|
1175
|
-
return sendJson(res, (await run).map(forViewer));
|
|
1244
|
+
return sendJson(
|
|
1245
|
+
res,
|
|
1246
|
+
(await sharedHealthRun(env, wsParam).run).map(forViewer),
|
|
1247
|
+
);
|
|
1176
1248
|
}
|
|
1177
1249
|
|
|
1178
1250
|
// Same checks, streamed: each result is sent the moment it lands so the fast
|
|
@@ -1204,13 +1276,16 @@ async function handleRequest(
|
|
|
1204
1276
|
}
|
|
1205
1277
|
|
|
1206
1278
|
try {
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1279
|
+
const shared = sharedHealthRun(env, wsParam);
|
|
1280
|
+
const forward = (r: HealthResult): void =>
|
|
1281
|
+
send({ type: "result", result: forViewer(r) });
|
|
1282
|
+
for (const r of shared.landed) forward(r);
|
|
1283
|
+
shared.listeners.add(forward);
|
|
1284
|
+
try {
|
|
1285
|
+
await shared.run;
|
|
1286
|
+
} finally {
|
|
1287
|
+
shared.listeners.delete(forward);
|
|
1288
|
+
}
|
|
1214
1289
|
send({ type: "done", cached: false });
|
|
1215
1290
|
} catch (e) {
|
|
1216
1291
|
send({ type: "error", error: (e as Error).message });
|
|
@@ -2311,6 +2386,11 @@ async function handleRequest(
|
|
|
2311
2386
|
port: PORT,
|
|
2312
2387
|
// Den's version: the MCP server compares it with the routes it needs.
|
|
2313
2388
|
version: engineVersion() ?? null,
|
|
2389
|
+
// Whose server this is: `<cli> web up|down`, `mcp connect` and
|
|
2390
|
+
// `service install` compare both with their own and never act on
|
|
2391
|
+
// another distribution's server (see cli/server-identity.ts).
|
|
2392
|
+
root: ROOT,
|
|
2393
|
+
product: product().name,
|
|
2314
2394
|
startedAt: new Date(SERVER_STARTED_AT).toISOString(),
|
|
2315
2395
|
uptimeMs: Date.now() - SERVER_STARTED_AT,
|
|
2316
2396
|
stale,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the version pop-up shows: the newest release only, or every
|
|
3
|
+
* version once the reader asks for them, plus the one button that toggles
|
|
4
|
+
* between the two (null when there is nothing earlier to show).
|
|
5
|
+
*/
|
|
6
|
+
export function changelogView(entries, showAll) {
|
|
7
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
8
|
+
const earlier = list.length - 1;
|
|
9
|
+
if (earlier < 1) return { shown: list.slice(0, 1), toggle: null };
|
|
10
|
+
return {
|
|
11
|
+
shown: showAll ? list : list.slice(0, 1),
|
|
12
|
+
toggle: showAll
|
|
13
|
+
? { label: "Show latest only", expanded: true }
|
|
14
|
+
: { label: `Show all versions (${earlier} earlier)`, expanded: false },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
buildWorkspaceMenuModel,
|
|
16
16
|
buildWorkspacePillModel,
|
|
17
17
|
} from "./workspace-picker.js";
|
|
18
|
+
import { changelogView } from "./changelog-view.js";
|
|
18
19
|
|
|
19
20
|
// 5s between polls — measured from each poll's COMPLETION (schedulePoll
|
|
20
21
|
// re-arms after renderHealthDots resolves), so slow probes can't overlap.
|
|
@@ -80,6 +81,8 @@ function _injectNavStyles() {
|
|
|
80
81
|
const LS_CHANGELOG_SEEN = "__DEN_CLI__-changelog-seen";
|
|
81
82
|
let _changelogEntries = [];
|
|
82
83
|
let _changelogInjected = false;
|
|
84
|
+
/** The pop-up lists every version only after the reader asks. */
|
|
85
|
+
let _clShowAll = false;
|
|
83
86
|
|
|
84
87
|
function _injectChangelogStyles() {
|
|
85
88
|
if (_changelogInjected) return;
|
|
@@ -134,6 +137,7 @@ function _injectChangelogStyles() {
|
|
|
134
137
|
.cl-entry { margin-bottom: 18px; }
|
|
135
138
|
.cl-ver-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
|
|
136
139
|
.cl-ver {
|
|
140
|
+
margin: 0;
|
|
137
141
|
font-family: var(--mono, monospace); font-size: 13px; font-weight: 700;
|
|
138
142
|
color: var(--accent, #58a6ff);
|
|
139
143
|
}
|
|
@@ -166,6 +170,13 @@ function _injectChangelogStyles() {
|
|
|
166
170
|
display: inline-flex; align-items: center; justify-content: center;
|
|
167
171
|
transition: color .1s, border-color .1s;
|
|
168
172
|
}
|
|
173
|
+
.cl-more-btn {
|
|
174
|
+
margin-right: auto; padding: 4px 12px;
|
|
175
|
+
font: inherit; font-size: 11px; color: var(--muted, #8b949e);
|
|
176
|
+
background: none; border: 1px solid var(--border, #30363d); border-radius: 6px;
|
|
177
|
+
cursor: pointer;
|
|
178
|
+
}
|
|
179
|
+
.cl-more-btn:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); }
|
|
169
180
|
.cl-copy-btn:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); }
|
|
170
181
|
.cl-copy-btn.copied { color: #3fb950; border-color: #3fb950; }
|
|
171
182
|
.cl-slack-btn:hover { color: #e01e5a; border-color: #e01e5a; }
|
|
@@ -189,12 +200,18 @@ function _injectChangelogStyles() {
|
|
|
189
200
|
overlay.id = "cl-overlay";
|
|
190
201
|
overlay.className = "cl-overlay";
|
|
191
202
|
overlay.innerHTML =
|
|
192
|
-
'<div class="cl-box">' +
|
|
193
|
-
'<div class="cl-hdr"><h3>What\'s new</h3><button class="cl-close" id="cl-close-btn" title="Close">×</button></div>' +
|
|
203
|
+
'<div class="cl-box" role="dialog" aria-modal="true" aria-labelledby="cl-title">' +
|
|
204
|
+
'<div class="cl-hdr"><h3 id="cl-title" tabindex="-1">What\'s new</h3><button type="button" class="cl-close" id="cl-close-btn" title="Close" aria-label="Close">×</button></div>' +
|
|
194
205
|
'<div class="cl-body" id="cl-body"></div>' +
|
|
195
|
-
'<div class="cl-footer"><button class="cl-got-it" id="cl-got-it-btn">Got it</button></div>' +
|
|
206
|
+
'<div class="cl-footer"><button type="button" class="cl-more-btn" id="cl-more" aria-controls="cl-body" aria-expanded="false" hidden></button><button class="cl-got-it" id="cl-got-it-btn">Got it</button></div>' +
|
|
196
207
|
"</div>";
|
|
197
208
|
document.body.appendChild(overlay);
|
|
209
|
+
document.getElementById("cl-more")?.addEventListener("click", () => {
|
|
210
|
+
_clShowAll = !_clShowAll;
|
|
211
|
+
_renderChangelogBody(_changelogEntries);
|
|
212
|
+
const body = document.getElementById("cl-body");
|
|
213
|
+
if (body) body.scrollTop = 0;
|
|
214
|
+
});
|
|
198
215
|
|
|
199
216
|
overlay.addEventListener("click", (e) => {
|
|
200
217
|
if (e.target === overlay) window.closeChangelog();
|
|
@@ -207,10 +224,44 @@ function _injectChangelogStyles() {
|
|
|
207
224
|
?.addEventListener("click", () => window.closeChangelog());
|
|
208
225
|
|
|
209
226
|
document.addEventListener("keydown", (e) => {
|
|
227
|
+
if (!_changelogIsOpen()) return;
|
|
210
228
|
if (e.key === "Escape") window.closeChangelog();
|
|
229
|
+
else if (e.key === "Tab") _trapChangelogTab(e);
|
|
211
230
|
});
|
|
212
231
|
}
|
|
213
232
|
|
|
233
|
+
function _changelogIsOpen() {
|
|
234
|
+
return !!document.getElementById("cl-overlay")?.classList.contains("open");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The dialog's focusable controls, in order (hidden ones excluded). */
|
|
238
|
+
function _changelogFocusables() {
|
|
239
|
+
const box = document.querySelector("#cl-overlay .cl-box");
|
|
240
|
+
if (!box) return [];
|
|
241
|
+
return [...box.querySelectorAll("button, a[href], [tabindex]")].filter(
|
|
242
|
+
(el) => !el.hidden && el.getAttribute("tabindex") !== "-1",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Keep Tab inside the open dialog (aria-modal). */
|
|
247
|
+
function _trapChangelogTab(e) {
|
|
248
|
+
const els = _changelogFocusables();
|
|
249
|
+
if (!els.length) return;
|
|
250
|
+
const first = els[0];
|
|
251
|
+
const last = els[els.length - 1];
|
|
252
|
+
const inside = els.includes(document.activeElement);
|
|
253
|
+
if (e.shiftKey && (document.activeElement === first || !inside)) {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
last.focus();
|
|
256
|
+
} else if (!e.shiftKey && (document.activeElement === last || !inside)) {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
first.focus();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** What had focus before the dialog opened; it gets it back on close. */
|
|
263
|
+
let _clReturnFocus = null;
|
|
264
|
+
|
|
214
265
|
function _renderChangelogBody(entries) {
|
|
215
266
|
const body = document.getElementById("cl-body");
|
|
216
267
|
if (!body) return;
|
|
@@ -228,22 +279,25 @@ function _renderChangelogBody(entries) {
|
|
|
228
279
|
escHtml(s)
|
|
229
280
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
230
281
|
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
231
|
-
|
|
282
|
+
const view = changelogView(entries, _clShowAll);
|
|
283
|
+
body.innerHTML = view.shown
|
|
232
284
|
.map(
|
|
233
285
|
(entry, i) =>
|
|
234
286
|
(i > 0 ? '<hr class="cl-divider">' : "") +
|
|
235
287
|
'<div class="cl-entry">' +
|
|
236
288
|
'<div class="cl-ver-row">' +
|
|
237
|
-
'<
|
|
289
|
+
'<h4 class="cl-ver">v' +
|
|
238
290
|
escHtml(entry.version) +
|
|
239
|
-
"</
|
|
291
|
+
"</h4>" +
|
|
240
292
|
'<span class="cl-date">' +
|
|
241
293
|
escHtml(entry.date) +
|
|
242
294
|
"</span>" +
|
|
243
295
|
'<div class="cl-copy-group">' +
|
|
244
296
|
'<button class="cl-copy-btn" data-cl-version="' +
|
|
245
297
|
escHtml(entry.version) +
|
|
246
|
-
'" title="Copy as Markdown"
|
|
298
|
+
'" title="Copy as Markdown" aria-label="Copy v' +
|
|
299
|
+
escHtml(entry.version) +
|
|
300
|
+
' as Markdown">' +
|
|
247
301
|
'<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
248
302
|
'<rect x="4" y="0.5" width="8.5" height="8.5" rx="1.5"/>' +
|
|
249
303
|
'<path d="M8.5 9v2.5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h2.5"/>' +
|
|
@@ -251,7 +305,9 @@ function _renderChangelogBody(entries) {
|
|
|
251
305
|
"</button>" +
|
|
252
306
|
'<button class="cl-slack-btn" data-cl-version="' +
|
|
253
307
|
escHtml(entry.version) +
|
|
254
|
-
'" title="Copy for Slack"
|
|
308
|
+
'" title="Copy for Slack" aria-label="Copy v' +
|
|
309
|
+
escHtml(entry.version) +
|
|
310
|
+
' for Slack">' +
|
|
255
311
|
'<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
256
312
|
'<path d="M1 2a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H7.5L5 12v-3H2a1 1 0 0 1-1-1V2z"/>' +
|
|
257
313
|
"</svg>" +
|
|
@@ -290,6 +346,17 @@ function _renderChangelogBody(entries) {
|
|
|
290
346
|
)
|
|
291
347
|
.join("");
|
|
292
348
|
|
|
349
|
+
// The toggle lives in the footer, so it is in view however long the
|
|
350
|
+
// newest entry is.
|
|
351
|
+
const more = document.getElementById("cl-more");
|
|
352
|
+
if (more) {
|
|
353
|
+
more.hidden = !view.toggle;
|
|
354
|
+
if (view.toggle) {
|
|
355
|
+
more.textContent = view.toggle.label;
|
|
356
|
+
more.setAttribute("aria-expanded", String(view.toggle.expanded));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
293
360
|
const _slackIcon =
|
|
294
361
|
'<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
295
362
|
'<path d="M1 2a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H7.5L5 12v-3H2a1 1 0 0 1-1-1V2z"/>' +
|
|
@@ -455,15 +522,39 @@ function _buildMarkdown(entry) {
|
|
|
455
522
|
}
|
|
456
523
|
|
|
457
524
|
window.openChangelog = function () {
|
|
525
|
+
_clShowAll = false;
|
|
458
526
|
_injectChangelogStyles();
|
|
459
527
|
_renderChangelogBody(_changelogEntries);
|
|
460
528
|
const overlay = document.getElementById("cl-overlay");
|
|
461
|
-
if (overlay)
|
|
529
|
+
if (!overlay) return;
|
|
530
|
+
if (!overlay.classList.contains("open")) {
|
|
531
|
+
// A first-visit auto-open has nothing focused: return to the pill.
|
|
532
|
+
const active = document.activeElement;
|
|
533
|
+
_clReturnFocus =
|
|
534
|
+
active && active !== document.body
|
|
535
|
+
? active
|
|
536
|
+
: document.getElementById("version-pill");
|
|
537
|
+
}
|
|
538
|
+
overlay.classList.add("open");
|
|
539
|
+
// After .open: while the overlay is display:none the body cannot scroll,
|
|
540
|
+
// and a reset made then does not stick. Always open at the newest release.
|
|
541
|
+
const body = document.getElementById("cl-body");
|
|
542
|
+
if (body) body.scrollTop = 0;
|
|
543
|
+
// The heading first, so a screen reader starts at the latest notes.
|
|
544
|
+
document.getElementById("cl-title")?.focus();
|
|
462
545
|
};
|
|
463
546
|
|
|
464
547
|
window.closeChangelog = function () {
|
|
465
548
|
const overlay = document.getElementById("cl-overlay");
|
|
549
|
+
const wasOpen = !!overlay?.classList.contains("open");
|
|
466
550
|
if (overlay) overlay.classList.remove("open");
|
|
551
|
+
if (wasOpen) {
|
|
552
|
+
const back = _clReturnFocus?.isConnected
|
|
553
|
+
? _clReturnFocus
|
|
554
|
+
: document.getElementById("version-pill");
|
|
555
|
+
_clReturnFocus = null;
|
|
556
|
+
back?.focus();
|
|
557
|
+
}
|
|
467
558
|
if (_changelogEntries[0]?.version) {
|
|
468
559
|
try {
|
|
469
560
|
localStorage.setItem(LS_CHANGELOG_SEEN, _changelogEntries[0].version);
|
|
@@ -490,6 +581,17 @@ export async function initChangelog() {
|
|
|
490
581
|
if (pill && latest?.version) {
|
|
491
582
|
pill.querySelector(".v-txt").textContent = "v" + latest.version;
|
|
492
583
|
}
|
|
584
|
+
// The pill names the running build (0.2.5-dev.2, not the changelog's
|
|
585
|
+
// newest heading, 0.2.5), so a prerelease under test says what it is.
|
|
586
|
+
fetch("/api/server-info")
|
|
587
|
+
.then((r) => r.json())
|
|
588
|
+
.then((info) => {
|
|
589
|
+
const txt = pill?.querySelector(".v-txt");
|
|
590
|
+
if (txt && typeof info?.version === "string" && info.version) {
|
|
591
|
+
txt.textContent = "v" + info.version;
|
|
592
|
+
}
|
|
593
|
+
})
|
|
594
|
+
.catch(() => {});
|
|
493
595
|
|
|
494
596
|
let lastSeen = null;
|
|
495
597
|
try {
|
|
@@ -1043,7 +1145,9 @@ function _repoHealthItem(repo, items, escAttr) {
|
|
|
1043
1145
|
const state = ok === true ? "ok" : ok === false ? "err" : "";
|
|
1044
1146
|
const tip =
|
|
1045
1147
|
ok == null
|
|
1046
|
-
?
|
|
1148
|
+
? error
|
|
1149
|
+
? `${what} — ${error}`
|
|
1150
|
+
: "no health check"
|
|
1047
1151
|
: error
|
|
1048
1152
|
? `${what} — ${error}`
|
|
1049
1153
|
: `${what} → ${status}`;
|
|
@@ -1063,14 +1167,19 @@ function _repoHealthItem(repo, items, escAttr) {
|
|
|
1063
1167
|
overallState = entry.ok === true ? "ok" : entry.ok === false ? "err" : "";
|
|
1064
1168
|
tip =
|
|
1065
1169
|
entry.ok == null
|
|
1066
|
-
?
|
|
1170
|
+
? entry.error
|
|
1171
|
+
? `${entry.url ?? entry.check} — ${entry.error}`
|
|
1172
|
+
: "no health check"
|
|
1067
1173
|
: entry.error
|
|
1068
1174
|
? `${entry.url ?? entry.check} — ${entry.error}`
|
|
1069
1175
|
: `${entry.url ?? entry.check} → ${entry.status}`;
|
|
1070
1176
|
} else {
|
|
1177
|
+
// Only a completed, failed check (ok false) is down; ok null (no check,
|
|
1178
|
+
// or no answer yet) is unknown and never turns the dot red.
|
|
1071
1179
|
const allOk = items.every((i) => i.ok === true);
|
|
1072
1180
|
const anyOk = items.some((i) => i.ok === true);
|
|
1073
|
-
|
|
1181
|
+
const anyDown = items.some((i) => i.ok === false);
|
|
1182
|
+
overallState = allOk ? "ok" : anyOk ? "running" : anyDown ? "err" : "";
|
|
1074
1183
|
tip = `${items.length} services`;
|
|
1075
1184
|
}
|
|
1076
1185
|
return `<div class="health-item" title="${escAttr(tip)}">
|
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
} from "../../shared/environment-manager/catalog.js";
|
|
91
91
|
import { getReposRoot, readUserConfig } from "../../shared/den-config.js";
|
|
92
92
|
import { getDistRoot } from "../../shared/paths.js";
|
|
93
|
+
import { recordRemovedFromWorkspace } from "../../shared/environment-manager/default-workspace.js";
|
|
93
94
|
import { getSudoEnv, prewarmSudo } from "./sudo-store.js";
|
|
94
95
|
import {
|
|
95
96
|
devManagedPaths,
|
|
@@ -994,6 +995,12 @@ export async function handleWorkspaceRoutes(
|
|
|
994
995
|
// Delete exact match, or all subsystem entries for the repo
|
|
995
996
|
// (handles both bare-key workspaces like "shop" and
|
|
996
997
|
// explicit-subsystem workspaces like "shop/api", "shop/db").
|
|
998
|
+
// Removal is deliberate: top-up must never bring the repo back.
|
|
999
|
+
recordRemovedFromWorkspace(
|
|
1000
|
+
file,
|
|
1001
|
+
wsName,
|
|
1002
|
+
parseAddress(address, catalog).repoName,
|
|
1003
|
+
);
|
|
997
1004
|
if (services[address] !== undefined) {
|
|
998
1005
|
delete services[address];
|
|
999
1006
|
} else {
|