@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.
Files changed (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  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 +99 -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,1489 @@
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__ · Logs</title>
7
+ <link rel="icon" href="/favicon.ico?v=2" />
8
+ <style>
9
+ *,
10
+ *::before,
11
+ *::after {
12
+ box-sizing: border-box;
13
+ margin: 0;
14
+ padding: 0;
15
+ }
16
+
17
+ :root {
18
+ --bg: #0d1117;
19
+ --surface: #161b22;
20
+ --border: #30363d;
21
+ --text: #c9d1d9;
22
+ --muted: #8b949e;
23
+ --accent: #58a6ff;
24
+ --green: #3fb950;
25
+ --red: #f85149;
26
+ --yellow: #d29922;
27
+ --mono: "SF Mono", "Cascadia Code", "Fira Code", "Menlo", monospace;
28
+ }
29
+
30
+ html,
31
+ body {
32
+ height: 100%;
33
+ background: var(--bg);
34
+ color: var(--text);
35
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
36
+ font-size: 13px;
37
+ }
38
+ body {
39
+ display: flex;
40
+ flex-direction: column;
41
+ overflow: hidden;
42
+ }
43
+
44
+ /* ── Header ───────────────────────────────────────────────────────────── */
45
+ header {
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 10px;
49
+ padding: 7px 14px;
50
+ background: var(--surface);
51
+ border-bottom: 1px solid var(--border);
52
+ flex-shrink: 0;
53
+ }
54
+ header h1 {
55
+ font-family: var(--mono);
56
+ font-size: 14px;
57
+ font-weight: 600;
58
+ letter-spacing: -0.02em;
59
+ }
60
+ .h-spacer {
61
+ flex: 1;
62
+ }
63
+ nav {
64
+ display: flex;
65
+ gap: 2px;
66
+ }
67
+ nav a {
68
+ color: var(--muted);
69
+ text-decoration: none;
70
+ padding: 3px 10px;
71
+ border-radius: 4px;
72
+ font-size: 12px;
73
+ font-weight: 500;
74
+ transition: color 0.1s;
75
+ display: flex;
76
+ align-items: center;
77
+ gap: 5px;
78
+ }
79
+ nav a:hover {
80
+ color: var(--text);
81
+ background: var(--border);
82
+ }
83
+ nav a.active {
84
+ color: var(--text);
85
+ }
86
+
87
+ .badge {
88
+ background: var(--yellow);
89
+ color: #0d1117;
90
+ font-size: 10px;
91
+ font-weight: 700;
92
+ padding: 1px 5px;
93
+ border-radius: 8px;
94
+ line-height: 1.4;
95
+ display: none;
96
+ }
97
+ .badge.visible {
98
+ display: inline;
99
+ }
100
+
101
+ /* ── Health dots ──────────────────────────────────────────────────────── */
102
+ .health {
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 10px;
106
+ padding: 0 4px;
107
+ }
108
+ .health-item {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 5px;
112
+ font-size: 11px;
113
+ color: var(--muted);
114
+ }
115
+ .health-group {
116
+ display: flex;
117
+ align-items: center;
118
+ gap: 4px;
119
+ font-size: 11px;
120
+ color: var(--muted);
121
+ }
122
+ .health-group-label {
123
+ color: var(--muted);
124
+ margin-right: 1px;
125
+ }
126
+ .health-group-dots {
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 3px;
130
+ }
131
+ .dot {
132
+ width: 7px;
133
+ height: 7px;
134
+ border-radius: 50%;
135
+ background: var(--muted);
136
+ flex-shrink: 0;
137
+ display: inline-block;
138
+ }
139
+ .dot.ok {
140
+ background: var(--green);
141
+ }
142
+ .dot.err {
143
+ background: var(--red);
144
+ }
145
+ .dot.running {
146
+ background: var(--yellow);
147
+ animation: pulse 1s ease-in-out infinite;
148
+ }
149
+ @keyframes pulse {
150
+ 0%,
151
+ 100% {
152
+ opacity: 1;
153
+ }
154
+ 50% {
155
+ opacity: 0.35;
156
+ }
157
+ }
158
+
159
+ /* ── Sub-bar ──────────────────────────────────────────────────────────── */
160
+ .subbar {
161
+ display: flex;
162
+ align-items: center;
163
+ gap: 8px;
164
+ padding: 5px 14px;
165
+ background: var(--surface);
166
+ border-bottom: 1px solid var(--border);
167
+ flex-shrink: 0;
168
+ }
169
+ .spacer {
170
+ flex: 1;
171
+ }
172
+
173
+ .env-badge {
174
+ display: inline-flex;
175
+ align-items: center;
176
+ gap: 5px;
177
+ padding: 2px 8px 2px 6px;
178
+ border-radius: 999px;
179
+ background: var(--surface);
180
+ border: 1px solid var(--border);
181
+ font-size: 11px;
182
+ font-weight: 500;
183
+ color: var(--muted);
184
+ }
185
+ .env-badge::before {
186
+ content: "env";
187
+ font-size: 9px;
188
+ font-weight: 600;
189
+ letter-spacing: 0.05em;
190
+ color: var(--muted);
191
+ opacity: 0.6;
192
+ text-transform: uppercase;
193
+ }
194
+ .env-badge select {
195
+ background: transparent;
196
+ border: none;
197
+ outline: none;
198
+ color: var(--text);
199
+ font-size: 11px;
200
+ font-weight: 600;
201
+ cursor: pointer;
202
+ padding: 0;
203
+ -webkit-appearance: none;
204
+ appearance: none;
205
+ }
206
+ .env-badge select option {
207
+ background: var(--surface);
208
+ }
209
+
210
+ .seg {
211
+ display: flex;
212
+ gap: 2px;
213
+ background: var(--bg);
214
+ border: 1px solid var(--border);
215
+ border-radius: 6px;
216
+ padding: 2px;
217
+ }
218
+ .seg button {
219
+ background: none;
220
+ border: none;
221
+ color: var(--muted);
222
+ padding: 3px 10px;
223
+ border-radius: 4px;
224
+ cursor: pointer;
225
+ font-size: 12px;
226
+ font-weight: 500;
227
+ transition: color 0.1s;
228
+ white-space: nowrap;
229
+ }
230
+ .seg button:hover {
231
+ color: var(--text);
232
+ }
233
+ .seg button.active {
234
+ background: var(--border);
235
+ color: var(--text);
236
+ }
237
+
238
+ /* ── Logs toolbar ─────────────────────────────────────────────────────── */
239
+ .logs-toolbar {
240
+ flex-shrink: 0;
241
+ display: flex;
242
+ align-items: center;
243
+ gap: 6px;
244
+ flex-wrap: wrap;
245
+ padding: 5px 14px;
246
+ background: var(--surface);
247
+ border-bottom: 1px solid var(--border);
248
+ }
249
+ /* Cross-system history search — pinned to the toolbar's right edge
250
+ (buttons are appended later in DOM order; flex `order` keeps this last). */
251
+ .hist-search-wrap {
252
+ order: 99;
253
+ margin-left: auto;
254
+ display: flex;
255
+ gap: 4px;
256
+ align-items: center;
257
+ }
258
+ .hist-search-wrap input {
259
+ background: var(--bg);
260
+ color: var(--text);
261
+ border: 1px solid var(--border);
262
+ border-radius: 4px;
263
+ font-family: var(--mono);
264
+ font-size: 11px;
265
+ padding: 3px 8px;
266
+ width: 200px;
267
+ }
268
+ .hist-search-wrap input:focus {
269
+ outline: none;
270
+ border-color: var(--accent);
271
+ }
272
+ .hist-search-wrap button {
273
+ background: none;
274
+ border: 1px solid var(--border);
275
+ color: var(--muted);
276
+ font-size: 11px;
277
+ padding: 3px 8px;
278
+ border-radius: 4px;
279
+ cursor: pointer;
280
+ }
281
+ .hist-search-wrap button:hover {
282
+ color: var(--accent);
283
+ border-color: var(--accent);
284
+ }
285
+ .hist-btn {
286
+ white-space: nowrap;
287
+ }
288
+ .search-hd {
289
+ cursor: pointer;
290
+ text-decoration: underline;
291
+ }
292
+ .logs-toolbar .logs-label {
293
+ font-size: 11px;
294
+ color: var(--muted);
295
+ margin-right: 2px;
296
+ }
297
+ .cmd-btn {
298
+ background: none;
299
+ border: 1px solid var(--border);
300
+ color: var(--text);
301
+ padding: 3px 10px;
302
+ border-radius: 4px;
303
+ font-size: 11px;
304
+ cursor: pointer;
305
+ font-family: var(--mono);
306
+ white-space: nowrap;
307
+ transition:
308
+ border-color 0.1s,
309
+ color 0.1s;
310
+ }
311
+ .cmd-btn:hover:not(:disabled) {
312
+ border-color: var(--accent);
313
+ color: var(--accent);
314
+ }
315
+ .logs-btn {
316
+ border-color: var(--muted);
317
+ }
318
+ .logs-btn:hover:not(:disabled) {
319
+ border-color: var(--yellow) !important;
320
+ color: var(--yellow) !important;
321
+ }
322
+ .logs-btn.streaming {
323
+ border-color: var(--yellow);
324
+ color: var(--yellow);
325
+ }
326
+
327
+ /* ── Logs area ────────────────────────────────────────────────────────── */
328
+ .logs-area {
329
+ flex: 1;
330
+ display: flex;
331
+ overflow: hidden;
332
+ min-height: 0;
333
+ }
334
+ .logs-area.dir-cols {
335
+ flex-direction: row;
336
+ }
337
+ .logs-area.dir-rows {
338
+ flex-direction: column;
339
+ }
340
+
341
+ .log-pane {
342
+ flex: 1;
343
+ display: flex;
344
+ flex-direction: column;
345
+ min-height: 0;
346
+ min-width: 0;
347
+ overflow: hidden;
348
+ border-right: 1px solid var(--border);
349
+ }
350
+ .log-pane:last-child {
351
+ border-right: none;
352
+ }
353
+ .logs-area.dir-rows .log-pane {
354
+ border-right: none;
355
+ border-bottom: 1px solid var(--border);
356
+ }
357
+ .logs-area.dir-rows .log-pane:last-child {
358
+ border-bottom: none;
359
+ }
360
+
361
+ .pane-bar {
362
+ display: flex;
363
+ align-items: center;
364
+ justify-content: space-between;
365
+ padding: 3px 12px;
366
+ background: var(--surface);
367
+ border-bottom: 1px solid var(--border);
368
+ flex-shrink: 0;
369
+ }
370
+ .pane-bar span {
371
+ font-size: 10px;
372
+ font-weight: 600;
373
+ letter-spacing: 0.07em;
374
+ color: var(--muted);
375
+ }
376
+ .pane-bar button {
377
+ background: none;
378
+ border: none;
379
+ color: var(--muted);
380
+ font-size: 11px;
381
+ cursor: pointer;
382
+ padding: 2px 6px;
383
+ border-radius: 3px;
384
+ }
385
+ .pane-bar button:hover {
386
+ color: var(--text);
387
+ background: var(--border);
388
+ }
389
+
390
+ .log-pane-title {
391
+ font-family: var(--mono);
392
+ font-size: 11px;
393
+ font-weight: 600;
394
+ color: var(--text);
395
+ letter-spacing: 0;
396
+ text-transform: none;
397
+ }
398
+
399
+ /* ── Per-pane filter ──────────────────────────────────────────────────────── */
400
+ .log-filter-wrap {
401
+ display: flex;
402
+ align-items: center;
403
+ gap: 4px;
404
+ }
405
+ .log-filter {
406
+ background: var(--bg);
407
+ border: 1px solid var(--border);
408
+ color: var(--text);
409
+ padding: 1px 6px;
410
+ border-radius: 4px;
411
+ font-size: 11px;
412
+ outline: none;
413
+ width: 140px;
414
+ font-family: var(--mono);
415
+ }
416
+ .log-filter:focus {
417
+ border-color: var(--accent);
418
+ }
419
+ .log-filter-count {
420
+ font-size: 10px;
421
+ color: var(--muted);
422
+ white-space: nowrap;
423
+ font-family: var(--mono);
424
+ }
425
+ .log-filter-clear {
426
+ background: none;
427
+ border: none;
428
+ color: var(--muted);
429
+ cursor: pointer;
430
+ font-size: 12px;
431
+ padding: 0 2px;
432
+ line-height: 1;
433
+ }
434
+ .log-filter-clear:hover {
435
+ color: var(--text);
436
+ }
437
+ .log-line-hidden {
438
+ display: none !important;
439
+ }
440
+ .log-line-match mark {
441
+ background: color-mix(in srgb, var(--yellow) 30%, transparent);
442
+ color: inherit;
443
+ border-radius: 2px;
444
+ }
445
+ /* Search-all results need real contrast — the filter's 30% wash is
446
+ invisible in a dense results pane on the dark background. */
447
+ .search-hit mark {
448
+ background: var(--yellow);
449
+ color: #0d1117;
450
+ border-radius: 2px;
451
+ font-weight: 600;
452
+ padding: 0 1px;
453
+ }
454
+
455
+ .log-output {
456
+ flex: 1;
457
+ overflow-y: auto;
458
+ padding: 8px 12px;
459
+ font-family: var(--mono);
460
+ font-size: 11px;
461
+ line-height: 1.55;
462
+ white-space: pre-wrap;
463
+ word-break: break-all;
464
+ min-height: 0;
465
+ }
466
+ .log-output .log-cmd {
467
+ color: var(--accent);
468
+ }
469
+ .log-output .log-err {
470
+ color: var(--red);
471
+ }
472
+ .log-output .log-done {
473
+ color: var(--muted);
474
+ font-style: italic;
475
+ }
476
+
477
+ .aws-auth-banner {
478
+ display: flex;
479
+ flex-wrap: wrap;
480
+ align-items: center;
481
+ gap: 8px;
482
+ padding: 8px 12px;
483
+ background: color-mix(in srgb, var(--yellow) 12%, var(--bg));
484
+ border-top: 1px solid color-mix(in srgb, var(--yellow) 40%, transparent);
485
+ }
486
+ .aws-auth-msg {
487
+ font-size: 12px;
488
+ color: var(--yellow);
489
+ flex: 1;
490
+ }
491
+ .aws-auth-btn {
492
+ font-size: 11px;
493
+ padding: 3px 10px;
494
+ border-radius: 4px;
495
+ cursor: pointer;
496
+ background: var(--yellow);
497
+ color: #000;
498
+ border: none;
499
+ font-weight: 600;
500
+ }
501
+ .aws-auth-btn:disabled {
502
+ opacity: 0.5;
503
+ cursor: default;
504
+ }
505
+ .aws-auth-output {
506
+ width: 100%;
507
+ margin: 0;
508
+ padding: 6px 8px;
509
+ font-family: var(--mono);
510
+ font-size: 11px;
511
+ line-height: 1.5;
512
+ background: var(--bg);
513
+ border-radius: 4px;
514
+ white-space: pre-wrap;
515
+ max-height: 120px;
516
+ overflow-y: auto;
517
+ }
518
+
519
+ .logs-empty {
520
+ flex: 1;
521
+ display: flex;
522
+ align-items: center;
523
+ justify-content: center;
524
+ color: var(--muted);
525
+ font-size: 12px;
526
+ }
527
+ </style>
528
+ </head>
529
+ <body>
530
+ <header>
531
+ <img
532
+ src="/favicon.ico"
533
+ width="16"
534
+ height="16"
535
+ alt=""
536
+ style="flex-shrink: 0"
537
+ />
538
+ <h1>__DEN_CLI__</h1>
539
+ <button
540
+ class="v-pill"
541
+ id="version-pill"
542
+ onclick="openChangelog()"
543
+ title="View changelog"
544
+ >
545
+ <span class="v-pip" id="v-pip"></span><span class="v-txt">v…</span>
546
+ </button>
547
+ <div class="health" id="health" title="Service health"></div>
548
+ <div class="h-spacer"></div>
549
+ <nav>
550
+ <a href="/">Local Dev</a>
551
+ <a href="/services">Services</a>
552
+ <a href="/environments">Environments</a>
553
+ <a href="/release">Releases</a>
554
+ <a href="/logs" class="active"
555
+ >Logs <span class="badge visible" id="log-badge"></span
556
+ ></a>
557
+ <a href="/tests">Tests</a>
558
+ </nav>
559
+ </header>
560
+
561
+ <div class="subbar">
562
+ <div class="env-badge">
563
+ <select id="env-sel" title="Environment">
564
+ <option value="local">local</option>
565
+ </select>
566
+ </div>
567
+ <div class="spacer"></div>
568
+ <div class="seg" id="log-dir-seg">
569
+ <button data-dir="cols" class="active" title="Side by side">
570
+ &#9134; cols
571
+ </button>
572
+ <button data-dir="rows" title="Stacked">&#9135; rows</button>
573
+ </div>
574
+ </div>
575
+
576
+ <div class="logs-toolbar" id="logs-toolbar">
577
+ <span class="logs-label">streams</span>
578
+ <span class="hist-search-wrap">
579
+ <input
580
+ id="hist-search"
581
+ type="text"
582
+ placeholder="Search across all logs…"
583
+ spellcheck="false"
584
+ />
585
+ <button
586
+ id="hist-search-btn"
587
+ title="Search the FULL log history of every running system (back to container startup)"
588
+ >
589
+ search
590
+ </button>
591
+ </span>
592
+ </div>
593
+
594
+ <div class="logs-area dir-cols" id="logs-area">
595
+ <div class="logs-empty">
596
+ No active log streams. Open a log from a service card or the toolbar
597
+ above.
598
+ </div>
599
+ </div>
600
+
601
+ <script type="module">
602
+ import {
603
+ initHealth,
604
+ refreshHealth,
605
+ applyDenConfig,
606
+ initChangelog,
607
+ computePrimaryByRepo,
608
+ } from "/header.js";
609
+
610
+ // ── Safe highlight rendering ──────────────────────────────────────────────
611
+ // Log lines are UNTRUSTED (any service can log attacker-influenced text,
612
+ // and this UI runs privileged actions). Every innerHTML sink that renders
613
+ // log content MUST go through markSafe: HTML-escape first, then wrap
614
+ // matches in <mark>. The term is escaped with the SAME transform so
615
+ // queries containing & < > still match.
616
+ function escHtmlText(s) {
617
+ return s
618
+ .replace(/&/g, "&amp;")
619
+ .replace(/</g, "&lt;")
620
+ .replace(/>/g, "&gt;");
621
+ }
622
+ function markSafe(text, term) {
623
+ const pattern = escHtmlText(term).replace(
624
+ /[.*+?^${}()|[\]\\]/g,
625
+ "\\$&",
626
+ );
627
+ return escHtmlText(text).replace(
628
+ new RegExp(pattern, "gi"),
629
+ (m) => `<mark>${m}</mark>`,
630
+ );
631
+ }
632
+
633
+ // ── Pane line window (user-configurable via `__DEN_CLI__ config log_window_lines=N`)
634
+ let LOG_WINDOW = 20000;
635
+ async function loadLogSettings() {
636
+ try {
637
+ const s = await fetch("/api/settings").then((r) => r.json());
638
+ if (Number.isFinite(s.log_window_lines) && s.log_window_lines > 0) {
639
+ LOG_WINDOW = s.log_window_lines;
640
+ }
641
+ } catch {
642
+ /* defaults are fine */
643
+ }
644
+ }
645
+
646
+ // ── LocalStorage persistence ──────────────────────────────────────────────
647
+ const LS_KEY = "__DEN_CLI__-open-logs";
648
+
649
+ function getStoredLogs() {
650
+ try {
651
+ return JSON.parse(localStorage.getItem(LS_KEY) || "[]");
652
+ } catch {
653
+ return [];
654
+ }
655
+ }
656
+
657
+ function setStoredLogs(logs) {
658
+ localStorage.setItem(LS_KEY, JSON.stringify(logs));
659
+ renderBadge(logs.length);
660
+ }
661
+
662
+ function addStoredLog(repo, service, env) {
663
+ const logs = getStoredLogs().filter(
664
+ (l) => !(l.repo === repo && l.service === service),
665
+ );
666
+ logs.push({ repo, service, env });
667
+ setStoredLogs(logs);
668
+ }
669
+
670
+ function removeStoredLog(repo, service) {
671
+ setStoredLogs(
672
+ getStoredLogs().filter(
673
+ (l) => !(l.repo === repo && l.service === service),
674
+ ),
675
+ );
676
+ }
677
+
678
+ // ── State ─────────────────────────────────────────────────────────────────
679
+ // Map<`${repo}/${service}`, { es: EventSource, paneEl, outputEl }>
680
+ const logStreams = new Map();
681
+
682
+ // "__DEN_CLI__/server" is a pseudo-service that taps the server's own log ring
683
+ // via /api/server-log instead of /api/service/logs. Everything else in the
684
+ // page treats it like a normal log stream.
685
+ function isServerLog(repo, service) {
686
+ return repo === "__DEN_CLI__" && service === "server";
687
+ }
688
+ function formatServerLogEntry(entry) {
689
+ // "14:07:41 [lifecycle] ▶ run synthetic/local · modules=all [abcxyz]"
690
+ const t = (entry.ts ?? "").slice(11, 19);
691
+ const kind = (entry.kind ?? "info").padEnd(9);
692
+ return `${t} [${kind}] ${entry.text ?? ""}`;
693
+ }
694
+ function serverLogClass(kind) {
695
+ if (kind === "error") return "log-err";
696
+ if (kind === "warn") return "log-err";
697
+ if (kind === "lifecycle") return "log-cmd";
698
+ return "";
699
+ }
700
+
701
+ // ── Init ──────────────────────────────────────────────────────────────────
702
+ async function init() {
703
+ const envs = await fetch("/api/environments")
704
+ .then((r) => r.json())
705
+ .catch(() => ["local"]);
706
+ const envSel = document.getElementById("env-sel");
707
+ envSel.innerHTML = envs
708
+ .map((e) => `<option value="${e}">${e}</option>`)
709
+ .join("");
710
+ const savedEnv = localStorage.getItem("__DEN_CLI__-env");
711
+ if (savedEnv && envs.includes(savedEnv)) envSel.value = savedEnv;
712
+
713
+ const savedDir = localStorage.getItem("__DEN_CLI__-log-dir") || "cols";
714
+ applyLogDir(savedDir);
715
+
716
+ await Promise.all([
717
+ loadLogToolbar(),
718
+ loadLogSettings(),
719
+ initHealth(),
720
+ applyDenConfig(),
721
+ initChangelog(),
722
+ ]);
723
+
724
+ // Re-open streams from localStorage. __DEN_CLI__/server is env-agnostic so
725
+ // it reopens regardless of the stored env value.
726
+ //
727
+ // Cap auto-restored streams to the per-host socket budget: each open log is
728
+ // a persistent SSE, and the browser allows only ~6 connections per host.
729
+ // Restoring all stored logs at once exhausts the pool and wedges the page
730
+ // (the document/API requests can't get a socket). Extra streams stay listed
731
+ // in the toolbar and can be opened manually.
732
+ const RESTORE_BUDGET = 4;
733
+ const currentEnv = envSel.value;
734
+ let restored = 0;
735
+
736
+ // A card's "logs" action (graph ⋮ menu) navigates here with
737
+ // ?open=repo/service — open and persist that stream FIRST so it's
738
+ // guaranteed enabled even when the restore budget is already full of other
739
+ // stored logs.
740
+ const openParam = new URLSearchParams(location.search).get("open");
741
+ let focus = null;
742
+ const slash = openParam ? openParam.lastIndexOf("/") : -1;
743
+ if (openParam && slash > 0) {
744
+ focus = {
745
+ repo: openParam.slice(0, slash),
746
+ service: openParam.slice(slash + 1),
747
+ };
748
+ addStoredLog(focus.repo, focus.service, currentEnv);
749
+ openLog(focus.repo, focus.service);
750
+ restored++;
751
+ }
752
+
753
+ for (const { repo, service, env } of getStoredLogs()) {
754
+ if (focus && repo === focus.repo && service === focus.service)
755
+ continue;
756
+ if (!(isServerLog(repo, service) || env === currentEnv)) continue;
757
+ if (restored >= RESTORE_BUDGET) break;
758
+ openLog(repo, service);
759
+ restored++;
760
+ }
761
+
762
+ renderBadge(getStoredLogs().length);
763
+ }
764
+
765
+ // Release SSE sockets on reload / navigation / bfcache-enter so the reloaded
766
+ // page can immediately get a connection instead of queuing behind held tails.
767
+ window.addEventListener("pagehide", () => {
768
+ for (const { es } of logStreams.values()) {
769
+ try {
770
+ es.close();
771
+ } catch {}
772
+ }
773
+ });
774
+
775
+ // ── Env ───────────────────────────────────────────────────────────────────
776
+ document.getElementById("env-sel").addEventListener("change", () => {
777
+ const env = document.getElementById("env-sel").value;
778
+ localStorage.setItem("__DEN_CLI__-env", env);
779
+ // Close all open streams (they're env-specific) — except __DEN_CLI__/server
780
+ // which is env-agnostic and should persist across env switches.
781
+ for (const [key] of [...logStreams]) {
782
+ const [repo, service] = key.split("/");
783
+ if (isServerLog(repo, service)) continue;
784
+ closeLog(repo, service, false); // don't remove from localStorage
785
+ }
786
+ refreshHealth(env);
787
+ loadLogToolbar();
788
+ // Re-open streams for the new env; __DEN_CLI__/server already stays open.
789
+ for (const { repo, service, env: storedEnv } of getStoredLogs()) {
790
+ if (isServerLog(repo, service)) continue;
791
+ if (storedEnv === env) openLog(repo, service);
792
+ }
793
+ });
794
+
795
+ // ── Log direction ─────────────────────────────────────────────────────────
796
+ document.getElementById("log-dir-seg").addEventListener("click", (e) => {
797
+ const btn = e.target.closest("[data-dir]");
798
+ if (!btn) return;
799
+ applyLogDir(btn.dataset.dir);
800
+ localStorage.setItem("__DEN_CLI__-log-dir", btn.dataset.dir);
801
+ });
802
+
803
+ function applyLogDir(dir) {
804
+ const area = document.getElementById("logs-area");
805
+ area.className = `logs-area dir-${dir}`;
806
+ document
807
+ .querySelectorAll("#log-dir-seg [data-dir]")
808
+ .forEach((b) => b.classList.toggle("active", b.dataset.dir === dir));
809
+ }
810
+
811
+ // ── Toolbar ───────────────────────────────────────────────────────────────
812
+ async function loadLogToolbar() {
813
+ const env = document.getElementById("env-sel").value;
814
+ const toolbar = document.getElementById("logs-toolbar");
815
+ let services;
816
+ try {
817
+ services = await fetch(`/api/services?env=${env}`).then((r) =>
818
+ r.json(),
819
+ );
820
+ } catch {
821
+ services = [];
822
+ }
823
+
824
+ toolbar.querySelectorAll(".cmd-btn").forEach((b) => b.remove());
825
+
826
+ // Pinned: __DEN_CLI__ server log (env-agnostic pseudo-service).
827
+ const serverKey = "__DEN_CLI__/server";
828
+ const serverBtn = document.createElement("button");
829
+ serverBtn.className = `cmd-btn logs-btn server-log-btn${logStreams.has(serverKey) ? " streaming" : ""}`;
830
+ serverBtn.textContent = `__DEN_CLI__/server${logStreams.has(serverKey) ? " \u25a0" : ""}`;
831
+ serverBtn.title = "__DEN_TITLE__ web-server log (lifecycle + requests)";
832
+ serverBtn.dataset.repo = "__DEN_CLI__";
833
+ serverBtn.dataset.service = "server";
834
+ serverBtn.addEventListener("click", () =>
835
+ toggleLog("__DEN_CLI__", "server"),
836
+ );
837
+ toolbar.appendChild(serverBtn);
838
+
839
+ // Main-system logs only: one button per system (its primary service),
840
+ // even if it declares no logs (the server falls back to a conventional
841
+ // container / daemon-file source). Exceptions:
842
+ // - infra providers (no entrypoint, multiple services \u2014 infra-stack):
843
+ // their subsystems ARE the systems \u2014 list the subsystems, skip the
844
+ // root service (its log is just an aggregate of the sidecars)
845
+ // - a logs entry marked `distinct: true` (e.g. shop/db \u2014 shop runs its
846
+ // OWN postgres, not infra-stack's shared instance)
847
+ const primary = computePrimaryByRepo(services);
848
+ const epRepos = new Set(
849
+ services.filter((s) => s.isEntrypoint).map((s) => s.repo),
850
+ );
851
+ const svcCount = new Map();
852
+ services.forEach((s) =>
853
+ svcCount.set(s.repo, (svcCount.get(s.repo) ?? 0) + 1),
854
+ );
855
+
856
+ const shown = services.filter((s) => {
857
+ if (!epRepos.has(s.repo) && svcCount.get(s.repo) > 1)
858
+ return s.service !== s.repo;
859
+ return (
860
+ primary.get(s.repo) === s.address || (s.logs && s.logs.distinct)
861
+ );
862
+ });
863
+
864
+ // Evict remembered streams that no longer earn toolbar placement for
865
+ // this env — otherwise stale entries (e.g. a subsystem opened before
866
+ // the main-systems-only scoping) silently re-open on every page load.
867
+ // Runs before the restore loop (init awaits this). Entries stored for
868
+ // OTHER envs are judged when their env's toolbar is built; the pinned
869
+ // __DEN_CLI__/server pseudo-service is always kept. Skipped entirely when
870
+ // the services fetch failed/came back empty — a transient API error
871
+ // must not wipe the remembered set.
872
+ if (services.length) {
873
+ const eligible = new Set(shown.map((s) => `${s.repo}/${s.service}`));
874
+ const stored = getStoredLogs();
875
+ const kept = stored.filter(
876
+ (l) =>
877
+ isServerLog(l.repo, l.service) ||
878
+ l.env !== env ||
879
+ eligible.has(`${l.repo}/${l.service}`),
880
+ );
881
+ if (kept.length !== stored.length) setStoredLogs(kept);
882
+ }
883
+
884
+ shown.forEach((s) => {
885
+ const key = `${s.repo}/${s.service}`;
886
+ const btn = document.createElement("button");
887
+ btn.className = `cmd-btn logs-btn${logStreams.has(key) ? " streaming" : ""}`;
888
+ btn.textContent = `${s.repo}/${s.service}${logStreams.has(key) ? " \u25a0" : ""}`;
889
+ btn.dataset.repo = s.repo;
890
+ btn.dataset.service = s.service;
891
+ btn.addEventListener("click", () => toggleLog(s.repo, s.service));
892
+ toolbar.appendChild(btn);
893
+ });
894
+ }
895
+
896
+ // ── Log stream management ─────────────────────────────────────────────────
897
+ function toggleLog(repo, service) {
898
+ if (logStreams.has(`${repo}/${service}`)) {
899
+ closeLog(repo, service, true);
900
+ } else {
901
+ const env = document.getElementById("env-sel").value;
902
+ addStoredLog(repo, service, env);
903
+ openLog(repo, service);
904
+ }
905
+ }
906
+
907
+ function openLog(repo, service) {
908
+ const key = `${repo}/${service}`;
909
+ if (logStreams.has(key)) return;
910
+
911
+ const env = document.getElementById("env-sel").value;
912
+
913
+ const paneEl = document.createElement("div");
914
+ paneEl.className = "log-pane";
915
+
916
+ const bar = document.createElement("div");
917
+ bar.className = "pane-bar";
918
+
919
+ const title = document.createElement("span");
920
+ title.className = "log-pane-title";
921
+ title.textContent = `${repo} / ${service}`;
922
+ bar.appendChild(title);
923
+
924
+ // History pager — page backwards to container startup (__DEN_CLI__/server is
925
+ // an in-memory ring with its own history event; no pager there).
926
+ const histBtn = document.createElement("button");
927
+ histBtn.className = "hist-btn";
928
+ histBtn.textContent = "⟰ earlier";
929
+ histBtn.title = "Load earlier log history (back to container startup)";
930
+ if (!isServerLog(repo, service)) bar.appendChild(histBtn);
931
+
932
+ // Filter input
933
+ const filterWrap = document.createElement("div");
934
+ filterWrap.className = "log-filter-wrap";
935
+
936
+ const filterInput = document.createElement("input");
937
+ filterInput.type = "text";
938
+ filterInput.className = "log-filter";
939
+ filterInput.placeholder = "filter…";
940
+ filterInput.setAttribute("spellcheck", "false");
941
+
942
+ const filterCount = document.createElement("span");
943
+ filterCount.className = "log-filter-count";
944
+
945
+ const filterClear = document.createElement("button");
946
+ filterClear.className = "log-filter-clear";
947
+ filterClear.textContent = "×";
948
+ filterClear.title = "Clear filter";
949
+ filterClear.style.display = "none";
950
+
951
+ filterWrap.appendChild(filterInput);
952
+ filterWrap.appendChild(filterCount);
953
+ filterWrap.appendChild(filterClear);
954
+ bar.appendChild(filterWrap);
955
+
956
+ const closeBtn = document.createElement("button");
957
+ closeBtn.textContent = "\u2715 Close";
958
+ closeBtn.addEventListener("click", () => closeLog(repo, service, true));
959
+ bar.appendChild(closeBtn);
960
+
961
+ const outputEl = document.createElement("pre");
962
+ outputEl.className = "log-output";
963
+ paneEl.appendChild(bar);
964
+ paneEl.appendChild(outputEl);
965
+
966
+ // rAF-batched, line-capped append. Window size is user-configurable:
967
+ // `__DEN_CLI__ config log_window_lines=50000` (default 20000).
968
+ let pending = [],
969
+ rafId = null;
970
+ let filterTerm = "";
971
+
972
+ function makeLineNode(text, cls, ts) {
973
+ const span = document.createElement("span");
974
+ if (cls) span.className = cls;
975
+ span.dataset.raw = text;
976
+ if (ts) {
977
+ span.dataset.ts = ts;
978
+ span.title = ts;
979
+ }
980
+ if (filterTerm && text.toLowerCase().includes(filterTerm)) {
981
+ span.classList.add("log-line-match");
982
+ span.innerHTML = markSafe(text, filterTerm) + "\n";
983
+ } else if (filterTerm) {
984
+ span.classList.add("log-line-hidden");
985
+ span.textContent = text + "\n";
986
+ } else {
987
+ span.textContent = text + "\n";
988
+ }
989
+ return span;
990
+ }
991
+
992
+ // "Tailing" = user is at (or near) the bottom. Only then do we trim old
993
+ // lines and auto-scroll — never while they're reading loaded history.
994
+ const nearBottom = () =>
995
+ outputEl.scrollTop + outputEl.clientHeight >=
996
+ outputEl.scrollHeight - 60;
997
+
998
+ let droppedNotice = false;
999
+ function flush() {
1000
+ rafId = null;
1001
+ if (!pending.length) return;
1002
+ const tailing = nearBottom();
1003
+ const items = pending.splice(0);
1004
+ // Reading history during a live flood: don't grow the DOM without
1005
+ // bound and don't evict what the user is reading — drop the incoming
1006
+ // lines from VIEW (they remain in the source; scroll to bottom and
1007
+ // they resume, or reload the stream for the tail).
1008
+ if (
1009
+ !tailing &&
1010
+ outputEl.childNodes.length + items.length > LOG_WINDOW * 2
1011
+ ) {
1012
+ if (!droppedNotice) {
1013
+ droppedNotice = true;
1014
+ outputEl.appendChild(
1015
+ makeLineNode(
1016
+ "── live lines paused in this view (window full while browsing history) ──",
1017
+ "log-done",
1018
+ ),
1019
+ );
1020
+ }
1021
+ return;
1022
+ }
1023
+ const frag = document.createDocumentFragment();
1024
+ for (const { text, cls } of items)
1025
+ frag.appendChild(makeLineNode(text, cls));
1026
+ outputEl.appendChild(frag);
1027
+ if (tailing) {
1028
+ droppedNotice = false;
1029
+ while (outputEl.childNodes.length > LOG_WINDOW)
1030
+ outputEl.removeChild(outputEl.firstChild);
1031
+ }
1032
+ // Auto-scroll only while tailing and unfiltered (filter or history
1033
+ // browsing implies "looking for something").
1034
+ if (!filterTerm && tailing)
1035
+ outputEl.scrollTop = outputEl.scrollHeight;
1036
+ // Update match count
1037
+ if (filterTerm) {
1038
+ const matched = outputEl.querySelectorAll(".log-line-match").length;
1039
+ const total = outputEl.childNodes.length;
1040
+ filterCount.textContent = `${matched}/${total}`;
1041
+ }
1042
+ }
1043
+
1044
+ function append(text, cls) {
1045
+ pending.push({ text, cls });
1046
+ if (!rafId) rafId = requestAnimationFrame(flush);
1047
+ }
1048
+
1049
+ // ── History paging (backwards, opaque cursor) ─────────────────────────
1050
+ const HIST_PAGE = Math.max(
1051
+ 500,
1052
+ Math.min(2000, Math.floor(LOG_WINDOW / 4)),
1053
+ );
1054
+ const TS_RE = /^(\d{4}-\d{2}-\d{2}T\S+Z)\s?/;
1055
+ let histCursor = null; // opaque server cursor; null = start from the end
1056
+ let histDone = false; // container start reached, unsupported, or window full
1057
+ let histLoading = false;
1058
+ let histFirstLoad = true;
1059
+
1060
+ function prependNotice(text, cls) {
1061
+ const span = makeLineNode(text, cls);
1062
+ outputEl.insertBefore(span, outputEl.firstChild);
1063
+ }
1064
+
1065
+ // Drop returned lines that overlap the live tail's initial backlog
1066
+ // (both read the same source, so the seam is an exact suffix/prefix match).
1067
+ function dedupeSeam(entries) {
1068
+ const displayed = [...outputEl.childNodes]
1069
+ .slice(0, 600)
1070
+ .map((n) => n.dataset?.raw ?? "");
1071
+ for (let k = Math.min(entries.length, displayed.length); k > 0; k--) {
1072
+ let match = true;
1073
+ for (let j = 0; j < k; j++) {
1074
+ if (entries[entries.length - k + j].text !== displayed[j]) {
1075
+ match = false;
1076
+ break;
1077
+ }
1078
+ }
1079
+ if (match) return entries.slice(0, entries.length - k);
1080
+ }
1081
+ return entries;
1082
+ }
1083
+
1084
+ async function loadEarlier() {
1085
+ if (histLoading || histDone || isServerLog(repo, service)) return;
1086
+ histLoading = true;
1087
+ histBtn.textContent = "… loading";
1088
+ try {
1089
+ const params = new URLSearchParams({
1090
+ repo,
1091
+ service,
1092
+ env,
1093
+ limit: String(HIST_PAGE),
1094
+ });
1095
+ if (histCursor) params.set("cursor", histCursor);
1096
+ const data = await fetch(`/api/service/history?${params}`).then(
1097
+ (r) => r.json(),
1098
+ );
1099
+ if (data.unsupported) {
1100
+ histDone = true;
1101
+ histBtn.style.display = "none";
1102
+ prependNotice(
1103
+ `── history unavailable: ${data.reason} ──`,
1104
+ "log-done",
1105
+ );
1106
+ return;
1107
+ }
1108
+ histCursor = data.cursor;
1109
+ let entries = data.lines.map((raw) => {
1110
+ const m = raw.match(TS_RE);
1111
+ return m
1112
+ ? { ts: m[1], text: raw.slice(m[0].length) }
1113
+ : { ts: null, text: raw };
1114
+ });
1115
+ if (histFirstLoad) {
1116
+ entries = dedupeSeam(entries);
1117
+ histFirstLoad = false;
1118
+ }
1119
+
1120
+ const frag = document.createDocumentFragment();
1121
+ for (const { ts, text } of entries)
1122
+ frag.appendChild(makeLineNode(text, "", ts));
1123
+ const prevHeight = outputEl.scrollHeight;
1124
+ outputEl.insertBefore(frag, outputEl.firstChild);
1125
+ if (data.atStart) {
1126
+ histDone = true;
1127
+ histBtn.style.display = "none";
1128
+ prependNotice("── container start ──", "log-cmd");
1129
+ } else if (outputEl.childNodes.length >= LOG_WINDOW) {
1130
+ histDone = true;
1131
+ histBtn.style.display = "none";
1132
+ prependNotice(
1133
+ `── history window full (${outputEl.childNodes.length.toLocaleString()} lines) — raise log_window_lines or use search ──`,
1134
+ "log-done",
1135
+ );
1136
+ }
1137
+ // Keep the viewport anchored on the line the user was looking at.
1138
+ outputEl.scrollTop += outputEl.scrollHeight - prevHeight;
1139
+ if (filterTerm) applyFilter();
1140
+ } catch (e) {
1141
+ prependNotice(
1142
+ `── history fetch failed: ${e.message} ──`,
1143
+ "log-err",
1144
+ );
1145
+ } finally {
1146
+ histLoading = false;
1147
+ histBtn.textContent = "⟰ earlier";
1148
+ }
1149
+ }
1150
+ histBtn.addEventListener("click", loadEarlier);
1151
+ // Scrolling to the very top also pages further back.
1152
+ outputEl.addEventListener("scroll", () => {
1153
+ if (
1154
+ outputEl.scrollTop === 0 &&
1155
+ !histDone &&
1156
+ !histLoading &&
1157
+ !histFirstLoad
1158
+ )
1159
+ loadEarlier();
1160
+ });
1161
+
1162
+ // ── Filter logic ──────────────────────────────────────────────────────
1163
+ function applyFilter() {
1164
+ filterTerm = filterInput.value.trim().toLowerCase();
1165
+ filterClear.style.display = filterTerm ? "" : "none";
1166
+ let matched = 0,
1167
+ total = 0;
1168
+ for (const node of outputEl.childNodes) {
1169
+ if (!(node instanceof HTMLElement)) continue;
1170
+ total++;
1171
+ if (!filterTerm) {
1172
+ node.classList.remove("log-line-hidden", "log-line-match");
1173
+ node.innerHTML = "";
1174
+ node.textContent = (node.dataset.raw ?? "") + "\n";
1175
+ } else {
1176
+ const raw = node.dataset.raw ?? node.textContent ?? "";
1177
+ if (raw.toLowerCase().includes(filterTerm)) {
1178
+ node.classList.remove("log-line-hidden");
1179
+ node.classList.add("log-line-match");
1180
+ node.innerHTML = markSafe(raw, filterTerm) + "\n";
1181
+ matched++;
1182
+ } else {
1183
+ node.classList.add("log-line-hidden");
1184
+ node.classList.remove("log-line-match");
1185
+ }
1186
+ }
1187
+ }
1188
+ filterCount.textContent = filterTerm ? `${matched}/${total}` : "";
1189
+ }
1190
+
1191
+ // Debounced — applyFilter walks every loaded node, and the window can
1192
+ // hold tens of thousands of history lines.
1193
+ let filterDebounce = null;
1194
+ filterInput.addEventListener("input", () => {
1195
+ if (filterDebounce) clearTimeout(filterDebounce);
1196
+ filterDebounce = setTimeout(applyFilter, 150);
1197
+ });
1198
+ filterClear.addEventListener("click", () => {
1199
+ filterInput.value = "";
1200
+ applyFilter();
1201
+ filterInput.focus();
1202
+ });
1203
+ // ─────────────────────────────────────────────────────────────────────
1204
+
1205
+ const serverLog = isServerLog(repo, service);
1206
+ // Local streams go through taillog — the same endpoint the workspace
1207
+ // page's tabs use. It has the full source-resolution chain (manifest
1208
+ // cmd → conventional opctl container → daemon startup log), whereas
1209
+ // /api/service/logs 404s for services with no logs block (shop-web,
1210
+ // control-plane). Non-local envs keep /api/service/logs for
1211
+ // cloudwatch sources + the AWS reauth flow.
1212
+ const es = serverLog
1213
+ ? new EventSource("/api/server-log")
1214
+ : env === "local"
1215
+ ? new EventSource(
1216
+ `/api/service/taillog?${new URLSearchParams({ repo, service })}`,
1217
+ )
1218
+ : new EventSource(
1219
+ `/api/service/logs?${new URLSearchParams({ repo, service, env })}`,
1220
+ );
1221
+
1222
+ es.onmessage = (e) => {
1223
+ const msg = JSON.parse(e.data);
1224
+ if (serverLog) {
1225
+ if (msg.type === "history" && Array.isArray(msg.entries)) {
1226
+ for (const entry of msg.entries) {
1227
+ append(formatServerLogEntry(entry), serverLogClass(entry.kind));
1228
+ }
1229
+ } else if (msg.type === "log" && msg.entry) {
1230
+ append(
1231
+ formatServerLogEntry(msg.entry),
1232
+ serverLogClass(msg.entry.kind),
1233
+ );
1234
+ }
1235
+ return;
1236
+ }
1237
+ if (msg.type === "cmd") append(msg.text, "log-cmd");
1238
+ else if (msg.type === "line") append(msg.text, "");
1239
+ else if (msg.type === "error") append(msg.text, "log-err");
1240
+ else if (msg.type === "done") {
1241
+ es.close();
1242
+ if (msg.authRequired) {
1243
+ showAwsReauthPrompt(paneEl, outputEl, repo, service, env);
1244
+ } else {
1245
+ append("--- stream ended ---", "log-done");
1246
+ }
1247
+ }
1248
+ };
1249
+ es.onerror = () => {
1250
+ append("--- connection closed ---", "log-done");
1251
+ es.close();
1252
+ };
1253
+
1254
+ logStreams.set(key, { es, paneEl, outputEl });
1255
+ updateToolbarButton(repo, service, true);
1256
+ renderLogPanes();
1257
+ }
1258
+
1259
+ function closeLog(repo, service, persist = true) {
1260
+ const key = `${repo}/${service}`;
1261
+ const stream = logStreams.get(key);
1262
+ if (!stream) return;
1263
+ stream.es.close();
1264
+ logStreams.delete(key);
1265
+ if (persist) removeStoredLog(repo, service);
1266
+ updateToolbarButton(repo, service, false);
1267
+ renderLogPanes();
1268
+ }
1269
+
1270
+ function renderLogPanes() {
1271
+ const area = document.getElementById("logs-area");
1272
+ const dirClass =
1273
+ [...area.classList].find((c) => c.startsWith("dir-")) || "dir-cols";
1274
+ area.innerHTML = "";
1275
+ area.className = `logs-area ${dirClass}`;
1276
+
1277
+ if (logStreams.size === 0) {
1278
+ const empty = document.createElement("div");
1279
+ empty.className = "logs-empty";
1280
+ empty.textContent =
1281
+ "No active log streams. Click a service name above to open one.";
1282
+ area.appendChild(empty);
1283
+ return;
1284
+ }
1285
+
1286
+ for (const { paneEl, outputEl } of logStreams.values()) {
1287
+ area.appendChild(paneEl);
1288
+ outputEl.scrollTop = outputEl.scrollHeight;
1289
+ }
1290
+ }
1291
+
1292
+ function renderBadge(count) {
1293
+ const badge = document.getElementById("log-badge");
1294
+ if (!badge) return;
1295
+ badge.textContent = count;
1296
+ badge.classList.toggle("visible", count > 0);
1297
+ }
1298
+
1299
+ function updateToolbarButton(repo, service, streaming) {
1300
+ const key = `${repo}/${service}`;
1301
+ document
1302
+ .querySelectorAll(
1303
+ `.logs-btn[data-repo="${repo}"][data-service="${service}"]`,
1304
+ )
1305
+ .forEach((btn) => {
1306
+ btn.classList.toggle("streaming", streaming);
1307
+ btn.textContent = `${key}${streaming ? " \u25a0" : ""}`;
1308
+ });
1309
+ }
1310
+
1311
+ // ── Cross-system history search ───────────────────────────────────────────
1312
+ //
1313
+ // Greps the FULL history (back to container startup) of every system the
1314
+ // toolbar lists, server-side, and renders grouped results in a pane.
1315
+ // The results pane lives in logStreams under a pseudo key (stub `es`) so
1316
+ // rendering/closing reuse the normal pane machinery; it is never persisted
1317
+ // (addStoredLog only runs through toggleLog).
1318
+ const SEARCH_KEY = "search/results";
1319
+
1320
+ async function runHistorySearch() {
1321
+ const input = document.getElementById("hist-search");
1322
+ const btn = document.getElementById("hist-search-btn");
1323
+ const q = input.value.trim();
1324
+ if (q.length < 2) {
1325
+ input.focus();
1326
+ return;
1327
+ }
1328
+ const env = document.getElementById("env-sel").value;
1329
+ const targets = [
1330
+ ...document.querySelectorAll("#logs-toolbar .logs-btn"),
1331
+ ]
1332
+ .filter((b) => !b.classList.contains("server-log-btn"))
1333
+ .map((b) => `${b.dataset.repo}/${b.dataset.service}`);
1334
+ if (targets.length === 0) return;
1335
+
1336
+ // (Re)create the results pane.
1337
+ let pane = logStreams.get(SEARCH_KEY);
1338
+ if (!pane) {
1339
+ const paneEl = document.createElement("div");
1340
+ paneEl.className = "log-pane";
1341
+ const bar = document.createElement("div");
1342
+ bar.className = "pane-bar";
1343
+ const title = document.createElement("span");
1344
+ title.className = "log-pane-title";
1345
+ bar.appendChild(title);
1346
+ const closeBtn = document.createElement("button");
1347
+ closeBtn.textContent = "✕ Close";
1348
+ closeBtn.addEventListener("click", () =>
1349
+ closeLog("search", "results", false),
1350
+ );
1351
+ bar.appendChild(closeBtn);
1352
+ const outputEl = document.createElement("pre");
1353
+ outputEl.className = "log-output";
1354
+ paneEl.appendChild(bar);
1355
+ paneEl.appendChild(outputEl);
1356
+ pane = { es: { close() {} }, paneEl, outputEl };
1357
+ logStreams.set(SEARCH_KEY, pane);
1358
+ }
1359
+ pane.paneEl.querySelector(".log-pane-title").textContent =
1360
+ `search: "${q}"`;
1361
+ pane.outputEl.textContent = "";
1362
+ renderLogPanes();
1363
+
1364
+ const line = (text, cls, onClick, highlight) => {
1365
+ const span = document.createElement("span");
1366
+ if (cls) span.className = cls;
1367
+ if (highlight) {
1368
+ span.innerHTML = markSafe(text, q) + "\n"; // escape-then-mark, shared helper
1369
+ } else {
1370
+ span.textContent = text + "\n";
1371
+ }
1372
+ if (onClick) {
1373
+ span.classList.add("search-hd");
1374
+ span.addEventListener("click", onClick);
1375
+ }
1376
+ pane.outputEl.appendChild(span);
1377
+ };
1378
+
1379
+ btn.textContent = "…";
1380
+ btn.disabled = true;
1381
+ try {
1382
+ const params = new URLSearchParams({
1383
+ q,
1384
+ env,
1385
+ targets: targets.join(","),
1386
+ });
1387
+ const data = await fetch(`/api/logs/search?${params}`).then((r) => {
1388
+ if (!r.ok) throw new Error(`search failed (${r.status})`);
1389
+ return r.json();
1390
+ });
1391
+ let total = 0;
1392
+ for (const result of data.results) {
1393
+ if (result.error) {
1394
+ line(`── ${result.system}: ${result.error} ──`, "log-done");
1395
+ continue;
1396
+ }
1397
+ if (result.matches.length === 0) continue;
1398
+ total += result.matches.length;
1399
+ const [sysRepo, sysService] = result.system.split("/");
1400
+ line(
1401
+ `── ${result.system} — ${result.matches.length}${result.truncated ? "+" : ""} match${result.matches.length === 1 ? "" : "es"} (click to open stream) ──`,
1402
+ "log-cmd",
1403
+ () => toggleLog(sysRepo, sysService),
1404
+ );
1405
+ for (const m of result.matches) {
1406
+ line(
1407
+ `${m.ts ? m.ts + " " : ""}${m.line}`,
1408
+ "log-line-match search-hit",
1409
+ null,
1410
+ true,
1411
+ );
1412
+ for (const c of m.context) line(` ${c}`, "log-done");
1413
+ }
1414
+ line("", "");
1415
+ }
1416
+ if (total === 0)
1417
+ line(`no matches for "${q}" in any system's history`, "log-done");
1418
+ pane.outputEl.scrollTop = 0;
1419
+ } catch (e) {
1420
+ line(`── ${e.message} ──`, "log-err");
1421
+ } finally {
1422
+ btn.textContent = "search";
1423
+ btn.disabled = false;
1424
+ }
1425
+ }
1426
+
1427
+ document
1428
+ .getElementById("hist-search-btn")
1429
+ .addEventListener("click", runHistorySearch);
1430
+ document
1431
+ .getElementById("hist-search")
1432
+ .addEventListener("keydown", (e) => {
1433
+ if (e.key === "Enter") runHistorySearch();
1434
+ });
1435
+
1436
+ function showAwsReauthPrompt(paneEl, outputEl, repo, service, env) {
1437
+ paneEl.querySelector(".aws-auth-banner")?.remove();
1438
+ const banner = document.createElement("div");
1439
+ banner.className = "aws-auth-banner";
1440
+ banner.innerHTML = `
1441
+ <span class="aws-auth-msg">⚠ AWS session expired</span>
1442
+ <button class="aws-auth-btn">Re-authenticate</button>
1443
+ <pre class="aws-auth-output" style="display:none"></pre>`;
1444
+ paneEl.appendChild(banner);
1445
+
1446
+ const btn = banner.querySelector(".aws-auth-btn");
1447
+ const authOut = banner.querySelector(".aws-auth-output");
1448
+ let loginEs = null;
1449
+
1450
+ btn.addEventListener("click", () => {
1451
+ btn.disabled = true;
1452
+ btn.textContent = "Authenticating…";
1453
+ authOut.style.display = "block";
1454
+ authOut.textContent = "";
1455
+ loginEs = new EventSource(
1456
+ `/api/aws/login?env=${encodeURIComponent(env)}`,
1457
+ );
1458
+ loginEs.onmessage = (ev) => {
1459
+ const msg = JSON.parse(ev.data);
1460
+ if (msg.type === "line" || msg.type === "cmd") {
1461
+ authOut.textContent += msg.text + "\n";
1462
+ authOut.scrollTop = authOut.scrollHeight;
1463
+ } else if (msg.type === "done") {
1464
+ loginEs.close();
1465
+ if (msg.exitCode === 0) {
1466
+ banner.remove();
1467
+ const key = `${repo}/${service}`;
1468
+ logStreams.delete(key);
1469
+ openLog(repo, service);
1470
+ } else {
1471
+ btn.disabled = false;
1472
+ btn.textContent = "Retry";
1473
+ authOut.textContent += "--- login failed ---\n";
1474
+ }
1475
+ }
1476
+ };
1477
+ loginEs.onerror = () => {
1478
+ loginEs.close();
1479
+ btn.disabled = false;
1480
+ btn.textContent = "Retry";
1481
+ authOut.textContent += "--- connection error ---\n";
1482
+ };
1483
+ });
1484
+ }
1485
+
1486
+ init();
1487
+ </script>
1488
+ </body>
1489
+ </html>