@monster-orange/den 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +61 -0
- package/docs/distributions.md +256 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +99 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +406 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +566 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +178 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/workspace-routes.ts +2154 -0
|
@@ -0,0 +1,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
|
+
⎮ cols
|
|
571
|
+
</button>
|
|
572
|
+
<button data-dir="rows" title="Stacked">⎯ 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, "&")
|
|
619
|
+
.replace(/</g, "<")
|
|
620
|
+
.replace(/>/g, ">");
|
|
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>
|