@monster-orange/den 0.2.3 → 0.2.5
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 +208 -0
- package/docs/distributions.md +64 -16
- package/package.json +1 -1
- package/service/install.sh +4 -2
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -87
- package/src/shared/environment-manager/health.ts +229 -30
- package/src/shared/environment-manager/opctl.ts +83 -1
- package/src/shared/environment-manager/service-runner.ts +49 -4
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/paths.ts +42 -0
- package/src/shared/product.ts +7 -1
- package/src/shared/profile.ts +8 -0
- package/src/shared/read-cache.ts +77 -0
- package/src/shared/stacks/compose-health.ts +150 -0
- package/src/shared/stacks/runtime.ts +71 -24
- package/src/shared/stacks/status.ts +112 -3
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +181 -31
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +25 -7
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +174 -197
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/ui-assets.ts +1 -0
- package/src/ui/server/workspace-routes.ts +247 -181
package/src/ui/server/index.ts
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* GET /api/run?pillar=X&... — SSE stream: runs tests, emits output line-by-line
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
composeHealthTarget,
|
|
15
|
+
type ComposeHealthTarget,
|
|
16
|
+
} from "../../shared/stacks/compose-health.js";
|
|
14
17
|
import { healthCheckFields } from "./health-row.js";
|
|
15
18
|
import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
|
|
16
19
|
import * as http from "http";
|
|
@@ -39,7 +42,6 @@ import type {
|
|
|
39
42
|
import { getRunDonePayload } from "./perf-report.js";
|
|
40
43
|
import { readCatalog } from "../../shared/environment-manager/catalog.js";
|
|
41
44
|
import {
|
|
42
|
-
readWorkspacesFile,
|
|
43
45
|
getActiveWorkspace,
|
|
44
46
|
getActiveWorkspaces,
|
|
45
47
|
getFocusedWorkspaceName,
|
|
@@ -49,10 +51,7 @@ import {
|
|
|
49
51
|
resolveDepAddress,
|
|
50
52
|
parseAddress,
|
|
51
53
|
readManifestAnyBranch,
|
|
52
|
-
noAutostartRepos,
|
|
53
54
|
} from "../../shared/environment-manager/workspaces.js";
|
|
54
|
-
import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
|
|
55
|
-
import { orderForDown } from "../../shared/environment-manager/derivation/derived-up.js";
|
|
56
55
|
import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
|
|
57
56
|
import {
|
|
58
57
|
resolveHistorySource,
|
|
@@ -64,7 +63,9 @@ import {
|
|
|
64
63
|
} from "./log-history.js";
|
|
65
64
|
import {
|
|
66
65
|
pingHttp,
|
|
67
|
-
|
|
66
|
+
pingShellCheck,
|
|
67
|
+
TimeoutStreaks,
|
|
68
|
+
prewarmHealthTls,
|
|
68
69
|
} from "../../shared/environment-manager/health.js";
|
|
69
70
|
import {
|
|
70
71
|
runServiceCommand,
|
|
@@ -74,7 +75,7 @@ import {
|
|
|
74
75
|
unregisterDaemon,
|
|
75
76
|
checkServiceHealth,
|
|
76
77
|
loadDaemonsFromFile,
|
|
77
|
-
|
|
78
|
+
pruneDeadDaemonsAsync,
|
|
78
79
|
isInteractiveServiceCommand,
|
|
79
80
|
rewriteLegacyOpctlKillCommandForContainer,
|
|
80
81
|
shouldDetachServiceCommand,
|
|
@@ -142,6 +143,10 @@ import {
|
|
|
142
143
|
import { handleReadonlyRoutes } from "./readonly-routes.js";
|
|
143
144
|
import { serveUiAsset } from "./ui-assets.js";
|
|
144
145
|
import { graphPrefs, mergeGraphPrefs } from "./graph-prefs.js";
|
|
146
|
+
import { singleFlight } from "./single-flight.js";
|
|
147
|
+
import { graphOrder } from "./graph-order.js";
|
|
148
|
+
import { clearReadCache, enableReadCache } from "../../shared/read-cache.js";
|
|
149
|
+
import { buildServicesList, type ServicesList } from "./services-list.js";
|
|
145
150
|
|
|
146
151
|
const __filename = fileURLToPath(import.meta.url);
|
|
147
152
|
const __dirname = path.dirname(__filename);
|
|
@@ -166,7 +171,10 @@ interface HealthResult {
|
|
|
166
171
|
repoPath: string;
|
|
167
172
|
mode: "dev-managed" | "managed";
|
|
168
173
|
isEntrypoint: boolean;
|
|
174
|
+
/** true up; false a completed check failed (down); null not known. */
|
|
169
175
|
ok: boolean | null;
|
|
176
|
+
/** With ok null: a check ran but has not answered (pending, not down). */
|
|
177
|
+
pending?: boolean;
|
|
170
178
|
status?: number;
|
|
171
179
|
error?: string;
|
|
172
180
|
/** Where the app opens for this env (open-in-new-tab), or null. */
|
|
@@ -208,7 +216,32 @@ function viewerRewrite(
|
|
|
208
216
|
}
|
|
209
217
|
|
|
210
218
|
const HEALTH_CACHE_TTL_MS = 8_000;
|
|
219
|
+
/** Consecutive timed-out health cycles per check (see TimeoutStreaks). */
|
|
220
|
+
const healthTimeouts = new TimeoutStreaks();
|
|
211
221
|
const healthCache = new Map<string, { results: HealthResult[]; at: number }>();
|
|
222
|
+
/**
|
|
223
|
+
* The full-env health run in flight per cache key. Concurrent polls and
|
|
224
|
+
* streams join it (a stream replays what already landed, then follows), so
|
|
225
|
+
* each cycle runs every check once: a second concurrent run doubled the
|
|
226
|
+
* shell checks' processes and pushed them past their budget under load.
|
|
227
|
+
*/
|
|
228
|
+
const healthInFlight = new Map<
|
|
229
|
+
string,
|
|
230
|
+
{
|
|
231
|
+
run: Promise<HealthResult[]>;
|
|
232
|
+
landed: HealthResult[];
|
|
233
|
+
listeners: Set<(r: HealthResult) => void>;
|
|
234
|
+
}
|
|
235
|
+
>();
|
|
236
|
+
/** /api/services builds, shared by concurrent polls and reused briefly. */
|
|
237
|
+
/** The startup sweep of stale daemon records (see below the routes). */
|
|
238
|
+
let startupPrune: Promise<void> = Promise.resolve();
|
|
239
|
+
const SERVICES_CACHE_TTL_MS = 2_000;
|
|
240
|
+
// Manifests, the edge config and the derived graph order are reused across
|
|
241
|
+
// a request and the polls after it (cleared by any mutating request).
|
|
242
|
+
const READ_CACHE_TTL_MS = 5_000;
|
|
243
|
+
enableReadCache(READ_CACHE_TTL_MS);
|
|
244
|
+
const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
|
|
212
245
|
/** A workspace-scoped run covers a different service set than the default one. */
|
|
213
246
|
function healthCacheKey(env: Environment, workspace: string | null): string {
|
|
214
247
|
return `${env}|${workspace ?? "*"}`;
|
|
@@ -534,6 +567,15 @@ async function handleRequest(
|
|
|
534
567
|
): Promise<void> {
|
|
535
568
|
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
|
|
536
569
|
const pathname = url.pathname;
|
|
570
|
+
// A mutating request may change what /api/services would list (exec and
|
|
571
|
+
// sync stream over GET, for EventSource).
|
|
572
|
+
if (
|
|
573
|
+
(req.method !== "GET" && req.method !== "HEAD") ||
|
|
574
|
+
/\/(exec|sync)$/.test(pathname)
|
|
575
|
+
) {
|
|
576
|
+
servicesCache.clear();
|
|
577
|
+
clearReadCache();
|
|
578
|
+
}
|
|
537
579
|
|
|
538
580
|
// ── Request logging ────────────────────────────────────────────────────────
|
|
539
581
|
// /api/health fires on the UI's 5s polling interval forever — it dominates
|
|
@@ -897,6 +939,8 @@ async function handleRequest(
|
|
|
897
939
|
org: string;
|
|
898
940
|
/** Codes that count as up for a remote target.url probe. */
|
|
899
941
|
expect?: Expect;
|
|
942
|
+
/** pingShell options (Den's own `stack health` can report unknown). */
|
|
943
|
+
compose: ComposeHealthTarget | null;
|
|
900
944
|
meta: Pick<HealthResult, "runs" | "target">;
|
|
901
945
|
}> = [];
|
|
902
946
|
let edgeDomain: string | null = null;
|
|
@@ -929,6 +973,7 @@ async function handleRequest(
|
|
|
929
973
|
openUrl: null,
|
|
930
974
|
links: [],
|
|
931
975
|
org: orgOf(resolved.repoName, catalog),
|
|
976
|
+
compose: null,
|
|
932
977
|
meta: { runs: "none", target: null },
|
|
933
978
|
});
|
|
934
979
|
continue;
|
|
@@ -936,9 +981,9 @@ async function handleRequest(
|
|
|
936
981
|
const svcCfg = manifest.services?.[resolved.subsystem];
|
|
937
982
|
// A compose-run repo with no declared check gets the stack's own health
|
|
938
983
|
// (container state + Docker healthchecks), as `den status` does.
|
|
939
|
-
const
|
|
984
|
+
const compose =
|
|
940
985
|
env === "local"
|
|
941
|
-
?
|
|
986
|
+
? composeHealthTarget(
|
|
942
987
|
manifest,
|
|
943
988
|
resolved.subsystem,
|
|
944
989
|
resolved.repoName,
|
|
@@ -951,7 +996,7 @@ async function handleRequest(
|
|
|
951
996
|
manifest,
|
|
952
997
|
resolved.subsystem,
|
|
953
998
|
env,
|
|
954
|
-
|
|
999
|
+
compose?.cmd ?? null,
|
|
955
1000
|
);
|
|
956
1001
|
const healthCfg = plan.check;
|
|
957
1002
|
const remote = plan.placement.runs === "remote";
|
|
@@ -1010,6 +1055,7 @@ async function handleRequest(
|
|
|
1010
1055
|
links: linksFor(manifest, resolved.subsystem, env),
|
|
1011
1056
|
org: orgOf(resolved.repoName, catalog),
|
|
1012
1057
|
...(plan.expect ? { expect: plan.expect } : {}),
|
|
1058
|
+
compose,
|
|
1013
1059
|
meta: { runs: plan.placement.runs, target: plan.placement.target },
|
|
1014
1060
|
});
|
|
1015
1061
|
}
|
|
@@ -1019,9 +1065,7 @@ async function handleRequest(
|
|
|
1019
1065
|
// direction as the graph (and `den status`). orderForDown is the reverse
|
|
1020
1066
|
// of the deps-first startup order.
|
|
1021
1067
|
const rank = new Map(
|
|
1022
|
-
|
|
1023
|
-
deriveGraph({ scope: "all", workspace: workspaces[0]?.name }),
|
|
1024
|
-
).map((repo, i) => [repo, i]),
|
|
1068
|
+
graphOrder(workspaces[0]?.name).map((repo, i) => [repo, i]),
|
|
1025
1069
|
);
|
|
1026
1070
|
const sortedEntries = [...entries].sort((a, b) => {
|
|
1027
1071
|
const ra = rank.get(a.repo) ?? Number.MAX_SAFE_INTEGER;
|
|
@@ -1041,12 +1085,18 @@ async function handleRequest(
|
|
|
1041
1085
|
conventional,
|
|
1042
1086
|
meta,
|
|
1043
1087
|
expect,
|
|
1088
|
+
compose,
|
|
1044
1089
|
}) => {
|
|
1045
1090
|
if (checkType !== "none") {
|
|
1046
1091
|
const started = Date.now();
|
|
1047
1092
|
const ping =
|
|
1048
1093
|
checkType === "shell"
|
|
1049
|
-
? await
|
|
1094
|
+
? await pingShellCheck(
|
|
1095
|
+
target,
|
|
1096
|
+
awsProfileEnv(env),
|
|
1097
|
+
repoPath,
|
|
1098
|
+
compose,
|
|
1099
|
+
)
|
|
1050
1100
|
: await pingHttp(
|
|
1051
1101
|
target,
|
|
1052
1102
|
env === "local" && meta.runs !== "remote"
|
|
@@ -1064,7 +1114,11 @@ async function handleRequest(
|
|
|
1064
1114
|
isEntrypoint,
|
|
1065
1115
|
...meta,
|
|
1066
1116
|
ms: Date.now() - started,
|
|
1067
|
-
|
|
1117
|
+
// A first timeout is pending; a repeat on the next cycle is down.
|
|
1118
|
+
...healthTimeouts.judge(
|
|
1119
|
+
`${env}|${repo}|${service}|${checkType}|${target}`,
|
|
1120
|
+
ping,
|
|
1121
|
+
),
|
|
1068
1122
|
};
|
|
1069
1123
|
}
|
|
1070
1124
|
// No declared health check: try the conventional probe when eligible.
|
|
@@ -1117,6 +1171,37 @@ async function handleRequest(
|
|
|
1117
1171
|
return results;
|
|
1118
1172
|
}
|
|
1119
1173
|
|
|
1174
|
+
/** Join the full-env health run in flight for (env, workspace), or start one. */
|
|
1175
|
+
function sharedHealthRun(env: Environment, workspace: string | null) {
|
|
1176
|
+
const key = healthCacheKey(env, workspace);
|
|
1177
|
+
const inFlight = healthInFlight.get(key);
|
|
1178
|
+
if (inFlight) return inFlight;
|
|
1179
|
+
const landed: HealthResult[] = [];
|
|
1180
|
+
const listeners = new Set<(r: HealthResult) => void>();
|
|
1181
|
+
const run = computeHealth(
|
|
1182
|
+
env,
|
|
1183
|
+
null,
|
|
1184
|
+
(r) => {
|
|
1185
|
+
landed.push(r);
|
|
1186
|
+
for (const l of listeners)
|
|
1187
|
+
try {
|
|
1188
|
+
l(r);
|
|
1189
|
+
} catch {
|
|
1190
|
+
/* a gone client must not stop the run */
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
workspace,
|
|
1194
|
+
)
|
|
1195
|
+
.then((results) => {
|
|
1196
|
+
healthCache.set(key, { results, at: Date.now() });
|
|
1197
|
+
return results;
|
|
1198
|
+
})
|
|
1199
|
+
.finally(() => healthInFlight.delete(key));
|
|
1200
|
+
const shared = { run, landed, listeners };
|
|
1201
|
+
healthInFlight.set(key, shared);
|
|
1202
|
+
return shared;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1120
1205
|
// Full-env health, cached. Every page navigation used to re-run all 30 checks
|
|
1121
1206
|
// from scratch — and the slowest are shell probes that shell out to the aws
|
|
1122
1207
|
// CLI (~0.45s each) — so the dots took ~0.6s to resolve on EVERY switch. A
|
|
@@ -1143,9 +1228,10 @@ async function handleRequest(
|
|
|
1143
1228
|
if (hit && Date.now() - hit.at < HEALTH_CACHE_TTL_MS) {
|
|
1144
1229
|
return sendJson(res, hit.results.map(forViewer));
|
|
1145
1230
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1231
|
+
return sendJson(
|
|
1232
|
+
res,
|
|
1233
|
+
(await sharedHealthRun(env, wsParam).run).map(forViewer),
|
|
1234
|
+
);
|
|
1149
1235
|
}
|
|
1150
1236
|
|
|
1151
1237
|
// Same checks, streamed: each result is sent the moment it lands so the fast
|
|
@@ -1177,13 +1263,16 @@ async function handleRequest(
|
|
|
1177
1263
|
}
|
|
1178
1264
|
|
|
1179
1265
|
try {
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1266
|
+
const shared = sharedHealthRun(env, wsParam);
|
|
1267
|
+
const forward = (r: HealthResult): void =>
|
|
1268
|
+
send({ type: "result", result: forViewer(r) });
|
|
1269
|
+
for (const r of shared.landed) forward(r);
|
|
1270
|
+
shared.listeners.add(forward);
|
|
1271
|
+
try {
|
|
1272
|
+
await shared.run;
|
|
1273
|
+
} finally {
|
|
1274
|
+
shared.listeners.delete(forward);
|
|
1275
|
+
}
|
|
1187
1276
|
send({ type: "done", cached: false });
|
|
1188
1277
|
} catch (e) {
|
|
1189
1278
|
send({ type: "error", error: (e as Error).message });
|
|
@@ -1196,169 +1285,15 @@ async function handleRequest(
|
|
|
1196
1285
|
const env = (url.searchParams.get("env") ?? "local") as Environment;
|
|
1197
1286
|
// Optional: caller may request services for a specific (non-active) workspace.
|
|
1198
1287
|
const wsOverride = url.searchParams.get("workspace") ?? null;
|
|
1199
|
-
|
|
1200
|
-
//
|
|
1201
|
-
// running containers with no declared command, no-ops gracefully), and
|
|
1202
|
-
// `restart` is orchestrated by the exec runner as down → verify → up — so
|
|
1203
|
-
// every service with an `up` can offer both even when the manifest doesn't
|
|
1204
|
-
// declare them.
|
|
1205
|
-
const withDown = (cmds: string[]): string[] => {
|
|
1206
|
-
if (env !== "local") return cmds;
|
|
1207
|
-
const out = [...cmds];
|
|
1208
|
-
if (!out.includes("down")) out.push("down");
|
|
1209
|
-
if (!out.includes("restart") && out.includes("up")) out.push("restart");
|
|
1210
|
-
return out;
|
|
1211
|
-
};
|
|
1212
|
-
|
|
1213
|
-
// Legacy: discovered repos from .den/ symlinks / registered-repos.json
|
|
1214
|
-
const legacyRepos = discoverRepos();
|
|
1215
|
-
const legacyEntries = legacyRepos.flatMap((repo) => {
|
|
1216
|
-
const manifestLayers = loadManifest(repo.repoPath).layers;
|
|
1217
|
-
return Object.entries(repo.manifest.services ?? {}).map(
|
|
1218
|
-
([svcName, svc]) => ({
|
|
1219
|
-
repo: repo.repoName,
|
|
1220
|
-
service: svcName,
|
|
1221
|
-
address: `${repo.repoName}/${svcName}`,
|
|
1222
|
-
repoPath: repo.repoPath,
|
|
1223
|
-
commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
|
|
1224
|
-
logs: svc.logs?.[env] ?? null,
|
|
1225
|
-
mode: "dev-managed" as const,
|
|
1226
|
-
branch: null as string | null,
|
|
1227
|
-
fromCatalog: false,
|
|
1228
|
-
isEntrypoint: repo.manifest.entrypoint === svcName,
|
|
1229
|
-
autostart: true, // legacy discovery has no workspace entry to toggle
|
|
1230
|
-
deps: (svc.depends_on ?? []).map(depService),
|
|
1231
|
-
cloned: true, // legacy discovery only ever finds on-disk repos
|
|
1232
|
-
manifestLayers,
|
|
1233
|
-
...servicePlacement(repo.manifest, svcName, env),
|
|
1234
|
-
}),
|
|
1235
|
-
);
|
|
1236
|
-
});
|
|
1237
|
-
|
|
1238
|
-
// Catalog entries (workspace-aware)
|
|
1239
|
-
const catalog = readCatalog();
|
|
1240
|
-
const wsFile = wsOverride ? readWorkspacesFile() : null;
|
|
1241
|
-
const ws = wsOverride
|
|
1242
|
-
? wsFile?.workspaces[wsOverride]
|
|
1243
|
-
? { name: wsOverride, config: wsFile!.workspaces[wsOverride] }
|
|
1244
|
-
: null
|
|
1245
|
-
: getActiveWorkspace();
|
|
1246
|
-
const getCurrentBranch = (repoPath: string): string | null => {
|
|
1247
|
-
try {
|
|
1248
|
-
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1249
|
-
cwd: repoPath,
|
|
1250
|
-
encoding: "utf-8",
|
|
1251
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
1252
|
-
timeout: GIT_REMOTE_TIMEOUT_MS,
|
|
1253
|
-
}).trim();
|
|
1254
|
-
} catch {
|
|
1255
|
-
return null;
|
|
1256
|
-
}
|
|
1257
|
-
};
|
|
1258
|
-
|
|
1259
|
-
const noAutostart = ws
|
|
1260
|
-
? noAutostartRepos(ws.config, catalog)
|
|
1261
|
-
: new Set<string>();
|
|
1262
|
-
const daemonOwners = new Map(
|
|
1263
|
-
listDaemons().map((d) => [d.address, d.workspaces ?? []] as const),
|
|
1264
|
-
);
|
|
1265
|
-
// Modules and libraries have no runtime: no card, health or commands.
|
|
1266
|
-
// They show as contributors on the Applications tab instead.
|
|
1267
|
-
const catalogEntries = ws
|
|
1268
|
-
? listWorkspaceServices(ws.config, catalog, ws.name)
|
|
1269
|
-
.filter((r) => hasRuntime(repoKind(r.repoPath)))
|
|
1270
|
-
.flatMap((resolved) => {
|
|
1271
|
-
// The manifest and the layers it was built from: repo or held,
|
|
1272
|
-
// plus override when manifests/<repo>.override.yml applied.
|
|
1273
|
-
const { manifest, layers: manifestLayers } = loadManifest(
|
|
1274
|
-
resolved.repoPath,
|
|
1275
|
-
);
|
|
1276
|
-
const svc = manifest?.services?.[resolved.subsystem];
|
|
1277
|
-
if (!manifest || !svc) {
|
|
1278
|
-
// Workspace membership is config-driven: a managed entry
|
|
1279
|
-
// whose clone isn't on disk yet (or a checkout without a usable
|
|
1280
|
-
// manifest) must still render everywhere — flagged `cloned:false`
|
|
1281
|
-
// so the UI can offer Sync/clone instead of dropping the system.
|
|
1282
|
-
// `branch` comes from the workspace entry; never probe git on a
|
|
1283
|
-
// path that may not exist.
|
|
1284
|
-
return [
|
|
1285
|
-
{
|
|
1286
|
-
repo: resolved.repoName,
|
|
1287
|
-
service: resolved.subsystem,
|
|
1288
|
-
address: resolved.address,
|
|
1289
|
-
repoPath: resolved.repoPath,
|
|
1290
|
-
commands: [] as string[],
|
|
1291
|
-
logs: null,
|
|
1292
|
-
mode: resolved.mode,
|
|
1293
|
-
branch: resolved.branch,
|
|
1294
|
-
fromCatalog: true,
|
|
1295
|
-
isEntrypoint: false,
|
|
1296
|
-
autostart: !noAutostart.has(resolved.repoName),
|
|
1297
|
-
owners: daemonOwners.get(resolved.address) ?? [],
|
|
1298
|
-
deps: [] as string[],
|
|
1299
|
-
cloned: fs.existsSync(resolved.repoPath),
|
|
1300
|
-
manifestLayers,
|
|
1301
|
-
...servicePlacement(null, resolved.subsystem, env),
|
|
1302
|
-
},
|
|
1303
|
-
];
|
|
1304
|
-
}
|
|
1305
|
-
return [
|
|
1306
|
-
{
|
|
1307
|
-
repo: resolved.repoName,
|
|
1308
|
-
service: resolved.subsystem,
|
|
1309
|
-
address: resolved.address,
|
|
1310
|
-
repoPath: resolved.repoPath,
|
|
1311
|
-
commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
|
|
1312
|
-
logs: svc.logs?.[env] ?? null,
|
|
1313
|
-
mode: resolved.mode,
|
|
1314
|
-
branch: resolved.branch ?? getCurrentBranch(resolved.repoPath),
|
|
1315
|
-
fromCatalog: true,
|
|
1316
|
-
isEntrypoint: manifest.entrypoint === resolved.subsystem,
|
|
1317
|
-
autostart: !noAutostart.has(resolved.repoName),
|
|
1318
|
-
owners: daemonOwners.get(resolved.address) ?? [],
|
|
1319
|
-
deps: (svc.depends_on ?? []).map((d) =>
|
|
1320
|
-
resolveDepAddress(depService(d), catalog, ws.config, ws.name),
|
|
1321
|
-
),
|
|
1322
|
-
cloned: true,
|
|
1323
|
-
manifestLayers,
|
|
1324
|
-
...servicePlacement(manifest, resolved.subsystem, env),
|
|
1325
|
-
},
|
|
1326
|
-
];
|
|
1327
|
-
})
|
|
1328
|
-
: [];
|
|
1329
|
-
|
|
1330
|
-
// Merge: catalog entries take precedence over legacy for same repo.
|
|
1331
|
-
// When a workspace filter is active, also exclude legacy entries not in
|
|
1332
|
-
// the workspace (legacy discovery is not workspace-scoped).
|
|
1333
|
-
const catalogRepos = new Set(Object.keys(catalog));
|
|
1334
|
-
const wsServiceKeys = ws ? new Set(Object.keys(ws.config.services)) : null;
|
|
1335
|
-
const merged = [
|
|
1336
|
-
...legacyEntries.filter((e) => {
|
|
1337
|
-
if (catalogRepos.has(e.repo)) return false;
|
|
1338
|
-
if (wsServiceKeys && !wsServiceKeys.has(e.repo)) return false;
|
|
1339
|
-
return true;
|
|
1340
|
-
}),
|
|
1341
|
-
...catalogEntries,
|
|
1342
|
-
];
|
|
1343
|
-
|
|
1344
|
-
// Attach the dependency-graph position so consumers can present systems
|
|
1345
|
-
// top-down (entry repo first, infra-stack last) — the same order the
|
|
1346
|
-
// health row and `den status` use. Added as a FIELD rather than by
|
|
1347
|
-
// re-sorting the array, because four pages consume this payload and some
|
|
1348
|
-
// rely on the current order.
|
|
1349
|
-
const graphRank = new Map(
|
|
1350
|
-
orderForDown(deriveGraph({ scope: "all", workspace: ws?.name })).map(
|
|
1351
|
-
(repo, i) => [repo, i],
|
|
1352
|
-
),
|
|
1353
|
-
);
|
|
1354
|
-
const infraRepo = readDenConfig()?.graph?.infra?.repo ?? null;
|
|
1288
|
+
// Every open tab polls this: concurrent requests share one build, and a
|
|
1289
|
+
// settled one is reused briefly (any mutating request clears it).
|
|
1355
1290
|
return sendJson(
|
|
1356
1291
|
res,
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1292
|
+
await servicesCache.get(
|
|
1293
|
+
`${env}|${wsOverride ?? ""}`,
|
|
1294
|
+
() => buildServicesList(env, wsOverride),
|
|
1295
|
+
url.searchParams.get("refresh") === "1",
|
|
1296
|
+
),
|
|
1362
1297
|
);
|
|
1363
1298
|
}
|
|
1364
1299
|
|
|
@@ -1836,6 +1771,7 @@ async function handleRequest(
|
|
|
1836
1771
|
|
|
1837
1772
|
// ── API: running daemons list ──────────────────────────────────────────────
|
|
1838
1773
|
if (pathname === "/api/service/daemons") {
|
|
1774
|
+
await startupPrune;
|
|
1839
1775
|
const daemons = listDaemons().map((d) => ({
|
|
1840
1776
|
address: d.address,
|
|
1841
1777
|
repoName: d.repoName,
|
|
@@ -3206,15 +3142,20 @@ server.on("error", (err: NodeJS.ErrnoException) => {
|
|
|
3206
3142
|
// service stopped outside den — otherwise reopen as tabs in the web UI and
|
|
3207
3143
|
// clutter the shutdown prompt. Container liveness (not health) is the signal: a
|
|
3208
3144
|
// service's shared-infra health check stays green even when the service is down.
|
|
3209
|
-
//
|
|
3210
|
-
//
|
|
3145
|
+
// The container listing runs as async child processes (a sync `docker ps`
|
|
3146
|
+
// here held the event loop at startup); /api/service/daemons waits for it,
|
|
3147
|
+
// so the UI never gets a stale entry on its first load.
|
|
3211
3148
|
loadDaemonsFromFile();
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3149
|
+
startupPrune = pruneDeadDaemonsAsync()
|
|
3150
|
+
.then((pruned) => {
|
|
3151
|
+
if (pruned.length > 0)
|
|
3152
|
+
console.error(
|
|
3153
|
+
`Cleared ${pruned.length} stale daemon(s): ${pruned.join(", ")}`,
|
|
3154
|
+
);
|
|
3155
|
+
})
|
|
3156
|
+
.catch((e: unknown) => {
|
|
3157
|
+
console.error(`[server] stale-daemon sweep failed: ${String(e)}`);
|
|
3158
|
+
});
|
|
3218
3159
|
|
|
3219
3160
|
// Background recovery: after a short delay (long enough for services to be
|
|
3220
3161
|
// responding), health-check each loaded daemon. Removal requires strong
|
|
@@ -3272,6 +3213,16 @@ process.on("unhandledRejection", (reason) => {
|
|
|
3272
3213
|
console.error("[server] unhandled rejection (continuing):", reason);
|
|
3273
3214
|
});
|
|
3274
3215
|
|
|
3216
|
+
// Build, before serving, what the first health poll and /api/services would
|
|
3217
|
+
// otherwise build inside a request: the dependency-graph order and the HTTPS
|
|
3218
|
+
// checks' trust context (both synchronous).
|
|
3219
|
+
try {
|
|
3220
|
+
for (const ws of getActiveWorkspaces()) graphOrder(ws);
|
|
3221
|
+
prewarmHealthTls();
|
|
3222
|
+
} catch (e) {
|
|
3223
|
+
serverLog(` ⚠ cache warm-up failed: ${(e as Error).message}`);
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3275
3226
|
// Bind loopback only — this UI runs privileged local actions (sudo-backed
|
|
3276
3227
|
// service commands), so it must not be reachable from the LAN. Opt out with
|
|
3277
3228
|
// DEN_BIND_HOST=0.0.0.0 for deliberate exposure.
|
|
@@ -3375,7 +3326,20 @@ async function stopDaemons(): Promise<void> {
|
|
|
3375
3326
|
|
|
3376
3327
|
async function handleShutdown(signal: string): Promise<void> {
|
|
3377
3328
|
clearSudoPassword();
|
|
3378
|
-
|
|
3329
|
+
// The registry lives in the state dir, which is resolved from the
|
|
3330
|
+
// distribution root on every call: if that can no longer be found (den.yml
|
|
3331
|
+
// moved or removed while the server ran), still exit. Never product() here:
|
|
3332
|
+
// it resolves the same root.
|
|
3333
|
+
let daemons: ReturnType<typeof listDaemons>;
|
|
3334
|
+
try {
|
|
3335
|
+
daemons = listDaemons();
|
|
3336
|
+
} catch (e) {
|
|
3337
|
+
console.error(
|
|
3338
|
+
`\n[server] ${signal}: cannot read the running-services registry ` +
|
|
3339
|
+
`(${(e as Error).message}); exiting and leaving any services running.`,
|
|
3340
|
+
);
|
|
3341
|
+
process.exit(0);
|
|
3342
|
+
}
|
|
3379
3343
|
if (daemons.length === 0) {
|
|
3380
3344
|
process.exit(0);
|
|
3381
3345
|
}
|
|
@@ -3419,16 +3383,29 @@ async function handleShutdown(signal: string): Promise<void> {
|
|
|
3419
3383
|
process.exit(0);
|
|
3420
3384
|
}
|
|
3421
3385
|
|
|
3386
|
+
/**
|
|
3387
|
+
* Run the shutdown for `signal`. A failure still ends the process: the guard
|
|
3388
|
+
* below ignores every later signal, so a shutdown that rejected would leave
|
|
3389
|
+
* the server running and deaf to SIGTERM (a service manager then waits out
|
|
3390
|
+
* its timeout and SIGKILLs it).
|
|
3391
|
+
*/
|
|
3392
|
+
function shutdownOn(signal: string): void {
|
|
3393
|
+
handleShutdown(signal).catch((e: unknown) => {
|
|
3394
|
+
console.error(`[server] ${signal}: shutdown failed (exiting):`, e);
|
|
3395
|
+
process.exit(1);
|
|
3396
|
+
});
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3422
3399
|
let _shuttingDown = false;
|
|
3423
3400
|
process.on("SIGINT", () => {
|
|
3424
3401
|
if (_shuttingDown) return;
|
|
3425
3402
|
_shuttingDown = true;
|
|
3426
3403
|
console.log(""); // newline after ^C
|
|
3427
|
-
|
|
3404
|
+
shutdownOn("SIGINT");
|
|
3428
3405
|
});
|
|
3429
3406
|
|
|
3430
3407
|
process.on("SIGTERM", () => {
|
|
3431
3408
|
if (_shuttingDown) return;
|
|
3432
3409
|
_shuttingDown = true;
|
|
3433
|
-
|
|
3410
|
+
shutdownOn("SIGTERM");
|
|
3434
3411
|
});
|
|
@@ -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
|
+
}
|