@monster-orange/den 0.0.0-stage → 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.
Files changed (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -2
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +104 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +406 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +108 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +729 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. package/src/ui/server/workspace-routes.ts +2154 -0
@@ -0,0 +1,2111 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>__DEN_TITLE__ · Releases</title>
7
+ <style>
8
+ *,
9
+ *::before,
10
+ *::after {
11
+ box-sizing: border-box;
12
+ margin: 0;
13
+ padding: 0;
14
+ }
15
+
16
+ :root {
17
+ --bg: #0d1117;
18
+ --surface: #161b22;
19
+ --border: #30363d;
20
+ --text: #c9d1d9;
21
+ --muted: #8b949e;
22
+ --accent: #58a6ff;
23
+ --green: #3fb950;
24
+ --red: #f85149;
25
+ --yellow: #d29922;
26
+ --mono: "SF Mono", "Cascadia Code", "Fira Code", "Menlo", monospace;
27
+ }
28
+
29
+ html,
30
+ body {
31
+ height: 100%;
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
35
+ font-size: 13px;
36
+ }
37
+ body {
38
+ display: flex;
39
+ flex-direction: column;
40
+ overflow: hidden;
41
+ }
42
+
43
+ /* ── Header ───────────────────────────────────────────────────────────── */
44
+ header {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 10px;
48
+ padding: 7px 14px;
49
+ background: var(--surface);
50
+ border-bottom: 1px solid var(--border);
51
+ flex-shrink: 0;
52
+ }
53
+ header h1 {
54
+ font-family: var(--mono);
55
+ font-size: 14px;
56
+ font-weight: 600;
57
+ letter-spacing: -0.02em;
58
+ }
59
+ .h-spacer {
60
+ flex: 1;
61
+ }
62
+
63
+ .v-pill {
64
+ font-family: var(--mono);
65
+ font-size: 10px;
66
+ padding: 2px 8px;
67
+ border-radius: 10px;
68
+ border: 1px solid var(--border);
69
+ color: var(--muted);
70
+ background: none;
71
+ cursor: pointer;
72
+ user-select: none;
73
+ position: relative;
74
+ }
75
+ .v-pill:hover {
76
+ color: var(--accent);
77
+ border-color: var(--accent);
78
+ }
79
+ .v-pip {
80
+ position: absolute;
81
+ top: -3px;
82
+ right: -3px;
83
+ width: 7px;
84
+ height: 7px;
85
+ border-radius: 50%;
86
+ background: var(--accent);
87
+ border: 1.5px solid var(--bg);
88
+ display: none;
89
+ }
90
+
91
+ /* ── Health dots ──────────────────────────────────────────────────────── */
92
+ .health {
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 10px;
96
+ padding: 0 4px;
97
+ flex-wrap: wrap;
98
+ }
99
+ .health-item,
100
+ .health-group {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 5px;
104
+ font-size: 11px;
105
+ color: var(--muted);
106
+ }
107
+ .dot {
108
+ width: 7px;
109
+ height: 7px;
110
+ border-radius: 50%;
111
+ background: var(--muted);
112
+ flex-shrink: 0;
113
+ display: inline-block;
114
+ }
115
+ .dot.ok {
116
+ background: var(--green);
117
+ }
118
+ .dot.err {
119
+ background: var(--red);
120
+ }
121
+ .dot.running {
122
+ background: var(--yellow);
123
+ animation: pulse 1s ease-in-out infinite;
124
+ }
125
+
126
+ /* ── Nav ──────────────────────────────────────────────────────────────── */
127
+ nav {
128
+ display: flex;
129
+ gap: 2px;
130
+ }
131
+ nav a {
132
+ color: var(--muted);
133
+ text-decoration: none;
134
+ padding: 3px 10px;
135
+ border-radius: 4px;
136
+ font-size: 12px;
137
+ font-weight: 500;
138
+ transition: color 0.1s;
139
+ }
140
+ nav a:hover {
141
+ color: var(--text);
142
+ background: var(--border);
143
+ }
144
+ nav a.active {
145
+ color: var(--text);
146
+ background: var(--surface);
147
+ }
148
+
149
+ /* ── Controls bar ─────────────────────────────────────────────────────── */
150
+ .controls {
151
+ display: flex;
152
+ align-items: center;
153
+ gap: 8px;
154
+ padding: 6px 14px;
155
+ background: var(--surface);
156
+ border-bottom: 1px solid var(--border);
157
+ flex-shrink: 0;
158
+ }
159
+ select,
160
+ .btn {
161
+ background: var(--bg);
162
+ border: 1px solid var(--border);
163
+ color: var(--text);
164
+ padding: 4px 10px;
165
+ border-radius: 6px;
166
+ font-size: 12px;
167
+ cursor: pointer;
168
+ outline: none;
169
+ }
170
+ select:focus {
171
+ border-color: var(--accent);
172
+ }
173
+ .btn {
174
+ font-weight: 500;
175
+ transition:
176
+ border-color 0.1s,
177
+ color 0.1s,
178
+ background 0.1s;
179
+ }
180
+ .btn:hover:not(:disabled) {
181
+ border-color: var(--accent);
182
+ color: var(--accent);
183
+ }
184
+ .btn:disabled {
185
+ opacity: 0.45;
186
+ cursor: not-allowed;
187
+ }
188
+ .btn.primary {
189
+ background: var(--accent);
190
+ color: #04101f;
191
+ border-color: var(--accent);
192
+ font-weight: 600;
193
+ }
194
+ .btn.primary:hover:not(:disabled) {
195
+ opacity: 0.88;
196
+ color: #04101f;
197
+ }
198
+ .btn.danger:hover:not(:disabled) {
199
+ border-color: var(--red);
200
+ color: var(--red);
201
+ }
202
+ .env-label {
203
+ font-size: 11px;
204
+ color: var(--muted);
205
+ }
206
+ #status-text {
207
+ font-size: 11px;
208
+ color: var(--muted);
209
+ }
210
+
211
+ /* ── Main ─────────────────────────────────────────────────────────────── */
212
+ main {
213
+ flex: 1;
214
+ display: flex;
215
+ flex-direction: column;
216
+ overflow: hidden;
217
+ min-height: 0;
218
+ }
219
+ .table-pane {
220
+ flex: 1;
221
+ overflow: auto;
222
+ min-height: 0;
223
+ }
224
+
225
+ table {
226
+ width: 100%;
227
+ border-collapse: collapse;
228
+ font-size: 12px;
229
+ }
230
+ thead th {
231
+ position: sticky;
232
+ top: 0;
233
+ z-index: 1;
234
+ background: var(--surface);
235
+ border-bottom: 1px solid var(--border);
236
+ text-align: left;
237
+ padding: 8px 12px;
238
+ white-space: nowrap;
239
+ font-size: 10px;
240
+ font-weight: 600;
241
+ text-transform: uppercase;
242
+ letter-spacing: 0.06em;
243
+ color: var(--muted);
244
+ cursor: pointer;
245
+ user-select: none;
246
+ }
247
+ thead th.no-sort {
248
+ cursor: default;
249
+ }
250
+ thead th:hover:not(.no-sort) {
251
+ color: var(--text);
252
+ }
253
+ th .arrow {
254
+ color: var(--accent);
255
+ font-size: 9px;
256
+ margin-left: 3px;
257
+ }
258
+ tbody td {
259
+ padding: 7px 12px;
260
+ border-bottom: 1px solid var(--border);
261
+ vertical-align: middle;
262
+ }
263
+ /* Row hover. The previous #ffffff06 is white at 2.4%, resolving to about
264
+ #13171c against the #0d1117 background — 1.05:1, effectively invisible.
265
+ An accent tint at 20% lands on #1c2f45 (1.39:1), and the 3px accent
266
+ rule carries the rest of the signal. For scale, --surface itself is
267
+ only 1.09:1 against the background, so this is a deliberate step past
268
+ the page's usual contrast rather than a mistake.
269
+ Applied to the cells, not the row: the table is border-collapse:
270
+ collapse, where box-shadow on a <tr> is unreliable across browsers. */
271
+ tbody tr:hover td {
272
+ background: rgba(88, 166, 255, 0.2);
273
+ }
274
+ tbody tr:hover td:first-child {
275
+ box-shadow: inset 3px 0 0 var(--accent);
276
+ }
277
+ .session-chips {
278
+ display: flex;
279
+ flex-wrap: wrap;
280
+ gap: 6px;
281
+ padding: 6px 10px;
282
+ border-bottom: 1px solid var(--border);
283
+ background: var(--surface);
284
+ }
285
+ .session-chip {
286
+ font-size: 11px;
287
+ font-family: var(--mono);
288
+ padding: 2px 8px;
289
+ border-radius: 10px;
290
+ border: 1px solid var(--border);
291
+ background: transparent;
292
+ color: var(--muted);
293
+ cursor: pointer;
294
+ }
295
+ .session-chip:hover {
296
+ color: var(--text);
297
+ }
298
+ .session-chip.active {
299
+ border-color: var(--accent);
300
+ color: var(--accent);
301
+ }
302
+ .session-chip.running {
303
+ border-style: dashed;
304
+ }
305
+ .session-chip.fail {
306
+ border-color: var(--red);
307
+ }
308
+ .repo-cell {
309
+ font-weight: 600;
310
+ }
311
+ /* Inherits the cell's weight/colour so the column still reads as a label,
312
+ not a wall of links; the underline appears on hover. */
313
+ .repo-link {
314
+ color: inherit;
315
+ text-decoration: none;
316
+ }
317
+ .repo-link:hover {
318
+ color: var(--accent);
319
+ text-decoration: underline;
320
+ }
321
+ .wave-tag {
322
+ font-size: 9px;
323
+ color: var(--muted);
324
+ border: 1px solid var(--border);
325
+ border-radius: 8px;
326
+ padding: 0 5px;
327
+ margin-left: 6px;
328
+ font-family: var(--mono);
329
+ }
330
+ .sha {
331
+ font-family: var(--mono);
332
+ font-size: 11px;
333
+ color: var(--muted);
334
+ }
335
+ .branch {
336
+ font-family: var(--mono);
337
+ font-size: 11px;
338
+ max-width: 240px;
339
+ overflow: hidden;
340
+ text-overflow: ellipsis;
341
+ white-space: nowrap;
342
+ display: inline-block;
343
+ vertical-align: bottom;
344
+ }
345
+ .deployer {
346
+ font-family: var(--mono);
347
+ font-size: 11px;
348
+ color: var(--accent);
349
+ text-decoration: none;
350
+ white-space: nowrap;
351
+ }
352
+ a.deployer:hover {
353
+ text-decoration: underline;
354
+ }
355
+ .deployer.bot {
356
+ color: var(--muted);
357
+ }
358
+ .when {
359
+ white-space: nowrap;
360
+ }
361
+ .when .rel {
362
+ color: var(--text);
363
+ }
364
+ .when .abs {
365
+ color: var(--muted);
366
+ font-size: 10px;
367
+ }
368
+
369
+ .st {
370
+ font-weight: 500;
371
+ }
372
+ .st-up {
373
+ color: var(--green);
374
+ }
375
+ .st-warn {
376
+ color: var(--yellow);
377
+ }
378
+ .st-bad {
379
+ color: var(--red);
380
+ }
381
+ .st-none {
382
+ color: var(--muted);
383
+ }
384
+ .st-note {
385
+ display: block;
386
+ color: var(--muted);
387
+ font-size: 10px;
388
+ margin-top: 2px;
389
+ }
390
+
391
+ .deploy-cell {
392
+ white-space: nowrap;
393
+ text-align: right;
394
+ }
395
+ /* release.deploy off: nothing on the page can dispatch a deploy. */
396
+ body.read-only .deploy-cell,
397
+ body.read-only #deploy-all-btn,
398
+ body.read-only #waves-btn {
399
+ display: none;
400
+ }
401
+ #read-only-tag {
402
+ display: none;
403
+ font-size: 12px;
404
+ color: var(--muted, #888);
405
+ border: 1px solid currentColor;
406
+ border-radius: 4px;
407
+ padding: 1px 6px;
408
+ }
409
+ body.read-only #read-only-tag {
410
+ display: inline-block;
411
+ }
412
+ .dep-btn {
413
+ background: var(--bg);
414
+ border: 1px solid var(--border);
415
+ color: var(--text);
416
+ padding: 3px 10px;
417
+ border-radius: 5px;
418
+ font-size: 11px;
419
+ font-weight: 500;
420
+ cursor: pointer;
421
+ transition:
422
+ border-color 0.1s,
423
+ color 0.1s;
424
+ }
425
+ .dep-btn:hover:not(:disabled) {
426
+ border-color: var(--green);
427
+ color: var(--green);
428
+ }
429
+ .dep-btn:disabled {
430
+ opacity: 0.4;
431
+ cursor: not-allowed;
432
+ }
433
+
434
+ /* run status pill (live during a deploy) */
435
+ .run-pill {
436
+ display: inline-flex;
437
+ align-items: center;
438
+ gap: 5px;
439
+ font-size: 10px;
440
+ font-family: var(--mono);
441
+ padding: 2px 8px;
442
+ border-radius: 10px;
443
+ border: 1px solid var(--border);
444
+ color: var(--muted);
445
+ text-decoration: none;
446
+ }
447
+ .run-pill .dot {
448
+ width: 7px;
449
+ height: 7px;
450
+ border-radius: 50%;
451
+ background: var(--muted);
452
+ flex-shrink: 0;
453
+ }
454
+ .run-pill.busy .dot {
455
+ background: var(--yellow);
456
+ animation: pulse 1s ease-in-out infinite;
457
+ }
458
+ .run-pill.ok {
459
+ color: var(--green);
460
+ border-color: #3fb95044;
461
+ }
462
+ .run-pill.ok .dot {
463
+ background: var(--green);
464
+ }
465
+ .run-pill.err {
466
+ color: var(--red);
467
+ border-color: #f8514944;
468
+ }
469
+ .run-pill.err .dot {
470
+ background: var(--red);
471
+ }
472
+ @keyframes pulse {
473
+ 0%,
474
+ 100% {
475
+ opacity: 1;
476
+ }
477
+ 50% {
478
+ opacity: 0.35;
479
+ }
480
+ }
481
+
482
+ .empty-state {
483
+ padding: 40px 14px;
484
+ color: var(--muted);
485
+ font-size: 12px;
486
+ text-align: center;
487
+ }
488
+
489
+ /* Loading state. Verifying an env fans out ~100s of GitHub API calls and
490
+ routinely takes 30-60s, so the table shows the SHAPE of the answer
491
+ while it waits instead of one static line — a blank-looking table for a
492
+ minute reads as broken. */
493
+ .loading-note {
494
+ padding: 14px;
495
+ color: var(--muted);
496
+ font-size: 12px;
497
+ text-align: center;
498
+ }
499
+ .loading-note .sub {
500
+ display: block;
501
+ margin-top: 4px;
502
+ opacity: 0.75;
503
+ font-size: 11px;
504
+ }
505
+ .loading-dots::after {
506
+ content: "";
507
+ animation: rel-dots 1.4s steps(4, end) infinite;
508
+ }
509
+ @keyframes rel-dots {
510
+ 0% {
511
+ content: "";
512
+ }
513
+ 25% {
514
+ content: ".";
515
+ }
516
+ 50% {
517
+ content: "..";
518
+ }
519
+ 75% {
520
+ content: "...";
521
+ }
522
+ }
523
+ .skel {
524
+ display: block;
525
+ height: 9px;
526
+ border-radius: 3px;
527
+ background: linear-gradient(
528
+ 90deg,
529
+ #ffffff0d 25%,
530
+ #ffffff1f 50%,
531
+ #ffffff0d 75%
532
+ );
533
+ background-size: 600px 100%;
534
+ animation: rel-shimmer 1.5s linear infinite;
535
+ }
536
+ @keyframes rel-shimmer {
537
+ 0% {
538
+ background-position: -300px 0;
539
+ }
540
+ 100% {
541
+ background-position: 300px 0;
542
+ }
543
+ }
544
+ /* Stagger the rows so the sweep reads as one wave down the table. */
545
+ .skel-row:nth-child(2) .skel {
546
+ animation-delay: 0.1s;
547
+ }
548
+ .skel-row:nth-child(3) .skel {
549
+ animation-delay: 0.2s;
550
+ }
551
+ .skel-row:nth-child(4) .skel {
552
+ animation-delay: 0.3s;
553
+ }
554
+ .skel-row:nth-child(5) .skel {
555
+ animation-delay: 0.4s;
556
+ }
557
+ .skel-row:nth-child(6) .skel {
558
+ animation-delay: 0.5s;
559
+ }
560
+ .skel-row:nth-child(7) .skel {
561
+ animation-delay: 0.6s;
562
+ }
563
+ .skel-row:nth-child(8) .skel {
564
+ animation-delay: 0.7s;
565
+ }
566
+ @media (prefers-reduced-motion: reduce) {
567
+ .skel,
568
+ .loading-dots::after {
569
+ animation: none;
570
+ }
571
+ .skel {
572
+ background: #ffffff14;
573
+ }
574
+ }
575
+
576
+ /* ── Output pane ──────────────────────────────────────────────────────── */
577
+ .pane-bar {
578
+ display: flex;
579
+ align-items: center;
580
+ justify-content: space-between;
581
+ padding: 3px 12px;
582
+ background: var(--surface);
583
+ border-top: 1px solid var(--border);
584
+ border-bottom: 1px solid var(--border);
585
+ flex-shrink: 0;
586
+ }
587
+ .pane-bar span {
588
+ font-size: 10px;
589
+ font-weight: 600;
590
+ text-transform: uppercase;
591
+ letter-spacing: 0.07em;
592
+ color: var(--muted);
593
+ }
594
+ .pane-bar button {
595
+ background: none;
596
+ border: none;
597
+ color: var(--muted);
598
+ font-size: 11px;
599
+ cursor: pointer;
600
+ padding: 2px 6px;
601
+ border-radius: 3px;
602
+ }
603
+ .pane-bar button:hover {
604
+ color: var(--text);
605
+ background: var(--border);
606
+ }
607
+ .output-pane {
608
+ height: 34%;
609
+ display: flex;
610
+ flex-direction: column;
611
+ min-height: 0;
612
+ }
613
+ .output-pane.hidden {
614
+ display: none;
615
+ }
616
+ #output {
617
+ flex: 1;
618
+ overflow-y: auto;
619
+ padding: 10px 14px;
620
+ font-family: var(--mono);
621
+ font-size: 11.5px;
622
+ line-height: 1.6;
623
+ white-space: pre-wrap;
624
+ word-break: break-all;
625
+ min-height: 0;
626
+ }
627
+ #output .cmd {
628
+ color: var(--accent);
629
+ }
630
+ #output .phase {
631
+ color: var(--text);
632
+ font-weight: 600;
633
+ }
634
+ #output .warn {
635
+ color: var(--yellow);
636
+ }
637
+ #output .err {
638
+ color: var(--red);
639
+ }
640
+ #output .ok {
641
+ color: var(--green);
642
+ }
643
+
644
+ /* ── Modal ────────────────────────────────────────────────────────────── */
645
+ .overlay {
646
+ position: fixed;
647
+ inset: 0;
648
+ z-index: 1000;
649
+ background: rgba(0, 0, 0, 0.65);
650
+ display: none;
651
+ align-items: center;
652
+ justify-content: center;
653
+ }
654
+ .overlay.open {
655
+ display: flex;
656
+ }
657
+ .modal {
658
+ background: var(--surface);
659
+ border: 1px solid var(--border);
660
+ border-radius: 10px;
661
+ width: min(560px, 94vw);
662
+ max-height: 86vh;
663
+ display: flex;
664
+ flex-direction: column;
665
+ overflow: hidden;
666
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.65);
667
+ }
668
+ .modal-hdr {
669
+ display: flex;
670
+ align-items: center;
671
+ gap: 10px;
672
+ padding: 13px 18px;
673
+ border-bottom: 1px solid var(--border);
674
+ }
675
+ .modal-hdr h3 {
676
+ font-family: var(--mono);
677
+ font-size: 13px;
678
+ font-weight: 600;
679
+ flex: 1;
680
+ }
681
+ .modal-close {
682
+ background: none;
683
+ border: none;
684
+ color: var(--muted);
685
+ font-size: 18px;
686
+ cursor: pointer;
687
+ line-height: 1;
688
+ }
689
+ .modal-close:hover {
690
+ color: var(--text);
691
+ }
692
+ .modal-body {
693
+ overflow-y: auto;
694
+ padding: 16px 18px;
695
+ }
696
+ .modal-body label {
697
+ display: block;
698
+ font-size: 11px;
699
+ color: var(--muted);
700
+ margin: 12px 0 4px;
701
+ }
702
+ .modal-body label:first-child {
703
+ margin-top: 0;
704
+ }
705
+ .modal-body select,
706
+ .modal-body input[type="text"] {
707
+ width: 100%;
708
+ background: var(--bg);
709
+ border: 1px solid var(--border);
710
+ color: var(--text);
711
+ padding: 7px 10px;
712
+ border-radius: 6px;
713
+ font-size: 12px;
714
+ font-family: var(--mono);
715
+ }
716
+ .modal-ftr {
717
+ padding: 12px 18px;
718
+ border-top: 1px solid var(--border);
719
+ display: flex;
720
+ gap: 8px;
721
+ justify-content: flex-end;
722
+ }
723
+ .banner {
724
+ border-radius: 6px;
725
+ padding: 9px 12px;
726
+ font-size: 12px;
727
+ margin-bottom: 6px;
728
+ }
729
+ .banner.hotfix {
730
+ border: 1px solid var(--yellow);
731
+ background: #d2992215;
732
+ color: var(--yellow);
733
+ }
734
+ .banner.prod {
735
+ border: 1px solid var(--red);
736
+ background: #f8514915;
737
+ color: #ffb3ae;
738
+ }
739
+ .plan-wave {
740
+ margin: 8px 0;
741
+ }
742
+ .plan-wave h4 {
743
+ font-size: 11px;
744
+ color: var(--muted);
745
+ text-transform: uppercase;
746
+ letter-spacing: 0.05em;
747
+ margin-bottom: 4px;
748
+ }
749
+ .plan-repo {
750
+ font-family: var(--mono);
751
+ font-size: 11px;
752
+ color: var(--text);
753
+ padding: 2px 0 2px 12px;
754
+ }
755
+ .plan-repo .off {
756
+ color: var(--yellow);
757
+ }
758
+
759
+ /* wave editor */
760
+ .wave-row {
761
+ display: grid;
762
+ grid-template-columns: 1fr 90px 130px 60px;
763
+ gap: 8px;
764
+ align-items: center;
765
+ padding: 5px 0;
766
+ border-bottom: 1px solid var(--border);
767
+ font-size: 12px;
768
+ }
769
+ .wave-row.head {
770
+ color: var(--muted);
771
+ font-size: 10px;
772
+ text-transform: uppercase;
773
+ letter-spacing: 0.05em;
774
+ border-bottom: 1px solid var(--border);
775
+ padding-bottom: 6px;
776
+ }
777
+ .wave-row input[type="number"] {
778
+ width: 100%;
779
+ background: var(--bg);
780
+ border: 1px solid var(--border);
781
+ color: var(--text);
782
+ padding: 4px 6px;
783
+ border-radius: 5px;
784
+ font-family: var(--mono);
785
+ }
786
+ .wave-row .rname {
787
+ font-family: var(--mono);
788
+ }
789
+ .wave-row .wf {
790
+ font-family: var(--mono);
791
+ font-size: 10px;
792
+ color: var(--muted);
793
+ }
794
+ .hint {
795
+ font-size: 11px;
796
+ color: var(--muted);
797
+ margin-top: 10px;
798
+ line-height: 1.5;
799
+ }
800
+ .err-text {
801
+ color: var(--red);
802
+ font-size: 11px;
803
+ }
804
+ </style>
805
+ </head>
806
+ <body>
807
+ <header>
808
+ <img
809
+ src="/favicon.ico"
810
+ width="16"
811
+ height="16"
812
+ alt=""
813
+ style="flex-shrink: 0"
814
+ />
815
+ <h1>__DEN_CLI__</h1>
816
+ <button
817
+ class="v-pill"
818
+ id="version-pill"
819
+ onclick="openChangelog()"
820
+ title="View changelog"
821
+ >
822
+ <span class="v-pip" id="v-pip"></span><span class="v-txt">v…</span>
823
+ </button>
824
+ <div class="health" id="health" title="Local service health"></div>
825
+ <!-- header.js polls /api/health with whatever #env-sel holds. This page's
826
+ VISIBLE env picker is the deploy target, so it must not use that id —
827
+ the header would then ask "are my local services healthy in sandbox?"
828
+ and light up entirely red.
829
+
830
+ This pinned-to-local select is what header.js reads. Deployed-env
831
+ health is not shown here because it is not observable from a laptop:
832
+ those checks target *.internal.<env>.example.dev (private, VPN-only) and
833
+ `aws rds describe-db-instances` (needs credentials), so off-VPN every
834
+ one fails and the row is a wall of red that means nothing. The
835
+ deployed-env signal this page actually needs is the Status column,
836
+ which compares main vs deployed SHA over the GitHub API and needs no
837
+ network path to the services. -->
838
+ <select
839
+ id="env-sel"
840
+ style="display: none"
841
+ aria-hidden="true"
842
+ tabindex="-1"
843
+ >
844
+ <option value="local" selected>local</option>
845
+ </select>
846
+ <div class="h-spacer"></div>
847
+ <nav>
848
+ <a href="/">Local Dev</a>
849
+ <a href="/services">Services</a>
850
+ <a href="/environments">Environments</a>
851
+ <a href="/release" class="active">Releases</a>
852
+ <a href="/logs"
853
+ >Logs
854
+ <span
855
+ id="log-badge"
856
+ style="
857
+ background: var(--yellow);
858
+ color: #0d1117;
859
+ font-size: 10px;
860
+ font-weight: 700;
861
+ padding: 1px 5px;
862
+ border-radius: 8px;
863
+ line-height: 1.4;
864
+ display: none;
865
+ "
866
+ ></span
867
+ ></a>
868
+ <a href="/tests">Tests</a>
869
+ </nav>
870
+ </header>
871
+
872
+ <div class="controls">
873
+ <span class="env-label">env:</span>
874
+ <select id="rel-env" title="Environment"></select>
875
+ <button class="btn" id="refresh-btn" title="Re-check deployed state">
876
+ ⟳ Refresh
877
+ </button>
878
+ <span id="status-text">idle</span>
879
+ <div class="h-spacer" style="flex: 1"></div>
880
+ <button class="btn" id="waves-btn" title="Edit wave assignment">
881
+ Waves…
882
+ </button>
883
+ <button class="btn primary" id="deploy-all-btn">Deploy all →</button>
884
+ <span
885
+ id="read-only-tag"
886
+ title="Deploys are off. Set release.deploy: true in __DEN_CONFIG_FILE__ to enable them."
887
+ >read-only</span
888
+ >
889
+ </div>
890
+
891
+ <main>
892
+ <div class="table-pane">
893
+ <table id="rel-table">
894
+ <thead>
895
+ <tr id="rel-head"></tr>
896
+ </thead>
897
+ <tbody id="rel-body">
898
+ <tr>
899
+ <td class="loading-note" colspan="8">
900
+ Loading deployed state<span class="loading-dots"></span>
901
+ <span class="sub"
902
+ >Checking every repo against GitHub — this can take a
903
+ minute.</span
904
+ >
905
+ </td>
906
+ </tr>
907
+ </tbody>
908
+ </table>
909
+ </div>
910
+
911
+ <div class="pane-bar" id="output-bar" style="display: none">
912
+ <span id="output-title">Deploy output</span>
913
+ <span>
914
+ <button onclick="window.__rel.clearOutput()">Clear</button>
915
+ <button
916
+ onclick="window.__rel.closeDeployLog()"
917
+ title="Close the deploy log"
918
+ >
919
+ ✕ Close
920
+ </button>
921
+ </span>
922
+ </div>
923
+ <!-- Only shown when more than one deploy session exists; switches which
924
+ one the single log pane below is attached to. -->
925
+ <div id="session-chips" class="session-chips" style="display: none"></div>
926
+ <div class="output-pane hidden" id="output-pane">
927
+ <pre id="output"></pre>
928
+ </div>
929
+ </main>
930
+
931
+ <!-- Generic modal -->
932
+ <div class="overlay" id="overlay">
933
+ <div class="modal">
934
+ <div class="modal-hdr">
935
+ <h3 id="modal-title">…</h3>
936
+ <button class="modal-close" id="modal-x" title="Close">×</button>
937
+ </div>
938
+ <div class="modal-body" id="modal-body"></div>
939
+ <div class="modal-ftr" id="modal-ftr"></div>
940
+ </div>
941
+ </div>
942
+
943
+ <script type="module">
944
+ import {
945
+ applyDenConfig,
946
+ initChangelog,
947
+ initLogBadge,
948
+ initHealth,
949
+ refreshHealth,
950
+ } from "/header.js";
951
+
952
+ // The header's dots report LOCAL service health on every page, this one
953
+ // included; #env-sel is pinned to "local" in the markup above. Changing
954
+ // the deploy target must NOT re-point them — that was the bug where
955
+ // selecting sandbox made the header ask /api/health?env=sandbox and show
956
+ // every system red while docker was perfectly healthy.
957
+ const HEADER_HEALTH_ENV = "local";
958
+
959
+ const LS_ENV = "__DEN_CLI__-release-env";
960
+ // Remembers the active/last deploy session so its log survives navigation.
961
+ const LS_DEPLOY = "__DEN_CLI__-release-deploy";
962
+ const COLS = [
963
+ { key: "repo", label: "Repo" },
964
+ { key: "deployedAt", label: "Deployed At" },
965
+ { key: "deployer", label: "Deployer" },
966
+ { key: "main", label: "Source" },
967
+ { key: "deployed", label: "Deployed" },
968
+ { key: "branch", label: "Branch" },
969
+ { key: "status", label: "Status" },
970
+ { key: "deploy", label: "Deploy", noSort: true },
971
+ ];
972
+ const ST_RANK = {
973
+ "up-to-date": 0,
974
+ ahead: 1,
975
+ "off-main": 2,
976
+ behind: 3,
977
+ "no-deploy": 4,
978
+ conflict: 5,
979
+ error: 6,
980
+ };
981
+ const ST_CLASS = {
982
+ "up-to-date": "st-up",
983
+ ahead: "st-warn",
984
+ "off-main": "st-warn",
985
+ behind: "st-bad",
986
+ "no-deploy": "st-none",
987
+ conflict: "st-bad",
988
+ error: "st-bad",
989
+ };
990
+
991
+ const state = {
992
+ config: null,
993
+ env: null,
994
+ rows: [],
995
+ sort: { key: "status", dir: "desc" }, // worst first by default
996
+ deploying: false, // is the ATTACHED session still running?
997
+ deploySession: null, // id of the server-side deploy session we're attached to
998
+ // Deploys run concurrently server-side; only one log is shown at a
999
+ // time. `sessions` is every restorable session (newest first) so the
1000
+ // chip bar can switch between them, and `deployingRepos` is the set
1001
+ // with a live deploy — only those repos' buttons are disabled.
1002
+ // null | "initial" | "refresh" — a status fetch is in flight, so the
1003
+ // table shows a loading row rather than the "no repos" empty state.
1004
+ loading: null,
1005
+ sessions: [],
1006
+ deployingRepos: new Set(),
1007
+ // Repos with a CD run in flight that WE didn't start — another
1008
+ // engineer's dispatch, a push to main, a re-run from the Actions tab.
1009
+ // Kept separate from deployingRepos so the two sources never fight.
1010
+ externalRuns: new Set(),
1011
+ runs: new Map(), // repo -> { status, conclusion, url, createdAt, external }
1012
+ };
1013
+
1014
+ /** A repo is un-deployable while any CD run is in flight, ours or not. */
1015
+ const isRepoBusy = (repo) =>
1016
+ state.deployingRepos.has(repo) || state.externalRuns.has(repo);
1017
+
1018
+ let es = null;
1019
+ window.addEventListener("pagehide", () => {
1020
+ try {
1021
+ es?.close();
1022
+ } catch {}
1023
+ es = null;
1024
+ });
1025
+
1026
+ const $ = (id) => document.getElementById(id);
1027
+ const esc = (s) =>
1028
+ String(s ?? "")
1029
+ .replace(/&/g, "&amp;")
1030
+ .replace(/</g, "&lt;")
1031
+ .replace(/>/g, "&gt;")
1032
+ .replace(/"/g, "&quot;");
1033
+
1034
+ // ── Init ──────────────────────────────────────────────────────────────────
1035
+ async function init() {
1036
+ try {
1037
+ state.config = await fetch("/api/release/config").then((r) =>
1038
+ r.json(),
1039
+ );
1040
+ } catch {
1041
+ state.config = {
1042
+ environments: ["dev", "sandbox", "staging", "prod"],
1043
+ repos: [],
1044
+ };
1045
+ }
1046
+ document.body.classList.toggle(
1047
+ "read-only",
1048
+ state.config.deploy !== true,
1049
+ );
1050
+
1051
+ // Read-only unless __DEN_CONFIG_FILE__ sets release.deploy: true. The page
1052
+ // then only checks what is deployed; every deploy control is hidden.
1053
+ state.canDeploy = state.config.deploy === true;
1054
+ if (!state.canDeploy) {
1055
+ $("deploy-all-btn").hidden = true;
1056
+ $("waves-btn").hidden = true;
1057
+ document.body.classList.add("read-only");
1058
+ COLS.find((c) => c.key === "deploy").label = "Deploy run";
1059
+ }
1060
+
1061
+ const envSel = $("rel-env");
1062
+ envSel.innerHTML = state.config.environments
1063
+ .map((e) => `<option value="${esc(e)}">${esc(e)}</option>`)
1064
+ .join("");
1065
+ const saved = localStorage.getItem(LS_ENV);
1066
+ state.env =
1067
+ saved && state.config.environments.includes(saved)
1068
+ ? saved
1069
+ : state.config.environments.includes("sandbox")
1070
+ ? "sandbox"
1071
+ : state.config.environments[0];
1072
+ envSel.value = state.env;
1073
+
1074
+ envSel.addEventListener("change", () => {
1075
+ state.env = envSel.value;
1076
+ localStorage.setItem(LS_ENV, state.env);
1077
+ state.runs.clear();
1078
+ // Local health is unaffected by the deploy target; re-ping the same
1079
+ // env so the dots stay live without following the selector.
1080
+ refreshHealth(HEADER_HEALTH_ENV);
1081
+ loadStatus();
1082
+ });
1083
+ $("refresh-btn").addEventListener("click", () => loadStatus(true));
1084
+ $("deploy-all-btn").addEventListener("click", openDeployAllModal);
1085
+ $("waves-btn").addEventListener("click", openWaveEditor);
1086
+ $("modal-x").addEventListener("click", closeModal);
1087
+ $("overlay").addEventListener("click", (e) => {
1088
+ if (e.target === $("overlay")) closeModal();
1089
+ });
1090
+ document.addEventListener("keydown", (e) => {
1091
+ if (e.key === "Escape") closeModal();
1092
+ });
1093
+
1094
+ renderHead();
1095
+ await Promise.all([applyDenConfig(), initChangelog()]);
1096
+ initLogBadge();
1097
+ initHealth();
1098
+ loadStatus();
1099
+ restoreDeploy(); // reattach to an in-flight/last deploy log if any
1100
+ startRunsPoll(); // keep "is this system deploying" fresh
1101
+ }
1102
+
1103
+ // ── Load status ─────────────────────────────────────────────────────────────
1104
+ async function loadStatus(refresh = false) {
1105
+ state.loading = refresh ? "refresh" : "initial";
1106
+ setStatus(refresh ? "refreshing…" : "checking deployed state…");
1107
+ renderTable(); // shows the loading row; see renderTable
1108
+ try {
1109
+ const data = await fetch(
1110
+ `/api/release/status?env=${encodeURIComponent(state.env)}&refresh=${refresh ? 1 : 0}`,
1111
+ ).then((r) => r.json());
1112
+ state.rows = data.rows || [];
1113
+ setStatus(
1114
+ data.error
1115
+ ? `error: ${data.error}`
1116
+ : `${state.rows.length} repo(s) · updated ${fmtClock(data.fetchedAt)}`,
1117
+ );
1118
+ } catch (e) {
1119
+ state.rows = [];
1120
+ setStatus("failed to load status");
1121
+ } finally {
1122
+ // Clear before the final render, or the table would stay on the
1123
+ // loading row forever if the fetch threw.
1124
+ state.loading = null;
1125
+ }
1126
+ renderTable();
1127
+ }
1128
+
1129
+ // ── Rendering ───────────────────────────────────────────────────────────────
1130
+ function renderHead() {
1131
+ $("rel-head").innerHTML = COLS.map((c) => {
1132
+ if (c.noSort)
1133
+ return `<th class="no-sort${c.key === "deploy" ? " deploy-cell" : ""}">${c.label}</th>`;
1134
+ const active = state.sort.key === c.key;
1135
+ const arrow = active
1136
+ ? `<span class="arrow">${state.sort.dir === "asc" ? "▲" : "▼"}</span>`
1137
+ : "";
1138
+ return `<th data-key="${c.key}">${c.label}${arrow}</th>`;
1139
+ }).join("");
1140
+ $("rel-head")
1141
+ .querySelectorAll("th[data-key]")
1142
+ .forEach((th) => {
1143
+ th.addEventListener("click", () => toggleSort(th.dataset.key));
1144
+ });
1145
+ }
1146
+
1147
+ function toggleSort(key) {
1148
+ if (state.sort.key === key)
1149
+ state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc";
1150
+ else state.sort = { key, dir: key === "repo" ? "asc" : "desc" };
1151
+ renderHead();
1152
+ renderTable();
1153
+ }
1154
+
1155
+ function sortVal(row, key) {
1156
+ switch (key) {
1157
+ case "repo":
1158
+ return row.repo;
1159
+ case "deployedAt":
1160
+ return row.deployedAt ? Date.parse(row.deployedAt) : -Infinity;
1161
+ case "deployer":
1162
+ return (row.deployer || "").toLowerCase();
1163
+ case "main":
1164
+ return row.mainShort || "";
1165
+ case "deployed":
1166
+ return row.deployedShort || "";
1167
+ case "branch":
1168
+ return row.branch || "";
1169
+ case "status":
1170
+ return ST_RANK[row.status?.kind] ?? 99;
1171
+ default:
1172
+ return 0;
1173
+ }
1174
+ }
1175
+
1176
+ /**
1177
+ * Placeholder rows shown while an env is being verified.
1178
+ *
1179
+ * `verifyEnv` fans out per repo (branch + deployments + a compare per
1180
+ * deployment scanned), so a cold check regularly runs 30-60s. A single
1181
+ * static line for that long reads as a hung page, so render the SHAPE of
1182
+ * the table — one shimmering row per repo we already know about — plus
1183
+ * copy that sets the expectation.
1184
+ */
1185
+ function loadingRowsHtml() {
1186
+ // Prefer the real repo count so the skeleton matches what's coming;
1187
+ // fall back to a sensible block on the very first load.
1188
+ const n = Math.min(Math.max(state.rows.length || 8, 3), 8);
1189
+ const verb = state.loading === "refresh" ? "Re-checking" : "Checking";
1190
+ const widths = ["55%", "70%", "45%", "60%", "60%", "50%", "40%", "35%"];
1191
+ const skelRows = Array.from(
1192
+ { length: n },
1193
+ (_, i) =>
1194
+ `<tr class="skel-row">${widths
1195
+ .map(
1196
+ (w) =>
1197
+ `<td><span class="skel" style="width:${w};opacity:${(1 - i * 0.07).toFixed(2)}"></span></td>`,
1198
+ )
1199
+ .join("")}</tr>`,
1200
+ ).join("");
1201
+ return (
1202
+ `<tr><td class="loading-note" colspan="8">` +
1203
+ `${verb} <strong>${esc(state.env)}</strong><span class="loading-dots"></span>` +
1204
+ `<span class="sub">Comparing every repo against GitHub — this may take a minute.</span>` +
1205
+ `</td></tr>${skelRows}`
1206
+ );
1207
+ }
1208
+
1209
+ function renderTable() {
1210
+ // Loading wins over the empty state. loadStatus() awaits a slow verify,
1211
+ // and anything that re-renders in that window (the CD-run poll, a deploy
1212
+ // event) would otherwise replace "Re-checking…" with "No repos
1213
+ // configured" — which reads as a broken config rather than a pending
1214
+ // fetch. Checked here so every caller gets it, not just loadStatus.
1215
+ if (state.loading) {
1216
+ $("rel-body").innerHTML = loadingRowsHtml();
1217
+ return;
1218
+ }
1219
+ if (!state.rows.length) {
1220
+ $("rel-body").innerHTML =
1221
+ `<tr><td class="empty-state" colspan="8">No repos to check. Give a repo's <code>__DEN_MANIFEST_YML__</code> a <code>release:</code> block saying where each environment's deployed commit is read.</td></tr>`;
1222
+ return;
1223
+ }
1224
+ const rows = [...state.rows].sort((a, b) => {
1225
+ const av = sortVal(a, state.sort.key),
1226
+ bv = sortVal(b, state.sort.key);
1227
+ const cmp = av < bv ? -1 : av > bv ? 1 : a.repo < b.repo ? -1 : 1;
1228
+ return state.sort.dir === "asc" ? cmp : -cmp;
1229
+ });
1230
+ $("rel-body").innerHTML = rows.map(rowHtml).join("");
1231
+ $("rel-body")
1232
+ .querySelectorAll(".dep-btn")
1233
+ .forEach((btn) => {
1234
+ btn.addEventListener("click", () =>
1235
+ openDeployModal(btn.dataset.repo),
1236
+ );
1237
+ });
1238
+ }
1239
+
1240
+ function rowHtml(r) {
1241
+ const stClass = ST_CLASS[r.status?.kind] || "st-none";
1242
+ const note =
1243
+ (r.status?.warn
1244
+ ? `<span class="st-note">${esc(r.status.warn)}</span>`
1245
+ : "") +
1246
+ (r.source ? `<span class="st-note">via ${esc(r.source)}</span>` : "");
1247
+ const run = state.runs.get(r.repo);
1248
+ const deployCell = run ? runPillHtml(run) : "";
1249
+ const btnDisabled = isRepoBusy(r.repo) ? "disabled" : "";
1250
+ const srcTag = `<span class="branch" title="branch this environment ships from">${esc(r.sourceBranch || "main")}</span> `;
1251
+ const mainCell = r.mainShort
1252
+ ? r.deployedShort && r.deployedShort !== r.mainShort
1253
+ ? `${srcTag}<span class="sha">${esc(r.mainShort)}</span>`
1254
+ : `${srcTag}<span class="sha" style="color:var(--green)">${esc(r.mainShort)}</span>`
1255
+ : '<span class="sha">—</span>';
1256
+ // The GitHub Environment checked, when it isn't the env's own name
1257
+ // (mapped CD environments, one row per tenant cell).
1258
+ const envTag =
1259
+ r.environment && r.environment !== state.env
1260
+ ? `<span class="sha" title="GitHub Environment" style="display:block">${esc(r.environment)}</span>`
1261
+ : "";
1262
+ const dep = r.deployer;
1263
+ const depIsBot =
1264
+ !!dep && (dep.endsWith("[bot]") || dep === "github-actions");
1265
+ const deployerCell = !dep
1266
+ ? '<span class="sha">—</span>'
1267
+ : depIsBot
1268
+ ? `<span class="deployer bot" title="automated">${esc(dep)}</span>`
1269
+ : `<a class="deployer" href="https://github.com/${encodeURIComponent(dep)}" target="_blank" rel="noopener" title="View ${esc(dep)} on GitHub">@${esc(dep)}</a>`;
1270
+ return `<tr>
1271
+ <td class="repo-cell">${
1272
+ r.gh && r.workflow
1273
+ ? `<a class="repo-link" href="https://github.com/${esc(r.gh)}/actions/workflows/${encodeURIComponent(r.workflow)}" target="_blank" rel="noopener" title="Open ${esc(r.workflow)} for ${esc(r.gh)} on GitHub">${esc(r.repo)}</a>`
1274
+ : esc(r.repo)
1275
+ }${state.canDeploy ? `<span class="wave-tag" title="wave">w${r.wave}</span>` : ""}${envTag}</td>
1276
+ <td class="when">${whenHtml(r.deployedAt, !!r.deployedSha)}</td>
1277
+ <td>${deployerCell}</td>
1278
+ <td>${mainCell}</td>
1279
+ <td><span class="sha">${esc(r.deployedShort || "—")}</span></td>
1280
+ <td>${r.branch ? `<span class="branch" title="${esc(r.branch)}">${esc(r.branch)}</span>` : '<span class="sha">—</span>'}</td>
1281
+ <td><span class="st ${stClass}">${esc(r.status?.text || "—")}</span>${note}</td>
1282
+ <td class="deploy-cell">
1283
+ ${deployCell}
1284
+ ${state.canDeploy ? `<button class="dep-btn" data-repo="${esc(r.repo)}" ${btnDisabled}>Deploy →</button>` : ""}
1285
+ </td>
1286
+ </tr>`;
1287
+ }
1288
+
1289
+ function runPillHtml(run) {
1290
+ const s = run.status,
1291
+ c = run.conclusion;
1292
+ let cls = "busy",
1293
+ label = s;
1294
+ if (s === "completed") {
1295
+ if (c === "success") {
1296
+ cls = "ok";
1297
+ label = "✓ success";
1298
+ } else {
1299
+ cls = "err";
1300
+ label = `✗ ${c || "failed"}`;
1301
+ }
1302
+ } else if (s === "skipped") {
1303
+ cls = "";
1304
+ label = "skipped";
1305
+ } else if (s === "dispatching") {
1306
+ cls = "busy";
1307
+ label = "dispatching…";
1308
+ } else if (s === "in_progress" || s === "queued") {
1309
+ cls = "busy";
1310
+ // Name the environment when we know it. Without this, a run targeting
1311
+ // another env reads as "this row is deploying" while its Deploy button
1312
+ // stays enabled — which looks like a bug rather than the point.
1313
+ label =
1314
+ (s === "queued" ? "queued" : "running") +
1315
+ (run.envHint ? ` → ${run.envHint}` : "");
1316
+ } else {
1317
+ cls = "busy";
1318
+ label = s;
1319
+ }
1320
+ const inner = `<span class="dot"></span>${esc(label)}`;
1321
+ return run.url
1322
+ ? `<a class="run-pill ${cls}" href="${esc(run.url)}" target="_blank" rel="noopener" style="margin-right:6px">${inner}</a>`
1323
+ : `<span class="run-pill ${cls}" style="margin-right:6px">${inner}</span>`;
1324
+ }
1325
+
1326
+ function whenHtml(iso, known) {
1327
+ // A commit read from a version endpoint has no date: unknown, not never.
1328
+ if (!iso)
1329
+ return known
1330
+ ? '<span class="sha" title="the service reports its commit, not when it shipped">—</span>'
1331
+ : '<span class="sha">never</span>';
1332
+ const d = new Date(iso);
1333
+ const abs = d.toISOString().slice(0, 16).replace("T", " ");
1334
+ return `<span class="rel" title="${esc(iso)}">${esc(relTime(d))}</span> <span class="abs">${esc(abs)}Z</span>`;
1335
+ }
1336
+
1337
+ function relTime(d) {
1338
+ const s = Math.floor((Date.now() - d.getTime()) / 1000);
1339
+ if (s < 60) return "just now";
1340
+ const m = Math.floor(s / 60);
1341
+ if (m < 60) return `${m}m ago`;
1342
+ const h = Math.floor(m / 60);
1343
+ if (h < 24) return `${h}h ago`;
1344
+ const days = Math.floor(h / 24);
1345
+ if (days < 30) return `${days}d ago`;
1346
+ return `${Math.floor(days / 30)}mo ago`;
1347
+ }
1348
+
1349
+ function fmtClock(iso) {
1350
+ try {
1351
+ return new Date(iso).toLocaleTimeString();
1352
+ } catch {
1353
+ return "";
1354
+ }
1355
+ }
1356
+ function setStatus(t) {
1357
+ $("status-text").textContent = t;
1358
+ }
1359
+
1360
+ // ── Deploy modals ───────────────────────────────────────────────────────────
1361
+ let modalKeyHandler = null;
1362
+
1363
+ function openModal(title, bodyHtml, footerHtml) {
1364
+ $("modal-title").textContent = title;
1365
+ $("modal-body").innerHTML = bodyHtml;
1366
+ $("modal-ftr").innerHTML = footerHtml;
1367
+ $("overlay").classList.add("open");
1368
+
1369
+ // Enter submits the primary action. Typing the confirmation word and
1370
+ // pressing Enter is the natural flow, and doing nothing reads as the
1371
+ // modal being stuck. Bound on the overlay so it also covers inputs
1372
+ // rendered later (the prod confirm box appears after branch load).
1373
+ modalKeyHandler = (e) => {
1374
+ if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
1375
+ // A focused button or textarea handles Enter itself — don't
1376
+ // double-fire, and don't hijack multi-line input.
1377
+ const el = e.target;
1378
+ if (
1379
+ el instanceof HTMLButtonElement ||
1380
+ el instanceof HTMLTextAreaElement
1381
+ )
1382
+ return;
1383
+ const primary = $("modal-ftr").querySelector(
1384
+ ".btn.primary:not([disabled])",
1385
+ );
1386
+ if (!primary) return; // still incomplete — leave the modal open
1387
+ e.preventDefault();
1388
+ primary.click();
1389
+ };
1390
+ $("overlay").addEventListener("keydown", modalKeyHandler);
1391
+ }
1392
+ function closeModal() {
1393
+ if (modalKeyHandler) {
1394
+ $("overlay").removeEventListener("keydown", modalKeyHandler);
1395
+ modalKeyHandler = null;
1396
+ }
1397
+ $("overlay").classList.remove("open");
1398
+ }
1399
+
1400
+ async function openDeployModal(repo) {
1401
+ const isProd = state.env === "prod";
1402
+ openModal(
1403
+ `Deploy ${repo} → ${state.env}`,
1404
+ `<label>Branch / ref</label>
1405
+ <select id="dep-branch"><option value="main">main</option></select>
1406
+ <div id="dep-hotfix"></div>
1407
+ <div id="dep-confirm-wrap"></div>
1408
+ <div id="dep-err" class="err-text"></div>`,
1409
+ `<button class="btn" id="dep-cancel">Cancel</button>
1410
+ <button class="btn primary" id="dep-go" disabled>Deploy</button>`,
1411
+ );
1412
+
1413
+ const branchSel = $("dep-branch");
1414
+ // Load branches lazily
1415
+ fetch(`/api/release/branches?repo=${encodeURIComponent(repo)}`)
1416
+ .then((r) => r.json())
1417
+ .then((branches) => {
1418
+ if (Array.isArray(branches) && branches.length) {
1419
+ branchSel.innerHTML = branches
1420
+ .map((b) => `<option value="${esc(b)}">${esc(b)}</option>`)
1421
+ .join("");
1422
+ branchSel.value = branches.includes("main")
1423
+ ? "main"
1424
+ : branches[0];
1425
+ updateDeployModal(repo, isProd);
1426
+ }
1427
+ })
1428
+ .catch(() => {});
1429
+
1430
+ branchSel.addEventListener("change", () =>
1431
+ updateDeployModal(repo, isProd),
1432
+ );
1433
+ $("dep-cancel").addEventListener("click", closeModal);
1434
+ updateDeployModal(repo, isProd);
1435
+ }
1436
+
1437
+ function updateDeployModal(repo, isProd) {
1438
+ const branch = $("dep-branch").value;
1439
+ const isHotfix = isProd && branch !== "main";
1440
+ const go = $("dep-go");
1441
+ const row = state.rows.find((r) => r.repo === repo);
1442
+
1443
+ // Hotfix banner
1444
+ let hot = "";
1445
+ if (isHotfix) {
1446
+ const drift =
1447
+ row?.status?.kind === "off-main"
1448
+ ? ` (deployed is ${esc(row.status.text)})`
1449
+ : "";
1450
+ hot = `<div class="banner hotfix">⚠ <b>HOTFIX</b>: deploying non-main branch <code>${esc(branch)}</code> to <b>PRODUCTION</b>${drift}. This bypasses the main-only prod policy — proceed only for an urgent fix.</div>`;
1451
+ }
1452
+ $("dep-hotfix").innerHTML = hot;
1453
+
1454
+ // Confirmation input(s)
1455
+ let confirmHtml = "";
1456
+ if (isProd) {
1457
+ confirmHtml += `<div class="banner prod">Producing a <b>production</b> deploy.</div>`;
1458
+ confirmHtml += `<label>Type <code>prod</code> to confirm</label><input type="text" id="dep-confirm" autocomplete="off" placeholder="prod">`;
1459
+ if (isHotfix)
1460
+ confirmHtml += `<label>Type the branch name <code>${esc(branch)}</code> to confirm the hotfix</label><input type="text" id="dep-confirm-branch" autocomplete="off" placeholder="${esc(branch)}">`;
1461
+ }
1462
+ $("dep-confirm-wrap").innerHTML = confirmHtml;
1463
+
1464
+ const validate = () => {
1465
+ let ok = true;
1466
+ if (isProd) {
1467
+ ok = $("dep-confirm")?.value.trim() === "prod";
1468
+ if (isHotfix)
1469
+ ok = ok && $("dep-confirm-branch")?.value.trim() === branch;
1470
+ }
1471
+ // Only THIS repo being mid-deploy blocks it; other systems deploying
1472
+ // concurrently are irrelevant.
1473
+ go.disabled = !ok || isRepoBusy(repo);
1474
+ };
1475
+ $("dep-confirm")?.addEventListener("input", validate);
1476
+ $("dep-confirm-branch")?.addEventListener("input", validate);
1477
+ go.textContent = isHotfix ? "Deploy hotfix →" : "Deploy →";
1478
+ go.className = "btn primary" + (isHotfix ? " danger" : "");
1479
+ go.onclick = () => {
1480
+ closeModal();
1481
+ startDeploy({
1482
+ mode: "single",
1483
+ repo,
1484
+ ref: branch,
1485
+ confirm: isProd ? "prod" : "",
1486
+ });
1487
+ };
1488
+ validate();
1489
+ }
1490
+
1491
+ function openDeployAllModal() {
1492
+ const cfg = state.config;
1493
+ const enabled = (cfg.repos || []).filter((r) => r.enabled !== false);
1494
+ const waves = [...new Set(enabled.map((r) => r.wave))].sort(
1495
+ (a, b) => a - b,
1496
+ );
1497
+ const isProd = state.env === "prod";
1498
+
1499
+ const planHtml = waves
1500
+ .map((w) => {
1501
+ const repos = enabled.filter((r) => r.wave === w);
1502
+ return (
1503
+ `<div class="plan-wave"><h4>Wave ${w} · ${repos.length} repo(s)</h4>` +
1504
+ repos
1505
+ .map((r) => {
1506
+ const ref = r.ref || "main";
1507
+ const off =
1508
+ ref !== "main"
1509
+ ? ` <span class="off">@${esc(ref)}</span>`
1510
+ : "";
1511
+ return `<div class="plan-repo">${esc(r.name)} <span class="wf" style="color:var(--muted)">${esc(r.workflow)}</span>${off}</div>`;
1512
+ })
1513
+ .join("") +
1514
+ `</div>`
1515
+ );
1516
+ })
1517
+ .join("");
1518
+
1519
+ const nonMain = enabled.filter((r) => (r.ref || "main") !== "main");
1520
+ let policy = "";
1521
+ if (isProd && nonMain.length) {
1522
+ policy = `<div class="banner prod">✗ Deploy-all to <b>prod</b> is <b>main-only</b>. These are configured off-main: ${nonMain.map((r) => `<code>${esc(r.name)}@${esc(r.ref)}</code>`).join(", ")}. Fix the config or use a per-repo <b>Hotfix</b>.</div>`;
1523
+ } else if (isProd) {
1524
+ policy = `<div class="banner prod">Deploying <b>main</b> of ${enabled.length} repo(s) to <b>PRODUCTION</b> across ${waves.length} wave(s). Each wave must succeed before the next.</div>
1525
+ <label>Type <code>prod</code> to confirm</label><input type="text" id="all-confirm" autocomplete="off" placeholder="prod">`;
1526
+ }
1527
+
1528
+ const blocked = isProd && nonMain.length > 0;
1529
+ openModal(
1530
+ `Deploy all → ${state.env}`,
1531
+ `${policy}<div style="margin-top:6px">${planHtml || '<div class="empty-state">No repos enabled.</div>'}</div>
1532
+ <div class="hint">Backends (lower waves) deploy first; the next wave only starts once the previous concludes success (20s stagger between dispatches).</div>`,
1533
+ `<button class="btn" id="all-cancel">Cancel</button>
1534
+ <button class="btn primary" id="all-go" ${blocked ? "disabled" : ""}>Deploy all →</button>`,
1535
+ );
1536
+
1537
+ $("all-cancel").addEventListener("click", closeModal);
1538
+ const go = $("all-go");
1539
+ const validate = () => {
1540
+ // "Deploy all" overlaps anything already in flight, so any live
1541
+ // deploy blocks it (the server would 409 on the overlap anyway).
1542
+ go.disabled =
1543
+ blocked ||
1544
+ (isProd && $("all-confirm")?.value.trim() !== "prod") ||
1545
+ state.deployingRepos.size > 0;
1546
+ };
1547
+ $("all-confirm")?.addEventListener("input", validate);
1548
+ go.onclick = () => {
1549
+ closeModal();
1550
+ startDeploy({ mode: "all", confirm: isProd ? "prod" : "" });
1551
+ };
1552
+ validate();
1553
+ }
1554
+
1555
+ // ── Wave editor ─────────────────────────────────────────────────────────────
1556
+ function openWaveEditor() {
1557
+ const repos = state.config.repos || [];
1558
+ const rowsHtml = repos
1559
+ .map(
1560
+ (r, i) => `
1561
+ <div class="wave-row" data-i="${i}">
1562
+ <span class="rname">${esc(r.name)}</span>
1563
+ <input type="number" min="0" max="20" value="${r.wave}" data-field="wave">
1564
+ <span class="wf">${esc(r.workflow)}</span>
1565
+ <label style="display:flex;align-items:center;gap:5px;margin:0;color:var(--text)"><input type="checkbox" data-field="enabled" ${r.enabled === false ? "" : "checked"}> on</label>
1566
+ </div>`,
1567
+ )
1568
+ .join("");
1569
+ openModal(
1570
+ "Wave assignment",
1571
+ `<div class="wave-row head"><span>Repo</span><span>Wave</span><span>Workflow</span><span>Enabled</span></div>
1572
+ ${rowsHtml}
1573
+ <div id="wave-err" class="err-text"></div>
1574
+ <div class="hint">Saved to your personal <code>config/local/user.yml</code> (gitignored) — the committed team default in <code>__DEN_CONFIG_FILE__</code> is unchanged. Lower wave numbers deploy first.</div>`,
1575
+ `<button class="btn" id="wave-cancel">Cancel</button>
1576
+ <button class="btn primary" id="wave-save">Save (personal)</button>`,
1577
+ );
1578
+ $("wave-cancel").addEventListener("click", closeModal);
1579
+ $("wave-save").addEventListener("click", saveWaves);
1580
+ }
1581
+
1582
+ async function saveWaves() {
1583
+ const repos = [];
1584
+ document.querySelectorAll(".wave-row[data-i]").forEach((el) => {
1585
+ const i = Number(el.dataset.i);
1586
+ const base = state.config.repos[i];
1587
+ const wave = Number(el.querySelector("input[data-field=wave]").value);
1588
+ const enabled = el.querySelector("input[data-field=enabled]").checked;
1589
+ repos.push({
1590
+ name: base.name,
1591
+ workflow: base.workflow,
1592
+ wave,
1593
+ enabled,
1594
+ ...(base.ref ? { ref: base.ref } : {}),
1595
+ });
1596
+ });
1597
+ try {
1598
+ const updated = await fetch("/api/release/config", {
1599
+ method: "PUT",
1600
+ headers: { "Content-Type": "application/json" },
1601
+ body: JSON.stringify({ repos }),
1602
+ }).then((r) => {
1603
+ if (!r.ok) throw new Error("save failed");
1604
+ return r.json();
1605
+ });
1606
+ state.config = updated;
1607
+ closeModal();
1608
+ renderTable();
1609
+ } catch (e) {
1610
+ $("wave-err").textContent = "Save failed: " + e.message;
1611
+ }
1612
+ }
1613
+
1614
+ // ── Deploy stream (SSE) ─────────────────────────────────────────────────────
1615
+ // Kick off a deploy: POST creates a server-side session (which runs the
1616
+ // deploy decoupled from this page), then we attach to stream its log. The
1617
+ // session id is remembered so the log survives navigation / reload.
1618
+ async function startDeploy(params) {
1619
+ showOutput(true);
1620
+ clearOutput();
1621
+ const label =
1622
+ params.mode === "all"
1623
+ ? `all → ${state.env}`
1624
+ : `${params.repo} → ${state.env}`;
1625
+ $("output-title").textContent = `Starting ${label}…`;
1626
+ appendLine(`▶ requesting deploy ${label}`, "cmd");
1627
+ setStatus(`starting ${label}…`);
1628
+
1629
+ let session;
1630
+ try {
1631
+ const res = await fetch("/api/release/deploy", {
1632
+ method: "POST",
1633
+ headers: { "Content-Type": "application/json" },
1634
+ body: JSON.stringify({
1635
+ env: state.env,
1636
+ mode: params.mode,
1637
+ repo: params.repo,
1638
+ ref: params.ref,
1639
+ confirm: params.confirm,
1640
+ }),
1641
+ });
1642
+ if (!res.ok) {
1643
+ // 409 carries a JSON body naming the repos already in flight.
1644
+ let text = await res.text().catch(() => "deploy request failed");
1645
+ try {
1646
+ const j = JSON.parse(text);
1647
+ if (j?.error) text = j.error;
1648
+ } catch {
1649
+ /* not JSON — show as-is */
1650
+ }
1651
+ appendLine(text, "err");
1652
+ setStatus("deploy not started");
1653
+ await refreshSessions();
1654
+ return;
1655
+ }
1656
+ session = await res.json();
1657
+ } catch (e) {
1658
+ appendLine("Failed to start deploy: " + e.message, "err");
1659
+ setStatus("deploy not started");
1660
+ await refreshSessions();
1661
+ return;
1662
+ }
1663
+
1664
+ localStorage.setItem(
1665
+ LS_DEPLOY,
1666
+ JSON.stringify({ sessionId: session.id }),
1667
+ );
1668
+ // Show the new session immediately, then reconcile against the server.
1669
+ state.sessions = [
1670
+ session,
1671
+ ...state.sessions.filter((s) => s.id !== session.id),
1672
+ ];
1673
+ attachDeploy(session.id, {
1674
+ status: session.status,
1675
+ label: session.label,
1676
+ });
1677
+ refreshSessions();
1678
+ }
1679
+
1680
+ // Attach an SSE stream to a deploy session: the server replays the full
1681
+ // log so far, then streams live events. Used both for a fresh deploy and
1682
+ // to restore a running/finished deploy after navigating back to Release.
1683
+ function attachDeploy(sessionId, { status, label }) {
1684
+ if (es) {
1685
+ es.close();
1686
+ es = null;
1687
+ }
1688
+ const running = status !== "done";
1689
+ // Drop the OUTGOING session's rows before switching; the replay below
1690
+ // rebuilds the incoming one. Other sessions' repos keep their
1691
+ // poll-derived status.
1692
+ clearRunsForSession(state.deploySession);
1693
+ state.deploySession = sessionId;
1694
+ state.deploying = running;
1695
+ clearRunsForSession(sessionId);
1696
+ showOutput(true);
1697
+ clearOutput(); // replay rebuilds the full log from scratch
1698
+ $("output-title").textContent =
1699
+ (running ? "Deploying " : "Deploy log — ") + (label || sessionId);
1700
+ setStatus(running ? "attaching to deploy…" : "deploy finished");
1701
+ // Button state comes from the full session list, not from whichever log
1702
+ // happens to be on screen — switching logs must not free a busy repo.
1703
+ recomputeDeployingRepos();
1704
+ renderTable();
1705
+
1706
+ es = new EventSource(
1707
+ `/api/release/deploy?session=${encodeURIComponent(sessionId)}`,
1708
+ );
1709
+ es.onmessage = (e) => {
1710
+ let msg;
1711
+ try {
1712
+ msg = JSON.parse(e.data);
1713
+ } catch {
1714
+ return;
1715
+ }
1716
+ if (msg.type === "log")
1717
+ appendLine(
1718
+ msg.text,
1719
+ msg.level === "error"
1720
+ ? "err"
1721
+ : msg.level === "warn"
1722
+ ? "warn"
1723
+ : "",
1724
+ );
1725
+ else if (msg.type === "phase")
1726
+ appendLine(`\n— ${msg.name} —`, "phase");
1727
+ else if (msg.type === "run") {
1728
+ state.runs.set(msg.repo, {
1729
+ status: msg.status,
1730
+ conclusion: msg.conclusion,
1731
+ url: msg.url,
1732
+ });
1733
+ updateRowRun(msg.repo);
1734
+ } else if (msg.type === "done") {
1735
+ appendLine(
1736
+ `\n${msg.ok ? "✓" : "✗"} ${msg.summary}`,
1737
+ msg.ok ? "ok" : "err",
1738
+ );
1739
+ finishDeploy();
1740
+ }
1741
+ };
1742
+ es.onerror = () => {
1743
+ // The stream dropped (navigation / reload / network). The deploy
1744
+ // itself lives on server-side — do NOT mark it finished; just stop
1745
+ // this attachment. Reopening Release reattaches and replays.
1746
+ if (es) {
1747
+ es.close();
1748
+ es = null;
1749
+ }
1750
+ if (state.deploying)
1751
+ setStatus("stream disconnected — reopen Releases to reattach");
1752
+ };
1753
+ }
1754
+
1755
+ // Deploy reached a terminal state. Keep the log pane + session open so it
1756
+ // survives navigation; the user closes it explicitly via ✕ Close.
1757
+ function finishDeploy() {
1758
+ if (es) {
1759
+ es.close();
1760
+ es = null;
1761
+ }
1762
+ state.deploying = false;
1763
+ // This session finished, but others may still be running — ask the
1764
+ // server rather than assuming everything is now free.
1765
+ refreshSessions();
1766
+ setStatus("deploy finished — re-checking…");
1767
+ const t = $("output-title");
1768
+ if (t && t.textContent.startsWith("Deploying "))
1769
+ t.textContent =
1770
+ "Deploy log — " + t.textContent.slice("Deploying ".length);
1771
+ renderTable();
1772
+ setTimeout(() => loadStatus(true), 2500);
1773
+ }
1774
+
1775
+ function readSavedDeploy() {
1776
+ try {
1777
+ return JSON.parse(localStorage.getItem(LS_DEPLOY) || "null");
1778
+ } catch {
1779
+ return null;
1780
+ }
1781
+ }
1782
+
1783
+ // On load, pull every live session (deploys outlive the page and run
1784
+ // concurrently) and reattach to the one we were last watching. If that
1785
+ // one is gone, fall back to the newest still-running deploy rather than
1786
+ // showing nothing while a deploy is in flight.
1787
+ async function restoreDeploy() {
1788
+ await refreshSessions();
1789
+ if (state.sessions.length === 0) {
1790
+ localStorage.removeItem(LS_DEPLOY);
1791
+ return;
1792
+ }
1793
+ const saved = readSavedDeploy();
1794
+ const target =
1795
+ state.sessions.find((s) => s.id === saved?.sessionId) ??
1796
+ state.sessions.find((s) => s.status === "running");
1797
+ if (!target) {
1798
+ localStorage.removeItem(LS_DEPLOY);
1799
+ return;
1800
+ }
1801
+ localStorage.setItem(
1802
+ LS_DEPLOY,
1803
+ JSON.stringify({ sessionId: target.id }),
1804
+ );
1805
+ attachDeploy(target.id, {
1806
+ status: target.status,
1807
+ label: target.label,
1808
+ });
1809
+ }
1810
+
1811
+ // User closed the log: dismiss ONLY the session on screen. Other deploys
1812
+ // keep running, so if one is left, switch the pane to it instead of
1813
+ // hiding the log entirely.
1814
+ async function closeDeployLog() {
1815
+ const closing = state.deploySession;
1816
+ if (closing) {
1817
+ fetch(`/api/release/deploy?session=${encodeURIComponent(closing)}`, {
1818
+ method: "DELETE",
1819
+ }).catch(() => {});
1820
+ state.sessions = state.sessions.filter((s) => s.id !== closing);
1821
+ }
1822
+ if (es) {
1823
+ es.close();
1824
+ es = null;
1825
+ }
1826
+ state.deploying = false;
1827
+ clearRunsForSession(state.deploySession);
1828
+ state.deploySession = null;
1829
+
1830
+ const next =
1831
+ state.sessions.find((s) => s.status === "running") ??
1832
+ state.sessions[0];
1833
+ if (next) {
1834
+ localStorage.setItem(
1835
+ LS_DEPLOY,
1836
+ JSON.stringify({ sessionId: next.id }),
1837
+ );
1838
+ attachDeploy(next.id, { status: next.status, label: next.label });
1839
+ return;
1840
+ }
1841
+
1842
+ localStorage.removeItem(LS_DEPLOY);
1843
+ recomputeDeployingRepos();
1844
+ showOutput(false);
1845
+ clearOutput();
1846
+ setStatus("idle");
1847
+ renderTable();
1848
+ }
1849
+
1850
+ function updateRowRun(repo) {
1851
+ // Re-render just the affected row's deploy cell if present.
1852
+ const btn = $("rel-body").querySelector(
1853
+ `.dep-btn[data-repo="${cssEsc(repo)}"]`,
1854
+ );
1855
+ const cell = btn?.closest(".deploy-cell");
1856
+ if (!cell) {
1857
+ renderTable();
1858
+ return;
1859
+ }
1860
+ const run = state.runs.get(repo);
1861
+ cell.innerHTML =
1862
+ (run ? runPillHtml(run) : "") +
1863
+ `<button class="dep-btn" data-repo="${esc(repo)}" ${isRepoBusy(repo) ? "disabled" : ""}>Deploy →</button>`;
1864
+ cell
1865
+ .querySelector(".dep-btn")
1866
+ .addEventListener("click", (ev) =>
1867
+ openDeployModal(ev.target.dataset.repo),
1868
+ );
1869
+ }
1870
+
1871
+ function cssEsc(s) {
1872
+ return window.CSS && CSS.escape
1873
+ ? CSS.escape(s)
1874
+ : String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
1875
+ }
1876
+
1877
+ // Only a repo with a live deploy is blocked — a global lock stopped
1878
+ // deploys that never collided. "Deploy all" is disabled while anything
1879
+ // runs, since its plan would overlap whatever is already in flight (the
1880
+ // server 409s on that). Refresh is never blocked.
1881
+ function syncButtons() {
1882
+ const anyRunning =
1883
+ state.deployingRepos.size > 0 || state.externalRuns.size > 0;
1884
+ const allBtn = $("deploy-all-btn");
1885
+ if (allBtn) {
1886
+ allBtn.disabled = anyRunning;
1887
+ allBtn.title = anyRunning
1888
+ ? "A CD run is in flight — wait for it to finish"
1889
+ : "";
1890
+ }
1891
+ const refresh = $("refresh-btn");
1892
+ if (refresh) refresh.disabled = false;
1893
+ document.querySelectorAll(".dep-btn").forEach((b) => {
1894
+ const repo = b.dataset.repo;
1895
+ b.disabled = isRepoBusy(repo);
1896
+ const ext = state.runs.get(repo);
1897
+ b.title = state.deployingRepos.has(repo)
1898
+ ? "Already deploying this system"
1899
+ : state.externalRuns.has(repo)
1900
+ ? ext?.envHint
1901
+ ? `A CD run for ${repo} → ${ext.envHint} is already in flight`
1902
+ : `A CD run for ${repo} is in flight; its target environment can't be determined, so redeploy is blocked`
1903
+ : "";
1904
+ });
1905
+ }
1906
+
1907
+ /**
1908
+ * Forget run state for one session's repos only.
1909
+ *
1910
+ * Clearing the whole map would also discard poll-derived status for repos
1911
+ * in OTHER concurrent sessions — which nothing would rebuild, since the
1912
+ * replay only covers the session being attached.
1913
+ */
1914
+ function clearRunsForSession(sessionId) {
1915
+ const s = state.sessions.find((x) => x.id === sessionId);
1916
+ for (const repo of s?.repos ?? []) state.runs.delete(repo);
1917
+ }
1918
+
1919
+ /** Recompute which repos are mid-deploy from the session list. */
1920
+ function recomputeDeployingRepos() {
1921
+ const live = new Set();
1922
+ for (const s of state.sessions) {
1923
+ if (s.status !== "running") continue;
1924
+ for (const r of s.repos || []) live.add(r);
1925
+ }
1926
+ state.deployingRepos = live;
1927
+ syncButtons();
1928
+ renderSessionChips();
1929
+ }
1930
+
1931
+ // ── Live CD run poll ──────────────────────────────────────────────────────
1932
+ // Deploys start in places this page can't see — another engineer, a push
1933
+ // to main, a re-run from the Actions tab — so poll GitHub for in-flight CD
1934
+ // runs rather than only knowing about deploys we started. Runs only while
1935
+ // the tab is visible; a hidden tab polling GitHub burns rate limit for a
1936
+ // table nobody is looking at.
1937
+ const RUNS_POLL_MS = 15_000;
1938
+ let runsTimer = null;
1939
+ let lastRunsSig = "";
1940
+
1941
+ async function pollRuns() {
1942
+ let runs = [];
1943
+ try {
1944
+ const j = await fetch("/api/release/runs").then((r) => r.json());
1945
+ runs = j.runs || [];
1946
+ } catch {
1947
+ return; // transient — keep the last known state
1948
+ }
1949
+
1950
+ const external = new Set();
1951
+ // Carve out only the repos of the session whose SSE we are ATTACHED to.
1952
+ //
1953
+ // Deploys run concurrently server-side but only one log is attached at
1954
+ // a time, so `deployingRepos` (every running session) is the wrong set
1955
+ // to skip: repos in the other sessions get no `run` events — nothing is
1956
+ // listening to them — AND were skipped here, so their Status cell
1957
+ // stayed empty for the whole deploy. Deploying four systems showed one
1958
+ // "running" and three blanks.
1959
+ const attached = state.sessions.find(
1960
+ (s) => s.id === state.deploySession,
1961
+ );
1962
+ const attachedRepos = new Set(attached?.repos ?? []);
1963
+ for (const r of runs) {
1964
+ // The attached session's SSE is finer-grained than this poll, so let
1965
+ // it own those repos. Every other repo — including ones we started in
1966
+ // another session — is this poll's job.
1967
+ if (attachedRepos.has(r.repo)) continue;
1968
+
1969
+ if (r.running) {
1970
+ // Block only when the run actually targets the env on screen.
1971
+ // envHint is null when the run's title carries no environment (see
1972
+ // CdRun.envHint) — unknown must stay conservative, since dispatching
1973
+ // a second run into the same env would collide. A run we KNOW is
1974
+ // for another env does not block: the workflows use
1975
+ // `concurrency: <repo>-cd-<env>`, so prod and sandbox are separate
1976
+ // groups that GitHub runs in parallel by design.
1977
+ const hint = r.running.envHint;
1978
+ const blocks = hint === null || hint === state.env;
1979
+ if (blocks) external.add(r.repo);
1980
+ state.runs.set(r.repo, {
1981
+ status: r.running.status, // queued | in_progress
1982
+ conclusion: null,
1983
+ url: r.running.url,
1984
+ createdAt: r.running.createdAt,
1985
+ title: r.running.title,
1986
+ envHint: hint,
1987
+ otherEnv: !blocks,
1988
+ external: true,
1989
+ });
1990
+ } else if (r.last) {
1991
+ // The newest FINISHED run is the truth for a repo we're not
1992
+ // deploying. This is what retires a stale ✗ pill: once a later run
1993
+ // has succeeded, the older failure is simply older and gets
1994
+ // replaced.
1995
+ const prev = state.runs.get(r.repo);
1996
+ if (!prev?.createdAt || prev.createdAt <= r.last.createdAt) {
1997
+ state.runs.set(r.repo, {
1998
+ status: "completed",
1999
+ conclusion: r.last.conclusion,
2000
+ url: r.last.url,
2001
+ createdAt: r.last.createdAt,
2002
+ title: r.last.title,
2003
+ external: true,
2004
+ });
2005
+ }
2006
+ }
2007
+ }
2008
+ state.externalRuns = external;
2009
+
2010
+ // Only touch the DOM when something actually changed — a 15s re-render
2011
+ // would otherwise drop the row you're hovering.
2012
+ const sig = JSON.stringify(
2013
+ runs.map((r) => [
2014
+ r.repo,
2015
+ r.running?.id ?? "",
2016
+ r.last?.id ?? "",
2017
+ r.last?.conclusion ?? "",
2018
+ ]),
2019
+ );
2020
+ syncButtons();
2021
+ if (sig !== lastRunsSig) {
2022
+ lastRunsSig = sig;
2023
+ renderTable();
2024
+ }
2025
+ }
2026
+
2027
+ function startRunsPoll() {
2028
+ stopRunsPoll();
2029
+ void pollRuns();
2030
+ runsTimer = setInterval(pollRuns, RUNS_POLL_MS);
2031
+ }
2032
+ function stopRunsPoll() {
2033
+ if (runsTimer) clearInterval(runsTimer);
2034
+ runsTimer = null;
2035
+ }
2036
+ document.addEventListener("visibilitychange", () => {
2037
+ if (document.hidden) stopRunsPoll();
2038
+ else startRunsPoll(); // refresh immediately on return, then resume
2039
+ });
2040
+ window.addEventListener("pagehide", stopRunsPoll);
2041
+
2042
+ /** Pull the authoritative session list from the server. */
2043
+ async function refreshSessions() {
2044
+ try {
2045
+ const list = await fetch("/api/release/deploy/active").then((r) =>
2046
+ r.json(),
2047
+ );
2048
+ state.sessions = Array.isArray(list) ? list : [];
2049
+ } catch {
2050
+ /* server unreachable — keep what we have */
2051
+ }
2052
+ recomputeDeployingRepos();
2053
+ }
2054
+
2055
+ // One log pane, N sessions: chips name each one and switch the pane.
2056
+ function renderSessionChips() {
2057
+ const bar = $("session-chips");
2058
+ if (!bar) return;
2059
+ if (state.sessions.length <= 1) {
2060
+ bar.style.display = "none";
2061
+ bar.innerHTML = "";
2062
+ return;
2063
+ }
2064
+ bar.style.display = "";
2065
+ bar.innerHTML = state.sessions
2066
+ .map((s) => {
2067
+ const cls = [
2068
+ "session-chip",
2069
+ s.id === state.deploySession ? "active" : "",
2070
+ s.status === "running" ? "running" : s.ok ? "ok" : "fail",
2071
+ ]
2072
+ .filter(Boolean)
2073
+ .join(" ");
2074
+ return `<button class="${cls}" data-session="${esc(s.id)}" title="${esc(s.label)}">${
2075
+ s.status === "running" ? "● " : ""
2076
+ }${esc(s.label)}</button>`;
2077
+ })
2078
+ .join("");
2079
+ bar.querySelectorAll(".session-chip").forEach((c) =>
2080
+ c.addEventListener("click", () => {
2081
+ const s = state.sessions.find((x) => x.id === c.dataset.session);
2082
+ if (s && s.id !== state.deploySession) {
2083
+ attachDeploy(s.id, { status: s.status, label: s.label });
2084
+ }
2085
+ }),
2086
+ );
2087
+ }
2088
+
2089
+ // ── Output helpers ──────────────────────────────────────────────────────────
2090
+ function showOutput(show) {
2091
+ $("output-bar").style.display = show ? "" : "none";
2092
+ $("output-pane").classList.toggle("hidden", !show);
2093
+ }
2094
+ function appendLine(text, cls) {
2095
+ const out = $("output");
2096
+ const span = document.createElement("span");
2097
+ if (cls) span.className = cls;
2098
+ span.textContent = text + "\n";
2099
+ out.appendChild(span);
2100
+ out.scrollTop = out.scrollHeight;
2101
+ }
2102
+ function clearOutput() {
2103
+ $("output").innerHTML = "";
2104
+ }
2105
+
2106
+ window.__rel = { clearOutput, closeDeployLog };
2107
+
2108
+ init();
2109
+ </script>
2110
+ </body>
2111
+ </html>