@monster-orange/den 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +166 -2
- 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 +104 -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,1098 @@
|
|
|
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__ · Services</title>
|
|
7
|
+
<style>
|
|
8
|
+
*,
|
|
9
|
+
*::before,
|
|
10
|
+
*::after {
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
margin: 0;
|
|
13
|
+
padding: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
:root {
|
|
17
|
+
--bg: #0d1117;
|
|
18
|
+
--surface: #161b22;
|
|
19
|
+
--border: #30363d;
|
|
20
|
+
--text: #c9d1d9;
|
|
21
|
+
--muted: #8b949e;
|
|
22
|
+
--accent: #58a6ff;
|
|
23
|
+
--green: #3fb950;
|
|
24
|
+
--red: #f85149;
|
|
25
|
+
--yellow: #d29922;
|
|
26
|
+
--mono: "SF Mono", "Cascadia Code", "Fira Code", "Menlo", monospace;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
html,
|
|
30
|
+
body {
|
|
31
|
+
height: 100%;
|
|
32
|
+
background: var(--bg);
|
|
33
|
+
color: var(--text);
|
|
34
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
35
|
+
font-size: 13px;
|
|
36
|
+
}
|
|
37
|
+
|
|
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
|
+
|
|
64
|
+
/* ── Health indicators ────────────────────────────────────────────────── */
|
|
65
|
+
.health {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 10px;
|
|
69
|
+
padding: 0 4px;
|
|
70
|
+
}
|
|
71
|
+
.health-item {
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 5px;
|
|
75
|
+
font-size: 11px;
|
|
76
|
+
color: var(--muted);
|
|
77
|
+
}
|
|
78
|
+
.health-group {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
gap: 4px;
|
|
82
|
+
font-size: 11px;
|
|
83
|
+
color: var(--muted);
|
|
84
|
+
}
|
|
85
|
+
.health-group-label {
|
|
86
|
+
color: var(--muted);
|
|
87
|
+
margin-right: 1px;
|
|
88
|
+
}
|
|
89
|
+
.health-group-dots {
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
gap: 3px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ── Nav ──────────────────────────────────────────────────────────────── */
|
|
96
|
+
nav {
|
|
97
|
+
display: flex;
|
|
98
|
+
gap: 2px;
|
|
99
|
+
}
|
|
100
|
+
nav a {
|
|
101
|
+
color: var(--muted);
|
|
102
|
+
text-decoration: none;
|
|
103
|
+
padding: 3px 10px;
|
|
104
|
+
border-radius: 4px;
|
|
105
|
+
font-size: 12px;
|
|
106
|
+
font-weight: 500;
|
|
107
|
+
transition: color 0.1s;
|
|
108
|
+
}
|
|
109
|
+
nav a:hover {
|
|
110
|
+
color: var(--text);
|
|
111
|
+
background: var(--border);
|
|
112
|
+
}
|
|
113
|
+
nav a.active {
|
|
114
|
+
color: var(--text);
|
|
115
|
+
background: var(--surface);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ── Shared: dot ──────────────────────────────────────────────────────── */
|
|
119
|
+
.dot {
|
|
120
|
+
width: 7px;
|
|
121
|
+
height: 7px;
|
|
122
|
+
border-radius: 50%;
|
|
123
|
+
background: var(--muted);
|
|
124
|
+
flex-shrink: 0;
|
|
125
|
+
display: inline-block;
|
|
126
|
+
}
|
|
127
|
+
.dot.ok {
|
|
128
|
+
background: var(--green);
|
|
129
|
+
}
|
|
130
|
+
.dot.err {
|
|
131
|
+
background: var(--red);
|
|
132
|
+
}
|
|
133
|
+
.dot.running {
|
|
134
|
+
background: var(--yellow);
|
|
135
|
+
animation: pulse 1s ease-in-out infinite;
|
|
136
|
+
}
|
|
137
|
+
@keyframes pulse {
|
|
138
|
+
0%,
|
|
139
|
+
100% {
|
|
140
|
+
opacity: 1;
|
|
141
|
+
}
|
|
142
|
+
50% {
|
|
143
|
+
opacity: 0.35;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ── Controls bar ─────────────────────────────────────────────────────── */
|
|
148
|
+
.controls {
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
gap: 8px;
|
|
152
|
+
padding: 6px 14px;
|
|
153
|
+
background: var(--surface);
|
|
154
|
+
border-bottom: 1px solid var(--border);
|
|
155
|
+
flex-shrink: 0;
|
|
156
|
+
}
|
|
157
|
+
select {
|
|
158
|
+
background: var(--bg);
|
|
159
|
+
border: 1px solid var(--border);
|
|
160
|
+
color: var(--text);
|
|
161
|
+
padding: 4px 8px;
|
|
162
|
+
border-radius: 6px;
|
|
163
|
+
font-size: 12px;
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
outline: none;
|
|
166
|
+
}
|
|
167
|
+
select:focus {
|
|
168
|
+
border-color: var(--accent);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ── Main ─────────────────────────────────────────────────────────────── */
|
|
172
|
+
main {
|
|
173
|
+
flex: 1;
|
|
174
|
+
display: flex;
|
|
175
|
+
flex-direction: column;
|
|
176
|
+
overflow: hidden;
|
|
177
|
+
min-height: 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ── Service cards ────────────────────────────────────────────────────── */
|
|
181
|
+
.services-pane {
|
|
182
|
+
padding: 12px 14px;
|
|
183
|
+
flex-shrink: 0;
|
|
184
|
+
display: flex;
|
|
185
|
+
flex-direction: column;
|
|
186
|
+
gap: 14px;
|
|
187
|
+
}
|
|
188
|
+
/* One section per application (Org > Application > Repo > Services). */
|
|
189
|
+
.app-section-head {
|
|
190
|
+
display: flex;
|
|
191
|
+
align-items: baseline;
|
|
192
|
+
gap: 8px;
|
|
193
|
+
flex-wrap: wrap;
|
|
194
|
+
margin-bottom: 8px;
|
|
195
|
+
padding-bottom: 4px;
|
|
196
|
+
border-bottom: 1px solid var(--border);
|
|
197
|
+
}
|
|
198
|
+
.app-section-title {
|
|
199
|
+
font-size: 13px;
|
|
200
|
+
font-weight: 600;
|
|
201
|
+
color: var(--text);
|
|
202
|
+
}
|
|
203
|
+
.app-section-owner {
|
|
204
|
+
font-size: 11px;
|
|
205
|
+
color: var(--text);
|
|
206
|
+
border: 1px solid var(--border);
|
|
207
|
+
border-radius: 10px;
|
|
208
|
+
padding: 0 8px;
|
|
209
|
+
}
|
|
210
|
+
.app-section-desc {
|
|
211
|
+
font-size: 11px;
|
|
212
|
+
color: var(--muted);
|
|
213
|
+
}
|
|
214
|
+
.app-cards {
|
|
215
|
+
display: flex;
|
|
216
|
+
flex-wrap: wrap;
|
|
217
|
+
gap: 10px;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* Systems and sub-services share one shape — solid vs dashed with a
|
|
221
|
+
coloured left rule — so the grid reads as one hierarchy rather than two
|
|
222
|
+
unrelated card styles. Systems take the app's accent blue; sub-services
|
|
223
|
+
are muted and dashed, so a scan lands on systems first. */
|
|
224
|
+
.service-card {
|
|
225
|
+
background: var(--surface);
|
|
226
|
+
border: 1px solid var(--border);
|
|
227
|
+
border-left: 3px solid var(--accent);
|
|
228
|
+
border-radius: 8px;
|
|
229
|
+
padding: 12px 14px;
|
|
230
|
+
min-width: 220px;
|
|
231
|
+
}
|
|
232
|
+
.service-card.subservice {
|
|
233
|
+
border-style: dashed;
|
|
234
|
+
border-color: #3d444d;
|
|
235
|
+
/* Left rule stays solid on both — a 3px dashed vertical rule renders as
|
|
236
|
+
scattered dots. The dashed outer border carries the distinction. */
|
|
237
|
+
border-left: 3px solid var(--muted);
|
|
238
|
+
}
|
|
239
|
+
.service-card.subservice .service-name {
|
|
240
|
+
color: var(--muted);
|
|
241
|
+
}
|
|
242
|
+
.service-card-header {
|
|
243
|
+
display: flex;
|
|
244
|
+
align-items: center;
|
|
245
|
+
gap: 8px;
|
|
246
|
+
margin-bottom: 10px;
|
|
247
|
+
}
|
|
248
|
+
.service-card-header .dot {
|
|
249
|
+
width: 8px;
|
|
250
|
+
height: 8px;
|
|
251
|
+
}
|
|
252
|
+
.service-name {
|
|
253
|
+
font-size: 12px;
|
|
254
|
+
font-weight: 600;
|
|
255
|
+
color: var(--text);
|
|
256
|
+
}
|
|
257
|
+
.service-repo {
|
|
258
|
+
font-size: 11px;
|
|
259
|
+
color: var(--muted);
|
|
260
|
+
}
|
|
261
|
+
/* Site link: the service's URL for the selected env, from the health
|
|
262
|
+
broadcast (openUrl). Hidden until health supplies one. */
|
|
263
|
+
.site-link {
|
|
264
|
+
display: inline-flex;
|
|
265
|
+
align-items: center;
|
|
266
|
+
gap: 4px;
|
|
267
|
+
margin-top: 3px;
|
|
268
|
+
font-size: 11px;
|
|
269
|
+
font-family: var(--mono);
|
|
270
|
+
color: var(--accent);
|
|
271
|
+
text-decoration: none;
|
|
272
|
+
word-break: break-all;
|
|
273
|
+
}
|
|
274
|
+
.site-link:hover,
|
|
275
|
+
.site-link:focus-visible {
|
|
276
|
+
text-decoration: underline;
|
|
277
|
+
}
|
|
278
|
+
.site-link[hidden] {
|
|
279
|
+
display: none;
|
|
280
|
+
}
|
|
281
|
+
.service-meta {
|
|
282
|
+
display: flex;
|
|
283
|
+
gap: 6px;
|
|
284
|
+
margin-bottom: 8px;
|
|
285
|
+
flex-wrap: wrap;
|
|
286
|
+
}
|
|
287
|
+
.badge {
|
|
288
|
+
font-size: 10px;
|
|
289
|
+
padding: 1px 6px;
|
|
290
|
+
border-radius: 10px;
|
|
291
|
+
border: 1px solid var(--border);
|
|
292
|
+
color: var(--muted);
|
|
293
|
+
}
|
|
294
|
+
.badge.dev-managed {
|
|
295
|
+
border-color: #388bfd44;
|
|
296
|
+
color: #58a6ff;
|
|
297
|
+
}
|
|
298
|
+
.badge.managed {
|
|
299
|
+
border-color: #a371f744;
|
|
300
|
+
color: #a371f7;
|
|
301
|
+
}
|
|
302
|
+
.badge.always-on {
|
|
303
|
+
border-color: #3fb95044;
|
|
304
|
+
color: #3fb950;
|
|
305
|
+
}
|
|
306
|
+
.badge.branch {
|
|
307
|
+
border-color: var(--border);
|
|
308
|
+
font-family: var(--mono);
|
|
309
|
+
}
|
|
310
|
+
.cmd-row {
|
|
311
|
+
display: flex;
|
|
312
|
+
flex-wrap: wrap;
|
|
313
|
+
gap: 6px;
|
|
314
|
+
}
|
|
315
|
+
.cmd-btn {
|
|
316
|
+
background: var(--bg);
|
|
317
|
+
border: 1px solid var(--border);
|
|
318
|
+
color: var(--text);
|
|
319
|
+
padding: 4px 10px;
|
|
320
|
+
border-radius: 5px;
|
|
321
|
+
font-size: 11px;
|
|
322
|
+
font-weight: 500;
|
|
323
|
+
cursor: pointer;
|
|
324
|
+
transition:
|
|
325
|
+
border-color 0.1s,
|
|
326
|
+
color 0.1s;
|
|
327
|
+
}
|
|
328
|
+
.cmd-btn:hover:not(:disabled) {
|
|
329
|
+
border-color: var(--accent);
|
|
330
|
+
color: var(--accent);
|
|
331
|
+
}
|
|
332
|
+
.cmd-btn:disabled {
|
|
333
|
+
opacity: 0.45;
|
|
334
|
+
cursor: not-allowed;
|
|
335
|
+
}
|
|
336
|
+
.cmd-btn.logs-btn {
|
|
337
|
+
border-color: var(--muted);
|
|
338
|
+
}
|
|
339
|
+
.cmd-btn.logs-btn:hover:not(:disabled) {
|
|
340
|
+
border-color: var(--yellow);
|
|
341
|
+
color: var(--yellow);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.empty-state {
|
|
345
|
+
padding: 40px 14px;
|
|
346
|
+
color: var(--muted);
|
|
347
|
+
font-size: 12px;
|
|
348
|
+
text-align: center;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* ── Pane bar ─────────────────────────────────────────────────────────── */
|
|
352
|
+
.pane-bar {
|
|
353
|
+
display: flex;
|
|
354
|
+
align-items: center;
|
|
355
|
+
justify-content: space-between;
|
|
356
|
+
padding: 3px 12px;
|
|
357
|
+
background: var(--surface);
|
|
358
|
+
border-bottom: 1px solid var(--border);
|
|
359
|
+
flex-shrink: 0;
|
|
360
|
+
}
|
|
361
|
+
.pane-bar span {
|
|
362
|
+
font-size: 10px;
|
|
363
|
+
font-weight: 600;
|
|
364
|
+
text-transform: uppercase;
|
|
365
|
+
letter-spacing: 0.07em;
|
|
366
|
+
color: var(--muted);
|
|
367
|
+
}
|
|
368
|
+
.pane-bar button {
|
|
369
|
+
background: none;
|
|
370
|
+
border: none;
|
|
371
|
+
color: var(--muted);
|
|
372
|
+
font-size: 11px;
|
|
373
|
+
cursor: pointer;
|
|
374
|
+
padding: 2px 6px;
|
|
375
|
+
border-radius: 3px;
|
|
376
|
+
}
|
|
377
|
+
.pane-bar button:hover {
|
|
378
|
+
color: var(--text);
|
|
379
|
+
background: var(--border);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* ── Output pane ──────────────────────────────────────────────────────── */
|
|
383
|
+
.output-pane {
|
|
384
|
+
flex: 1;
|
|
385
|
+
display: flex;
|
|
386
|
+
flex-direction: column;
|
|
387
|
+
min-height: 0;
|
|
388
|
+
}
|
|
389
|
+
#output {
|
|
390
|
+
flex: 1;
|
|
391
|
+
overflow-y: auto;
|
|
392
|
+
padding: 10px 14px;
|
|
393
|
+
font-family: var(--mono);
|
|
394
|
+
font-size: 11.5px;
|
|
395
|
+
line-height: 1.65;
|
|
396
|
+
white-space: pre-wrap;
|
|
397
|
+
word-break: break-all;
|
|
398
|
+
min-height: 0;
|
|
399
|
+
}
|
|
400
|
+
#output .cmd {
|
|
401
|
+
color: var(--accent);
|
|
402
|
+
}
|
|
403
|
+
#output .err {
|
|
404
|
+
color: var(--red);
|
|
405
|
+
}
|
|
406
|
+
#output .done-ok {
|
|
407
|
+
color: var(--green);
|
|
408
|
+
}
|
|
409
|
+
#output .done-fail {
|
|
410
|
+
color: var(--red);
|
|
411
|
+
}
|
|
412
|
+
</style>
|
|
413
|
+
</head>
|
|
414
|
+
<body>
|
|
415
|
+
<header>
|
|
416
|
+
<img
|
|
417
|
+
src="/favicon.ico"
|
|
418
|
+
width="16"
|
|
419
|
+
height="16"
|
|
420
|
+
alt=""
|
|
421
|
+
style="flex-shrink: 0"
|
|
422
|
+
/>
|
|
423
|
+
<h1>__DEN_CLI__</h1>
|
|
424
|
+
<button
|
|
425
|
+
class="v-pill"
|
|
426
|
+
id="version-pill"
|
|
427
|
+
onclick="openChangelog()"
|
|
428
|
+
title="View changelog"
|
|
429
|
+
>
|
|
430
|
+
<span class="v-pip" id="v-pip"></span><span class="v-txt">v…</span>
|
|
431
|
+
</button>
|
|
432
|
+
<div class="health" id="health" title="Service health"></div>
|
|
433
|
+
<span
|
|
434
|
+
id="ws-pill"
|
|
435
|
+
style="
|
|
436
|
+
font-size: 11px;
|
|
437
|
+
font-family: var(--mono);
|
|
438
|
+
padding: 2px 8px;
|
|
439
|
+
border-radius: 10px;
|
|
440
|
+
border: 1px solid #388bfd44;
|
|
441
|
+
color: #58a6ff;
|
|
442
|
+
background: #58a6ff10;
|
|
443
|
+
display: none;
|
|
444
|
+
"
|
|
445
|
+
></span>
|
|
446
|
+
<div class="h-spacer"></div>
|
|
447
|
+
<nav>
|
|
448
|
+
<a href="/">Local Dev</a>
|
|
449
|
+
<a href="/services" class="active">Services</a>
|
|
450
|
+
<a href="/environments">Environments</a>
|
|
451
|
+
<a href="/release">Releases</a>
|
|
452
|
+
<a href="/logs"
|
|
453
|
+
>Logs
|
|
454
|
+
<span
|
|
455
|
+
id="log-badge"
|
|
456
|
+
style="
|
|
457
|
+
background: var(--yellow);
|
|
458
|
+
color: #0d1117;
|
|
459
|
+
font-size: 10px;
|
|
460
|
+
font-weight: 700;
|
|
461
|
+
padding: 1px 5px;
|
|
462
|
+
border-radius: 8px;
|
|
463
|
+
line-height: 1.4;
|
|
464
|
+
display: none;
|
|
465
|
+
"
|
|
466
|
+
></span
|
|
467
|
+
></a>
|
|
468
|
+
<a href="/tests">Tests</a>
|
|
469
|
+
</nav>
|
|
470
|
+
</header>
|
|
471
|
+
|
|
472
|
+
<div class="controls">
|
|
473
|
+
<select id="env-sel" title="Environment">
|
|
474
|
+
<option value="local">local</option>
|
|
475
|
+
</select>
|
|
476
|
+
<span style="font-size: 11px; color: var(--muted)">workspace:</span>
|
|
477
|
+
<select
|
|
478
|
+
id="workspace-sel"
|
|
479
|
+
title="Active workspace"
|
|
480
|
+
style="font-family: var(--mono)"
|
|
481
|
+
>
|
|
482
|
+
<option value="default">default</option>
|
|
483
|
+
</select>
|
|
484
|
+
<span id="status-text" style="font-size: 11px; color: var(--muted)"
|
|
485
|
+
>idle</span
|
|
486
|
+
>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<main>
|
|
490
|
+
<div class="services-pane" id="services-pane">
|
|
491
|
+
<div class="empty-state">Loading services…</div>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
<div class="pane-bar">
|
|
495
|
+
<span>Output</span>
|
|
496
|
+
<button onclick="clearOutput()">Clear</button>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
<div class="output-pane">
|
|
500
|
+
<pre id="output"></pre>
|
|
501
|
+
</div>
|
|
502
|
+
</main>
|
|
503
|
+
|
|
504
|
+
<script type="module">
|
|
505
|
+
import {
|
|
506
|
+
initHealth,
|
|
507
|
+
refreshHealth,
|
|
508
|
+
initWorkspacePill,
|
|
509
|
+
applyDenConfig,
|
|
510
|
+
initLogBadge,
|
|
511
|
+
initChangelog,
|
|
512
|
+
computePrimaryByRepo,
|
|
513
|
+
} from "/header.js";
|
|
514
|
+
|
|
515
|
+
let es = null;
|
|
516
|
+
|
|
517
|
+
// Release any open SSE socket on reload / navigation / bfcache-enter so the
|
|
518
|
+
// reloaded page isn't starved of a connection by a lingering stream.
|
|
519
|
+
window.addEventListener("pagehide", () => {
|
|
520
|
+
try {
|
|
521
|
+
es?.close();
|
|
522
|
+
} catch {}
|
|
523
|
+
es = null;
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ── Init ──────────────────────────────────────────────────────────────────
|
|
527
|
+
async function init() {
|
|
528
|
+
const envs = await fetch("/api/environments")
|
|
529
|
+
.then((r) => r.json())
|
|
530
|
+
.catch(() => ["local"]);
|
|
531
|
+
const envSel = document.getElementById("env-sel");
|
|
532
|
+
envSel.innerHTML = envs
|
|
533
|
+
.map((e) => `<option value="${e}">${e}</option>`)
|
|
534
|
+
.join("");
|
|
535
|
+
// '__DEN_CLI__-env' matches index.html/logs.html.
|
|
536
|
+
const savedEnv = localStorage.getItem("__DEN_CLI__-env");
|
|
537
|
+
if (savedEnv && envs.includes(savedEnv)) envSel.value = savedEnv;
|
|
538
|
+
|
|
539
|
+
await Promise.all([
|
|
540
|
+
loadWorkspaces(),
|
|
541
|
+
loadServices(),
|
|
542
|
+
initHealth(),
|
|
543
|
+
initWorkspacePill("env-sel"),
|
|
544
|
+
applyDenConfig(),
|
|
545
|
+
initChangelog(),
|
|
546
|
+
]);
|
|
547
|
+
initLogBadge();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
document.getElementById("env-sel").addEventListener("change", () => {
|
|
551
|
+
const env = document.getElementById("env-sel").value;
|
|
552
|
+
localStorage.setItem("__DEN_CLI__-env", env);
|
|
553
|
+
loadServices();
|
|
554
|
+
// Restart the header health poll for the new env — without this the dots
|
|
555
|
+
// kept polling the page-load env forever (index.html/logs.html do this too).
|
|
556
|
+
refreshHealth(env);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
document
|
|
560
|
+
.getElementById("workspace-sel")
|
|
561
|
+
.addEventListener("change", async () => {
|
|
562
|
+
const name = document.getElementById("workspace-sel").value;
|
|
563
|
+
await fetch("/api/workspaces", {
|
|
564
|
+
method: "POST",
|
|
565
|
+
headers: { "Content-Type": "application/json" },
|
|
566
|
+
body: JSON.stringify({ name }),
|
|
567
|
+
});
|
|
568
|
+
await loadServices();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// ── Load workspaces ───────────────────────────────────────────────────────
|
|
572
|
+
async function loadWorkspaces() {
|
|
573
|
+
try {
|
|
574
|
+
const data = await fetch("/api/workspaces").then((r) => r.json());
|
|
575
|
+
const sel = document.getElementById("workspace-sel");
|
|
576
|
+
const names = Object.keys(data.workspaces ?? {});
|
|
577
|
+
const focus = Array.isArray(data.active)
|
|
578
|
+
? (data.focus ?? data.active[0])
|
|
579
|
+
: data.active;
|
|
580
|
+
sel.innerHTML = names
|
|
581
|
+
.map(
|
|
582
|
+
(n) =>
|
|
583
|
+
`<option value="${n}" ${n === focus ? "selected" : ""}>${n}</option>`,
|
|
584
|
+
)
|
|
585
|
+
.join("");
|
|
586
|
+
} catch {
|
|
587
|
+
/* ignore — default workspace shown */
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── Workspace panel ───────────────────────────────────────────────────────
|
|
592
|
+
window.toggleWsPanel = function () {
|
|
593
|
+
const panel = document.getElementById("ws-panel");
|
|
594
|
+
const open = panel.classList.toggle("open");
|
|
595
|
+
if (open) refreshWsPanel();
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
async function refreshWsPanel() {
|
|
599
|
+
const data = await fetch("/api/workspaces")
|
|
600
|
+
.then((r) => r.json())
|
|
601
|
+
.catch(() => null);
|
|
602
|
+
if (!data) return;
|
|
603
|
+
|
|
604
|
+
const focus = Array.isArray(data.active)
|
|
605
|
+
? (data.focus ?? data.active[0])
|
|
606
|
+
: data.active;
|
|
607
|
+
// Update active label
|
|
608
|
+
document.getElementById("ws-active-label").textContent = Array.isArray(
|
|
609
|
+
data.active,
|
|
610
|
+
)
|
|
611
|
+
? data.active.join(", ")
|
|
612
|
+
: (data.active ?? "");
|
|
613
|
+
|
|
614
|
+
// Current services list
|
|
615
|
+
const services = Object.entries(
|
|
616
|
+
data.workspaces?.[focus]?.services ?? {},
|
|
617
|
+
);
|
|
618
|
+
const listEl = document.getElementById("ws-service-list");
|
|
619
|
+
if (services.length === 0) {
|
|
620
|
+
listEl.innerHTML = '<span class="ws-msg">No services yet.</span>';
|
|
621
|
+
} else {
|
|
622
|
+
listEl.innerHTML = services
|
|
623
|
+
.map(([addr, entry]) => {
|
|
624
|
+
const meta =
|
|
625
|
+
entry.mode === "dev-managed"
|
|
626
|
+
? `<span style="color:var(--muted);font-size:11px">dev-managed · ${entry.path ?? ""}</span>`
|
|
627
|
+
: `<span style="color:var(--muted);font-size:11px">managed · ${entry.branch ?? "main"}</span>`;
|
|
628
|
+
return `<div class="ws-service-row">
|
|
629
|
+
<span>${addr}</span>${meta}
|
|
630
|
+
<button title="Remove" onclick="removeService('${addr}')">×</button>
|
|
631
|
+
</div>`;
|
|
632
|
+
})
|
|
633
|
+
.join("");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Available repos dropdown
|
|
637
|
+
const repos = await fetch("/api/workspaces/available-repos")
|
|
638
|
+
.then((r) => r.json())
|
|
639
|
+
.catch(() => []);
|
|
640
|
+
const repoSel = document.getElementById("ws-repo-sel");
|
|
641
|
+
repoSel.innerHTML =
|
|
642
|
+
'<option value="">Select repo…</option>' +
|
|
643
|
+
repos
|
|
644
|
+
.map((r) => {
|
|
645
|
+
const label = r.hasManifest ? `${r.name} ✓` : r.name;
|
|
646
|
+
const disabled = r.alreadyAdded ? " disabled" : "";
|
|
647
|
+
const suffix = r.alreadyAdded ? " (added)" : "";
|
|
648
|
+
return `<option value="${r.name}" data-path="${r.absPath}"${disabled}>${label}${suffix}</option>`;
|
|
649
|
+
})
|
|
650
|
+
.join("");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
window.createWorkspace = async function () {
|
|
654
|
+
const name = document.getElementById("ws-name-input").value.trim();
|
|
655
|
+
const msg = document.getElementById("ws-create-msg");
|
|
656
|
+
if (!name) {
|
|
657
|
+
msg.textContent = "Name required";
|
|
658
|
+
msg.className = "ws-msg err";
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const res = await fetch("/api/workspaces/create", {
|
|
662
|
+
method: "POST",
|
|
663
|
+
headers: { "Content-Type": "application/json" },
|
|
664
|
+
body: JSON.stringify({ name }),
|
|
665
|
+
});
|
|
666
|
+
if (res.ok) {
|
|
667
|
+
msg.textContent = `✓ Created "${name}"`;
|
|
668
|
+
msg.className = "ws-msg ok";
|
|
669
|
+
document.getElementById("ws-name-input").value = "";
|
|
670
|
+
await loadWorkspaces();
|
|
671
|
+
await refreshWsPanel();
|
|
672
|
+
} else {
|
|
673
|
+
msg.textContent = await res.text();
|
|
674
|
+
msg.className = "ws-msg err";
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
window.deleteWorkspace = async function () {
|
|
679
|
+
const name = document.getElementById("workspace-sel").value;
|
|
680
|
+
const msg = document.getElementById("ws-delete-msg");
|
|
681
|
+
if (name === "default") {
|
|
682
|
+
msg.textContent = "Cannot delete default";
|
|
683
|
+
msg.className = "ws-msg err";
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (!confirm(`Delete workspace "${name}"?`)) return;
|
|
687
|
+
const res = await fetch("/api/workspaces", {
|
|
688
|
+
method: "DELETE",
|
|
689
|
+
headers: { "Content-Type": "application/json" },
|
|
690
|
+
body: JSON.stringify({ name }),
|
|
691
|
+
});
|
|
692
|
+
if (res.ok) {
|
|
693
|
+
msg.textContent = `✓ Deleted "${name}"`;
|
|
694
|
+
msg.className = "ws-msg ok";
|
|
695
|
+
await loadWorkspaces();
|
|
696
|
+
await Promise.all([loadServices(), refreshWsPanel()]);
|
|
697
|
+
} else {
|
|
698
|
+
msg.textContent = await res.text();
|
|
699
|
+
msg.className = "ws-msg err";
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
window.addService = async function () {
|
|
704
|
+
const repoSel = document.getElementById("ws-repo-sel");
|
|
705
|
+
const mode = document.getElementById("ws-mode-sel").value;
|
|
706
|
+
const msg = document.getElementById("ws-add-msg");
|
|
707
|
+
const repoName = repoSel.value;
|
|
708
|
+
if (!repoName) {
|
|
709
|
+
msg.textContent = "Select a repo";
|
|
710
|
+
msg.className = "ws-msg err";
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const opt = repoSel.options[repoSel.selectedIndex];
|
|
714
|
+
const linkedPath = opt.dataset.path;
|
|
715
|
+
const res = await fetch("/api/workspaces/services", {
|
|
716
|
+
method: "POST",
|
|
717
|
+
headers: { "Content-Type": "application/json" },
|
|
718
|
+
body: JSON.stringify({ address: repoName, mode, linkedPath }),
|
|
719
|
+
});
|
|
720
|
+
if (res.ok) {
|
|
721
|
+
msg.textContent = `✓ Added ${repoName}`;
|
|
722
|
+
msg.className = "ws-msg ok";
|
|
723
|
+
await Promise.all([loadServices(), refreshWsPanel()]);
|
|
724
|
+
} else {
|
|
725
|
+
msg.textContent = await res.text();
|
|
726
|
+
msg.className = "ws-msg err";
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
window.removeService = async function (address) {
|
|
731
|
+
const msg = document.getElementById("ws-add-msg");
|
|
732
|
+
const res = await fetch("/api/workspaces/services", {
|
|
733
|
+
method: "DELETE",
|
|
734
|
+
headers: { "Content-Type": "application/json" },
|
|
735
|
+
body: JSON.stringify({ address }),
|
|
736
|
+
});
|
|
737
|
+
if (res.ok) {
|
|
738
|
+
await Promise.all([loadServices(), refreshWsPanel()]);
|
|
739
|
+
} else {
|
|
740
|
+
msg.textContent = await res.text();
|
|
741
|
+
msg.className = "ws-msg err";
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
// ── Load services ─────────────────────────────────────────────────────────
|
|
746
|
+
async function loadServices() {
|
|
747
|
+
const env = document.getElementById("env-sel").value;
|
|
748
|
+
const pane = document.getElementById("services-pane");
|
|
749
|
+
pane.innerHTML = '<div class="empty-state">Loading…</div>';
|
|
750
|
+
|
|
751
|
+
let services;
|
|
752
|
+
let apps = [];
|
|
753
|
+
try {
|
|
754
|
+
const [svcRes, appRes] = await Promise.all([
|
|
755
|
+
fetch(`/api/services?env=${env}`),
|
|
756
|
+
fetch("/api/applications").catch(() => null),
|
|
757
|
+
]);
|
|
758
|
+
services = await svcRes.json();
|
|
759
|
+
if (appRes?.ok) apps = (await appRes.json()).applications ?? [];
|
|
760
|
+
} catch {
|
|
761
|
+
pane.innerHTML =
|
|
762
|
+
'<div class="empty-state">Failed to load services</div>';
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (!services.length) {
|
|
767
|
+
pane.innerHTML = `<div class="empty-state">No services registered.<br><code>__DEN_CLI__ register <path></code> to add a repo.</div>`;
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// A "system" is a repo's primary service (entrypoint > root-named >
|
|
772
|
+
// first seen) — the same rollup the health dots, log tabs and logs
|
|
773
|
+
// toolbar use, so "one thing per system" means the same thing here.
|
|
774
|
+
// Everything else is a sub-service.
|
|
775
|
+
const primaryByRepo = computePrimaryByRepo(services);
|
|
776
|
+
|
|
777
|
+
// The shared-infra repo's own sub-services (`graph.infra.repo`; the
|
|
778
|
+
// server marks them `infra`) are the shared infra that repo IS. They
|
|
779
|
+
// carry no usable actions of their own — their only command was a
|
|
780
|
+
// lifecycle one, already dropped for sub-services — so the cards were
|
|
781
|
+
// pure noise beside the system card that already represents them.
|
|
782
|
+
// Sub-services of OTHER repos are kept: several own real work
|
|
783
|
+
// (shop/db's migrate, notes-api/db's lint) that exists nowhere else.
|
|
784
|
+
const visible = services.filter(
|
|
785
|
+
(s) =>
|
|
786
|
+
!s.infra ||
|
|
787
|
+
primaryByRepo.get(s.repo) ===
|
|
788
|
+
(s.address ?? `${s.repo}/${s.service}`),
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
// Top-down dependency order — entry repo (shop-web) first, infra-stack
|
|
792
|
+
// last — matching the health row, the dep graph and `__DEN_CLI__ status`.
|
|
793
|
+
// graphRank comes from the server, which owns deriveGraph. Sub-services
|
|
794
|
+
// sort under their own system rather than floating to the end.
|
|
795
|
+
visible.sort(
|
|
796
|
+
(a, b) =>
|
|
797
|
+
(a.graphRank ?? Number.MAX_SAFE_INTEGER) -
|
|
798
|
+
(b.graphRank ?? Number.MAX_SAFE_INTEGER) ||
|
|
799
|
+
a.repo.localeCompare(b.repo) ||
|
|
800
|
+
(primaryByRepo.get(a.repo) ===
|
|
801
|
+
(a.address ?? `${a.repo}/${a.service}`)
|
|
802
|
+
? -1
|
|
803
|
+
: 0) -
|
|
804
|
+
(primaryByRepo.get(b.repo) ===
|
|
805
|
+
(b.address ?? `${b.repo}/${b.service}`)
|
|
806
|
+
? -1
|
|
807
|
+
: 0) ||
|
|
808
|
+
a.service.localeCompare(b.service),
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
// Lifecycle commands on a sub-service are a lie: the resource is owned by
|
|
812
|
+
// its system or by shared infra-stack infra (files-api/db, for one,
|
|
813
|
+
// is a handle onto shared localstack — `up` there does nothing useful).
|
|
814
|
+
// Domain commands are NOT hidden: shop/db's migrate/seed/rollback and
|
|
815
|
+
// notes-api/db's migrate/lint are real work you can only do here.
|
|
816
|
+
const LIFECYCLE_CMDS = new Set(["up", "down", "restart"]);
|
|
817
|
+
|
|
818
|
+
const cardHtml = (s) => {
|
|
819
|
+
const addr = s.address ?? `${s.repo}/${s.service}`;
|
|
820
|
+
const isSub = primaryByRepo.get(s.repo) !== addr;
|
|
821
|
+
const usableCmds = isSub
|
|
822
|
+
? s.commands.filter((cmd) => !LIFECYCLE_CMDS.has(cmd))
|
|
823
|
+
: s.commands;
|
|
824
|
+
const cmdButtons = usableCmds
|
|
825
|
+
.map(
|
|
826
|
+
(cmd) =>
|
|
827
|
+
`<button class="cmd-btn" data-repo="${s.repo}" data-service="${s.service}" data-cmd="${cmd}" data-type="exec">${cmd}</button>`,
|
|
828
|
+
)
|
|
829
|
+
.join("");
|
|
830
|
+
const logsButton = s.logs
|
|
831
|
+
? `<button class="cmd-btn logs-btn" data-repo="${s.repo}" data-service="${s.service}" data-type="logs">logs</button>`
|
|
832
|
+
: "";
|
|
833
|
+
const hasCaps = usableCmds.length > 0 || s.logs;
|
|
834
|
+
const emptyNote =
|
|
835
|
+
s.cloned === false
|
|
836
|
+
? "not cloned yet — run Sync in Local Dev"
|
|
837
|
+
: isSub
|
|
838
|
+
? "managed by its system"
|
|
839
|
+
: "no commands for this env";
|
|
840
|
+
const modeBadge = s.mode
|
|
841
|
+
? `<span class="badge ${s.mode}">${s.mode}</span>`
|
|
842
|
+
: "";
|
|
843
|
+
const unclonedBadge =
|
|
844
|
+
s.cloned === false
|
|
845
|
+
? `<span class="badge" style="border-color:var(--yellow);color:var(--yellow)" title="Not cloned yet">⬇ not cloned</span>`
|
|
846
|
+
: "";
|
|
847
|
+
const branchBadge = s.branch
|
|
848
|
+
? `<span class="badge branch">${s.branch}</span>`
|
|
849
|
+
: "";
|
|
850
|
+
return `
|
|
851
|
+
<div class="service-card${isSub ? " subservice" : ""}">
|
|
852
|
+
<div class="service-card-header">
|
|
853
|
+
<span class="dot" id="dot-${s.repo}-${s.service}"></span>
|
|
854
|
+
<div>
|
|
855
|
+
<div class="service-name">${addr}</div>
|
|
856
|
+
<div class="service-repo">${s.repo}</div>
|
|
857
|
+
<a class="site-link" id="site-${s.repo}-${s.service}" target="_blank" rel="noopener" hidden></a>
|
|
858
|
+
</div>
|
|
859
|
+
</div>
|
|
860
|
+
<div class="service-meta">${modeBadge}${branchBadge}${unclonedBadge}</div>
|
|
861
|
+
<div class="cmd-row">
|
|
862
|
+
${hasCaps ? cmdButtons + logsButton : `<span style="color:var(--muted);font-size:11px">${emptyNote}</span>`}
|
|
863
|
+
</div>
|
|
864
|
+
</div>`;
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
// Section the cards by application, keeping the order above within
|
|
868
|
+
// each. Repos that are their own application share one section; any
|
|
869
|
+
// card no application claims goes last.
|
|
870
|
+
const esc = (t) =>
|
|
871
|
+
String(t ?? "").replace(
|
|
872
|
+
/[&<>"]/g,
|
|
873
|
+
(c) =>
|
|
874
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
|
875
|
+
);
|
|
876
|
+
const appOf = new Map();
|
|
877
|
+
for (const a of apps) for (const r of a.repos) appOf.set(r, a);
|
|
878
|
+
const sections = [];
|
|
879
|
+
for (const a of apps.filter((x) => x.source === "declared")) {
|
|
880
|
+
const cards = visible.filter((s) => appOf.get(s.repo) === a);
|
|
881
|
+
if (cards.length) sections.push({ app: a, cards });
|
|
882
|
+
}
|
|
883
|
+
const standalone = visible.filter(
|
|
884
|
+
(s) => appOf.get(s.repo)?.source === "repo",
|
|
885
|
+
);
|
|
886
|
+
if (standalone.length)
|
|
887
|
+
sections.push({
|
|
888
|
+
app: {
|
|
889
|
+
title: "Single-repo applications",
|
|
890
|
+
description: "Each repo is its own application.",
|
|
891
|
+
},
|
|
892
|
+
cards: standalone,
|
|
893
|
+
});
|
|
894
|
+
const rest = visible.filter((s) => !appOf.has(s.repo));
|
|
895
|
+
if (rest.length)
|
|
896
|
+
sections.push({
|
|
897
|
+
app: apps.length
|
|
898
|
+
? {
|
|
899
|
+
title: "Not in an application",
|
|
900
|
+
description: "Registered, but not in this workspace.",
|
|
901
|
+
}
|
|
902
|
+
: null,
|
|
903
|
+
cards: rest,
|
|
904
|
+
});
|
|
905
|
+
pane.innerHTML = sections
|
|
906
|
+
.map(
|
|
907
|
+
({ app, cards }) => `
|
|
908
|
+
<section class="app-section"${app?.name ? ` data-app="${esc(app.name)}"` : ""}>
|
|
909
|
+
${
|
|
910
|
+
app
|
|
911
|
+
? `<div class="app-section-head">
|
|
912
|
+
<span class="app-section-title">${esc(app.title)}</span>
|
|
913
|
+
${app.owner ? `<span class="app-section-owner" title="Owner">${esc(app.owner)}</span>` : ""}
|
|
914
|
+
${app.description ? `<span class="app-section-desc">${esc(app.description)}</span>` : ""}
|
|
915
|
+
</div>`
|
|
916
|
+
: ""
|
|
917
|
+
}
|
|
918
|
+
<div class="app-cards">${cards.map(cardHtml).join("")}</div>
|
|
919
|
+
</section>`,
|
|
920
|
+
)
|
|
921
|
+
.join("");
|
|
922
|
+
|
|
923
|
+
// Attach click handlers
|
|
924
|
+
pane.querySelectorAll(".cmd-btn").forEach((btn) => {
|
|
925
|
+
btn.addEventListener("click", () => {
|
|
926
|
+
const { repo, service, cmd, type } = btn.dataset;
|
|
927
|
+
if (type === "exec") runExec(repo, service, cmd, btn);
|
|
928
|
+
else runLogs(repo, service, btn);
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
// Re-render destroys the dot elements, and the poll may have already
|
|
933
|
+
// fired before this ran — repaint from the last broadcast so the cards
|
|
934
|
+
// aren't blank until the next tick.
|
|
935
|
+
applyHealthToCards(_lastHealth);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ── Run command ───────────────────────────────────────────────────────────
|
|
939
|
+
function runExec(repo, service, cmd, btn) {
|
|
940
|
+
if (es) {
|
|
941
|
+
es.close();
|
|
942
|
+
es = null;
|
|
943
|
+
}
|
|
944
|
+
const env = document.getElementById("env-sel").value;
|
|
945
|
+
|
|
946
|
+
disableAllButtons();
|
|
947
|
+
setDot(repo, service, "running");
|
|
948
|
+
setStatus(`running ${service}:${cmd}…`);
|
|
949
|
+
appendLine(`\n▶ ${repo}/${service} · ${cmd} · ${env}\n`, "cmd");
|
|
950
|
+
|
|
951
|
+
const params = new URLSearchParams({ repo, service, cmd, env });
|
|
952
|
+
es = new EventSource(`/api/service/exec?${params}`);
|
|
953
|
+
handleSSE(es, repo, service);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function runLogs(repo, service, btn) {
|
|
957
|
+
if (es) {
|
|
958
|
+
es.close();
|
|
959
|
+
es = null;
|
|
960
|
+
}
|
|
961
|
+
const env = document.getElementById("env-sel").value;
|
|
962
|
+
|
|
963
|
+
// A log tail is a continuous stream, not a command. It never emits
|
|
964
|
+
// `done`, so disabling the buttons here left every button on the page
|
|
965
|
+
// dead — cursor and all — until a manual reload. Tailing also says
|
|
966
|
+
// nothing about health, so the dot is left to the health poll rather
|
|
967
|
+
// than being forced to "running". Starting any other action closes this
|
|
968
|
+
// stream (both runners close `es` first).
|
|
969
|
+
setStatus(`streaming ${service} logs — start any command to stop`);
|
|
970
|
+
appendLine(`\n▶ ${repo}/${service} logs · ${env}\n`, "cmd");
|
|
971
|
+
|
|
972
|
+
const params = new URLSearchParams({ repo, service, env });
|
|
973
|
+
es = new EventSource(`/api/service/logs?${params}`);
|
|
974
|
+
handleSSE(es, repo, service, { stream: true });
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function handleSSE(evtSrc, repo, service, opts = {}) {
|
|
978
|
+
// Streams (log tails) never disabled the buttons and never own the dot,
|
|
979
|
+
// so they must not re-enable or repaint either.
|
|
980
|
+
const isStream = opts.stream === true;
|
|
981
|
+
evtSrc.onmessage = (e) => {
|
|
982
|
+
const msg = JSON.parse(e.data);
|
|
983
|
+
if (msg.type === "cmd") {
|
|
984
|
+
appendLine(msg.text, "cmd");
|
|
985
|
+
} else if (msg.type === "line") {
|
|
986
|
+
appendLine(msg.text, "");
|
|
987
|
+
} else if (msg.type === "error") {
|
|
988
|
+
appendLine(msg.text, "err");
|
|
989
|
+
} else if (msg.type === "done") {
|
|
990
|
+
const ok = msg.exitCode === 0;
|
|
991
|
+
if (msg.duration > 0) {
|
|
992
|
+
appendLine(
|
|
993
|
+
`\n${ok ? "✓ Done" : "✗ Failed"} in ${fmtDuration(msg.duration)}\n`,
|
|
994
|
+
ok ? "done-ok" : "done-fail",
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
if (!isStream) {
|
|
998
|
+
setDot(repo, service, ok ? "ok" : "err");
|
|
999
|
+
enableAllButtons();
|
|
1000
|
+
}
|
|
1001
|
+
setStatus("idle");
|
|
1002
|
+
evtSrc.close();
|
|
1003
|
+
es = null;
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
evtSrc.onerror = () => {
|
|
1007
|
+
appendLine("Connection closed", "err");
|
|
1008
|
+
// A tail ending is not a health signal — leave the dot to the poll.
|
|
1009
|
+
if (!isStream) {
|
|
1010
|
+
setDot(repo, service, "err");
|
|
1011
|
+
enableAllButtons();
|
|
1012
|
+
}
|
|
1013
|
+
setStatus("idle");
|
|
1014
|
+
evtSrc?.close();
|
|
1015
|
+
es = null;
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// ── Output ────────────────────────────────────────────────────────────────
|
|
1020
|
+
function appendLine(text, cls) {
|
|
1021
|
+
const out = document.getElementById("output");
|
|
1022
|
+
const span = document.createElement("span");
|
|
1023
|
+
if (cls) span.className = cls;
|
|
1024
|
+
span.textContent = text + "\n";
|
|
1025
|
+
out.appendChild(span);
|
|
1026
|
+
out.scrollTop = out.scrollHeight;
|
|
1027
|
+
}
|
|
1028
|
+
function clearOutput() {
|
|
1029
|
+
document.getElementById("output").innerHTML = "";
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ── Status / dots ─────────────────────────────────────────────────────────
|
|
1033
|
+
function setStatus(text) {
|
|
1034
|
+
document.getElementById("status-text").textContent = text;
|
|
1035
|
+
}
|
|
1036
|
+
function setDot(repo, service, state) {
|
|
1037
|
+
const el = document.getElementById(`dot-${repo}-${service}`);
|
|
1038
|
+
if (el) el.className = `dot ${state}`;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// ── Card health ───────────────────────────────────────────────────────────
|
|
1042
|
+
// Card dots follow the header's existing poll instead of running their own:
|
|
1043
|
+
// header.js broadcasts every poll and manual refresh as
|
|
1044
|
+
// `__DEN_CLI__:health-update`, the same feed local-dev's graph subscribes to.
|
|
1045
|
+
// Nothing had ever subscribed here, so a card dot was only written by a
|
|
1046
|
+
// command's outcome — meaning it showed a stale exit status, or stayed
|
|
1047
|
+
// muted forever on a page where you'd run nothing. That looked like the
|
|
1048
|
+
// header and the cards disagreeing about health.
|
|
1049
|
+
let _lastHealth = [];
|
|
1050
|
+
function applyHealthToCards(results) {
|
|
1051
|
+
for (const h of results || []) {
|
|
1052
|
+
// ok === null means "no health check declared". Leave it muted rather
|
|
1053
|
+
// than painting it red, which would claim the service is down.
|
|
1054
|
+
const state = h.ok === true ? "ok" : h.ok === false ? "err" : "";
|
|
1055
|
+
const el = document.getElementById(`dot-${h.repo}-${h.service}`);
|
|
1056
|
+
if (el) el.className = `dot${state ? " " + state : ""}`;
|
|
1057
|
+
const link = document.getElementById(`site-${h.repo}-${h.service}`);
|
|
1058
|
+
if (link) {
|
|
1059
|
+
if (h.openUrl) {
|
|
1060
|
+
link.href = h.openUrl;
|
|
1061
|
+
link.textContent = `${h.openUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")} ↗`;
|
|
1062
|
+
link.title = `Open ${h.openUrl} in a new tab`;
|
|
1063
|
+
link.hidden = false;
|
|
1064
|
+
} else {
|
|
1065
|
+
link.hidden = true;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
document.addEventListener("__DEN_CLI__:health-update", (e) => {
|
|
1071
|
+
_lastHealth = e.detail || [];
|
|
1072
|
+
applyHealthToCards(_lastHealth);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
// ── Button helpers ────────────────────────────────────────────────────────
|
|
1076
|
+
function disableAllButtons() {
|
|
1077
|
+
document
|
|
1078
|
+
.querySelectorAll(".cmd-btn")
|
|
1079
|
+
.forEach((b) => (b.disabled = true));
|
|
1080
|
+
}
|
|
1081
|
+
function enableAllButtons() {
|
|
1082
|
+
document
|
|
1083
|
+
.querySelectorAll(".cmd-btn")
|
|
1084
|
+
.forEach((b) => (b.disabled = false));
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
1088
|
+
function fmtDuration(ms) {
|
|
1089
|
+
if (ms < 1000) return `${ms}ms`;
|
|
1090
|
+
const s = Math.floor(ms / 1000),
|
|
1091
|
+
m = Math.floor(s / 60);
|
|
1092
|
+
return m > 0 ? `${m}m ${s % 60}s` : `${s}s`;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
init();
|
|
1096
|
+
</script>
|
|
1097
|
+
</body>
|
|
1098
|
+
</html>
|