@monster-orange/den 0.2.7 → 0.3.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 +1655 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +619 -3
- 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 +9 -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/agent-actor.ts +38 -3
- 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 +63 -0
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +185 -7
- 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/environment-manager/catalog.ts +52 -1
- package/src/shared/environment-manager/default-workspace.ts +26 -3
- package/src/shared/environment-manager/git-remote.ts +49 -0
- 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/workspace-manager.ts +67 -25
- package/src/shared/environment-manager/workspace-org.ts +26 -11
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +328 -0
- 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/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/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 +24 -5
- package/src/ui/cli/clone-plan.ts +154 -23
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +131 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/flag-parser.ts +62 -3
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +649 -72
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/org-command.ts +12 -2
- package/src/ui/cli/plugin-command.ts +849 -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/up-args.ts +15 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +298 -0
- package/src/ui/mcp/server.ts +70 -8
- package/src/ui/mcp/tools.ts +151 -24
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/audit.ts +90 -3
- 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 +29 -45
- 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 +590 -45
- 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 +15 -3
- 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 +24 -4
- package/src/ui/server/release-service.ts +25 -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 +55 -9
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- package/themes/den/mark.svg +6 -0
|
@@ -1,31 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* It expects
|
|
6
|
-
*
|
|
7
|
-
* - a <select id="env-sel"> whose change event triggers a re-ping
|
|
2
|
+
* The shell's behaviour (shell.js draws the frame: top bar and rail):
|
|
3
|
+
* the health poll that feeds the status chip and drawer (topbar.js), the
|
|
4
|
+
* version pill and changelog, the workspace menu, the env picker (one for
|
|
5
|
+
* the whole shell) and the log badge. Every page imports it. It expects
|
|
6
|
+
* a <select id="env-sel"> (shell.js) whose change event triggers a re-ping.
|
|
8
7
|
*
|
|
9
8
|
* Health data is driven entirely by __DEN_CLI__.json manifests — no hardcoded
|
|
10
9
|
* service names. Each registered service that declares a health.url for
|
|
11
|
-
* the current env
|
|
10
|
+
* the current env is counted.
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from "./workspace-picker.js";
|
|
13
|
+
import { buildScopeMenuModel } from "./workspace-picker.js";
|
|
14
|
+
import { initAppearance } from "./appearance.js";
|
|
15
|
+
import { versionLabel } from "./version-label.js";
|
|
18
16
|
import { changelogView } from "./changelog-view.js";
|
|
17
|
+
import { buildRow, healthCycle, paintGate, shellHealth } from "./health-fit.js";
|
|
18
|
+
import { initTopbar, paintChecking, paintStatus } from "./topbar.js";
|
|
19
|
+
import { registerDenSettings } from "./settings-registry.js";
|
|
20
|
+
|
|
21
|
+
/** The shell's message lookup (R19; shell.js MESSAGES). */
|
|
22
|
+
const t = (key, vars) =>
|
|
23
|
+
typeof window !== "undefined" && window.denShell?.t
|
|
24
|
+
? window.denShell.t(key, vars)
|
|
25
|
+
: key;
|
|
26
|
+
const escT = (key, vars) =>
|
|
27
|
+
String(t(key, vars)).replace(
|
|
28
|
+
/[&<>"]/g,
|
|
29
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
isUnlocked,
|
|
34
|
+
onLockChange,
|
|
35
|
+
problemCards,
|
|
36
|
+
problemFix,
|
|
37
|
+
requireUnlock,
|
|
38
|
+
setServiceRunner,
|
|
39
|
+
takePendingAction,
|
|
40
|
+
} from "./topbar.js";
|
|
41
|
+
export { buildRow } from "./health-fit.js";
|
|
42
|
+
|
|
43
|
+
// Every page with this header gets the appearance controls (mode, theme)
|
|
44
|
+
// and the top bar's chips, palette and drawer (topbar.js).
|
|
45
|
+
void initAppearance();
|
|
46
|
+
// Den's SETTINGS rows on every page, not only /settings: each is a ⌘K
|
|
47
|
+
// palette row (kind "setting"), so "terminal" finds Default terminal.
|
|
48
|
+
if (typeof window !== "undefined")
|
|
49
|
+
(window.denShellQueue = window.denShellQueue || []).push((sh) => {
|
|
50
|
+
if (sh?.registerSetting) registerDenSettings(sh);
|
|
51
|
+
});
|
|
52
|
+
if (typeof document !== "undefined")
|
|
53
|
+
initTopbar({
|
|
54
|
+
refreshHealth: (env, opts) => refreshHealth(env, opts),
|
|
55
|
+
healthEnv: () => healthEnv(),
|
|
56
|
+
storeOpenLog: (repo, service, env) => storeOpenLog(repo, service, env),
|
|
57
|
+
});
|
|
19
58
|
|
|
20
59
|
// 5s between polls — measured from each poll's COMPLETION (schedulePoll
|
|
21
|
-
// re-arms after
|
|
60
|
+
// re-arms after renderHealth resolves), so slow probes can't overlap.
|
|
22
61
|
const POLL_INTERVAL_MS = 5_000;
|
|
23
62
|
|
|
24
63
|
// Last painted health results, keyed `repo/service`, so a new poll can seed
|
|
25
64
|
// from them and update dots in place instead of collapsing the strip and
|
|
26
|
-
// regrowing it (
|
|
65
|
+
// regrowing it (see renderHealth).
|
|
27
66
|
let _healthAcc = new Map();
|
|
28
67
|
let _healthAccGen = -1;
|
|
68
|
+
// The poll generation that last painted _healthAcc (healthCycle's `last`).
|
|
69
|
+
let _healthPaintGen = -1;
|
|
29
70
|
let pollTimer = null;
|
|
30
71
|
// Generation token: every (re)start of polling bumps this. A stale tick (an
|
|
31
72
|
// env the user already switched away from, or a slow fetch resolving late)
|
|
@@ -34,46 +75,63 @@ let pollTimer = null;
|
|
|
34
75
|
// the dots back to the old env.
|
|
35
76
|
let pollGen = 0;
|
|
36
77
|
|
|
37
|
-
// ──
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
78
|
+
// ── The env picker: one for the whole shell (0.3.0-dev.14) ─────────────────────
|
|
79
|
+
|
|
80
|
+
const LS_ENV = "__DEN_CLI__-env";
|
|
81
|
+
let _envReady = null;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fill the shell's env picker from /api/environments and restore the last
|
|
85
|
+
* choice (one key for every page). Resolves with the env list once the
|
|
86
|
+
* picker holds the right value; pages await it before reading #env-sel.
|
|
87
|
+
* Started as soon as header.js loads.
|
|
88
|
+
*/
|
|
89
|
+
export function envReady() {
|
|
90
|
+
if (_envReady) return _envReady;
|
|
91
|
+
_envReady = (async () => {
|
|
92
|
+
const sel = document.getElementById("env-sel");
|
|
93
|
+
const envs = await fetch("/api/environments")
|
|
94
|
+
.then((r) => r.json())
|
|
95
|
+
.then((v) => (Array.isArray(v) ? v : ["local"]))
|
|
96
|
+
.catch(() => ["local"]);
|
|
97
|
+
if (!sel) return envs;
|
|
98
|
+
const esc = (s) =>
|
|
99
|
+
String(s).replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
100
|
+
sel.innerHTML = envs
|
|
101
|
+
.map((e) => `<option value="${esc(e)}">${esc(e)}</option>`)
|
|
102
|
+
.join("");
|
|
103
|
+
let saved = null;
|
|
104
|
+
try {
|
|
105
|
+
saved = localStorage.getItem(LS_ENV);
|
|
106
|
+
} catch {}
|
|
107
|
+
if (saved && envs.includes(saved)) sel.value = saved;
|
|
108
|
+
// The Env ▾ menu (shell.js) shows and sets this select's value.
|
|
109
|
+
window.denShell?.syncEnv?.();
|
|
110
|
+
sel.addEventListener("change", () => {
|
|
111
|
+
try {
|
|
112
|
+
localStorage.setItem(LS_ENV, sel.value);
|
|
113
|
+
} catch {}
|
|
114
|
+
});
|
|
115
|
+
return envs;
|
|
116
|
+
})();
|
|
117
|
+
return _envReady;
|
|
49
118
|
}
|
|
119
|
+
if (typeof document !== "undefined" && document.getElementById?.("env-sel"))
|
|
120
|
+
void envReady();
|
|
50
121
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.
|
|
63
|
-
|
|
64
|
-
.nav-open-btn:hover { color: var(--accent, #58a6ff) !important; }
|
|
65
|
-
/* Repo dots boxed by application (Org > Application > Repo). */
|
|
66
|
-
.health-app {
|
|
67
|
-
display: flex; align-items: center; gap: 8px;
|
|
68
|
-
padding: 1px 8px 1px 6px; border: 1px solid var(--border, #30363d);
|
|
69
|
-
border-radius: 10px;
|
|
70
|
-
}
|
|
71
|
-
.health-app-label {
|
|
72
|
-
font-size: 10px; font-weight: 600; letter-spacing: .03em;
|
|
73
|
-
color: var(--text, #c9d1d9); opacity: .7; white-space: nowrap;
|
|
74
|
-
}
|
|
75
|
-
`;
|
|
76
|
-
document.head.appendChild(s);
|
|
122
|
+
/** The env picker's value: what this page shows. */
|
|
123
|
+
export function currentEnv() {
|
|
124
|
+
return document.getElementById("env-sel")?.value ?? "local";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The env the health row checks: the page's pin (Local Dev and Releases are
|
|
129
|
+
* local by nature: `data-health-env` on #den-shell), else the env picker.
|
|
130
|
+
*/
|
|
131
|
+
export function healthEnv() {
|
|
132
|
+
return (
|
|
133
|
+
document.getElementById("den-shell")?.dataset?.healthEnv || currentEnv()
|
|
134
|
+
);
|
|
77
135
|
}
|
|
78
136
|
|
|
79
137
|
// ── Changelog modal ────────────────────────────────────────────────────────────
|
|
@@ -91,19 +149,19 @@ function _injectChangelogStyles() {
|
|
|
91
149
|
const style = document.createElement("style");
|
|
92
150
|
style.textContent = `
|
|
93
151
|
.v-pill {
|
|
94
|
-
font-family: var(--mono
|
|
152
|
+
font-family: var(--mono); font-size: 10px;
|
|
95
153
|
padding: 2px 8px; border-radius: 10px;
|
|
96
|
-
border: 1px solid var(--border
|
|
154
|
+
border: 1px solid var(--border); color: var(--muted);
|
|
97
155
|
background: none; cursor: pointer; user-select: none;
|
|
98
156
|
transition: color .1s, border-color .1s; position: relative;
|
|
99
157
|
flex-shrink: 0;
|
|
100
158
|
}
|
|
101
|
-
.v-pill:hover { color: var(--accent
|
|
159
|
+
.v-pill:hover { color: var(--accent-text); border-color: var(--accent); }
|
|
102
160
|
.v-pip {
|
|
103
161
|
position: absolute; top: -3px; right: -3px;
|
|
104
162
|
width: 7px; height: 7px; border-radius: 50%;
|
|
105
|
-
background: var(--accent
|
|
106
|
-
border: 1.5px solid var(--bg
|
|
163
|
+
background: var(--accent);
|
|
164
|
+
border: 1.5px solid var(--bg);
|
|
107
165
|
display: none; pointer-events: none;
|
|
108
166
|
}
|
|
109
167
|
.cl-overlay {
|
|
@@ -113,58 +171,58 @@ function _injectChangelogStyles() {
|
|
|
113
171
|
}
|
|
114
172
|
.cl-overlay.open { display: flex; }
|
|
115
173
|
.cl-box {
|
|
116
|
-
background: var(--surface
|
|
117
|
-
border: 1px solid var(--border
|
|
174
|
+
background: var(--surface);
|
|
175
|
+
border: 1px solid var(--border);
|
|
118
176
|
border-radius: 10px; width: min(580px, 92vw); max-height: 78vh;
|
|
119
177
|
display: flex; flex-direction: column; overflow: hidden;
|
|
120
|
-
box-shadow:
|
|
178
|
+
box-shadow: var(--shadow);
|
|
121
179
|
}
|
|
122
180
|
.cl-hdr {
|
|
123
181
|
display: flex; align-items: center; gap: 10px;
|
|
124
|
-
padding: 13px 18px; border-bottom: 1px solid var(--border
|
|
182
|
+
padding: 13px 18px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
|
125
183
|
}
|
|
126
184
|
.cl-hdr h3 {
|
|
127
|
-
font-family: var(--mono
|
|
128
|
-
color: var(--text
|
|
185
|
+
font-family: var(--mono); font-size: 13px; font-weight: 600;
|
|
186
|
+
color: var(--text); flex: 1; margin: 0;
|
|
129
187
|
}
|
|
130
188
|
.cl-close {
|
|
131
|
-
background: none; border: none; color: var(--muted
|
|
189
|
+
background: none; border: none; color: var(--muted);
|
|
132
190
|
font-size: 18px; cursor: pointer; line-height: 1;
|
|
133
191
|
padding: 2px 5px; border-radius: 3px; transition: color .1s, background .1s;
|
|
134
192
|
}
|
|
135
|
-
.cl-close:hover { color: var(--text
|
|
193
|
+
.cl-close:hover { color: var(--text); background: var(--border); }
|
|
136
194
|
.cl-body { overflow-y: auto; padding: 16px 18px; flex: 1; }
|
|
137
195
|
.cl-entry { margin-bottom: 18px; }
|
|
138
196
|
.cl-ver-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
|
|
139
197
|
.cl-ver {
|
|
140
198
|
margin: 0;
|
|
141
|
-
font-family: var(--mono
|
|
142
|
-
color: var(--accent
|
|
199
|
+
font-family: var(--mono); font-size: 13px; font-weight: 700;
|
|
200
|
+
color: var(--accent-text);
|
|
143
201
|
}
|
|
144
|
-
.cl-date { font-size: 11px; color: var(--muted
|
|
145
|
-
.cl-note { font-size: 12px; color: var(--muted
|
|
202
|
+
.cl-date { font-size: 11px; color: var(--muted); }
|
|
203
|
+
.cl-note { font-size: 12px; color: var(--muted); margin: 0 0 8px; font-style: italic; }
|
|
146
204
|
.cl-sec {
|
|
147
205
|
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
|
148
|
-
letter-spacing: .07em; color: var(--muted
|
|
206
|
+
letter-spacing: .07em; color: var(--muted); margin: 8px 0 4px;
|
|
149
207
|
}
|
|
150
208
|
.cl-items { list-style: none; padding: 0; margin: 0; }
|
|
151
209
|
.cl-items li {
|
|
152
|
-
font-size: 12px; color: var(--text
|
|
210
|
+
font-size: 12px; color: var(--text);
|
|
153
211
|
padding: 2px 0 2px 14px; position: relative; line-height: 1.5;
|
|
154
212
|
}
|
|
155
|
-
.cl-items li::before { content: "·"; position: absolute; left: 2px; color: var(--muted
|
|
213
|
+
.cl-items li::before { content: "·"; position: absolute; left: 2px; color: var(--muted); }
|
|
156
214
|
.cl-subitems { list-style: none; padding: 0; margin: 2px 0 0 0; }
|
|
157
|
-
.cl-subitems li { font-size: 11.5px; padding: 1px 0 1px 14px; color: var(--muted
|
|
215
|
+
.cl-subitems li { font-size: 11.5px; padding: 1px 0 1px 14px; color: var(--muted); }
|
|
158
216
|
.cl-subitems li::before { content: "–"; position: absolute; left: 2px; }
|
|
159
217
|
.cl-items code {
|
|
160
|
-
font-family: var(--mono
|
|
161
|
-
color: var(--accent
|
|
218
|
+
font-family: var(--mono); font-size: 10.5px;
|
|
219
|
+
color: var(--accent-text); background: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
162
220
|
padding: 1px 4px; border-radius: 3px;
|
|
163
221
|
}
|
|
164
|
-
.cl-divider { border: none; border-top: 1px solid var(--border
|
|
222
|
+
.cl-divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
|
165
223
|
.cl-copy-group { display: flex; gap: 4px; margin-left: auto; flex-shrink: 0; }
|
|
166
224
|
.cl-copy-btn, .cl-slack-btn {
|
|
167
|
-
background: none; border: 1px solid var(--border
|
|
225
|
+
background: none; border: 1px solid var(--border); color: var(--muted);
|
|
168
226
|
padding: 3px 6px; border-radius: 4px; line-height: 0;
|
|
169
227
|
cursor: pointer; flex-shrink: 0;
|
|
170
228
|
display: inline-flex; align-items: center; justify-content: center;
|
|
@@ -172,22 +230,22 @@ function _injectChangelogStyles() {
|
|
|
172
230
|
}
|
|
173
231
|
.cl-more-btn {
|
|
174
232
|
margin-right: auto; padding: 4px 12px;
|
|
175
|
-
font: inherit; font-size: 11px; color: var(--muted
|
|
176
|
-
background: none; border: 1px solid var(--border
|
|
233
|
+
font: inherit; font-size: 11px; color: var(--muted);
|
|
234
|
+
background: none; border: 1px solid var(--border); border-radius: 6px;
|
|
177
235
|
cursor: pointer;
|
|
178
236
|
}
|
|
179
|
-
.cl-more-btn:hover { color: var(--accent
|
|
180
|
-
.cl-copy-btn:hover { color: var(--accent
|
|
181
|
-
.cl-copy-btn.copied { color:
|
|
182
|
-
.cl-slack-btn:hover { color:
|
|
183
|
-
.cl-slack-btn.copied { color:
|
|
237
|
+
.cl-more-btn:hover { color: var(--accent-text); border-color: var(--accent); }
|
|
238
|
+
.cl-copy-btn:hover { color: var(--accent-text); border-color: var(--accent); }
|
|
239
|
+
.cl-copy-btn.copied { color: var(--ok); border-color: var(--ok); }
|
|
240
|
+
.cl-slack-btn:hover { color: var(--accent-text); border-color: var(--accent); }
|
|
241
|
+
.cl-slack-btn.copied { color: var(--ok); border-color: var(--ok); }
|
|
184
242
|
.cl-footer {
|
|
185
|
-
padding: 10px 18px; border-top: 1px solid var(--border
|
|
243
|
+
padding: 10px 18px; border-top: 1px solid var(--border);
|
|
186
244
|
display: flex; justify-content: flex-end; flex-shrink: 0;
|
|
187
|
-
background: var(--surface
|
|
245
|
+
background: var(--surface);
|
|
188
246
|
}
|
|
189
247
|
.cl-got-it {
|
|
190
|
-
background: var(--accent
|
|
248
|
+
background: var(--accent-fill); border: none; color: var(--on-accent);
|
|
191
249
|
padding: 5px 16px; border-radius: 5px; font-size: 12px;
|
|
192
250
|
font-weight: 600; cursor: pointer;
|
|
193
251
|
}
|
|
@@ -201,9 +259,11 @@ function _injectChangelogStyles() {
|
|
|
201
259
|
overlay.className = "cl-overlay";
|
|
202
260
|
overlay.innerHTML =
|
|
203
261
|
'<div class="cl-box" role="dialog" aria-modal="true" aria-labelledby="cl-title">' +
|
|
204
|
-
|
|
262
|
+
`<div class="cl-hdr"><h3 id="cl-title" tabindex="-1">${escT("cl.title")}</h3><button type="button" class="cl-close" id="cl-close-btn" title="${escT("cl.close")}" aria-label="${escT("cl.close")}">×</button></div>` +
|
|
205
263
|
'<div class="cl-body" id="cl-body"></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">
|
|
264
|
+
'<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">' +
|
|
265
|
+
escT("cl.gotIt") +
|
|
266
|
+
"</button></div>" +
|
|
207
267
|
"</div>";
|
|
208
268
|
document.body.appendChild(overlay);
|
|
209
269
|
document.getElementById("cl-more")?.addEventListener("click", () => {
|
|
@@ -224,50 +284,41 @@ function _injectChangelogStyles() {
|
|
|
224
284
|
?.addEventListener("click", () => window.closeChangelog());
|
|
225
285
|
|
|
226
286
|
document.addEventListener("keydown", (e) => {
|
|
227
|
-
if (!
|
|
228
|
-
if (e.key === "Escape")
|
|
229
|
-
|
|
287
|
+
if (!overlay.classList.contains("open")) return;
|
|
288
|
+
if (e.key === "Escape") {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
window.closeChangelog();
|
|
291
|
+
} else if (e.key === "Tab") _trapChangelogTab(e, overlay);
|
|
230
292
|
});
|
|
231
293
|
}
|
|
232
294
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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)) {
|
|
295
|
+
/** Tab and Shift+Tab stay inside the open dialog (aria-modal). */
|
|
296
|
+
function _trapChangelogTab(e, overlay) {
|
|
297
|
+
const box = overlay.querySelector(".cl-box");
|
|
298
|
+
const stops = [
|
|
299
|
+
...box.querySelectorAll("button, a[href], [tabindex]:not([tabindex='-1'])"),
|
|
300
|
+
].filter((el) => !el.hidden && !el.disabled && el.getClientRects().length);
|
|
301
|
+
if (!stops.length) return;
|
|
302
|
+
const first = stops[0];
|
|
303
|
+
const last = stops[stops.length - 1];
|
|
304
|
+
const at = document.activeElement;
|
|
305
|
+
// Anything not one of the stops (the focused heading, or outside) counts
|
|
306
|
+
// as outside: Shift+Tab from the heading must not leave the dialog.
|
|
307
|
+
const inside = stops.includes(at);
|
|
308
|
+
if (e.shiftKey ? at === first || !inside : at === last) {
|
|
254
309
|
e.preventDefault();
|
|
255
|
-
last.focus();
|
|
256
|
-
} else if (!
|
|
310
|
+
(e.shiftKey ? last : first).focus();
|
|
311
|
+
} else if (!inside) {
|
|
257
312
|
e.preventDefault();
|
|
258
313
|
first.focus();
|
|
259
314
|
}
|
|
260
315
|
}
|
|
261
316
|
|
|
262
|
-
/** What had focus before the dialog opened; it gets it back on close. */
|
|
263
|
-
let _clReturnFocus = null;
|
|
264
|
-
|
|
265
317
|
function _renderChangelogBody(entries) {
|
|
266
318
|
const body = document.getElementById("cl-body");
|
|
267
319
|
if (!body) return;
|
|
268
320
|
if (!entries.length) {
|
|
269
|
-
body.innerHTML =
|
|
270
|
-
'<p style="color:var(--muted,#8b949e);font-size:12px">No entries found.</p>';
|
|
321
|
+
body.innerHTML = `<p style="color:var(--muted);font-size:12px">${escT("cl.none")}</p>`;
|
|
271
322
|
return;
|
|
272
323
|
}
|
|
273
324
|
const escHtml = (s) =>
|
|
@@ -295,9 +346,11 @@ function _renderChangelogBody(entries) {
|
|
|
295
346
|
'<div class="cl-copy-group">' +
|
|
296
347
|
'<button class="cl-copy-btn" data-cl-version="' +
|
|
297
348
|
escHtml(entry.version) +
|
|
298
|
-
'" title="
|
|
299
|
-
|
|
300
|
-
'
|
|
349
|
+
'" title="' +
|
|
350
|
+
escT("cl.copyMd") +
|
|
351
|
+
'" aria-label="' +
|
|
352
|
+
escT("cl.copyMdX", { v: entry.version }) +
|
|
353
|
+
'">' +
|
|
301
354
|
'<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">' +
|
|
302
355
|
'<rect x="4" y="0.5" width="8.5" height="8.5" rx="1.5"/>' +
|
|
303
356
|
'<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"/>' +
|
|
@@ -305,9 +358,11 @@ function _renderChangelogBody(entries) {
|
|
|
305
358
|
"</button>" +
|
|
306
359
|
'<button class="cl-slack-btn" data-cl-version="' +
|
|
307
360
|
escHtml(entry.version) +
|
|
308
|
-
'" title="
|
|
309
|
-
|
|
310
|
-
'
|
|
361
|
+
'" title="' +
|
|
362
|
+
escT("cl.copySlack") +
|
|
363
|
+
'" aria-label="' +
|
|
364
|
+
escT("cl.copySlackX", { v: entry.version }) +
|
|
365
|
+
'">' +
|
|
311
366
|
'<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">' +
|
|
312
367
|
'<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"/>' +
|
|
313
368
|
"</svg>" +
|
|
@@ -352,7 +407,11 @@ function _renderChangelogBody(entries) {
|
|
|
352
407
|
if (more) {
|
|
353
408
|
more.hidden = !view.toggle;
|
|
354
409
|
if (view.toggle) {
|
|
355
|
-
|
|
410
|
+
// changelog-view.js's labels are its en; the button says them in
|
|
411
|
+
// the page's language.
|
|
412
|
+
more.textContent = view.toggle.expanded
|
|
413
|
+
? t("cl.latest")
|
|
414
|
+
: t("cl.all", { n: entries.length - 1 });
|
|
356
415
|
more.setAttribute("aria-expanded", String(view.toggle.expanded));
|
|
357
416
|
}
|
|
358
417
|
}
|
|
@@ -387,8 +446,8 @@ function _renderChangelogBody(entries) {
|
|
|
387
446
|
btn.classList.remove("copied");
|
|
388
447
|
}, 2000);
|
|
389
448
|
} catch {
|
|
390
|
-
btn.title = "
|
|
391
|
-
setTimeout(() => (btn.title = "
|
|
449
|
+
btn.title = t("cl.copyFailed");
|
|
450
|
+
setTimeout(() => (btn.title = t("cl.copyMd")), 2000);
|
|
392
451
|
}
|
|
393
452
|
});
|
|
394
453
|
});
|
|
@@ -410,8 +469,8 @@ function _renderChangelogBody(entries) {
|
|
|
410
469
|
btn.classList.remove("copied");
|
|
411
470
|
}, 2000);
|
|
412
471
|
} catch {
|
|
413
|
-
btn.title = "
|
|
414
|
-
setTimeout(() => (btn.title = "
|
|
472
|
+
btn.title = t("cl.copyFailed");
|
|
473
|
+
setTimeout(() => (btn.title = t("cl.copySlack")), 2000);
|
|
415
474
|
}
|
|
416
475
|
});
|
|
417
476
|
});
|
|
@@ -521,23 +580,27 @@ function _buildMarkdown(entry) {
|
|
|
521
580
|
return lines.join("\n");
|
|
522
581
|
}
|
|
523
582
|
|
|
583
|
+
// Where focus goes back to when the dialog closes.
|
|
584
|
+
let _clReturnFocus = null;
|
|
585
|
+
|
|
524
586
|
window.openChangelog = function () {
|
|
525
587
|
_clShowAll = false;
|
|
526
588
|
_injectChangelogStyles();
|
|
527
589
|
_renderChangelogBody(_changelogEntries);
|
|
528
590
|
const overlay = document.getElementById("cl-overlay");
|
|
529
591
|
if (!overlay) return;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
592
|
+
const wasOpen = overlay.classList.contains("open");
|
|
593
|
+
if (!wasOpen) {
|
|
594
|
+
const at = document.activeElement;
|
|
595
|
+
// Nothing focused (a first-visit auto-open): the version pill.
|
|
533
596
|
_clReturnFocus =
|
|
534
|
-
|
|
535
|
-
?
|
|
597
|
+
at && at !== document.body && !overlay.contains(at)
|
|
598
|
+
? at
|
|
536
599
|
: document.getElementById("version-pill");
|
|
537
600
|
}
|
|
538
601
|
overlay.classList.add("open");
|
|
539
|
-
// After
|
|
540
|
-
//
|
|
602
|
+
// Always opens at the newest release. After `.open`: while the overlay is
|
|
603
|
+
// display:none the body has no scroll box, so a reset there doesn't stick.
|
|
541
604
|
const body = document.getElementById("cl-body");
|
|
542
605
|
if (body) body.scrollTop = 0;
|
|
543
606
|
// The heading first, so a screen reader starts at the latest notes.
|
|
@@ -578,20 +641,16 @@ export async function initChangelog() {
|
|
|
578
641
|
|
|
579
642
|
const latest = _changelogEntries[0];
|
|
580
643
|
const pill = document.getElementById("version-pill");
|
|
581
|
-
if (pill
|
|
582
|
-
|
|
644
|
+
if (pill) {
|
|
645
|
+
let running = null;
|
|
646
|
+
try {
|
|
647
|
+
running = (await fetch("/api/server-info").then((r) => r.json())).version;
|
|
648
|
+
} catch {
|
|
649
|
+
/* fall back to the changelog's newest version */
|
|
650
|
+
}
|
|
651
|
+
const label = versionLabel(running, _changelogEntries);
|
|
652
|
+
if (label) pill.querySelector(".v-txt").textContent = label;
|
|
583
653
|
}
|
|
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(() => {});
|
|
595
654
|
|
|
596
655
|
let lastSeen = null;
|
|
597
656
|
try {
|
|
@@ -624,7 +683,7 @@ async function getDenConfig() {
|
|
|
624
683
|
}
|
|
625
684
|
|
|
626
685
|
/**
|
|
627
|
-
* Apply __DEN_CLI__ config to the nav — hide
|
|
686
|
+
* Apply __DEN_CLI__ config to the nav — hide Workspaces when the local environment is disabled.
|
|
628
687
|
* Call once on page load from each page's init function.
|
|
629
688
|
*/
|
|
630
689
|
export async function applyDenConfig() {
|
|
@@ -633,7 +692,7 @@ export async function applyDenConfig() {
|
|
|
633
692
|
const localEnabled = config.environments?.includes("local") ?? true;
|
|
634
693
|
if (!localEnabled) {
|
|
635
694
|
document
|
|
636
|
-
.querySelectorAll("
|
|
695
|
+
.querySelectorAll(".den-rail a[href='/local-dev']")
|
|
637
696
|
.forEach((el) => {
|
|
638
697
|
el.style.display = "none";
|
|
639
698
|
});
|
|
@@ -646,18 +705,20 @@ export async function applyDenConfig() {
|
|
|
646
705
|
* before the product name: "Globex › __DEN_CLI__".
|
|
647
706
|
*/
|
|
648
707
|
function _renderOrgCrumb(org) {
|
|
649
|
-
|
|
708
|
+
// The shell's scope has an org part (the prototype's "/ ORG name").
|
|
709
|
+
if (window.denShell?.setOrg?.(org)) return;
|
|
710
|
+
const h1 = document.querySelector(".den-top .den-title, header h1");
|
|
650
711
|
if (!h1 || !org?.title || document.getElementById("org-crumb")) return;
|
|
651
712
|
const crumb = document.createElement("span");
|
|
652
713
|
crumb.id = "org-crumb";
|
|
653
|
-
crumb.title =
|
|
714
|
+
crumb.title = t("org.titleId", { title: org.title, id: org.id });
|
|
654
715
|
crumb.style.cssText =
|
|
655
|
-
"font-size:12px;font-weight:600;color:var(--text
|
|
716
|
+
"font-size:12px;font-weight:600;color:var(--text);" +
|
|
656
717
|
"white-space:nowrap;flex-shrink:0;display:flex;align-items:center;gap:8px";
|
|
657
718
|
crumb.textContent = org.title;
|
|
658
719
|
const sep = document.createElement("span");
|
|
659
720
|
sep.textContent = "›";
|
|
660
|
-
sep.style.cssText = "color:var(--muted
|
|
721
|
+
sep.style.cssText = "color:var(--muted);font-weight:400";
|
|
661
722
|
crumb.appendChild(sep);
|
|
662
723
|
h1.parentNode.insertBefore(crumb, h1);
|
|
663
724
|
}
|
|
@@ -706,243 +767,127 @@ export function groupReposByApplication(repos, apps) {
|
|
|
706
767
|
return out;
|
|
707
768
|
}
|
|
708
769
|
|
|
709
|
-
// ── Workspace
|
|
710
|
-
|
|
711
|
-
// Set when a page opts into switching (initWorkspacePill's `onSwitch`): the
|
|
712
|
-
// pill becomes a dropdown of every known workspace instead of a static label.
|
|
713
|
-
let _wsOnSwitch = null;
|
|
714
|
-
let _wsMenuOpen = false;
|
|
770
|
+
// ── Workspace: the scope breadcrumb's Workspace ▾ menu (R4) ─────────────────
|
|
715
771
|
|
|
716
772
|
/**
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
773
|
+
* Feed the shell's Workspace part (shell.js #ws-pill, a menu): "All active"
|
|
774
|
+
* and each active workspace, the focused one marked. Choosing one sets the
|
|
775
|
+
* view scope (POST /api/scope, a preference) for every page. It replaces
|
|
776
|
+
* dev.17's workspace pill and its focus menu (one control, not two): the
|
|
777
|
+
* focus moves only through the menu's separate "Set <ws> as focus".
|
|
720
778
|
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
* onSwitch(name) so the page can reload whatever it derives from the focused
|
|
724
|
-
* workspace. Pages with their own workspace UI (Local Dev) omit it.
|
|
779
|
+
* The arguments are dev.17's (an env select, an onSwitch callback) and are
|
|
780
|
+
* no longer used: the view applies to every env.
|
|
725
781
|
*/
|
|
726
|
-
export async function initWorkspacePill(
|
|
727
|
-
|
|
728
|
-
_wsOnSwitch = onSwitch ?? null;
|
|
729
|
-
await _renderPill(envSel);
|
|
730
|
-
if (envSel) {
|
|
731
|
-
envSel.addEventListener("change", () => _renderPill(envSel));
|
|
732
|
-
}
|
|
782
|
+
export async function initWorkspacePill() {
|
|
783
|
+
await _renderScopeMenu();
|
|
733
784
|
}
|
|
734
785
|
|
|
735
|
-
/** Re-fetch and
|
|
786
|
+
/** Re-fetch and repaint the Workspace part (after a workspace change). */
|
|
736
787
|
export async function refreshWorkspacePill() {
|
|
737
|
-
|
|
738
|
-
await _renderPill(envSel);
|
|
788
|
+
await _renderScopeMenu();
|
|
739
789
|
}
|
|
740
790
|
|
|
741
|
-
async function
|
|
742
|
-
const
|
|
743
|
-
if (!
|
|
744
|
-
const isLocal = !envSel || envSel.value === "local";
|
|
745
|
-
if (!isLocal) {
|
|
746
|
-
_closeWorkspaceMenu();
|
|
747
|
-
_pillHost(el).style.display = "none";
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
791
|
+
async function _renderScopeMenu() {
|
|
792
|
+
const sh = window.denShell;
|
|
793
|
+
if (!sh?.setWorkspaces) return;
|
|
750
794
|
try {
|
|
751
|
-
const data = await
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
_pillHost(el).style.display = "";
|
|
795
|
+
const [data, scope] = await Promise.all([
|
|
796
|
+
fetch("/api/workspaces").then((r) => r.json()),
|
|
797
|
+
fetch("/api/scope")
|
|
798
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
799
|
+
.catch(() => null),
|
|
800
|
+
]);
|
|
801
|
+
sh.setWorkspaces({
|
|
802
|
+
...buildScopeMenuModel(pageScope(scope, data), data, t),
|
|
803
|
+
onSelect: _switchScope,
|
|
804
|
+
onFocus: _setFocus,
|
|
805
|
+
});
|
|
763
806
|
} catch {
|
|
764
|
-
|
|
765
|
-
_pillHost(el).style.display = "none";
|
|
807
|
+
sh.setWorkspaces(null);
|
|
766
808
|
}
|
|
767
809
|
}
|
|
768
810
|
|
|
769
811
|
/**
|
|
770
|
-
* The
|
|
771
|
-
*
|
|
812
|
+
* The Graph page draws what its ?ws= names (it wins over the stored view,
|
|
813
|
+
* 0.3.0-dev.33), so its pill shows that workspace too: the pill, the URL,
|
|
814
|
+
* the header's line and the graph agree (0.3.0-dev.34, a consumer's
|
|
815
|
+
* finding). Elsewhere, and for a ?ws= that names no active workspace,
|
|
816
|
+
* the stored view.
|
|
772
817
|
*/
|
|
773
|
-
function
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
s.id = "__DEN_CLI__-ws-picker-styles";
|
|
783
|
-
s.textContent = `
|
|
784
|
-
.ws-pill-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
|
|
785
|
-
#ws-pill.ws-pill-btn { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 5px; }
|
|
786
|
-
#ws-pill.ws-pill-btn:hover, #ws-pill.ws-pill-btn.open { border-color: var(--accent, #58a6ff); }
|
|
787
|
-
.ws-pill-caret { font-size: 8px; opacity: .7; }
|
|
788
|
-
.ws-menu {
|
|
789
|
-
display: none; position: absolute; top: calc(100% + 4px); left: 0; z-index: 200;
|
|
790
|
-
background: var(--surface, #161b22); border: 1px solid var(--border, #30363d);
|
|
791
|
-
border-radius: 6px; min-width: 190px; max-height: 280px; overflow-y: auto;
|
|
792
|
-
box-shadow: 0 4px 16px rgba(0,0,0,.4); padding: 3px 0;
|
|
793
|
-
}
|
|
794
|
-
.ws-menu.open { display: block; }
|
|
795
|
-
.ws-menu-item {
|
|
796
|
-
display: flex; align-items: center; gap: 8px; width: 100%;
|
|
797
|
-
background: none; border: none; text-align: left;
|
|
798
|
-
padding: 6px 12px; cursor: pointer; font-size: 12px;
|
|
799
|
-
font-family: var(--mono, monospace); color: var(--text, #c9d1d9); white-space: nowrap;
|
|
800
|
-
}
|
|
801
|
-
.ws-menu-item:hover { background: var(--border, #30363d); }
|
|
802
|
-
.ws-menu-item[disabled] { cursor: default; }
|
|
803
|
-
.ws-menu-mark { width: 10px; flex-shrink: 0; color: var(--accent, #58a6ff); }
|
|
804
|
-
.ws-menu-tag { margin-left: auto; font-size: 10px; color: var(--muted, #8b949e); }
|
|
805
|
-
`;
|
|
806
|
-
document.head.appendChild(s);
|
|
818
|
+
function pageScope(scope, data) {
|
|
819
|
+
if (location.pathname !== "/graph") return scope;
|
|
820
|
+
const ws = new URLSearchParams(location.search).get("ws");
|
|
821
|
+
const active = Array.isArray(data?.active)
|
|
822
|
+
? data.active
|
|
823
|
+
: data?.active
|
|
824
|
+
? [data.active]
|
|
825
|
+
: [];
|
|
826
|
+
return ws && active.includes(ws) ? { ...scope, scope: ws } : scope;
|
|
807
827
|
}
|
|
808
828
|
|
|
809
|
-
/**
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
el.setAttribute("tabindex", "0");
|
|
822
|
-
el.addEventListener("click", () => _toggleWorkspaceMenu());
|
|
823
|
-
el.addEventListener("keydown", (e) => {
|
|
824
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
825
|
-
e.preventDefault();
|
|
826
|
-
_toggleWorkspaceMenu();
|
|
827
|
-
}
|
|
828
|
-
});
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
el.replaceChildren();
|
|
832
|
-
const name = document.createElement("span");
|
|
833
|
-
name.textContent = label;
|
|
834
|
-
const caret = document.createElement("span");
|
|
835
|
-
caret.className = "ws-pill-caret";
|
|
836
|
-
caret.textContent = "▾";
|
|
837
|
-
el.append(name, caret);
|
|
838
|
-
el.title = title;
|
|
839
|
-
|
|
840
|
-
let menu = wrap.querySelector(".ws-menu");
|
|
841
|
-
if (!menu) {
|
|
842
|
-
menu = document.createElement("div");
|
|
843
|
-
menu.className = "ws-menu";
|
|
844
|
-
wrap.appendChild(menu);
|
|
845
|
-
}
|
|
846
|
-
_paintWorkspaceMenu(menu, data);
|
|
829
|
+
/**
|
|
830
|
+
* On the Graph page a choice replaces ?ws= (All active drops it, a
|
|
831
|
+
* workspace sets it), so the reload shows the choice rather than the old
|
|
832
|
+
* ?ws=, which would win over it. The history entry is replaced, not
|
|
833
|
+
* pushed: Back does not return to a view the picker has left.
|
|
834
|
+
*/
|
|
835
|
+
function followScopeInUrl(value) {
|
|
836
|
+
if (location.pathname !== "/graph") return;
|
|
837
|
+
const u = new URL(location.href);
|
|
838
|
+
if (value === "all") u.searchParams.delete("ws");
|
|
839
|
+
else u.searchParams.set("ws", value);
|
|
840
|
+
history.replaceState(history.state, "", u.pathname + u.search + u.hash);
|
|
847
841
|
}
|
|
848
842
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
843
|
+
/**
|
|
844
|
+
* A view filter only: POST /api/scope stores the preference (user.yml
|
|
845
|
+
* `view`), then the page reloads to show the new view whole. No service,
|
|
846
|
+
* container, daemon or route changes, and the focus stays where it is.
|
|
847
|
+
*/
|
|
848
|
+
async function _switchScope(value) {
|
|
849
|
+
try {
|
|
850
|
+
await window.denShell.api("POST", "/api/scope", { scope: value });
|
|
851
|
+
} catch (err) {
|
|
852
|
+
if (err?.denApi) {
|
|
853
|
+
err.message = t("ws.viewFailed", { msg: err.message });
|
|
854
|
+
window.denShell.errorToast(err);
|
|
855
|
+
} else window.denShell?.toast?.(t("ws.viewFailed", { msg: err.message }));
|
|
858
856
|
return;
|
|
859
857
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
btn.className = "ws-menu-item";
|
|
863
|
-
btn.type = "button";
|
|
864
|
-
// Selecting an inactive workspace activates it (POST `use`), which only
|
|
865
|
-
// rewrites workspaces.json — no services are started.
|
|
866
|
-
btn.title = item.focused
|
|
867
|
-
? "Current workspace"
|
|
868
|
-
: item.active
|
|
869
|
-
? "Focus this workspace"
|
|
870
|
-
: "Activate and focus this workspace";
|
|
871
|
-
|
|
872
|
-
const mark = document.createElement("span");
|
|
873
|
-
mark.className = "ws-menu-mark";
|
|
874
|
-
mark.textContent = item.focused ? "●" : "";
|
|
875
|
-
const name = document.createElement("span");
|
|
876
|
-
name.textContent = item.name;
|
|
877
|
-
btn.append(mark, name);
|
|
878
|
-
|
|
879
|
-
if (!item.active) {
|
|
880
|
-
const tag = document.createElement("span");
|
|
881
|
-
tag.className = "ws-menu-tag";
|
|
882
|
-
tag.textContent = "not active";
|
|
883
|
-
btn.appendChild(tag);
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
btn.addEventListener("click", () => _switchWorkspace(item));
|
|
887
|
-
menu.appendChild(btn);
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
function _toggleWorkspaceMenu(force = null) {
|
|
892
|
-
const menu = document.querySelector(".ws-pill-wrap .ws-menu");
|
|
893
|
-
const pill = document.getElementById("ws-pill");
|
|
894
|
-
if (!menu) return;
|
|
895
|
-
_wsMenuOpen = force ?? !_wsMenuOpen;
|
|
896
|
-
menu.classList.toggle("open", _wsMenuOpen);
|
|
897
|
-
pill?.classList.toggle("open", _wsMenuOpen);
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
function _closeWorkspaceMenu() {
|
|
901
|
-
if (_wsMenuOpen) _toggleWorkspaceMenu(false);
|
|
858
|
+
followScopeInUrl(value);
|
|
859
|
+
location.reload();
|
|
902
860
|
}
|
|
903
861
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
async function _switchWorkspace(item) {
|
|
912
|
-
_closeWorkspaceMenu();
|
|
913
|
-
if (item.focused) return;
|
|
914
|
-
const onSwitch = _wsOnSwitch;
|
|
862
|
+
/**
|
|
863
|
+
* "Set <ws> as focus": Local Dev's focus action (POST /api/workspaces
|
|
864
|
+
* `focus`, which writes workspaces.yml and retargets the CLI). Separate
|
|
865
|
+
* from the view: it is the only thing in the menu that moves the focus.
|
|
866
|
+
*/
|
|
867
|
+
async function _setFocus(name) {
|
|
915
868
|
try {
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
// `use` = activate + focus, matching `__DEN_CLI__ workspace use`. Focus alone
|
|
920
|
-
// is rejected by the server for a workspace that is not active yet.
|
|
921
|
-
body: JSON.stringify({ name: item.name, action: "use" }),
|
|
869
|
+
await window.denShell.api("POST", "/api/workspaces", {
|
|
870
|
+
name,
|
|
871
|
+
action: "focus",
|
|
922
872
|
});
|
|
923
|
-
if (!res.ok) {
|
|
924
|
-
const text = await res.text().catch(() => "");
|
|
925
|
-
throw new Error(text || `HTTP ${res.status}`);
|
|
926
|
-
}
|
|
927
873
|
} catch (err) {
|
|
928
|
-
|
|
874
|
+
if (err?.denApi) {
|
|
875
|
+
err.message = t("ws.focusFailed", { msg: err.message });
|
|
876
|
+
window.denShell.errorToast(err);
|
|
877
|
+
} else window.denShell?.toast?.(t("ws.focusFailed", { msg: err.message }));
|
|
929
878
|
return;
|
|
930
879
|
}
|
|
931
|
-
|
|
932
|
-
try {
|
|
933
|
-
await onSwitch?.(item.name);
|
|
934
|
-
} catch {
|
|
935
|
-
/* the page's own refresh failing must not break the switch */
|
|
936
|
-
}
|
|
880
|
+
location.reload();
|
|
937
881
|
}
|
|
938
882
|
|
|
939
|
-
// ── Health
|
|
883
|
+
// ── Health: the status chip and drawer (topbar.js, R6 and R15) ─────────────
|
|
940
884
|
|
|
941
885
|
/** Call once on page load. Does NOT manage env changes — caller handles that. */
|
|
942
886
|
export async function initHealth() {
|
|
943
|
-
|
|
887
|
+
await envReady();
|
|
888
|
+
const env = healthEnv();
|
|
944
889
|
const gen = ++pollGen;
|
|
945
|
-
await
|
|
890
|
+
await renderHealth(env, gen);
|
|
946
891
|
schedulePoll(env, gen);
|
|
947
892
|
}
|
|
948
893
|
|
|
@@ -950,120 +895,75 @@ export async function initHealth() {
|
|
|
950
895
|
export async function refreshHealth(env, { force = false } = {}) {
|
|
951
896
|
const gen = ++pollGen;
|
|
952
897
|
clearTimeout(pollTimer);
|
|
953
|
-
|
|
954
|
-
|
|
898
|
+
// The chip goes neutral while the new env is checked: never red from
|
|
899
|
+
// the old env's answers, never a partial count.
|
|
900
|
+
if (env !== _healthEnvPainted) paintChecking();
|
|
901
|
+
await renderHealth(env, gen, force);
|
|
955
902
|
schedulePoll(env, gen);
|
|
956
903
|
}
|
|
957
904
|
|
|
958
905
|
// Background tabs throttle chained timers (Chrome clamps to ~1/min after a few
|
|
959
|
-
// hidden minutes), so the
|
|
906
|
+
// hidden minutes), so the chip freezes while hidden — refresh immediately on
|
|
960
907
|
// return instead of showing pre-hide state for up to another minute.
|
|
961
908
|
document.addEventListener("visibilitychange", () => {
|
|
962
909
|
if (!document.hidden && pollGen > 0) {
|
|
963
|
-
refreshHealth(
|
|
910
|
+
refreshHealth(healthEnv());
|
|
964
911
|
}
|
|
965
912
|
});
|
|
966
913
|
|
|
967
|
-
/** Immediately show all dots as "checking" so the user can see the env changed. */
|
|
968
|
-
function setCheckingState(env) {
|
|
969
|
-
const container = document.getElementById("health");
|
|
970
|
-
if (!container) return;
|
|
971
|
-
// Pulse all dots yellow while we re-ping
|
|
972
|
-
container.querySelectorAll(".dot").forEach((d) => {
|
|
973
|
-
d.className = "dot running";
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
|
|
977
914
|
function schedulePoll(env, gen) {
|
|
978
915
|
if (gen !== pollGen) return; // superseded by an env switch/re-init
|
|
979
916
|
clearTimeout(pollTimer);
|
|
980
917
|
pollTimer = setTimeout(async () => {
|
|
981
918
|
try {
|
|
982
|
-
await
|
|
919
|
+
await renderHealth(env, gen);
|
|
983
920
|
} finally {
|
|
984
921
|
// ALWAYS re-arm: a thrown/aborted tick must not silently kill polling
|
|
985
|
-
// (that froze the
|
|
922
|
+
// (that froze the chip at its last state until a page reload).
|
|
986
923
|
schedulePoll(env, gen);
|
|
987
924
|
}
|
|
988
925
|
}, POLL_INTERVAL_MS);
|
|
989
926
|
}
|
|
990
927
|
|
|
991
928
|
/**
|
|
992
|
-
* Stream health results
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
* resolve at the pace of the worst one. Streaming paints ~75% of them inside
|
|
997
|
-
* 200ms. Batched through requestAnimationFrame so 30 results cause a handful of
|
|
998
|
-
* repaints, not 30.
|
|
929
|
+
* Stream health results and paint them through the gate (health-fit.js):
|
|
930
|
+
* a first cycle (a page load, an env switch) paints once, complete, so a
|
|
931
|
+
* cold load never shows a partial count or a flash of red; later polls
|
|
932
|
+
* seed from the last one and repaint debounced.
|
|
999
933
|
*/
|
|
1000
|
-
async function
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
await Promise.all([_loadEditorKey(), _loadApps()]);
|
|
1005
|
-
_injectNavStyles();
|
|
1006
|
-
|
|
1007
|
-
// Merge into the PREVIOUS poll's results rather than starting empty.
|
|
1008
|
-
//
|
|
1009
|
-
// Each poll streams results as checks complete, repainting per batch. Painting
|
|
1010
|
-
// only what has landed so far made the strip collapse (13 items → 2) and
|
|
1011
|
-
// regrow on every 5s poll; because the strip sits above the content, the whole
|
|
1012
|
-
// page below it — the dependency graph especially — visibly jumped ~30px each
|
|
1013
|
-
// time. Seeding from the last known set keeps the item count stable, so a poll
|
|
1014
|
-
// updates dot COLOURS in place instead of rebuilding the row.
|
|
1015
|
-
const acc = new Map(gen === _healthAccGen ? _healthAcc : []);
|
|
934
|
+
async function renderHealth(env, gen = pollGen, force = false) {
|
|
935
|
+
await _loadApps();
|
|
936
|
+
const seed = gen === _healthAccGen ? [..._healthAcc] : [];
|
|
1016
937
|
_healthAccGen = gen;
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
938
|
+
await healthCycle({
|
|
939
|
+
seed,
|
|
940
|
+
gate: _healthGate,
|
|
941
|
+
stream: (onResult) => _streamHealth(env, force, onResult),
|
|
942
|
+
// Stream unavailable or broken (older server, a proxy that buffers SSE,
|
|
943
|
+
// a dropped connection): the plain endpoint completes the cycle.
|
|
944
|
+
fetchAll: () =>
|
|
945
|
+
fetch(`/api/health?env=${env}${force ? "&refresh=1" : ""}`, {
|
|
946
|
+
signal: AbortSignal.timeout(10_000),
|
|
947
|
+
})
|
|
948
|
+
.then((r) => r.json())
|
|
949
|
+
.catch(() => null),
|
|
950
|
+
// What the chip shows now, for a paint before this cycle completes.
|
|
951
|
+
last: () => (_healthPaintGen === gen ? _healthAcc : null),
|
|
952
|
+
paint: (m) => {
|
|
953
|
+
_healthAcc = m;
|
|
954
|
+
_healthPaintGen = gen;
|
|
955
|
+
_paintHealth(env, _sortHealth([...m.values()]));
|
|
956
|
+
},
|
|
957
|
+
current: () => gen === pollGen,
|
|
1036
958
|
});
|
|
1037
|
-
|
|
1038
|
-
if (!streamed) {
|
|
1039
|
-
// Stream unavailable (older server, proxy that buffers SSE) — fall back to
|
|
1040
|
-
// the plain endpoint so the dots still resolve.
|
|
1041
|
-
try {
|
|
1042
|
-
const rs = await fetch(
|
|
1043
|
-
`/api/health?env=${env}${force ? "&refresh=1" : ""}`,
|
|
1044
|
-
{
|
|
1045
|
-
signal: AbortSignal.timeout(10_000),
|
|
1046
|
-
},
|
|
1047
|
-
).then((r) => r.json());
|
|
1048
|
-
fresh.clear();
|
|
1049
|
-
for (const r of rs) fresh.set(keyOf(r), r);
|
|
1050
|
-
} catch {
|
|
1051
|
-
/* server unreachable */
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
if (gen !== pollGen) return;
|
|
1055
|
-
// Settle on exactly what THIS poll returned, so a service removed from the
|
|
1056
|
-
// workspace disappears instead of lingering from the seeded set.
|
|
1057
|
-
if (fresh.size > 0) {
|
|
1058
|
-
_healthAcc = fresh;
|
|
1059
|
-
_paintHealthDots(container, env, _sortHealth([...fresh.values()]));
|
|
1060
|
-
}
|
|
1061
959
|
}
|
|
960
|
+
const _healthGate = paintGate();
|
|
961
|
+
let _healthEnvPainted = null;
|
|
1062
962
|
|
|
1063
963
|
/**
|
|
1064
964
|
* Stable display order. `idx` is the server's graph-ranked position (entry repo
|
|
1065
965
|
* first … infra-stack last); results arrive in completion order, which differs
|
|
1066
|
-
* every poll, so sorting by it is what keeps the
|
|
966
|
+
* every poll, so sorting by it is what keeps the drawer from reshuffling. Falls
|
|
1067
967
|
* back to repo name when the server doesn't send `idx`.
|
|
1068
968
|
*/
|
|
1069
969
|
function _sortHealth(results) {
|
|
@@ -1077,18 +977,21 @@ function _sortHealth(results) {
|
|
|
1077
977
|
|
|
1078
978
|
/**
|
|
1079
979
|
* Read /api/health/stream to completion, calling onResult per check.
|
|
1080
|
-
* Resolves
|
|
980
|
+
* Resolves "done" when the server ended the cycle (every check answered),
|
|
981
|
+
* "partial" when it broke after some results, "none" when it gave nothing:
|
|
982
|
+
* only "done" is a whole cycle (healthCycle).
|
|
1081
983
|
*/
|
|
1082
984
|
function _streamHealth(env, force, onResult) {
|
|
1083
985
|
return new Promise((resolve) => {
|
|
1084
986
|
let es;
|
|
1085
987
|
let got = false;
|
|
1086
988
|
// Backstop: a hung stream must not stall the poll chain forever.
|
|
989
|
+
const broken = () => (got ? "partial" : "none");
|
|
1087
990
|
const guard = setTimeout(() => {
|
|
1088
991
|
try {
|
|
1089
992
|
es?.close();
|
|
1090
993
|
} catch {}
|
|
1091
|
-
resolve(
|
|
994
|
+
resolve(broken());
|
|
1092
995
|
}, 15_000);
|
|
1093
996
|
const done = (ok) => {
|
|
1094
997
|
clearTimeout(guard);
|
|
@@ -1105,7 +1008,7 @@ function _streamHealth(env, force, onResult) {
|
|
|
1105
1008
|
);
|
|
1106
1009
|
} catch {
|
|
1107
1010
|
clearTimeout(guard);
|
|
1108
|
-
return resolve(
|
|
1011
|
+
return resolve("none");
|
|
1109
1012
|
}
|
|
1110
1013
|
es.onmessage = (e) => {
|
|
1111
1014
|
let msg;
|
|
@@ -1118,149 +1021,36 @@ function _streamHealth(env, force, onResult) {
|
|
|
1118
1021
|
got = true;
|
|
1119
1022
|
onResult(msg.result);
|
|
1120
1023
|
} else if (msg.type === "done") {
|
|
1121
|
-
done(
|
|
1024
|
+
done("done");
|
|
1122
1025
|
} else if (msg.type === "error") {
|
|
1123
|
-
done(
|
|
1026
|
+
done(broken());
|
|
1124
1027
|
}
|
|
1125
1028
|
};
|
|
1126
|
-
//
|
|
1127
|
-
|
|
1029
|
+
// An error ends the stream: before any result, fall back; after some,
|
|
1030
|
+
// the cycle is partial and /api/health completes it.
|
|
1031
|
+
es.onerror = () => done(broken());
|
|
1128
1032
|
});
|
|
1129
1033
|
}
|
|
1130
1034
|
|
|
1131
|
-
/**
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
const state = ok === true ? "ok" : ok === false ? "err" : "";
|
|
1146
|
-
const tip =
|
|
1147
|
-
ok == null
|
|
1148
|
-
? error
|
|
1149
|
-
? `${what} — ${error}`
|
|
1150
|
-
: "no health check"
|
|
1151
|
-
: error
|
|
1152
|
-
? `${what} — ${error}`
|
|
1153
|
-
: `${what} → ${status}`;
|
|
1154
|
-
return `<div class="health-item" title="${escAttr(tip)}">
|
|
1155
|
-
<span class="dot ${state}" id="h-${repo}-${service}"></span>
|
|
1156
|
-
<span>${repo}</span>
|
|
1157
|
-
${openIcon}
|
|
1158
|
-
</div>`;
|
|
1159
|
-
}
|
|
1160
|
-
// Multi-service repo: ONE dot for the system's main component (the
|
|
1161
|
-
// entrypoint), so a down app isn't masked green by its shared-infra
|
|
1162
|
-
// sidecars. An entrypoint-less infra provider (infra-stack) falls back
|
|
1163
|
-
// to an aggregate across its subsystems.
|
|
1164
|
-
const entry = items.find((i) => i.isEntrypoint);
|
|
1165
|
-
let overallState, tip;
|
|
1166
|
-
if (entry) {
|
|
1167
|
-
overallState = entry.ok === true ? "ok" : entry.ok === false ? "err" : "";
|
|
1168
|
-
tip =
|
|
1169
|
-
entry.ok == null
|
|
1170
|
-
? entry.error
|
|
1171
|
-
? `${entry.url ?? entry.check} — ${entry.error}`
|
|
1172
|
-
: "no health check"
|
|
1173
|
-
: entry.error
|
|
1174
|
-
? `${entry.url ?? entry.check} — ${entry.error}`
|
|
1175
|
-
: `${entry.url ?? entry.check} → ${entry.status}`;
|
|
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.
|
|
1179
|
-
const allOk = items.every((i) => i.ok === true);
|
|
1180
|
-
const anyOk = items.some((i) => i.ok === true);
|
|
1181
|
-
const anyDown = items.some((i) => i.ok === false);
|
|
1182
|
-
overallState = allOk ? "ok" : anyOk ? "running" : anyDown ? "err" : "";
|
|
1183
|
-
tip = `${items.length} services`;
|
|
1184
|
-
}
|
|
1185
|
-
return `<div class="health-item" title="${escAttr(tip)}">
|
|
1186
|
-
<span class="dot ${overallState}"></span>
|
|
1187
|
-
<span>${repo}</span>
|
|
1188
|
-
${openIcon}
|
|
1189
|
-
</div>`;
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
function _paintHealthDots(container, env, results) {
|
|
1193
|
-
const escAttr = (s) =>
|
|
1194
|
-
String(s ?? "")
|
|
1195
|
-
.replace(/&/g, "&")
|
|
1196
|
-
.replace(/"/g, """);
|
|
1197
|
-
|
|
1198
|
-
if (!results.length) {
|
|
1199
|
-
container.innerHTML = `<div class="health-item" style="color:var(--muted);font-size:11px">${env}: no services</div>`;
|
|
1200
|
-
} else {
|
|
1201
|
-
// Group by repo so multi-service repos don't produce duplicate labels.
|
|
1202
|
-
const byRepo = new Map();
|
|
1203
|
-
for (const item of results) {
|
|
1204
|
-
if (!byRepo.has(item.repo)) byRepo.set(item.repo, []);
|
|
1205
|
-
byRepo.get(item.repo).push(item);
|
|
1206
|
-
}
|
|
1207
|
-
const repoHtml = new Map(
|
|
1208
|
-
[...byRepo.entries()].map(([repo, items]) => [
|
|
1209
|
-
repo,
|
|
1210
|
-
_repoHealthItem(repo, items, escAttr),
|
|
1211
|
-
]),
|
|
1212
|
-
);
|
|
1213
|
-
container.innerHTML = groupReposByApplication([...byRepo.keys()], _apps)
|
|
1214
|
-
.map(({ app, repos }) => {
|
|
1215
|
-
const inner = repos.map((r) => repoHtml.get(r)).join("");
|
|
1216
|
-
if (!app) return inner;
|
|
1217
|
-
const tip = [app.title, app.owner, app.description]
|
|
1218
|
-
.filter(Boolean)
|
|
1219
|
-
.join(" · ");
|
|
1220
|
-
return `<div class="health-app" title="${escAttr(tip)}"><span class="health-app-label">${escAttr(app.name)}</span>${inner}</div>`;
|
|
1221
|
-
})
|
|
1222
|
-
.join("");
|
|
1223
|
-
|
|
1224
|
-
// Wire open-in-editor click handlers (data-open-path avoids inline onclick escaping)
|
|
1225
|
-
container
|
|
1226
|
-
.querySelectorAll(".nav-open-btn[data-open-path]")
|
|
1227
|
-
.forEach((btn) => {
|
|
1228
|
-
btn.addEventListener("click", (e) => {
|
|
1229
|
-
e.stopPropagation();
|
|
1230
|
-
fetch("/api/editor/open", {
|
|
1231
|
-
method: "POST",
|
|
1232
|
-
headers: { "Content-Type": "application/json" },
|
|
1233
|
-
body: JSON.stringify({ path: btn.dataset.openPath }),
|
|
1234
|
-
}).then((r) => {
|
|
1235
|
-
if (!r.ok)
|
|
1236
|
-
r.json().then((d) =>
|
|
1237
|
-
console.warn("[__DEN_CLI__] open-in-editor:", d.error),
|
|
1238
|
-
);
|
|
1239
|
-
});
|
|
1240
|
-
});
|
|
1241
|
-
});
|
|
1242
|
-
}
|
|
1035
|
+
/**
|
|
1036
|
+
* One painted cycle: the shared rollup (buildRow, health-rollup.js) feeds
|
|
1037
|
+
* the rail (the Services badge and pinned dots) and the status chip and
|
|
1038
|
+
* drawer alike, so they never disagree (R6, R13): a down service counts
|
|
1039
|
+
* only once its repo's checks have all answered, pending and unchecked
|
|
1040
|
+
* ones never do.
|
|
1041
|
+
*/
|
|
1042
|
+
function _paintHealth(env, results) {
|
|
1043
|
+
const repos = [...new Set(results.map((r) => r.repo))];
|
|
1044
|
+
const groups = groupReposByApplication(repos, _apps);
|
|
1045
|
+
const row = buildRow(results, groups);
|
|
1046
|
+
window.denShell?.setState(shellHealth(row));
|
|
1047
|
+
_healthEnvPainted = env;
|
|
1048
|
+
paintStatus({ row, groups, env });
|
|
1243
1049
|
|
|
1244
1050
|
// Broadcast results so other components (e.g. dep graph) can stay in sync
|
|
1245
1051
|
document.dispatchEvent(
|
|
1246
1052
|
new CustomEvent("__DEN_CLI__:health-update", { detail: results }),
|
|
1247
1053
|
);
|
|
1248
|
-
|
|
1249
|
-
// Refresh button — shown on every page, no per-page HTML changes needed
|
|
1250
|
-
const btn = document.createElement("button");
|
|
1251
|
-
btn.title = "Refresh service health";
|
|
1252
|
-
btn.textContent = "⟳";
|
|
1253
|
-
btn.style.cssText =
|
|
1254
|
-
"background:none;border:none;color:var(--muted);cursor:pointer;" +
|
|
1255
|
-
"font-size:13px;padding:1px 4px;border-radius:3px;line-height:1;margin-left:4px;" +
|
|
1256
|
-
"transition:color .1s;";
|
|
1257
|
-
btn.addEventListener("mouseenter", () => (btn.style.color = "var(--text)"));
|
|
1258
|
-
btn.addEventListener("mouseleave", () => (btn.style.color = "var(--muted)"));
|
|
1259
|
-
btn.addEventListener("click", () => {
|
|
1260
|
-
const currentEnv = document.getElementById("env-sel")?.value ?? env;
|
|
1261
|
-
refreshHealth(currentEnv); // pulses dots yellow while re-pinging
|
|
1262
|
-
});
|
|
1263
|
-
container.appendChild(btn);
|
|
1264
1054
|
}
|
|
1265
1055
|
|
|
1266
1056
|
// ── Log badge (shared across pages) ───────────────────────────────────────────
|
|
@@ -1279,16 +1069,14 @@ export function initLogBadge() {
|
|
|
1279
1069
|
});
|
|
1280
1070
|
}
|
|
1281
1071
|
|
|
1072
|
+
/** The Logs page's NAV badge (shell.js) reads `openLogs`. */
|
|
1282
1073
|
function _updateBadgeFromStorage() {
|
|
1283
|
-
const el = document.getElementById("log-badge");
|
|
1284
|
-
if (!el) return;
|
|
1285
1074
|
try {
|
|
1286
1075
|
const logs = JSON.parse(localStorage.getItem(LS_OPEN_LOGS) || "[]");
|
|
1287
1076
|
const count = Array.isArray(logs) ? logs.length : 0;
|
|
1288
|
-
|
|
1289
|
-
el.style.display = count > 0 ? "inline" : "none";
|
|
1077
|
+
window.denShell?.setState({ openLogs: count });
|
|
1290
1078
|
} catch {
|
|
1291
|
-
|
|
1079
|
+
window.denShell?.setState({ openLogs: 0 });
|
|
1292
1080
|
}
|
|
1293
1081
|
}
|
|
1294
1082
|
|
|
@@ -1317,7 +1105,7 @@ export function storeOpenLog(repo, service, env) {
|
|
|
1317
1105
|
export function computePrimaryByRepo(svcList) {
|
|
1318
1106
|
const primary = new Map();
|
|
1319
1107
|
const rank = new Map();
|
|
1320
|
-
for (const s of svcList
|
|
1108
|
+
for (const s of Array.isArray(svcList) ? svcList : []) {
|
|
1321
1109
|
const r = s.isEntrypoint ? 2 : s.service === s.repo ? 1 : 0;
|
|
1322
1110
|
if (!primary.has(s.repo) || r > (rank.get(s.repo) ?? -1)) {
|
|
1323
1111
|
primary.set(s.repo, s.address);
|
|
@@ -1346,31 +1134,31 @@ function _injectStaleStyles() {
|
|
|
1346
1134
|
const s = document.createElement("style");
|
|
1347
1135
|
s.textContent = `
|
|
1348
1136
|
.stale-pill {
|
|
1349
|
-
font-family: var(--mono
|
|
1137
|
+
font-family: var(--mono); font-size: 10px; font-weight: 600;
|
|
1350
1138
|
padding: 2px 9px; border-radius: 10px; cursor: pointer; user-select: none;
|
|
1351
|
-
border: 1px solid var(--
|
|
1352
|
-
background:
|
|
1139
|
+
border: 1px solid var(--warn); color: var(--warn);
|
|
1140
|
+
background: color-mix(in srgb, var(--warn) 12%, transparent);
|
|
1353
1141
|
display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0;
|
|
1354
1142
|
animation: stale-pulse 2s ease-in-out infinite;
|
|
1355
1143
|
}
|
|
1356
|
-
.stale-pill:hover { color:
|
|
1144
|
+
.stale-pill:hover { color: var(--bg); background: var(--warn); }
|
|
1357
1145
|
.stale-pill b { font-weight: 700; }
|
|
1358
1146
|
@keyframes stale-pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
|
1359
1147
|
.restart-overlay {
|
|
1360
|
-
position: fixed; inset: 0; z-index: 2000; background:
|
|
1148
|
+
position: fixed; inset: 0; z-index: 2000; background: color-mix(in srgb, var(--bg) 90%, transparent);
|
|
1361
1149
|
display: flex; align-items: center; justify-content: center;
|
|
1362
1150
|
flex-direction: column; gap: 16px;
|
|
1363
|
-
color: var(--text
|
|
1151
|
+
color: var(--text); font-family: var(--mono);
|
|
1364
1152
|
}
|
|
1365
1153
|
.restart-overlay .spinner {
|
|
1366
1154
|
width: 30px; height: 30px; border-radius: 50%;
|
|
1367
|
-
border: 3px solid var(--border
|
|
1155
|
+
border: 3px solid var(--border); border-top-color: var(--accent);
|
|
1368
1156
|
animation: stale-spin .8s linear infinite;
|
|
1369
1157
|
}
|
|
1370
1158
|
@keyframes stale-spin { to { transform: rotate(360deg); } }
|
|
1371
1159
|
.restart-overlay .msg { font-size: 13px; }
|
|
1372
1160
|
.restart-overlay .hint {
|
|
1373
|
-
font-size: 11px; color: var(--muted
|
|
1161
|
+
font-size: 11px; color: var(--muted);
|
|
1374
1162
|
max-width: 380px; text-align: center; line-height: 1.5;
|
|
1375
1163
|
}
|
|
1376
1164
|
`;
|
|
@@ -1385,16 +1173,15 @@ function _renderStalePill(info) {
|
|
|
1385
1173
|
}
|
|
1386
1174
|
_injectStaleStyles();
|
|
1387
1175
|
if (!pill) {
|
|
1388
|
-
const header =
|
|
1176
|
+
const header =
|
|
1177
|
+
document.querySelector(".den-top") || document.querySelector("header");
|
|
1389
1178
|
if (!header) return;
|
|
1390
1179
|
pill = document.createElement("button");
|
|
1391
1180
|
pill.id = "stale-pill";
|
|
1392
1181
|
pill.className = "stale-pill";
|
|
1393
|
-
pill.title =
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
"GitHub; an open deploy log reattaches after the reload.";
|
|
1397
|
-
pill.innerHTML = "⚠ stale server · <b>Restart</b>";
|
|
1182
|
+
pill.title = t("stale.title", { cli: "__DEN_CLI__" });
|
|
1183
|
+
pill.setAttribute("aria-label", t("stale.aria"));
|
|
1184
|
+
pill.innerHTML = `<span aria-hidden="true">⚠</span><span class="stale-lbl"> ${escT("stale.label")} · <b>${escT("stale.restart")}</b></span>`;
|
|
1398
1185
|
pill.addEventListener("click", _requestRestart);
|
|
1399
1186
|
const vpill = document.getElementById("version-pill");
|
|
1400
1187
|
if (vpill && vpill.parentNode)
|
|
@@ -1420,6 +1207,13 @@ async function _staleTick() {
|
|
|
1420
1207
|
if (info && info.pid != null)
|
|
1421
1208
|
_lastServerInfo = { pid: info.pid, startedAt: info.startedAt };
|
|
1422
1209
|
_renderStalePill(info);
|
|
1210
|
+
// MD-2: a newer engine on disk than the one this server runs.
|
|
1211
|
+
if (info && "engineStale" in info)
|
|
1212
|
+
window.denShell?.setEngine?.({
|
|
1213
|
+
stale: info.engineStale === true,
|
|
1214
|
+
loaded: info.loaded?.version ?? null,
|
|
1215
|
+
onDisk: info.onDisk?.version ?? null,
|
|
1216
|
+
});
|
|
1423
1217
|
setTimeout(_staleTick, SERVER_INFO_POLL_MS);
|
|
1424
1218
|
}
|
|
1425
1219
|
|
|
@@ -1449,8 +1243,8 @@ function _showRestartOverlay() {
|
|
|
1449
1243
|
o.className = "restart-overlay";
|
|
1450
1244
|
o.innerHTML =
|
|
1451
1245
|
'<div class="spinner"></div>' +
|
|
1452
|
-
|
|
1453
|
-
|
|
1246
|
+
`<div class="msg">${escT("restart.msg", { cli: "__DEN_CLI__" })}</div>` +
|
|
1247
|
+
`<div class="hint" id="restart-hint">${escT("restart.hint")}</div>`;
|
|
1454
1248
|
document.body.appendChild(o);
|
|
1455
1249
|
}
|
|
1456
1250
|
|
|
@@ -1467,8 +1261,10 @@ async function _pollForNewServer(before, startedAt) {
|
|
|
1467
1261
|
if (Date.now() - startedAt > 90_000) {
|
|
1468
1262
|
const hint = document.getElementById("restart-hint");
|
|
1469
1263
|
if (hint)
|
|
1470
|
-
hint.innerHTML =
|
|
1471
|
-
"
|
|
1264
|
+
hint.innerHTML = escT("restart.still", { cmd: "\u0000" }).replace(
|
|
1265
|
+
"\u0000",
|
|
1266
|
+
"<b>__DEN_CLI__ web restart</b>",
|
|
1267
|
+
);
|
|
1472
1268
|
}
|
|
1473
1269
|
setTimeout(() => _pollForNewServer(before, startedAt), 1500);
|
|
1474
1270
|
}
|