@monster-orange/den 0.0.0 → 0.1.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 +67 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +61 -0
- package/docs/distributions.md +256 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +99 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +406 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +566 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +178 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/workspace-routes.ts +2154 -0
|
@@ -0,0 +1,1371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared header logic — service health dots
|
|
3
|
+
*
|
|
4
|
+
* Both index.html and services.html include this script.
|
|
5
|
+
* It expects:
|
|
6
|
+
* - a <div id="health"> in the header to render dots into
|
|
7
|
+
* - a <select id="env-sel"> whose change event triggers a re-ping
|
|
8
|
+
*
|
|
9
|
+
* Health data is driven entirely by __DEN_CLI__.json manifests — no hardcoded
|
|
10
|
+
* service names. Each registered service that declares a health.url for
|
|
11
|
+
* the current env gets a dot.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
buildWorkspaceMenuModel,
|
|
16
|
+
buildWorkspacePillModel,
|
|
17
|
+
} from "./workspace-picker.js";
|
|
18
|
+
|
|
19
|
+
// 5s between polls — measured from each poll's COMPLETION (schedulePoll
|
|
20
|
+
// re-arms after renderHealthDots resolves), so slow probes can't overlap.
|
|
21
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
22
|
+
|
|
23
|
+
// Last painted health results, keyed `repo/service`, so a new poll can seed
|
|
24
|
+
// from them and update dots in place instead of collapsing the strip and
|
|
25
|
+
// regrowing it (which shifted the whole page below — see renderHealthDots).
|
|
26
|
+
let _healthAcc = new Map();
|
|
27
|
+
let _healthAccGen = -1;
|
|
28
|
+
let pollTimer = null;
|
|
29
|
+
// Generation token: every (re)start of polling bumps this. A stale tick (an
|
|
30
|
+
// env the user already switched away from, or a slow fetch resolving late)
|
|
31
|
+
// must neither paint the DOM nor re-arm the loop for its outdated env —
|
|
32
|
+
// without this, one in-flight poll during an env switch permanently hijacked
|
|
33
|
+
// the dots back to the old env.
|
|
34
|
+
let pollGen = 0;
|
|
35
|
+
|
|
36
|
+
// ── Editor preference (cached) ─────────────────────────────────────────────────
|
|
37
|
+
let _editorKey = null;
|
|
38
|
+
let _editorKeyFetched = false;
|
|
39
|
+
async function _loadEditorKey() {
|
|
40
|
+
if (_editorKeyFetched) return;
|
|
41
|
+
_editorKeyFetched = true;
|
|
42
|
+
try {
|
|
43
|
+
const data = await fetch("/api/settings").then((r) => r.json());
|
|
44
|
+
_editorKey = data.editor ?? null;
|
|
45
|
+
} catch {
|
|
46
|
+
/* settings unavailable */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function _injectNavStyles() {
|
|
51
|
+
if (document.getElementById("__DEN_CLI__-nav-styles")) return;
|
|
52
|
+
const s = document.createElement("style");
|
|
53
|
+
s.id = "__DEN_CLI__-nav-styles";
|
|
54
|
+
s.textContent = `
|
|
55
|
+
.nav-open-btn {
|
|
56
|
+
background: none; border: none; color: var(--muted, #8b949e);
|
|
57
|
+
cursor: pointer; font-size: 11px; padding: 0 3px; line-height: 1;
|
|
58
|
+
opacity: 0; transition: opacity .15s, color .1s;
|
|
59
|
+
flex-shrink: 0;
|
|
60
|
+
}
|
|
61
|
+
.health-item:hover .nav-open-btn,
|
|
62
|
+
.health-group:hover .nav-open-btn { opacity: 1; }
|
|
63
|
+
.nav-open-btn:hover { color: var(--accent, #58a6ff) !important; }
|
|
64
|
+
/* Repo dots boxed by application (Org > Application > Repo). */
|
|
65
|
+
.health-app {
|
|
66
|
+
display: flex; align-items: center; gap: 8px;
|
|
67
|
+
padding: 1px 8px 1px 6px; border: 1px solid var(--border, #30363d);
|
|
68
|
+
border-radius: 10px;
|
|
69
|
+
}
|
|
70
|
+
.health-app-label {
|
|
71
|
+
font-size: 10px; font-weight: 600; letter-spacing: .03em;
|
|
72
|
+
color: var(--text, #c9d1d9); opacity: .7; white-space: nowrap;
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
document.head.appendChild(s);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Changelog modal ────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const LS_CHANGELOG_SEEN = "__DEN_CLI__-changelog-seen";
|
|
81
|
+
let _changelogEntries = [];
|
|
82
|
+
let _changelogInjected = false;
|
|
83
|
+
|
|
84
|
+
function _injectChangelogStyles() {
|
|
85
|
+
if (_changelogInjected) return;
|
|
86
|
+
_changelogInjected = true;
|
|
87
|
+
|
|
88
|
+
const style = document.createElement("style");
|
|
89
|
+
style.textContent = `
|
|
90
|
+
.v-pill {
|
|
91
|
+
font-family: var(--mono, monospace); font-size: 10px;
|
|
92
|
+
padding: 2px 8px; border-radius: 10px;
|
|
93
|
+
border: 1px solid var(--border, #30363d); color: var(--muted, #8b949e);
|
|
94
|
+
background: none; cursor: pointer; user-select: none;
|
|
95
|
+
transition: color .1s, border-color .1s; position: relative;
|
|
96
|
+
flex-shrink: 0;
|
|
97
|
+
}
|
|
98
|
+
.v-pill:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); }
|
|
99
|
+
.v-pip {
|
|
100
|
+
position: absolute; top: -3px; right: -3px;
|
|
101
|
+
width: 7px; height: 7px; border-radius: 50%;
|
|
102
|
+
background: var(--accent, #58a6ff);
|
|
103
|
+
border: 1.5px solid var(--bg, #0d1117);
|
|
104
|
+
display: none; pointer-events: none;
|
|
105
|
+
}
|
|
106
|
+
.cl-overlay {
|
|
107
|
+
position: fixed; inset: 0; z-index: 1000;
|
|
108
|
+
background: rgba(0,0,0,.65);
|
|
109
|
+
display: none; align-items: center; justify-content: center;
|
|
110
|
+
}
|
|
111
|
+
.cl-overlay.open { display: flex; }
|
|
112
|
+
.cl-box {
|
|
113
|
+
background: var(--surface, #161b22);
|
|
114
|
+
border: 1px solid var(--border, #30363d);
|
|
115
|
+
border-radius: 10px; width: min(580px, 92vw); max-height: 78vh;
|
|
116
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
117
|
+
box-shadow: 0 8px 40px rgba(0,0,0,.65);
|
|
118
|
+
}
|
|
119
|
+
.cl-hdr {
|
|
120
|
+
display: flex; align-items: center; gap: 10px;
|
|
121
|
+
padding: 13px 18px; border-bottom: 1px solid var(--border, #30363d); flex-shrink: 0;
|
|
122
|
+
}
|
|
123
|
+
.cl-hdr h3 {
|
|
124
|
+
font-family: var(--mono, monospace); font-size: 13px; font-weight: 600;
|
|
125
|
+
color: var(--text, #c9d1d9); flex: 1; margin: 0;
|
|
126
|
+
}
|
|
127
|
+
.cl-close {
|
|
128
|
+
background: none; border: none; color: var(--muted, #8b949e);
|
|
129
|
+
font-size: 18px; cursor: pointer; line-height: 1;
|
|
130
|
+
padding: 2px 5px; border-radius: 3px; transition: color .1s, background .1s;
|
|
131
|
+
}
|
|
132
|
+
.cl-close:hover { color: var(--text, #c9d1d9); background: var(--border, #30363d); }
|
|
133
|
+
.cl-body { overflow-y: auto; padding: 16px 18px; flex: 1; }
|
|
134
|
+
.cl-entry { margin-bottom: 18px; }
|
|
135
|
+
.cl-ver-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
|
|
136
|
+
.cl-ver {
|
|
137
|
+
font-family: var(--mono, monospace); font-size: 13px; font-weight: 700;
|
|
138
|
+
color: var(--accent, #58a6ff);
|
|
139
|
+
}
|
|
140
|
+
.cl-date { font-size: 11px; color: var(--muted, #8b949e); }
|
|
141
|
+
.cl-note { font-size: 12px; color: var(--muted, #8b949e); margin: 0 0 8px; font-style: italic; }
|
|
142
|
+
.cl-sec {
|
|
143
|
+
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
|
144
|
+
letter-spacing: .07em; color: var(--muted, #8b949e); margin: 8px 0 4px;
|
|
145
|
+
}
|
|
146
|
+
.cl-items { list-style: none; padding: 0; margin: 0; }
|
|
147
|
+
.cl-items li {
|
|
148
|
+
font-size: 12px; color: var(--text, #c9d1d9);
|
|
149
|
+
padding: 2px 0 2px 14px; position: relative; line-height: 1.5;
|
|
150
|
+
}
|
|
151
|
+
.cl-items li::before { content: "·"; position: absolute; left: 2px; color: var(--muted, #8b949e); }
|
|
152
|
+
.cl-subitems { list-style: none; padding: 0; margin: 2px 0 0 0; }
|
|
153
|
+
.cl-subitems li { font-size: 11.5px; padding: 1px 0 1px 14px; color: var(--muted, #8b949e); }
|
|
154
|
+
.cl-subitems li::before { content: "–"; position: absolute; left: 2px; }
|
|
155
|
+
.cl-items code {
|
|
156
|
+
font-family: var(--mono, monospace); font-size: 10.5px;
|
|
157
|
+
color: var(--accent, #58a6ff); background: rgba(88,166,255,.1);
|
|
158
|
+
padding: 1px 4px; border-radius: 3px;
|
|
159
|
+
}
|
|
160
|
+
.cl-divider { border: none; border-top: 1px solid var(--border, #30363d); margin: 14px 0; }
|
|
161
|
+
.cl-copy-group { display: flex; gap: 4px; margin-left: auto; flex-shrink: 0; }
|
|
162
|
+
.cl-copy-btn, .cl-slack-btn {
|
|
163
|
+
background: none; border: 1px solid var(--border, #30363d); color: var(--muted, #8b949e);
|
|
164
|
+
padding: 3px 6px; border-radius: 4px; line-height: 0;
|
|
165
|
+
cursor: pointer; flex-shrink: 0;
|
|
166
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
167
|
+
transition: color .1s, border-color .1s;
|
|
168
|
+
}
|
|
169
|
+
.cl-copy-btn:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); }
|
|
170
|
+
.cl-copy-btn.copied { color: #3fb950; border-color: #3fb950; }
|
|
171
|
+
.cl-slack-btn:hover { color: #e01e5a; border-color: #e01e5a; }
|
|
172
|
+
.cl-slack-btn.copied { color: #3fb950; border-color: #3fb950; }
|
|
173
|
+
.cl-footer {
|
|
174
|
+
padding: 10px 18px; border-top: 1px solid var(--border, #30363d);
|
|
175
|
+
display: flex; justify-content: flex-end; flex-shrink: 0;
|
|
176
|
+
background: var(--surface, #161b22);
|
|
177
|
+
}
|
|
178
|
+
.cl-got-it {
|
|
179
|
+
background: var(--accent, #58a6ff); border: none; color: #000;
|
|
180
|
+
padding: 5px 16px; border-radius: 5px; font-size: 12px;
|
|
181
|
+
font-weight: 600; cursor: pointer;
|
|
182
|
+
}
|
|
183
|
+
.cl-got-it:hover { opacity: .85; }
|
|
184
|
+
`;
|
|
185
|
+
document.head.appendChild(style);
|
|
186
|
+
|
|
187
|
+
// Modal overlay
|
|
188
|
+
const overlay = document.createElement("div");
|
|
189
|
+
overlay.id = "cl-overlay";
|
|
190
|
+
overlay.className = "cl-overlay";
|
|
191
|
+
overlay.innerHTML =
|
|
192
|
+
'<div class="cl-box">' +
|
|
193
|
+
'<div class="cl-hdr"><h3>What\'s new</h3><button class="cl-close" id="cl-close-btn" title="Close">×</button></div>' +
|
|
194
|
+
'<div class="cl-body" id="cl-body"></div>' +
|
|
195
|
+
'<div class="cl-footer"><button class="cl-got-it" id="cl-got-it-btn">Got it</button></div>' +
|
|
196
|
+
"</div>";
|
|
197
|
+
document.body.appendChild(overlay);
|
|
198
|
+
|
|
199
|
+
overlay.addEventListener("click", (e) => {
|
|
200
|
+
if (e.target === overlay) window.closeChangelog();
|
|
201
|
+
});
|
|
202
|
+
document
|
|
203
|
+
.getElementById("cl-close-btn")
|
|
204
|
+
?.addEventListener("click", () => window.closeChangelog());
|
|
205
|
+
document
|
|
206
|
+
.getElementById("cl-got-it-btn")
|
|
207
|
+
?.addEventListener("click", () => window.closeChangelog());
|
|
208
|
+
|
|
209
|
+
document.addEventListener("keydown", (e) => {
|
|
210
|
+
if (e.key === "Escape") window.closeChangelog();
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function _renderChangelogBody(entries) {
|
|
215
|
+
const body = document.getElementById("cl-body");
|
|
216
|
+
if (!body) return;
|
|
217
|
+
if (!entries.length) {
|
|
218
|
+
body.innerHTML =
|
|
219
|
+
'<p style="color:var(--muted,#8b949e);font-size:12px">No entries found.</p>';
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const escHtml = (s) =>
|
|
223
|
+
String(s)
|
|
224
|
+
.replace(/&/g, "&")
|
|
225
|
+
.replace(/</g, "<")
|
|
226
|
+
.replace(/>/g, ">");
|
|
227
|
+
const renderItem = (s) =>
|
|
228
|
+
escHtml(s)
|
|
229
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
230
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
231
|
+
body.innerHTML = entries
|
|
232
|
+
.map(
|
|
233
|
+
(entry, i) =>
|
|
234
|
+
(i > 0 ? '<hr class="cl-divider">' : "") +
|
|
235
|
+
'<div class="cl-entry">' +
|
|
236
|
+
'<div class="cl-ver-row">' +
|
|
237
|
+
'<span class="cl-ver">v' +
|
|
238
|
+
escHtml(entry.version) +
|
|
239
|
+
"</span>" +
|
|
240
|
+
'<span class="cl-date">' +
|
|
241
|
+
escHtml(entry.date) +
|
|
242
|
+
"</span>" +
|
|
243
|
+
'<div class="cl-copy-group">' +
|
|
244
|
+
'<button class="cl-copy-btn" data-cl-version="' +
|
|
245
|
+
escHtml(entry.version) +
|
|
246
|
+
'" title="Copy as Markdown">' +
|
|
247
|
+
'<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
248
|
+
'<rect x="4" y="0.5" width="8.5" height="8.5" rx="1.5"/>' +
|
|
249
|
+
'<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"/>' +
|
|
250
|
+
"</svg>" +
|
|
251
|
+
"</button>" +
|
|
252
|
+
'<button class="cl-slack-btn" data-cl-version="' +
|
|
253
|
+
escHtml(entry.version) +
|
|
254
|
+
'" title="Copy for Slack">' +
|
|
255
|
+
'<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
256
|
+
'<path d="M1 2a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H7.5L5 12v-3H2a1 1 0 0 1-1-1V2z"/>' +
|
|
257
|
+
"</svg>" +
|
|
258
|
+
"</button>" +
|
|
259
|
+
"</div>" +
|
|
260
|
+
"</div>" +
|
|
261
|
+
(entry.note
|
|
262
|
+
? '<p class="cl-note">' + renderItem(entry.note) + "</p>"
|
|
263
|
+
: "") +
|
|
264
|
+
Object.entries(entry.sections ?? {})
|
|
265
|
+
.map(
|
|
266
|
+
([sec, items]) =>
|
|
267
|
+
'<div class="cl-sec">' +
|
|
268
|
+
escHtml(sec) +
|
|
269
|
+
"</div>" +
|
|
270
|
+
'<ul class="cl-items">' +
|
|
271
|
+
items
|
|
272
|
+
.map(
|
|
273
|
+
(it) =>
|
|
274
|
+
"<li>" +
|
|
275
|
+
renderItem(it.text) +
|
|
276
|
+
(it.sub?.length
|
|
277
|
+
? '<ul class="cl-subitems">' +
|
|
278
|
+
it.sub
|
|
279
|
+
.map((s) => "<li>" + renderItem(s) + "</li>")
|
|
280
|
+
.join("") +
|
|
281
|
+
"</ul>"
|
|
282
|
+
: "") +
|
|
283
|
+
"</li>",
|
|
284
|
+
)
|
|
285
|
+
.join("") +
|
|
286
|
+
"</ul>",
|
|
287
|
+
)
|
|
288
|
+
.join("") +
|
|
289
|
+
"</div>",
|
|
290
|
+
)
|
|
291
|
+
.join("");
|
|
292
|
+
|
|
293
|
+
const _slackIcon =
|
|
294
|
+
'<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
295
|
+
'<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"/>' +
|
|
296
|
+
"</svg>";
|
|
297
|
+
const _copyIcon =
|
|
298
|
+
'<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">' +
|
|
299
|
+
'<rect x="4" y="0.5" width="8.5" height="8.5" rx="1.5"/>' +
|
|
300
|
+
'<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"/>' +
|
|
301
|
+
"</svg>";
|
|
302
|
+
const _checkIcon =
|
|
303
|
+
'<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
304
|
+
'<polyline points="2,7 5,10 11,3"/>' +
|
|
305
|
+
"</svg>";
|
|
306
|
+
|
|
307
|
+
// Markdown copy — plain text for GitHub, MD editors, etc.
|
|
308
|
+
body.querySelectorAll(".cl-copy-btn").forEach((btn) => {
|
|
309
|
+
btn.addEventListener("click", async () => {
|
|
310
|
+
const entry = _changelogEntries.find(
|
|
311
|
+
(e) => e.version === btn.dataset.clVersion,
|
|
312
|
+
);
|
|
313
|
+
if (!entry) return;
|
|
314
|
+
try {
|
|
315
|
+
await copyToClipboard(entry.raw || _buildMarkdown(entry));
|
|
316
|
+
btn.innerHTML = _checkIcon;
|
|
317
|
+
btn.classList.add("copied");
|
|
318
|
+
setTimeout(() => {
|
|
319
|
+
btn.innerHTML = _copyIcon;
|
|
320
|
+
btn.classList.remove("copied");
|
|
321
|
+
}, 2000);
|
|
322
|
+
} catch {
|
|
323
|
+
btn.title = "Copy failed — try again";
|
|
324
|
+
setTimeout(() => (btn.title = "Copy as Markdown"), 2000);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Rich copy — contenteditable + execCommand produces DOM-originated HTML that
|
|
330
|
+
// Slack (and Notion, Docs) trusts and renders as formatted text.
|
|
331
|
+
body.querySelectorAll(".cl-slack-btn").forEach((btn) => {
|
|
332
|
+
btn.addEventListener("click", async () => {
|
|
333
|
+
const entry = _changelogEntries.find(
|
|
334
|
+
(e) => e.version === btn.dataset.clVersion,
|
|
335
|
+
);
|
|
336
|
+
if (!entry) return;
|
|
337
|
+
try {
|
|
338
|
+
await _writeRichToClipboard(_buildRichHtml(entry));
|
|
339
|
+
btn.innerHTML = _checkIcon;
|
|
340
|
+
btn.classList.add("copied");
|
|
341
|
+
setTimeout(() => {
|
|
342
|
+
btn.innerHTML = _slackIcon;
|
|
343
|
+
btn.classList.remove("copied");
|
|
344
|
+
}, 2000);
|
|
345
|
+
} catch {
|
|
346
|
+
btn.title = "Copy failed — try again";
|
|
347
|
+
setTimeout(() => (btn.title = "Copy for Slack"), 2000);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Write plain text to clipboard.
|
|
355
|
+
* Uses navigator.clipboard when available (secure contexts), falls back
|
|
356
|
+
* to execCommand('copy') for http:// origins like http://__DEN_CLI__:7777.
|
|
357
|
+
* Shared utility — use this instead of rolling your own copy logic.
|
|
358
|
+
*/
|
|
359
|
+
export function copyToClipboard(text) {
|
|
360
|
+
if (navigator.clipboard?.writeText)
|
|
361
|
+
return navigator.clipboard.writeText(text);
|
|
362
|
+
return new Promise((resolve, reject) => {
|
|
363
|
+
const ta = document.createElement("textarea");
|
|
364
|
+
ta.value = text;
|
|
365
|
+
ta.style.cssText = "position:fixed;opacity:0;top:0;left:0";
|
|
366
|
+
document.body.appendChild(ta);
|
|
367
|
+
ta.focus();
|
|
368
|
+
ta.select();
|
|
369
|
+
try {
|
|
370
|
+
document.execCommand("copy")
|
|
371
|
+
? resolve()
|
|
372
|
+
: reject(new Error("execCommand failed"));
|
|
373
|
+
} catch (e) {
|
|
374
|
+
reject(e);
|
|
375
|
+
} finally {
|
|
376
|
+
document.body.removeChild(ta);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Write rich HTML to the clipboard via a hidden contenteditable element.
|
|
383
|
+
*
|
|
384
|
+
* ClipboardItem produces clipboard entries that Slack ignores. Copying from
|
|
385
|
+
* an actual DOM selection (the "copy from webpage" path) puts HTML on the
|
|
386
|
+
* clipboard in the format Slack's paste handler trusts and renders as
|
|
387
|
+
* formatted text.
|
|
388
|
+
*/
|
|
389
|
+
function _writeRichToClipboard(html) {
|
|
390
|
+
return new Promise((resolve, reject) => {
|
|
391
|
+
const el = document.createElement("div");
|
|
392
|
+
el.contentEditable = "true";
|
|
393
|
+
el.style.cssText =
|
|
394
|
+
"position:fixed;opacity:0;top:0;left:0;pointer-events:none";
|
|
395
|
+
el.innerHTML = html;
|
|
396
|
+
document.body.appendChild(el);
|
|
397
|
+
const range = document.createRange();
|
|
398
|
+
range.selectNodeContents(el);
|
|
399
|
+
const sel = window.getSelection();
|
|
400
|
+
sel.removeAllRanges();
|
|
401
|
+
sel.addRange(range);
|
|
402
|
+
try {
|
|
403
|
+
document.execCommand("copy")
|
|
404
|
+
? resolve()
|
|
405
|
+
: reject(new Error("execCommand failed"));
|
|
406
|
+
} catch (e) {
|
|
407
|
+
reject(e);
|
|
408
|
+
} finally {
|
|
409
|
+
sel.removeAllRanges();
|
|
410
|
+
document.body.removeChild(el);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Build rich HTML for Slack paste. Uses inline formatting Slack renders:
|
|
417
|
+
* <strong> for bold, <code> for inline code, <ul><li> for bullets.
|
|
418
|
+
* Block-level headings are replaced with bold paragraphs so Slack doesn't
|
|
419
|
+
* strip them.
|
|
420
|
+
*/
|
|
421
|
+
function _buildRichHtml(entry) {
|
|
422
|
+
const esc = (s) =>
|
|
423
|
+
String(s)
|
|
424
|
+
.replace(/&/g, "&")
|
|
425
|
+
.replace(/</g, "<")
|
|
426
|
+
.replace(/>/g, ">");
|
|
427
|
+
const renderItem = (s) =>
|
|
428
|
+
esc(s)
|
|
429
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
430
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
431
|
+
|
|
432
|
+
const sections = Object.entries(entry.sections ?? {})
|
|
433
|
+
.map(([sec, items]) => {
|
|
434
|
+
const liItems = items.flatMap((it) => [
|
|
435
|
+
`<li>${renderItem(it.text)}</li>`,
|
|
436
|
+
...(it.sub ?? []).map((s) => `<li>${renderItem(s)}</li>`),
|
|
437
|
+
]);
|
|
438
|
+
return `<p><strong>${esc(sec)}</strong></p><ul>${liItems.join("")}</ul>`;
|
|
439
|
+
})
|
|
440
|
+
.join("");
|
|
441
|
+
return `<p><strong>v${esc(entry.version)} — ${esc(entry.date)}</strong></p>${sections}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Reconstruct markdown from a structured entry as a fallback. */
|
|
445
|
+
function _buildMarkdown(entry) {
|
|
446
|
+
const lines = [`## [${entry.version}] — ${entry.date}`];
|
|
447
|
+
for (const [sec, items] of Object.entries(entry.sections ?? {})) {
|
|
448
|
+
lines.push("", `### ${sec}`, "");
|
|
449
|
+
for (const item of items) {
|
|
450
|
+
lines.push(`- ${item.text}`);
|
|
451
|
+
for (const s of item.sub ?? []) lines.push(` - ${s}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return lines.join("\n");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
window.openChangelog = function () {
|
|
458
|
+
_injectChangelogStyles();
|
|
459
|
+
_renderChangelogBody(_changelogEntries);
|
|
460
|
+
const overlay = document.getElementById("cl-overlay");
|
|
461
|
+
if (overlay) overlay.classList.add("open");
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
window.closeChangelog = function () {
|
|
465
|
+
const overlay = document.getElementById("cl-overlay");
|
|
466
|
+
if (overlay) overlay.classList.remove("open");
|
|
467
|
+
if (_changelogEntries[0]?.version) {
|
|
468
|
+
try {
|
|
469
|
+
localStorage.setItem(LS_CHANGELOG_SEEN, _changelogEntries[0].version);
|
|
470
|
+
} catch {}
|
|
471
|
+
}
|
|
472
|
+
const pip = document.getElementById("v-pip");
|
|
473
|
+
if (pip) pip.style.display = "none";
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Fetch changelog, populate the version pill, and auto-open if unread.
|
|
478
|
+
* Call once per page from each page's init function.
|
|
479
|
+
*/
|
|
480
|
+
export async function initChangelog() {
|
|
481
|
+
_injectChangelogStyles();
|
|
482
|
+
try {
|
|
483
|
+
_changelogEntries = await fetch("/api/changelog").then((r) => r.json());
|
|
484
|
+
} catch {
|
|
485
|
+
_changelogEntries = [];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const latest = _changelogEntries[0];
|
|
489
|
+
const pill = document.getElementById("version-pill");
|
|
490
|
+
if (pill && latest?.version) {
|
|
491
|
+
pill.querySelector(".v-txt").textContent = "v" + latest.version;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let lastSeen = null;
|
|
495
|
+
try {
|
|
496
|
+
lastSeen = localStorage.getItem(LS_CHANGELOG_SEEN);
|
|
497
|
+
} catch {}
|
|
498
|
+
|
|
499
|
+
if (latest?.version && lastSeen !== latest.version) {
|
|
500
|
+
const pip = document.getElementById("v-pip");
|
|
501
|
+
if (pip) pip.style.display = "block";
|
|
502
|
+
window.openChangelog();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── __DEN_TITLE__ config ──────────────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
let ___DEN_CLI__Config = null;
|
|
509
|
+
|
|
510
|
+
async function getDenConfig() {
|
|
511
|
+
if (___DEN_CLI__Config) return ___DEN_CLI__Config;
|
|
512
|
+
try {
|
|
513
|
+
___DEN_CLI__Config = await fetch("/api/__DEN_CLI__/config").then((r) =>
|
|
514
|
+
r.json(),
|
|
515
|
+
);
|
|
516
|
+
} catch {
|
|
517
|
+
___DEN_CLI__Config = {
|
|
518
|
+
environments: ["local"],
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return ___DEN_CLI__Config;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Apply __DEN_CLI__ config to the nav — hide "Local Dev" when local environment is disabled.
|
|
526
|
+
* Call once on page load from each page's init function.
|
|
527
|
+
*/
|
|
528
|
+
export async function applyDenConfig() {
|
|
529
|
+
const config = await getDenConfig();
|
|
530
|
+
_renderOrgCrumb(config.org);
|
|
531
|
+
const localEnabled = config.environments?.includes("local") ?? true;
|
|
532
|
+
if (!localEnabled) {
|
|
533
|
+
document
|
|
534
|
+
.querySelectorAll("nav a[href='/'], nav a[href='/local-dev']")
|
|
535
|
+
.forEach((el) => {
|
|
536
|
+
el.style.display = "none";
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
return config;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The org (root of Org > Application > Repo > Services) as a small crumb
|
|
544
|
+
* before the product name: "Globex › __DEN_CLI__".
|
|
545
|
+
*/
|
|
546
|
+
function _renderOrgCrumb(org) {
|
|
547
|
+
const h1 = document.querySelector("header h1");
|
|
548
|
+
if (!h1 || !org?.title || document.getElementById("org-crumb")) return;
|
|
549
|
+
const crumb = document.createElement("span");
|
|
550
|
+
crumb.id = "org-crumb";
|
|
551
|
+
crumb.title = `Org: ${org.title} (${org.id})`;
|
|
552
|
+
crumb.style.cssText =
|
|
553
|
+
"font-size:12px;font-weight:600;color:var(--text,#c9d1d9);" +
|
|
554
|
+
"white-space:nowrap;flex-shrink:0;display:flex;align-items:center;gap:8px";
|
|
555
|
+
crumb.textContent = org.title;
|
|
556
|
+
const sep = document.createElement("span");
|
|
557
|
+
sep.textContent = "›";
|
|
558
|
+
sep.style.cssText = "color:var(--muted,#8b949e);font-weight:400";
|
|
559
|
+
crumb.appendChild(sep);
|
|
560
|
+
h1.parentNode.insertBefore(crumb, h1);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ── Applications (for grouping the health dots) ───────────────────────────────
|
|
564
|
+
|
|
565
|
+
// Focused workspace's applications, refreshed at most every 30s so a config
|
|
566
|
+
// edit shows up without a reload but polls don't refetch every 5s.
|
|
567
|
+
let _apps = null;
|
|
568
|
+
let _appsAt = 0;
|
|
569
|
+
async function _loadApps() {
|
|
570
|
+
if (_apps && Date.now() - _appsAt < 30_000) return _apps;
|
|
571
|
+
try {
|
|
572
|
+
const r = await fetch("/api/applications", {
|
|
573
|
+
signal: AbortSignal.timeout(5_000),
|
|
574
|
+
});
|
|
575
|
+
if (r.ok) {
|
|
576
|
+
_apps = (await r.json()).applications ?? [];
|
|
577
|
+
_appsAt = Date.now();
|
|
578
|
+
}
|
|
579
|
+
} catch {
|
|
580
|
+
/* keep the last set (or none: dots render ungrouped) */
|
|
581
|
+
}
|
|
582
|
+
return _apps ?? [];
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Order repos by application and say which application box each sits in.
|
|
587
|
+
* Declared applications get a box; a repo that is its own application (or is
|
|
588
|
+
* in none) renders bare. Pure — exported for tests.
|
|
589
|
+
*/
|
|
590
|
+
export function groupReposByApplication(repos, apps) {
|
|
591
|
+
const appOf = new Map();
|
|
592
|
+
for (const a of apps || [])
|
|
593
|
+
for (const r of a.repos || []) if (!appOf.has(r)) appOf.set(r, a);
|
|
594
|
+
const out = [];
|
|
595
|
+
const seen = new Set();
|
|
596
|
+
for (const a of apps || []) {
|
|
597
|
+
const members = repos.filter((r) => appOf.get(r) === a);
|
|
598
|
+
if (!members.length) continue;
|
|
599
|
+
members.forEach((r) => seen.add(r));
|
|
600
|
+
out.push({ app: a.source === "declared" ? a : null, repos: members });
|
|
601
|
+
}
|
|
602
|
+
const rest = repos.filter((r) => !seen.has(r));
|
|
603
|
+
if (rest.length) out.push({ app: null, repos: rest });
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── Workspace pill ────────────────────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
// Set when a page opts into switching (initWorkspacePill's `onSwitch`): the
|
|
610
|
+
// pill becomes a dropdown of every known workspace instead of a static label.
|
|
611
|
+
let _wsOnSwitch = null;
|
|
612
|
+
let _wsMenuOpen = false;
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Render the active workspace name into <span id="ws-pill">.
|
|
616
|
+
* Pass the id of the env <select> to show the pill only when env is "local".
|
|
617
|
+
* Omit envSelId (or pass null) to always show it (e.g. Local Dev page).
|
|
618
|
+
*
|
|
619
|
+
* Pass `onSwitch` to make the pill a workspace picker: choosing an entry POSTs
|
|
620
|
+
* `use` (activate + focus, same as `__DEN_CLI__ workspace use`) and then calls
|
|
621
|
+
* onSwitch(name) so the page can reload whatever it derives from the focused
|
|
622
|
+
* workspace. Pages with their own workspace UI (Local Dev) omit it.
|
|
623
|
+
*/
|
|
624
|
+
export async function initWorkspacePill(envSelId = null, { onSwitch } = {}) {
|
|
625
|
+
const envSel = envSelId ? document.getElementById(envSelId) : null;
|
|
626
|
+
_wsOnSwitch = onSwitch ?? null;
|
|
627
|
+
await _renderPill(envSel);
|
|
628
|
+
if (envSel) {
|
|
629
|
+
envSel.addEventListener("change", () => _renderPill(envSel));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Re-fetch and re-render the workspace pill (call after switching workspaces). */
|
|
634
|
+
export async function refreshWorkspacePill() {
|
|
635
|
+
const envSel = document.getElementById("env-sel");
|
|
636
|
+
await _renderPill(envSel);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function _renderPill(envSel) {
|
|
640
|
+
const el = document.getElementById("ws-pill");
|
|
641
|
+
if (!el) return;
|
|
642
|
+
const isLocal = !envSel || envSel.value === "local";
|
|
643
|
+
if (!isLocal) {
|
|
644
|
+
_closeWorkspaceMenu();
|
|
645
|
+
_pillHost(el).style.display = "none";
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
const data = await fetch("/api/workspaces").then((r) => r.json());
|
|
650
|
+
const { label, title } = buildWorkspacePillModel(data);
|
|
651
|
+
if (_wsOnSwitch) {
|
|
652
|
+
_renderPickerPill(el, data, label, title);
|
|
653
|
+
} else {
|
|
654
|
+
el.textContent = label;
|
|
655
|
+
el.title = data.active?.length > 1 ? title : "";
|
|
656
|
+
}
|
|
657
|
+
// The pill starts as inline display:none in every page's markup; clear it
|
|
658
|
+
// on the pill AND its picker wrapper, or the wrapper shows an empty box.
|
|
659
|
+
el.style.display = "";
|
|
660
|
+
_pillHost(el).style.display = "";
|
|
661
|
+
} catch {
|
|
662
|
+
_closeWorkspaceMenu();
|
|
663
|
+
_pillHost(el).style.display = "none";
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The element whose visibility tracks the pill's: the wrapper once the picker
|
|
669
|
+
* has been built (so the menu is hidden with it), the pill itself before that.
|
|
670
|
+
*/
|
|
671
|
+
function _pillHost(el) {
|
|
672
|
+
return el.parentElement?.classList.contains("ws-pill-wrap")
|
|
673
|
+
? el.parentElement
|
|
674
|
+
: el;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function _injectWorkspacePickerStyles() {
|
|
678
|
+
if (document.getElementById("__DEN_CLI__-ws-picker-styles")) return;
|
|
679
|
+
const s = document.createElement("style");
|
|
680
|
+
s.id = "__DEN_CLI__-ws-picker-styles";
|
|
681
|
+
s.textContent = `
|
|
682
|
+
.ws-pill-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
|
|
683
|
+
#ws-pill.ws-pill-btn { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 5px; }
|
|
684
|
+
#ws-pill.ws-pill-btn:hover, #ws-pill.ws-pill-btn.open { border-color: var(--accent, #58a6ff); }
|
|
685
|
+
.ws-pill-caret { font-size: 8px; opacity: .7; }
|
|
686
|
+
.ws-menu {
|
|
687
|
+
display: none; position: absolute; top: calc(100% + 4px); left: 0; z-index: 200;
|
|
688
|
+
background: var(--surface, #161b22); border: 1px solid var(--border, #30363d);
|
|
689
|
+
border-radius: 6px; min-width: 190px; max-height: 280px; overflow-y: auto;
|
|
690
|
+
box-shadow: 0 4px 16px rgba(0,0,0,.4); padding: 3px 0;
|
|
691
|
+
}
|
|
692
|
+
.ws-menu.open { display: block; }
|
|
693
|
+
.ws-menu-item {
|
|
694
|
+
display: flex; align-items: center; gap: 8px; width: 100%;
|
|
695
|
+
background: none; border: none; text-align: left;
|
|
696
|
+
padding: 6px 12px; cursor: pointer; font-size: 12px;
|
|
697
|
+
font-family: var(--mono, monospace); color: var(--text, #c9d1d9); white-space: nowrap;
|
|
698
|
+
}
|
|
699
|
+
.ws-menu-item:hover { background: var(--border, #30363d); }
|
|
700
|
+
.ws-menu-item[disabled] { cursor: default; }
|
|
701
|
+
.ws-menu-mark { width: 10px; flex-shrink: 0; color: var(--accent, #58a6ff); }
|
|
702
|
+
.ws-menu-tag { margin-left: auto; font-size: 10px; color: var(--muted, #8b949e); }
|
|
703
|
+
`;
|
|
704
|
+
document.head.appendChild(s);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Turn the static pill into a button + dropdown (idempotent). */
|
|
708
|
+
function _renderPickerPill(el, data, label, title) {
|
|
709
|
+
_injectWorkspacePickerStyles();
|
|
710
|
+
|
|
711
|
+
let wrap = el.parentElement;
|
|
712
|
+
if (!wrap?.classList.contains("ws-pill-wrap")) {
|
|
713
|
+
wrap = document.createElement("span");
|
|
714
|
+
wrap.className = "ws-pill-wrap";
|
|
715
|
+
el.replaceWith(wrap);
|
|
716
|
+
wrap.appendChild(el);
|
|
717
|
+
el.classList.add("ws-pill-btn");
|
|
718
|
+
el.setAttribute("role", "button");
|
|
719
|
+
el.setAttribute("tabindex", "0");
|
|
720
|
+
el.addEventListener("click", () => _toggleWorkspaceMenu());
|
|
721
|
+
el.addEventListener("keydown", (e) => {
|
|
722
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
723
|
+
e.preventDefault();
|
|
724
|
+
_toggleWorkspaceMenu();
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
el.replaceChildren();
|
|
730
|
+
const name = document.createElement("span");
|
|
731
|
+
name.textContent = label;
|
|
732
|
+
const caret = document.createElement("span");
|
|
733
|
+
caret.className = "ws-pill-caret";
|
|
734
|
+
caret.textContent = "▾";
|
|
735
|
+
el.append(name, caret);
|
|
736
|
+
el.title = title;
|
|
737
|
+
|
|
738
|
+
let menu = wrap.querySelector(".ws-menu");
|
|
739
|
+
if (!menu) {
|
|
740
|
+
menu = document.createElement("div");
|
|
741
|
+
menu.className = "ws-menu";
|
|
742
|
+
wrap.appendChild(menu);
|
|
743
|
+
}
|
|
744
|
+
_paintWorkspaceMenu(menu, data);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function _paintWorkspaceMenu(menu, data) {
|
|
748
|
+
const { items } = buildWorkspaceMenuModel(data);
|
|
749
|
+
menu.replaceChildren();
|
|
750
|
+
if (items.length === 0) {
|
|
751
|
+
const empty = document.createElement("button");
|
|
752
|
+
empty.className = "ws-menu-item";
|
|
753
|
+
empty.disabled = true;
|
|
754
|
+
empty.textContent = "No workspaces";
|
|
755
|
+
menu.appendChild(empty);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
for (const item of items) {
|
|
759
|
+
const btn = document.createElement("button");
|
|
760
|
+
btn.className = "ws-menu-item";
|
|
761
|
+
btn.type = "button";
|
|
762
|
+
// Selecting an inactive workspace activates it (POST `use`), which only
|
|
763
|
+
// rewrites workspaces.json — no services are started.
|
|
764
|
+
btn.title = item.focused
|
|
765
|
+
? "Current workspace"
|
|
766
|
+
: item.active
|
|
767
|
+
? "Focus this workspace"
|
|
768
|
+
: "Activate and focus this workspace";
|
|
769
|
+
|
|
770
|
+
const mark = document.createElement("span");
|
|
771
|
+
mark.className = "ws-menu-mark";
|
|
772
|
+
mark.textContent = item.focused ? "●" : "";
|
|
773
|
+
const name = document.createElement("span");
|
|
774
|
+
name.textContent = item.name;
|
|
775
|
+
btn.append(mark, name);
|
|
776
|
+
|
|
777
|
+
if (!item.active) {
|
|
778
|
+
const tag = document.createElement("span");
|
|
779
|
+
tag.className = "ws-menu-tag";
|
|
780
|
+
tag.textContent = "not active";
|
|
781
|
+
btn.appendChild(tag);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
btn.addEventListener("click", () => _switchWorkspace(item));
|
|
785
|
+
menu.appendChild(btn);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function _toggleWorkspaceMenu(force = null) {
|
|
790
|
+
const menu = document.querySelector(".ws-pill-wrap .ws-menu");
|
|
791
|
+
const pill = document.getElementById("ws-pill");
|
|
792
|
+
if (!menu) return;
|
|
793
|
+
_wsMenuOpen = force ?? !_wsMenuOpen;
|
|
794
|
+
menu.classList.toggle("open", _wsMenuOpen);
|
|
795
|
+
pill?.classList.toggle("open", _wsMenuOpen);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function _closeWorkspaceMenu() {
|
|
799
|
+
if (_wsMenuOpen) _toggleWorkspaceMenu(false);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
document.addEventListener("click", (e) => {
|
|
803
|
+
if (_wsMenuOpen && !e.target.closest(".ws-pill-wrap")) _closeWorkspaceMenu();
|
|
804
|
+
});
|
|
805
|
+
document.addEventListener("keydown", (e) => {
|
|
806
|
+
if (e.key === "Escape") _closeWorkspaceMenu();
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
async function _switchWorkspace(item) {
|
|
810
|
+
_closeWorkspaceMenu();
|
|
811
|
+
if (item.focused) return;
|
|
812
|
+
const onSwitch = _wsOnSwitch;
|
|
813
|
+
try {
|
|
814
|
+
const res = await fetch("/api/workspaces", {
|
|
815
|
+
method: "POST",
|
|
816
|
+
headers: { "Content-Type": "application/json" },
|
|
817
|
+
// `use` = activate + focus, matching `__DEN_CLI__ workspace use`. Focus alone
|
|
818
|
+
// is rejected by the server for a workspace that is not active yet.
|
|
819
|
+
body: JSON.stringify({ name: item.name, action: "use" }),
|
|
820
|
+
});
|
|
821
|
+
if (!res.ok) {
|
|
822
|
+
const text = await res.text().catch(() => "");
|
|
823
|
+
throw new Error(text || `HTTP ${res.status}`);
|
|
824
|
+
}
|
|
825
|
+
} catch (err) {
|
|
826
|
+
alert(`Could not switch to workspace "${item.name}": ${err.message}`);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
await refreshWorkspacePill();
|
|
830
|
+
try {
|
|
831
|
+
await onSwitch?.(item.name);
|
|
832
|
+
} catch {
|
|
833
|
+
/* the page's own refresh failing must not break the switch */
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ── Health dots ───────────────────────────────────────────────────────────────
|
|
838
|
+
|
|
839
|
+
/** Call once on page load. Does NOT manage env changes — caller handles that. */
|
|
840
|
+
export async function initHealth() {
|
|
841
|
+
const env = document.getElementById("env-sel")?.value ?? "local";
|
|
842
|
+
const gen = ++pollGen;
|
|
843
|
+
await renderHealthDots(env, gen);
|
|
844
|
+
schedulePoll(env, gen);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Call whenever the environment changes. Cancels the current poll and restarts it for the new env. */
|
|
848
|
+
export async function refreshHealth(env, { force = false } = {}) {
|
|
849
|
+
const gen = ++pollGen;
|
|
850
|
+
clearTimeout(pollTimer);
|
|
851
|
+
setCheckingState(env);
|
|
852
|
+
await renderHealthDots(env, gen, force);
|
|
853
|
+
schedulePoll(env, gen);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Background tabs throttle chained timers (Chrome clamps to ~1/min after a few
|
|
857
|
+
// hidden minutes), so the dots freeze while hidden — refresh immediately on
|
|
858
|
+
// return instead of showing pre-hide state for up to another minute.
|
|
859
|
+
document.addEventListener("visibilitychange", () => {
|
|
860
|
+
if (!document.hidden && pollGen > 0) {
|
|
861
|
+
refreshHealth(document.getElementById("env-sel")?.value ?? "local");
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
/** Immediately show all dots as "checking" so the user can see the env changed. */
|
|
866
|
+
function setCheckingState(env) {
|
|
867
|
+
const container = document.getElementById("health");
|
|
868
|
+
if (!container) return;
|
|
869
|
+
// Pulse all dots yellow while we re-ping
|
|
870
|
+
container.querySelectorAll(".dot").forEach((d) => {
|
|
871
|
+
d.className = "dot running";
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function schedulePoll(env, gen) {
|
|
876
|
+
if (gen !== pollGen) return; // superseded by an env switch/re-init
|
|
877
|
+
clearTimeout(pollTimer);
|
|
878
|
+
pollTimer = setTimeout(async () => {
|
|
879
|
+
try {
|
|
880
|
+
await renderHealthDots(env, gen);
|
|
881
|
+
} finally {
|
|
882
|
+
// ALWAYS re-arm: a thrown/aborted tick must not silently kill polling
|
|
883
|
+
// (that froze the dots at their last state until a page reload).
|
|
884
|
+
schedulePoll(env, gen);
|
|
885
|
+
}
|
|
886
|
+
}, POLL_INTERVAL_MS);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Stream health results, painting each batch as it lands.
|
|
891
|
+
*
|
|
892
|
+
* The checks run concurrently but the slowest are shell probes that shell out
|
|
893
|
+
* to the aws CLI (~0.45s each), so waiting for the whole set made every dot
|
|
894
|
+
* resolve at the pace of the worst one. Streaming paints ~75% of them inside
|
|
895
|
+
* 200ms. Batched through requestAnimationFrame so 30 results cause a handful of
|
|
896
|
+
* repaints, not 30.
|
|
897
|
+
*/
|
|
898
|
+
async function renderHealthDots(env, gen = pollGen, force = false) {
|
|
899
|
+
const container = document.getElementById("health");
|
|
900
|
+
if (!container) return;
|
|
901
|
+
|
|
902
|
+
await Promise.all([_loadEditorKey(), _loadApps()]);
|
|
903
|
+
_injectNavStyles();
|
|
904
|
+
|
|
905
|
+
// Merge into the PREVIOUS poll's results rather than starting empty.
|
|
906
|
+
//
|
|
907
|
+
// Each poll streams results as checks complete, repainting per batch. Painting
|
|
908
|
+
// only what has landed so far made the strip collapse (13 items → 2) and
|
|
909
|
+
// regrow on every 5s poll; because the strip sits above the content, the whole
|
|
910
|
+
// page below it — the dependency graph especially — visibly jumped ~30px each
|
|
911
|
+
// time. Seeding from the last known set keeps the item count stable, so a poll
|
|
912
|
+
// updates dot COLOURS in place instead of rebuilding the row.
|
|
913
|
+
const acc = new Map(gen === _healthAccGen ? _healthAcc : []);
|
|
914
|
+
_healthAccGen = gen;
|
|
915
|
+
const keyOf = (r) => `${r.repo}/${r.service}`;
|
|
916
|
+
let scheduled = false;
|
|
917
|
+
const paint = () => {
|
|
918
|
+
scheduled = false;
|
|
919
|
+
if (gen !== pollGen) return; // a newer poll owns the DOM now
|
|
920
|
+
_healthAcc = acc;
|
|
921
|
+
_paintHealthDots(container, env, _sortHealth([...acc.values()]));
|
|
922
|
+
};
|
|
923
|
+
const schedule = () => {
|
|
924
|
+
if (scheduled) return;
|
|
925
|
+
scheduled = true;
|
|
926
|
+
requestAnimationFrame(paint);
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const fresh = new Map();
|
|
930
|
+
const streamed = await _streamHealth(env, force, (r) => {
|
|
931
|
+
acc.set(keyOf(r), r);
|
|
932
|
+
fresh.set(keyOf(r), r);
|
|
933
|
+
schedule();
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
if (!streamed) {
|
|
937
|
+
// Stream unavailable (older server, proxy that buffers SSE) — fall back to
|
|
938
|
+
// the plain endpoint so the dots still resolve.
|
|
939
|
+
try {
|
|
940
|
+
const rs = await fetch(
|
|
941
|
+
`/api/health?env=${env}${force ? "&refresh=1" : ""}`,
|
|
942
|
+
{
|
|
943
|
+
signal: AbortSignal.timeout(10_000),
|
|
944
|
+
},
|
|
945
|
+
).then((r) => r.json());
|
|
946
|
+
fresh.clear();
|
|
947
|
+
for (const r of rs) fresh.set(keyOf(r), r);
|
|
948
|
+
} catch {
|
|
949
|
+
/* server unreachable */
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
if (gen !== pollGen) return;
|
|
953
|
+
// Settle on exactly what THIS poll returned, so a service removed from the
|
|
954
|
+
// workspace disappears instead of lingering from the seeded set.
|
|
955
|
+
if (fresh.size > 0) {
|
|
956
|
+
_healthAcc = fresh;
|
|
957
|
+
_paintHealthDots(container, env, _sortHealth([...fresh.values()]));
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Stable display order. `idx` is the server's graph-ranked position (entry repo
|
|
963
|
+
* first … infra-stack last); results arrive in completion order, which differs
|
|
964
|
+
* every poll, so sorting by it is what keeps the dots from reshuffling. Falls
|
|
965
|
+
* back to repo name when the server doesn't send `idx`.
|
|
966
|
+
*/
|
|
967
|
+
function _sortHealth(results) {
|
|
968
|
+
return results.sort(
|
|
969
|
+
(a, b) =>
|
|
970
|
+
(a.idx ?? Number.MAX_SAFE_INTEGER) - (b.idx ?? Number.MAX_SAFE_INTEGER) ||
|
|
971
|
+
a.repo.localeCompare(b.repo) ||
|
|
972
|
+
String(a.service).localeCompare(String(b.service)),
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Read /api/health/stream to completion, calling onResult per check.
|
|
978
|
+
* Resolves true if the stream ran, false if the caller should fall back.
|
|
979
|
+
*/
|
|
980
|
+
function _streamHealth(env, force, onResult) {
|
|
981
|
+
return new Promise((resolve) => {
|
|
982
|
+
let es;
|
|
983
|
+
let got = false;
|
|
984
|
+
// Backstop: a hung stream must not stall the poll chain forever.
|
|
985
|
+
const guard = setTimeout(() => {
|
|
986
|
+
try {
|
|
987
|
+
es?.close();
|
|
988
|
+
} catch {}
|
|
989
|
+
resolve(got);
|
|
990
|
+
}, 15_000);
|
|
991
|
+
const done = (ok) => {
|
|
992
|
+
clearTimeout(guard);
|
|
993
|
+
try {
|
|
994
|
+
es?.close();
|
|
995
|
+
} catch {}
|
|
996
|
+
resolve(ok);
|
|
997
|
+
};
|
|
998
|
+
try {
|
|
999
|
+
// force bypasses the server-side health cache — used right after a repo
|
|
1000
|
+
// sync, where the probes on disk may have just changed.
|
|
1001
|
+
es = new EventSource(
|
|
1002
|
+
`/api/health/stream?env=${encodeURIComponent(env)}${force ? "&refresh=1" : ""}`,
|
|
1003
|
+
);
|
|
1004
|
+
} catch {
|
|
1005
|
+
clearTimeout(guard);
|
|
1006
|
+
return resolve(false);
|
|
1007
|
+
}
|
|
1008
|
+
es.onmessage = (e) => {
|
|
1009
|
+
let msg;
|
|
1010
|
+
try {
|
|
1011
|
+
msg = JSON.parse(e.data);
|
|
1012
|
+
} catch {
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (msg.type === "result") {
|
|
1016
|
+
got = true;
|
|
1017
|
+
onResult(msg.result);
|
|
1018
|
+
} else if (msg.type === "done") {
|
|
1019
|
+
done(true);
|
|
1020
|
+
} else if (msg.type === "error") {
|
|
1021
|
+
done(got);
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
// Errors before any result mean "fall back"; after results, keep what we got.
|
|
1025
|
+
es.onerror = () => done(got);
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** One repo's dot + label in the health row. */
|
|
1030
|
+
function _repoHealthItem(repo, items, escAttr) {
|
|
1031
|
+
// Show open-in-editor icon if editor is configured and any item is dev-managed
|
|
1032
|
+
const devItem = items.find((i) => i.mode === "dev-managed" && i.repoPath);
|
|
1033
|
+
const openIcon =
|
|
1034
|
+
_editorKey && devItem
|
|
1035
|
+
? `<button class="nav-open-btn" data-open-path="${escAttr(devItem.repoPath)}" title="Open in editor">↗</button>`
|
|
1036
|
+
: "";
|
|
1037
|
+
|
|
1038
|
+
const isSingle = items.length === 1 && items[0].service === repo;
|
|
1039
|
+
if (isSingle) {
|
|
1040
|
+
const { service, url, ok, status, error } = items[0];
|
|
1041
|
+
const state = ok === true ? "ok" : ok === false ? "err" : "";
|
|
1042
|
+
const tip =
|
|
1043
|
+
ok == null
|
|
1044
|
+
? "no health check"
|
|
1045
|
+
: error
|
|
1046
|
+
? `${url} — ${error}`
|
|
1047
|
+
: `${url} → ${status}`;
|
|
1048
|
+
return `<div class="health-item" title="${escAttr(tip)}">
|
|
1049
|
+
<span class="dot ${state}" id="h-${repo}-${service}"></span>
|
|
1050
|
+
<span>${repo}</span>
|
|
1051
|
+
${openIcon}
|
|
1052
|
+
</div>`;
|
|
1053
|
+
}
|
|
1054
|
+
// Multi-service repo: ONE dot for the system's main component (the
|
|
1055
|
+
// entrypoint), so a down app isn't masked green by its shared-infra
|
|
1056
|
+
// sidecars. An entrypoint-less infra provider (infra-stack) falls back
|
|
1057
|
+
// to an aggregate across its subsystems.
|
|
1058
|
+
const entry = items.find((i) => i.isEntrypoint);
|
|
1059
|
+
let overallState, tip;
|
|
1060
|
+
if (entry) {
|
|
1061
|
+
overallState = entry.ok === true ? "ok" : entry.ok === false ? "err" : "";
|
|
1062
|
+
tip =
|
|
1063
|
+
entry.ok == null
|
|
1064
|
+
? "no health check"
|
|
1065
|
+
: entry.error
|
|
1066
|
+
? `${entry.url} — ${entry.error}`
|
|
1067
|
+
: `${entry.url} → ${entry.status}`;
|
|
1068
|
+
} else {
|
|
1069
|
+
const allOk = items.every((i) => i.ok === true);
|
|
1070
|
+
const anyOk = items.some((i) => i.ok === true);
|
|
1071
|
+
overallState = allOk ? "ok" : anyOk ? "running" : "err";
|
|
1072
|
+
tip = `${items.length} services`;
|
|
1073
|
+
}
|
|
1074
|
+
return `<div class="health-item" title="${escAttr(tip)}">
|
|
1075
|
+
<span class="dot ${overallState}"></span>
|
|
1076
|
+
<span>${repo}</span>
|
|
1077
|
+
${openIcon}
|
|
1078
|
+
</div>`;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function _paintHealthDots(container, env, results) {
|
|
1082
|
+
const escAttr = (s) =>
|
|
1083
|
+
String(s ?? "")
|
|
1084
|
+
.replace(/&/g, "&")
|
|
1085
|
+
.replace(/"/g, """);
|
|
1086
|
+
|
|
1087
|
+
if (!results.length) {
|
|
1088
|
+
container.innerHTML = `<div class="health-item" style="color:var(--muted);font-size:11px">${env}: no services</div>`;
|
|
1089
|
+
} else {
|
|
1090
|
+
// Group by repo so multi-service repos don't produce duplicate labels.
|
|
1091
|
+
const byRepo = new Map();
|
|
1092
|
+
for (const item of results) {
|
|
1093
|
+
if (!byRepo.has(item.repo)) byRepo.set(item.repo, []);
|
|
1094
|
+
byRepo.get(item.repo).push(item);
|
|
1095
|
+
}
|
|
1096
|
+
const repoHtml = new Map(
|
|
1097
|
+
[...byRepo.entries()].map(([repo, items]) => [
|
|
1098
|
+
repo,
|
|
1099
|
+
_repoHealthItem(repo, items, escAttr),
|
|
1100
|
+
]),
|
|
1101
|
+
);
|
|
1102
|
+
container.innerHTML = groupReposByApplication([...byRepo.keys()], _apps)
|
|
1103
|
+
.map(({ app, repos }) => {
|
|
1104
|
+
const inner = repos.map((r) => repoHtml.get(r)).join("");
|
|
1105
|
+
if (!app) return inner;
|
|
1106
|
+
const tip = [app.title, app.owner, app.description]
|
|
1107
|
+
.filter(Boolean)
|
|
1108
|
+
.join(" · ");
|
|
1109
|
+
return `<div class="health-app" title="${escAttr(tip)}"><span class="health-app-label">${escAttr(app.name)}</span>${inner}</div>`;
|
|
1110
|
+
})
|
|
1111
|
+
.join("");
|
|
1112
|
+
|
|
1113
|
+
// Wire open-in-editor click handlers (data-open-path avoids inline onclick escaping)
|
|
1114
|
+
container
|
|
1115
|
+
.querySelectorAll(".nav-open-btn[data-open-path]")
|
|
1116
|
+
.forEach((btn) => {
|
|
1117
|
+
btn.addEventListener("click", (e) => {
|
|
1118
|
+
e.stopPropagation();
|
|
1119
|
+
fetch("/api/editor/open", {
|
|
1120
|
+
method: "POST",
|
|
1121
|
+
headers: { "Content-Type": "application/json" },
|
|
1122
|
+
body: JSON.stringify({ path: btn.dataset.openPath }),
|
|
1123
|
+
}).then((r) => {
|
|
1124
|
+
if (!r.ok)
|
|
1125
|
+
r.json().then((d) =>
|
|
1126
|
+
console.warn("[__DEN_CLI__] open-in-editor:", d.error),
|
|
1127
|
+
);
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Broadcast results so other components (e.g. dep graph) can stay in sync
|
|
1134
|
+
document.dispatchEvent(
|
|
1135
|
+
new CustomEvent("__DEN_CLI__:health-update", { detail: results }),
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
// Refresh button — shown on every page, no per-page HTML changes needed
|
|
1139
|
+
const btn = document.createElement("button");
|
|
1140
|
+
btn.title = "Refresh service health";
|
|
1141
|
+
btn.textContent = "⟳";
|
|
1142
|
+
btn.style.cssText =
|
|
1143
|
+
"background:none;border:none;color:var(--muted);cursor:pointer;" +
|
|
1144
|
+
"font-size:13px;padding:1px 4px;border-radius:3px;line-height:1;margin-left:4px;" +
|
|
1145
|
+
"transition:color .1s;";
|
|
1146
|
+
btn.addEventListener("mouseenter", () => (btn.style.color = "var(--text)"));
|
|
1147
|
+
btn.addEventListener("mouseleave", () => (btn.style.color = "var(--muted)"));
|
|
1148
|
+
btn.addEventListener("click", () => {
|
|
1149
|
+
const currentEnv = document.getElementById("env-sel")?.value ?? env;
|
|
1150
|
+
refreshHealth(currentEnv); // pulses dots yellow while re-pinging
|
|
1151
|
+
});
|
|
1152
|
+
container.appendChild(btn);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// ── Log badge (shared across pages) ───────────────────────────────────────────
|
|
1156
|
+
|
|
1157
|
+
const LS_OPEN_LOGS = "__DEN_CLI__-open-logs";
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Read __DEN_CLI__-open-logs from localStorage and populate the #log-badge element.
|
|
1161
|
+
* Call once on page load from any page that has a Logs nav link with a badge.
|
|
1162
|
+
*/
|
|
1163
|
+
export function initLogBadge() {
|
|
1164
|
+
_updateBadgeFromStorage();
|
|
1165
|
+
// Stay in sync if another tab opens/closes a log stream
|
|
1166
|
+
window.addEventListener("storage", (e) => {
|
|
1167
|
+
if (e.key === LS_OPEN_LOGS) _updateBadgeFromStorage();
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function _updateBadgeFromStorage() {
|
|
1172
|
+
const el = document.getElementById("log-badge");
|
|
1173
|
+
if (!el) return;
|
|
1174
|
+
try {
|
|
1175
|
+
const logs = JSON.parse(localStorage.getItem(LS_OPEN_LOGS) || "[]");
|
|
1176
|
+
const count = Array.isArray(logs) ? logs.length : 0;
|
|
1177
|
+
el.textContent = count;
|
|
1178
|
+
el.style.display = count > 0 ? "inline" : "none";
|
|
1179
|
+
} catch {
|
|
1180
|
+
el.style.display = "none";
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** Add a log entry to localStorage (call before navigating to /logs). */
|
|
1185
|
+
export function storeOpenLog(repo, service, env) {
|
|
1186
|
+
try {
|
|
1187
|
+
const logs = JSON.parse(localStorage.getItem(LS_OPEN_LOGS) || "[]");
|
|
1188
|
+
const filtered = logs.filter(
|
|
1189
|
+
(l) => !(l.repo === repo && l.service === service),
|
|
1190
|
+
);
|
|
1191
|
+
filtered.push({ repo, service, env });
|
|
1192
|
+
localStorage.setItem(LS_OPEN_LOGS, JSON.stringify(filtered));
|
|
1193
|
+
_updateBadgeFromStorage();
|
|
1194
|
+
} catch {
|
|
1195
|
+
/* noop */
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// ── Primary-service resolution (shared by local-dev tabs + logs toolbar) ─────
|
|
1200
|
+
/**
|
|
1201
|
+
* Map repo → its PRIMARY service (the "main system" service) from an
|
|
1202
|
+
* /api/services payload. Precedence: entrypoint > root-named (service===repo)
|
|
1203
|
+
* > first seen. This is the same rollup the health dots, log tabs, and logs
|
|
1204
|
+
* toolbar key off, so "one thing per system" means the same thing everywhere.
|
|
1205
|
+
*/
|
|
1206
|
+
export function computePrimaryByRepo(svcList) {
|
|
1207
|
+
const primary = new Map();
|
|
1208
|
+
const rank = new Map();
|
|
1209
|
+
for (const s of svcList || []) {
|
|
1210
|
+
const r = s.isEntrypoint ? 2 : s.service === s.repo ? 1 : 0;
|
|
1211
|
+
if (!primary.has(s.repo) || r > (rank.get(s.repo) ?? -1)) {
|
|
1212
|
+
primary.set(s.repo, s.address);
|
|
1213
|
+
rank.set(s.repo, r);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
return primary;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// ── Stale-server watch (self-initializing on every page) ─────────────────────
|
|
1220
|
+
// The __DEN_CLI__ web server loads its TypeScript at startup; edit server code
|
|
1221
|
+
// without restarting and it keeps serving the OLD handlers while still serving
|
|
1222
|
+
// FRESH HTML from disk — a confusing mismatch (the Releases-tab POST/SSE bug was
|
|
1223
|
+
// exactly this). The server exposes /api/server-info { stale } (any src/*.ts
|
|
1224
|
+
// newer than its start); when stale we surface an amber header pill offering a
|
|
1225
|
+
// one-click Restart (POST /api/web/restart → detached `__DEN_CLI__ web restart`),
|
|
1226
|
+
// then reconnect to the fresh daemon and reload.
|
|
1227
|
+
|
|
1228
|
+
const SERVER_INFO_POLL_MS = 12_000;
|
|
1229
|
+
let _lastServerInfo = null; // most recent { pid, startedAt }
|
|
1230
|
+
let _staleStylesInjected = false;
|
|
1231
|
+
|
|
1232
|
+
function _injectStaleStyles() {
|
|
1233
|
+
if (_staleStylesInjected) return;
|
|
1234
|
+
_staleStylesInjected = true;
|
|
1235
|
+
const s = document.createElement("style");
|
|
1236
|
+
s.textContent = `
|
|
1237
|
+
.stale-pill {
|
|
1238
|
+
font-family: var(--mono, monospace); font-size: 10px; font-weight: 600;
|
|
1239
|
+
padding: 2px 9px; border-radius: 10px; cursor: pointer; user-select: none;
|
|
1240
|
+
border: 1px solid var(--yellow, #d29922); color: var(--yellow, #d29922);
|
|
1241
|
+
background: rgba(210,153,34,0.12);
|
|
1242
|
+
display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0;
|
|
1243
|
+
animation: stale-pulse 2s ease-in-out infinite;
|
|
1244
|
+
}
|
|
1245
|
+
.stale-pill:hover { color: #0d1117; background: var(--yellow, #d29922); }
|
|
1246
|
+
.stale-pill b { font-weight: 700; }
|
|
1247
|
+
@keyframes stale-pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
|
1248
|
+
.restart-overlay {
|
|
1249
|
+
position: fixed; inset: 0; z-index: 2000; background: rgba(0,0,0,.82);
|
|
1250
|
+
display: flex; align-items: center; justify-content: center;
|
|
1251
|
+
flex-direction: column; gap: 16px;
|
|
1252
|
+
color: var(--text, #c9d1d9); font-family: var(--mono, monospace);
|
|
1253
|
+
}
|
|
1254
|
+
.restart-overlay .spinner {
|
|
1255
|
+
width: 30px; height: 30px; border-radius: 50%;
|
|
1256
|
+
border: 3px solid var(--border, #30363d); border-top-color: var(--accent, #58a6ff);
|
|
1257
|
+
animation: stale-spin .8s linear infinite;
|
|
1258
|
+
}
|
|
1259
|
+
@keyframes stale-spin { to { transform: rotate(360deg); } }
|
|
1260
|
+
.restart-overlay .msg { font-size: 13px; }
|
|
1261
|
+
.restart-overlay .hint {
|
|
1262
|
+
font-size: 11px; color: var(--muted, #8b949e);
|
|
1263
|
+
max-width: 380px; text-align: center; line-height: 1.5;
|
|
1264
|
+
}
|
|
1265
|
+
`;
|
|
1266
|
+
document.head.appendChild(s);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function _renderStalePill(info) {
|
|
1270
|
+
let pill = document.getElementById("stale-pill");
|
|
1271
|
+
if (!info || !info.stale) {
|
|
1272
|
+
if (pill) pill.style.display = "none";
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
_injectStaleStyles();
|
|
1276
|
+
if (!pill) {
|
|
1277
|
+
const header = document.querySelector("header");
|
|
1278
|
+
if (!header) return;
|
|
1279
|
+
pill = document.createElement("button");
|
|
1280
|
+
pill.id = "stale-pill";
|
|
1281
|
+
pill.className = "stale-pill";
|
|
1282
|
+
pill.title =
|
|
1283
|
+
"This __DEN_CLI__ web server is running code older than your working tree — " +
|
|
1284
|
+
"click to restart and pick up your changes. Running deploys continue on " +
|
|
1285
|
+
"GitHub; an open deploy log reattaches after the reload.";
|
|
1286
|
+
pill.innerHTML = "⚠ stale server · <b>Restart</b>";
|
|
1287
|
+
pill.addEventListener("click", _requestRestart);
|
|
1288
|
+
const vpill = document.getElementById("version-pill");
|
|
1289
|
+
if (vpill && vpill.parentNode)
|
|
1290
|
+
vpill.parentNode.insertBefore(pill, vpill.nextSibling);
|
|
1291
|
+
else header.appendChild(pill);
|
|
1292
|
+
}
|
|
1293
|
+
pill.style.display = "";
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
async function _fetchServerInfo() {
|
|
1297
|
+
try {
|
|
1298
|
+
return await fetch("/api/server-info", {
|
|
1299
|
+
cache: "no-store",
|
|
1300
|
+
signal: AbortSignal.timeout(4000),
|
|
1301
|
+
}).then((r) => r.json());
|
|
1302
|
+
} catch {
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async function _staleTick() {
|
|
1308
|
+
const info = await _fetchServerInfo();
|
|
1309
|
+
if (info && info.pid != null)
|
|
1310
|
+
_lastServerInfo = { pid: info.pid, startedAt: info.startedAt };
|
|
1311
|
+
_renderStalePill(info);
|
|
1312
|
+
setTimeout(_staleTick, SERVER_INFO_POLL_MS);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
async function _requestRestart() {
|
|
1316
|
+
// No confirm(): the pill exists only when the server IS stale, and clicking
|
|
1317
|
+
// it is already the statement of intent. The restart is cheap and reversible
|
|
1318
|
+
// — running deploys continue on GitHub regardless; only an open deploy LOG in
|
|
1319
|
+
// this tab is lost, and it reattaches on reload.
|
|
1320
|
+
const before = _lastServerInfo;
|
|
1321
|
+
_showRestartOverlay();
|
|
1322
|
+
try {
|
|
1323
|
+
await fetch("/api/web/restart", {
|
|
1324
|
+
method: "POST",
|
|
1325
|
+
signal: AbortSignal.timeout(4000),
|
|
1326
|
+
});
|
|
1327
|
+
} catch {
|
|
1328
|
+
// The server usually dies before it can answer — expected; poll for the new one.
|
|
1329
|
+
}
|
|
1330
|
+
_pollForNewServer(before, Date.now());
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function _showRestartOverlay() {
|
|
1334
|
+
_injectStaleStyles();
|
|
1335
|
+
if (document.getElementById("restart-overlay")) return;
|
|
1336
|
+
const o = document.createElement("div");
|
|
1337
|
+
o.id = "restart-overlay";
|
|
1338
|
+
o.className = "restart-overlay";
|
|
1339
|
+
o.innerHTML =
|
|
1340
|
+
'<div class="spinner"></div>' +
|
|
1341
|
+
'<div class="msg">Restarting __DEN_CLI__ web…</div>' +
|
|
1342
|
+
'<div class="hint" id="restart-hint">Reconnecting — this page will reload automatically.</div>';
|
|
1343
|
+
document.body.appendChild(o);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
async function _pollForNewServer(before, startedAt) {
|
|
1347
|
+
const info = await _fetchServerInfo();
|
|
1348
|
+
const isNew =
|
|
1349
|
+
info &&
|
|
1350
|
+
info.pid != null &&
|
|
1351
|
+
(!before || info.pid !== before.pid || info.startedAt !== before.startedAt);
|
|
1352
|
+
if (isNew) {
|
|
1353
|
+
location.reload();
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (Date.now() - startedAt > 90_000) {
|
|
1357
|
+
const hint = document.getElementById("restart-hint");
|
|
1358
|
+
if (hint)
|
|
1359
|
+
hint.innerHTML =
|
|
1360
|
+
"Still restarting… if this persists, run <b>__DEN_CLI__ web restart</b> in a terminal, then reload.";
|
|
1361
|
+
}
|
|
1362
|
+
setTimeout(() => _pollForNewServer(before, startedAt), 1500);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Self-init: begin watching once there's a <header> to anchor the pill into.
|
|
1366
|
+
if (typeof document !== "undefined") {
|
|
1367
|
+
const _bootStaleWatch = () => _staleTick();
|
|
1368
|
+
if (document.readyState === "loading")
|
|
1369
|
+
document.addEventListener("DOMContentLoaded", _bootStaleWatch);
|
|
1370
|
+
else _bootStaleWatch();
|
|
1371
|
+
}
|