@stacksjs/ts-cloud 0.7.105 → 0.7.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/bin/cli.js +784 -785
  2. package/dist/bin/dashboard-server.js +292 -292
  3. package/dist/{chunk-dmzez5f8.js → chunk-33sy72r8.js} +52 -3
  4. package/dist/{chunk-vkd2y0fs.js → chunk-eg8ck25v.js} +858 -150
  5. package/dist/deploy/dashboard-bundle-encoding.test.d.ts +1 -0
  6. package/dist/deploy/dashboard-payload.d.ts +20 -0
  7. package/dist/deploy/dashboard-payload.test.d.ts +1 -0
  8. package/dist/deploy/dashboard-topology.d.ts +85 -0
  9. package/dist/deploy/dashboard-topology.test.d.ts +1 -0
  10. package/dist/deploy/egress-collection.d.ts +60 -0
  11. package/dist/deploy/index.d.ts +2 -0
  12. package/dist/deploy/index.js +14 -2
  13. package/dist/drivers/index.js +1 -1
  14. package/dist/index.js +2 -2
  15. package/dist/ui/404.html +1 -0
  16. package/dist/ui/access-denied.html +1 -1
  17. package/dist/ui/account/automation.html +4 -4
  18. package/dist/ui/account/security.html +2 -2
  19. package/dist/ui/applications/compose.html +4 -4
  20. package/dist/ui/applications/new.html +2 -2
  21. package/dist/ui/data/backups.html +22 -9
  22. package/dist/ui/data/services.html +22 -9
  23. package/dist/ui/data/volumes.html +22 -9
  24. package/dist/ui/index.html +22 -9
  25. package/dist/ui/infrastructure/topology.html +2223 -0
  26. package/dist/ui/integrations.html +2 -2
  27. package/dist/ui/operations/alerts.html +22 -9
  28. package/dist/ui/operations/configuration.html +4 -4
  29. package/dist/ui/operations/jobs.html +22 -9
  30. package/dist/ui/operations/maintenance.html +4 -4
  31. package/dist/ui/operations/observability.html +22 -9
  32. package/dist/ui/operations/previews.html +4 -4
  33. package/dist/ui/operations/queue.html +4 -4
  34. package/dist/ui/operations/regions.html +4 -4
  35. package/dist/ui/operations/releases.html +4 -4
  36. package/dist/ui/operations/workloads.html +22 -9
  37. package/dist/ui/security.html +2 -2
  38. package/dist/ui/server/actions.html +3 -3
  39. package/dist/ui/server/activity.html +1 -1
  40. package/dist/ui/server/capacity.html +4 -4
  41. package/dist/ui/server/database.html +4 -4
  42. package/dist/ui/server/deployments.html +4 -4
  43. package/dist/ui/server/diagnostics.html +2 -2
  44. package/dist/ui/server/firewall.html +4 -4
  45. package/dist/ui/server/fleet.html +22 -9
  46. package/dist/ui/server/logs.html +22 -9
  47. package/dist/ui/server/metrics.html +130 -14
  48. package/dist/ui/server/security.html +2 -2
  49. package/dist/ui/server/services.html +2 -2
  50. package/dist/ui/server/sites.html +22 -9
  51. package/dist/ui/server/ssh-keys.html +4 -4
  52. package/dist/ui/server/team.html +4 -4
  53. package/dist/ui/server/terminal.html +2 -2
  54. package/dist/ui/serverless/alarms.html +3 -3
  55. package/dist/ui/serverless/assets.html +2 -2
  56. package/dist/ui/serverless/cost.html +2 -2
  57. package/dist/ui/serverless/data.html +4 -4
  58. package/dist/ui/serverless/deployments.html +2 -2
  59. package/dist/ui/serverless/firewall.html +2 -2
  60. package/dist/ui/serverless/functions.html +4 -4
  61. package/dist/ui/serverless/logs.html +21 -8
  62. package/dist/ui/serverless/metrics.html +2 -2
  63. package/dist/ui/serverless/queues.html +4 -4
  64. package/dist/ui/serverless/secrets.html +4 -4
  65. package/dist/ui/serverless/traces.html +4 -4
  66. package/dist/ui/serverless.html +4 -4
  67. package/dist/ui-src/functions/requestJson.ts +41 -0
  68. package/dist/ui-src/functions/useOpConfirm.ts +132 -0
  69. package/dist/ui-src/functions/usePolling.ts +98 -0
  70. package/dist/ui-src/functions/useTerminal.ts +79 -0
  71. package/dist/ui-src/pages/infrastructure/topology.stx +517 -0
  72. package/dist/ui-src/pages/partials/nav.stx +2 -0
  73. package/dist/ui-src/pages/server/metrics.stx +140 -6
  74. package/dist/ui-src/src/topology.ts +485 -0
  75. package/package.json +3 -3
@@ -0,0 +1,517 @@
1
+ <script server>
2
+ import { topologyGeometry } from '../../src/topology.ts'
3
+
4
+ // Infrastructure diagram. The topology model is resolved alongside every other
5
+ // dashboard slice (see `dashboard-topology.ts`) and laid out with ts-charts'
6
+ // layered graph layout — the same layout runs again in <script client> when a
7
+ // poll brings a changed topology, so both paints agree.
8
+ let __d = null
9
+ try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') }
10
+ catch { __d = null }
11
+
12
+ // Representative shape so the page renders as designed before any data is
13
+ // injected. It is badged as a sample in the header — never passed off as real.
14
+ const sampleTopology = {
15
+ mode: 'server',
16
+ project: 'sample-project',
17
+ environment: 'production',
18
+ provider: 'hetzner',
19
+ region: 'fsn1',
20
+ generatedAt: '',
21
+ layers: [
22
+ { index: 0, id: 'clients', label: 'Clients' },
23
+ { index: 1, id: 'dns', label: 'DNS & TLS' },
24
+ { index: 2, id: 'edge', label: 'Edge' },
25
+ { index: 3, id: 'routing', label: 'Routing' },
26
+ { index: 4, id: 'apps', label: 'Applications' },
27
+ { index: 5, id: 'runtime', label: 'Background runtime' },
28
+ { index: 6, id: 'data', label: 'Data services' },
29
+ { index: 7, id: 'durability', label: 'Durability' },
30
+ ],
31
+ groups: [{ id: 'box', label: 'sample-production-app', sub: 'hetzner · fsn1' }],
32
+ nodes: [
33
+ { id: 'internet', label: 'Internet', kind: 'internet', layer: 0, status: 'ok', sub: 'Public traffic', facts: [], external: true },
34
+ { id: 'dns', label: 'example.com', kind: 'dns', layer: 1, status: 'ok', sub: 'DNS & certificates', facts: [], external: true },
35
+ { id: 'firewall', label: 'Host firewall', kind: 'firewall', layer: 2, status: 'ok', sub: 'Inbound filtering', facts: [], group: 'box' },
36
+ { id: 'proxy', label: 'rpx-gateway', kind: 'proxy', layer: 3, status: 'ok', sub: 'TLS termination & routing', facts: [], group: 'box' },
37
+ { id: 'site:main', label: 'main', kind: 'site', layer: 4, status: 'ok', sub: 'bun', facts: [], group: 'box' },
38
+ { id: 'site:docs', label: 'docs', kind: 'site', layer: 4, status: 'ok', sub: 'static/bun', facts: [], group: 'box' },
39
+ { id: 'worker:default', label: 'default', kind: 'worker', layer: 5, status: 'ok', sub: '2 processes', facts: [], group: 'box' },
40
+ { id: 'data:redis', label: 'Redis', kind: 'cache', layer: 6, status: 'ok', sub: 'redis', facts: [], group: 'box' },
41
+ { id: 'data:mysql', label: 'MySQL', kind: 'database', layer: 6, status: 'ok', sub: 'mysql', facts: [], group: 'box' },
42
+ { id: 'backup', label: 'Backups', kind: 'backup', layer: 7, status: 'ok', sub: 'off-box', facts: [] },
43
+ ],
44
+ links: [
45
+ { source: 'internet', target: 'dns', flow: 'request', label: 'https' },
46
+ { source: 'dns', target: 'firewall', flow: 'request', label: '443/80' },
47
+ { source: 'firewall', target: 'proxy', flow: 'request' },
48
+ { source: 'proxy', target: 'site:main', flow: 'request' },
49
+ { source: 'proxy', target: 'site:docs', flow: 'request' },
50
+ { source: 'site:main', target: 'worker:default', flow: 'async', label: 'queue' },
51
+ { source: 'site:main', target: 'data:redis', flow: 'data', label: 'localhost' },
52
+ { source: 'site:main', target: 'data:mysql', flow: 'data', label: 'localhost' },
53
+ { source: 'worker:default', target: 'data:mysql', flow: 'data', label: 'localhost' },
54
+ { source: 'data:mysql', target: 'backup', flow: 'backup', label: 'snapshot' },
55
+ ],
56
+ notes: [],
57
+ }
58
+
59
+ const liveTopology = __d?.topology ?? null
60
+ const initialTopology = liveTopology ?? sampleTopology
61
+ const initialGeometry = topologyGeometry(initialTopology)
62
+ const initialIsSample = !liveTopology
63
+
64
+ // The svg's coordinate system is a fixed literal — a `:viewBox` binding is
65
+ // lowercased to `viewbox` by the HTML parser and silently ignored, so it cannot
66
+ // track the drawing. Instead the stage is deliberately over-tall and the
67
+ // wrapper crops it to the band actually in use; that crop IS bindable, so a
68
+ // client-side re-layout (taller topology than the one baked in) still fills the
69
+ // panel rather than shrinking to fit a stale stage.
70
+ const stageWidth = 1600
71
+ const stageHeight = 1200
72
+ const stageViewBox = `0 0 ${stageWidth} ${stageHeight}`
73
+ </script>
74
+ <script client>
75
+ import { requestJson } from '../../functions/requestJson.ts'
76
+ import { usePolling } from '../../functions/usePolling.ts'
77
+ import { highlightFor, matchTopology, topologyGeometry } from '../../src/topology.ts'
78
+
79
+ // Everything below is signal-driven: the SVG is bound to `geo()`, and pan/zoom
80
+ // is expressed as a viewBox rather than by touching the DOM, so the diagram
81
+ // stays declarative and re-renders from data alone.
82
+ const model = state(initialTopology)
83
+ const geo = state(initialGeometry)
84
+ const isSample = state(initialIsSample)
85
+ const selectedId = state('')
86
+ const hoveredId = state('')
87
+ const query = state('')
88
+ const flows = state({ request: true, async: true, data: true, backup: true })
89
+ const animate = state(true)
90
+ const zoom = state(1)
91
+ const panX = state(0)
92
+ const panY = state(0)
93
+ let dragging = null
94
+
95
+ async function load() {
96
+ // requestJson raises a DashboardRequestError carrying the HTTP status, which
97
+ // is how usePolling can tell "exported without an API" from "the API broke".
98
+ const body = await requestJson('/api/dashboard-data')
99
+ if (!body?.topology?.nodes?.length) throw new Error('This deployment reported no topology to draw.')
100
+ // Re-laying out only when the shape changed keeps positions stable across
101
+ // polls — a diagram that reshuffles every 30s is unreadable.
102
+ if (shapeOf(body.topology) !== shapeOf(model())) geo.set(topologyGeometry(body.topology))
103
+ else geo.set(restatus(geo(), body.topology))
104
+ model.set(body.topology)
105
+ isSample.set(false)
106
+ }
107
+ function shapeOf(value) {
108
+ return [
109
+ (value?.nodes || []).map(node => node.id + ':' + node.layer).sort().join(','),
110
+ (value?.links || []).map(link => link.source + '>' + link.target).sort().join(','),
111
+ ].join('|')
112
+ }
113
+ /** Same shape, fresh state: keep the geometry, swap statuses and facts in. */
114
+ function restatus(geometry, next) {
115
+ const byId = new Map((next.nodes || []).map(node => [node.id, node]))
116
+ return {
117
+ ...geometry,
118
+ nodes: geometry.nodes.map(node => {
119
+ const fresh = byId.get(node.id)
120
+ return fresh ? { ...node, status: fresh.status, sub: fresh.sub, facts: fresh.facts } : node
121
+ }),
122
+ }
123
+ }
124
+ // The map is a read-only view of a payload that is fully baked in at build
125
+ // time, so a deploy without a control plane costs it nothing but freshness.
126
+ const { pollingState, pollingError, lastUpdatedAt, isSnapshot, refresh } = usePolling(load, 30000, {
127
+ snapshotWhenApiMissing: true,
128
+ })
129
+ // When there is no API to poll, the honest timestamp is when the payload was
130
+ // resolved, not when the browser last asked.
131
+ const builtAtLabel = derived(() => {
132
+ const at = model().generatedAt
133
+ if (!at) return 'build time unknown'
134
+ const when = new Date(at)
135
+ return Number.isNaN(when.getTime()) ? 'build time unknown' : `built ${when.toLocaleString()}`
136
+ })
137
+
138
+ const visible = derived(() => matchTopology(geo().nodes, query()))
139
+ const focus = derived(() => highlightFor(geo(), hoveredId() || selectedId() || null))
140
+ const selected = derived(() => geo().nodes.find(node => node.id === selectedId()) || null)
141
+ const counts = derived(() => {
142
+ const tally = { ok: 0, warn: 0, bad: 0, idle: 0, unknown: 0 }
143
+ for (const node of geo().nodes) tally[node.status] = (tally[node.status] || 0) + 1
144
+ return tally
145
+ })
146
+ // Every `:for` binds a bare derived: stx auto-unwraps signals inside loop
147
+ // expressions, so `geo().nodes` there would evaluate to nothing.
148
+ const nodes = derived(() => geo().nodes)
149
+ const links = derived(() => geo().links)
150
+ const lanes = derived(() => geo().lanes)
151
+ const groups = derived(() => geo().groups)
152
+ const labelledLinks = derived(() => geo().links.filter(link => !!link.label))
153
+ const notes = derived(() => model().notes || [])
154
+ const selectedFacts = derived(() => selected()?.facts || [])
155
+ // Resolved to plain values here rather than in the template: `:for` item
156
+ // expressions run under the auto-unwrap proxy, where calling a signal throws.
157
+ const selectedLinks = derived(() => {
158
+ const id = selectedId()
159
+ const { nodes: all, links: edges } = geo()
160
+ const nameOf = other => all.find(node => node.id === other)?.label || other
161
+ return edges
162
+ .filter(link => link.source === id || link.target === id)
163
+ .map(link => ({
164
+ flow: link.flow,
165
+ arrow: link.source === id ? '→' : '←',
166
+ other: nameOf(link.source === id ? link.target : link.source),
167
+ note: link.label || '',
168
+ }))
169
+ })
170
+
171
+ // Pan and zoom live in one transform on the canvas group rather than on the
172
+ // svg's viewBox, which cannot be bound (see the server block).
173
+ const STAGE_W = stageWidth
174
+ const STAGE_H = stageHeight
175
+ // Fit to the stage WIDTH and anchor at the top, then crop the stage to the band
176
+ // the drawing actually occupies. Fitting to both axes would shrink a tall
177
+ // topology to nothing inside the fixed-height stage.
178
+ const fitScale = derived(() => STAGE_W / Math.max(1, geo().width))
179
+ const canvasScale = derived(() => fitScale() * zoom())
180
+ const stageCrop = derived(() => Math.round(Math.min(STAGE_H, Math.max(240, geo().height * fitScale()))))
181
+ const canvasTransform = derived(() => {
182
+ const scale = canvasScale()
183
+ const x = (STAGE_W - geo().width * scale) / 2 + panX()
184
+ return `translate(${x.toFixed(2)},${panY().toFixed(2)}) scale(${scale.toFixed(4)})`
185
+ })
186
+
187
+ function dim(id) { return !visible().has(id) || (!!focus() && !focus().has(id)) }
188
+ function linkOn(link) { return flows()[link.flow] !== false }
189
+ function linkOff(link) { return !linkOn(link) || dim(link.source) || dim(link.target) }
190
+ // SVG `class` is a read-only SVGAnimatedString, so `:class` must use the
191
+ // object form throughout the diagram — that path goes through classList.
192
+ function nodeClasses(node) {
193
+ return {
194
+ ok: node.status === 'ok',
195
+ warn: node.status === 'warn',
196
+ bad: node.status === 'bad',
197
+ idle: node.status === 'idle',
198
+ external: !!node.external,
199
+ dim: dim(node.id),
200
+ selected: selectedId() === node.id,
201
+ }
202
+ }
203
+ function linkClasses(link) {
204
+ const off = linkOff(link)
205
+ return {
206
+ async: link.flow === 'async',
207
+ data: link.flow === 'data',
208
+ backup: link.flow === 'backup',
209
+ dim: off,
210
+ animate: animate() && !off,
211
+ }
212
+ }
213
+ function toggleFlow(name) { flows.set({ ...flows(), [name]: !flows()[name] }) }
214
+ function select(node) { selectedId.set(selectedId() === node.id ? '' : node.id) }
215
+
216
+ // Pointer maths in geometry units, derived from the event's own target box —
217
+ // no element refs, no measuring the document.
218
+ /** Client pixels → stage units, and the diagram point currently under them. */
219
+ function atPointer(event) {
220
+ const box = event.currentTarget.getBoundingClientRect()
221
+ // The svg fills the panel's width exactly (width:100%, height:auto), so one
222
+ // stage unit is a constant fraction of the element — no letterboxing to undo.
223
+ const unit = (box.width / STAGE_W) || 1
224
+ const stageX = (event.clientX - box.left) / unit
225
+ const stageY = (event.clientY - box.top) / unit
226
+ const scale = canvasScale()
227
+ const originX = (STAGE_W - geo().width * scale) / 2 + panX()
228
+ return { unit, stageX, stageY, contentX: (stageX - originX) / scale, contentY: (stageY - panY()) / scale }
229
+ }
230
+ /** Re-scale while holding one diagram point under the same stage position. */
231
+ function zoomTo(next, point) {
232
+ const level = Math.min(4, Math.max(0.4, next))
233
+ const scale = fitScale() * level
234
+ panX.set(point.stageX - point.contentX * scale - (STAGE_W - geo().width * scale) / 2)
235
+ panY.set(point.stageY - point.contentY * scale)
236
+ zoom.set(level)
237
+ }
238
+ function onWheel(event) {
239
+ event.preventDefault()
240
+ zoomTo(zoom() * (event.deltaY < 0 ? 1.12 : 1 / 1.12), atPointer(event))
241
+ }
242
+ function onDown(event) {
243
+ const point = atPointer(event)
244
+ dragging = { x: event.clientX, y: event.clientY, panX: panX(), panY: panY(), unit: point.unit }
245
+ }
246
+ function onMove(event) {
247
+ if (!dragging) return
248
+ panX.set(dragging.panX + (event.clientX - dragging.x) / dragging.unit)
249
+ panY.set(dragging.panY + (event.clientY - dragging.y) / dragging.unit)
250
+ }
251
+ function onUp() { dragging = null }
252
+ function zoomBy(factor) {
253
+ // Anchor on the middle of what is actually visible, which is the cropped
254
+ // band rather than the full stage.
255
+ const scale = canvasScale()
256
+ const originX = (STAGE_W - geo().width * scale) / 2 + panX()
257
+ const midY = stageCrop() / 2
258
+ zoomTo(zoom() * factor, {
259
+ stageX: STAGE_W / 2,
260
+ stageY: midY,
261
+ contentX: (STAGE_W / 2 - originX) / scale,
262
+ contentY: (midY - panY()) / scale,
263
+ })
264
+ }
265
+ function fit() { zoom.set(1); panX.set(0); panY.set(0) }
266
+ /** Carry the environment across, the way every nav link does. */
267
+ function manageHref(node) {
268
+ if (!node?.href) return '#'
269
+ const env = new URLSearchParams(location.search).get('env')
270
+ return env ? `${node.href}${node.href.includes('?') ? '&' : '?'}env=${encodeURIComponent(env)}` : node.href
271
+ }
272
+ function clear() { selectedId.set(''); hoveredId.set(''); query.set('') }
273
+ </script>
274
+ <!DOCTYPE html>
275
+ <html lang="en" data-theme="dark"><head>@include('../partials/head', { title: 'ts-cloud - Infrastructure map' })</head><body>
276
+ @include('../partials/nav', { active: 'topology', env: 'production' })
277
+ <main class="topo-wrap wrap" id="main-content">
278
+ <div class="crumbs">Infrastructure <span class="sep">/</span> Map</div>
279
+ <header class="page">
280
+ <div>
281
+ <h1>Infrastructure map</h1>
282
+ <p>Every resource this environment deploys, in the order a request meets them. Hover to trace a path, click to inspect.</p>
283
+ </div>
284
+ <div class="header-actions">
285
+ <span class="pill warn" @show="isSample()">sample topology</span>
286
+ <span class="pill" :class="pollingState() === 'live' ? 'ok' : 'warn'">{{ pollingState() }}</span>
287
+ <small>{{ isSnapshot() ? builtAtLabel() : lastUpdatedAt() ? lastUpdatedAt().toLocaleTimeString() : 'connecting' }}</small>
288
+ <button class="btn ghost sm" type="button" @show="!isSnapshot()" @click="refresh()">Refresh</button>
289
+ </div>
290
+ </header>
291
+
292
+ <p class="topo-notice bad" role="alert" @show="!!pollingError()">{{ pollingError() }}</p>
293
+ <p class="topo-notice" role="status" @show="isSample() && !pollingError()">Showing a representative topology until this deployment reports one.</p>
294
+ <p class="topo-notice" role="status" @show="isSnapshot() && !isSample()">This cockpit was exported without its control plane, so the map cannot refresh. It shows the infrastructure as it stood when the dashboard was built.</p>
295
+ <p class="topo-notice" :for="note in notes">{{ note }}</p>
296
+
297
+ <section class="topo-summary" aria-label="Topology summary">
298
+ <article class="panel"><b>{{ model().mode === 'serverless' ? 'Serverless' : 'Server' }}</b><small>{{ model().provider }} · {{ model().region }}</small></article>
299
+ <article class="panel"><b>{{ geo().nodes.length }} resources</b><small>{{ geo().lanes.length }} layers · {{ geo().links.length }} connections</small></article>
300
+ <article class="panel"><b>{{ counts().ok }} healthy</b><small>{{ counts().warn }} attention · {{ counts().bad }} failing · {{ counts().idle }} idle</small></article>
301
+ <article class="panel"><b>{{ model().environment }}</b><small>{{ model().project }}</small></article>
302
+ </section>
303
+
304
+ <section class="topo-controls panel" aria-label="Diagram controls">
305
+ <label class="topo-search">
306
+ <span class="sr-only">Filter resources</span>
307
+ <input type="search" placeholder="Filter resources (name, kind, value)" :value="query()" @input="query.set($event.target.value)">
308
+ </label>
309
+ <div class="topo-flows" role="group" aria-label="Connection types">
310
+ <button type="button" class="chip request" :class="flows().request ? '' : 'off'" @click="toggleFlow('request')">Requests</button>
311
+ <button type="button" class="async chip" :class="flows().async ? '' : 'off'" @click="toggleFlow('async')">Async</button>
312
+ <button type="button" class="chip data" :class="flows().data ? '' : 'off'" @click="toggleFlow('data')">Data</button>
313
+ <button type="button" class="backup chip" :class="flows().backup ? '' : 'off'" @click="toggleFlow('backup')">Backup</button>
314
+ </div>
315
+ <div class="topo-zoom">
316
+ <button class="btn ghost sm" type="button" @click="animate.set(!animate())">{{ animate() ? 'Pause flow' : 'Animate flow' }}</button>
317
+ <button class="btn ghost sm" type="button" aria-label="Zoom out" @click="zoomBy(1 / 1.2)">−</button>
318
+ <span class="topo-level">{{ Math.round(zoom() * 100) }}%</span>
319
+ <button class="btn ghost sm" type="button" aria-label="Zoom in" @click="zoomBy(1.2)">+</button>
320
+ <button class="btn ghost sm" type="button" @click="fit()">Fit</button>
321
+ <button class="btn ghost sm" type="button" @click="clear()">Reset</button>
322
+ </div>
323
+ </section>
324
+
325
+ <div class="topo-stage">
326
+ <section class="topo-panel panel">
327
+ <div class="topo-viewport" :style="'aspect-ratio: 1600 / ' + stageCrop()">
328
+ <svg class="topo-canvas" viewBox="{{ stageViewBox }}"
329
+ preserveAspectRatio="xMidYMin meet" role="img"
330
+ aria-label="Infrastructure topology diagram"
331
+ @wheel="onWheel($event)" @pointerdown="onDown($event)" @pointermove="onMove($event)"
332
+ @pointerup="onUp()" @pointerleave="onUp()">
333
+ <defs>
334
+ <marker id="topo-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
335
+ <path d="M0,0 L10,5 L0,10 z" fill="currentColor"></path>
336
+ </marker>
337
+ </defs>
338
+
339
+ <!-- Text is bound with :text, never {{ }}: stx wraps interpolations in
340
+ a <span>, which an SVG <text> will not render. -->
341
+ <g class="topo-canvas-inner" :transform="canvasTransform()">
342
+ <g class="topo-lanes">
343
+ <g :for="lane in lanes">
344
+ <rect class="topo-lane" :x="lane.x" :y="lane.y" :width="lane.width" :height="lane.height" rx="18"></rect>
345
+ <text class="topo-lane-label" :x="lane.x + lane.width / 2" :y="lane.y - 24" text-anchor="middle" :text="lane.label"></text>
346
+ <text class="topo-lane-count" :x="lane.x + lane.width / 2" :y="lane.y - 8" text-anchor="middle" :text="lane.count + (lane.count === 1 ? ' resource' : ' resources')"></text>
347
+ </g>
348
+ </g>
349
+
350
+ <g class="topo-groups">
351
+ <g :for="group in groups">
352
+ <rect class="topo-group" :x="group.x" :y="group.y" :width="group.width" :height="group.height" rx="20"></rect>
353
+ <text class="topo-group-label" :x="group.x + 16" :y="group.y + 22" :text="group.sub ? group.label + ' · ' + group.sub : group.label"></text>
354
+ </g>
355
+ </g>
356
+
357
+ <g class="topo-edges">
358
+ <path :for="link in links" class="topo-link" :class="linkClasses(link)" :d="link.d" marker-end="url(#topo-arrow)"></path>
359
+ </g>
360
+
361
+ <g class="topo-edge-labels">
362
+ <text :for="link in labelledLinks" class="topo-edge-label" :class="{ dim: linkOff(link) }"
363
+ :x="link.labelX" :y="link.labelY" text-anchor="middle" :text="link.label"></text>
364
+ </g>
365
+
366
+ <g class="topo-nodes">
367
+ <g :for="node in nodes" class="topo-node" :class="nodeClasses(node)" :transform="'translate(' + node.x + ',' + node.y + ')'"
368
+ tabindex="0" role="button" :aria-label="node.label + ', ' + node.kind + ', ' + node.status"
369
+ @pointerenter="hoveredId.set(node.id)" @pointerleave="hoveredId.set('')"
370
+ @focus="hoveredId.set(node.id)" @blur="hoveredId.set('')"
371
+ @click="select(node)" @keydown.enter="select(node)">
372
+ <rect class="topo-box" :width="node.width" :height="node.height" rx="14"></rect>
373
+ <g class="topo-icon" transform="translate(15,20) scale(0.83)"><path :d="node.icon"></path></g>
374
+ <text class="topo-label" x="48" y="27" :text="node.label"></text>
375
+ <text class="topo-sub" x="48" y="45" :text="node.sub || node.kind"></text>
376
+ <circle class="topo-dot" :cx="node.width - 17" cy="20" r="4.5"></circle>
377
+ </g>
378
+ </g>
379
+ </g>
380
+ </svg>
381
+ </div>
382
+ <p class="topo-hint">Drag to pan · scroll to zoom · Tab to walk the graph. Hovering a resource keeps its upstream path lit.</p>
383
+ </section>
384
+
385
+ <aside class="topo-detail panel" @show="!!selected()" aria-label="Resource detail">
386
+ <div class="topo-detail-head">
387
+ <div>
388
+ <span class="pill" :class="selected()?.status === 'ok' ? 'ok' : selected()?.status === 'bad' ? 'bad' : 'warn'">{{ selected()?.status }}</span>
389
+ <h2>{{ selected()?.label }}</h2>
390
+ <p>{{ selected()?.kind }} · {{ selected()?.sub }}</p>
391
+ </div>
392
+ <button class="btn ghost sm" type="button" @click="selectedId.set('')">Close</button>
393
+ </div>
394
+ <div class="kv" :for="fact in selectedFacts"><span>{{ fact.label }}</span><b>{{ fact.value }}</b></div>
395
+ <p class="topo-empty-facts" @show="!selectedFacts().length">No further detail is reported for this resource.</p>
396
+ <h3>Connections</h3>
397
+ <div class="topo-conn" :for="link in selectedLinks">
398
+ <span class="chip" :class="link.flow">{{ link.flow }}</span>
399
+ <span>{{ link.arrow }} {{ link.other }}</span>
400
+ <small>{{ link.note }}</small>
401
+ </div>
402
+ <a class="btn sm" :href="manageHref(selected())" @show="!!selected()?.href">Manage this resource</a>
403
+ </aside>
404
+ </div>
405
+
406
+ <section class="topo-legend" aria-label="Legend">
407
+ <span><i class="ok swatch"></i>healthy</span>
408
+ <span><i class="swatch warn"></i>needs attention</span>
409
+ <span><i class="bad swatch"></i>failing</span>
410
+ <span><i class="idle swatch"></i>idle</span>
411
+ <span><i class="swatch unknown"></i>not reported</span>
412
+ <span class="topo-legend-sep"></span>
413
+ <span><i class="stroke request"></i>request</span>
414
+ <span><i class="stroke async"></i>async</span>
415
+ <span><i class="stroke data"></i>data</span>
416
+ <span><i class="stroke backup"></i>backup</span>
417
+ </section>
418
+ </main>
419
+ <style>
420
+ .topo-wrap { max-width: 1560px; }
421
+ .topo-notice { margin: 10px 0; padding: 10px 14px; border-radius: 12px; background: rgba(255,255,255,0.04); border: 1px solid var(--panel-br); color: var(--txt2); font-size: 13px; }
422
+ .topo-notice.bad { background: var(--badbg); border-color: rgba(240,115,110,0.3); color: var(--bad); }
423
+ .topo-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 16px 0 12px; }
424
+ .topo-summary article { display: grid; gap: 5px; }
425
+ .topo-summary b { font-size: 19px; }
426
+ .topo-summary small { color: var(--txt3); }
427
+ .topo-controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 12px 14px; }
428
+ .topo-search { flex: 1 1 260px; }
429
+ .topo-search input { width: 100%; background: var(--bg2); border: 1px solid var(--panel-br); border-radius: 10px; color: var(--txt); padding: 8px 12px; }
430
+ .topo-flows, .topo-zoom { display: flex; align-items: center; gap: 7px; }
431
+ .topo-level { min-width: 46px; text-align: center; color: var(--txt3); font: 600 12px var(--mono); }
432
+ .chip { border: 1px solid var(--panel-br); background: rgba(255,255,255,0.04); color: var(--txt2); border-radius: 999px; padding: 5px 11px; font-size: 12px; font-weight: 600; cursor: pointer; }
433
+ .chip::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 7px; vertical-align: 0; background: currentColor; }
434
+ .chip.request { color: var(--accent); }
435
+ .chip.async { color: #c08ae8; }
436
+ .chip.data { color: var(--ok); }
437
+ .chip.backup { color: var(--txt3); }
438
+ .chip.off { opacity: .38; }
439
+
440
+ .topo-stage { position: relative; }
441
+ .topo-panel { padding: 8px; min-width: 0; }
442
+ /* The svg is a fixed, over-tall stage; the viewport crops it to the band the
443
+ drawing occupies. That crop is bound to the live geometry, so the diagram
444
+ fills the panel even when a re-layout is taller than the baked stage. */
445
+ .topo-viewport { position: relative; width: 100%; max-height: 78vh; overflow: hidden; }
446
+ .topo-canvas { display: block; width: 100%; height: auto; touch-action: none; cursor: grab; color: var(--txt3); }
447
+ .topo-canvas:active { cursor: grabbing; }
448
+ .topo-hint { padding: 6px 12px 4px; color: var(--txt3); font-size: 11.5px; }
449
+
450
+ .topo-lane { fill: rgba(255,255,255,0.016); stroke: rgba(255,255,255,0.05); }
451
+ .topo-lane-label { fill: var(--txt3); font: 700 13px 'Inter', sans-serif; letter-spacing: .09em; text-transform: uppercase; }
452
+ .topo-lane-count { fill: var(--txt3); font: 600 12px var(--mono); opacity: .6; }
453
+ .topo-group { fill: none; stroke: rgba(90,139,224,0.34); stroke-dasharray: 6 6; }
454
+ .topo-group-label { fill: var(--accent); font: 700 13px 'Inter', sans-serif; }
455
+ .topo-group-sub { fill: var(--txt3); font-weight: 500; }
456
+
457
+ .topo-link { fill: none; stroke: var(--accent); stroke-width: 1.7; opacity: .55; color: var(--accent); transition: opacity .18s ease; }
458
+ .topo-link.async { stroke: #c08ae8; color: #c08ae8; stroke-dasharray: 7 5; }
459
+ .topo-link.data { stroke: var(--ok); color: var(--ok); }
460
+ .topo-link.backup { stroke: var(--txt3); color: var(--txt3); stroke-dasharray: 2 6; }
461
+ .topo-link.animate { stroke-dasharray: 9 7; animation: topo-flow 1.5s linear infinite; }
462
+ .topo-link.dim { opacity: .07; animation: none; }
463
+ @keyframes topo-flow { to { stroke-dashoffset: -32; } }
464
+ @media (prefers-reduced-motion: reduce) { .topo-link.animate { animation: none; } }
465
+ .topo-edge-label { fill: var(--txt3); font: 600 11.5px var(--mono); paint-order: stroke; stroke: var(--bg); stroke-width: 4px; }
466
+ .topo-edge-label.dim { opacity: .06; }
467
+
468
+ .topo-node { cursor: pointer; }
469
+ .topo-node .topo-box { fill: rgba(20,26,38,0.94); stroke: var(--panel-br); stroke-width: 1.2; transition: stroke .18s ease, fill .18s ease; }
470
+ .topo-node .topo-icon { fill: none; stroke: var(--txt2); stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
471
+ .topo-node .topo-label { fill: var(--txt); font: 700 15px 'Inter', sans-serif; }
472
+ .topo-node .topo-sub { fill: var(--txt3); font: 500 12px var(--mono); }
473
+ .topo-node .topo-dot { fill: var(--txt3); }
474
+ .topo-node.ok .topo-dot { fill: var(--ok); }
475
+ .topo-node.warn .topo-dot { fill: var(--warn); }
476
+ .topo-node.bad .topo-dot { fill: var(--bad); }
477
+ .topo-node.idle .topo-dot { fill: var(--txt3); opacity: .5; }
478
+ .topo-node.bad .topo-box { stroke: rgba(240,115,110,0.5); }
479
+ .topo-node.warn .topo-box { stroke: rgba(237,185,73,0.42); }
480
+ .topo-node.external .topo-box { fill: rgba(255,255,255,0.02); stroke-dasharray: 5 5; }
481
+ .topo-node:hover .topo-box, .topo-node:focus-visible .topo-box { stroke: var(--accent); fill: rgba(28,38,58,0.98); }
482
+ .topo-node:focus { outline: none; }
483
+ .topo-node:focus-visible .topo-box { stroke-width: 2; }
484
+ .topo-node.selected .topo-box { stroke: var(--accent); stroke-width: 2; }
485
+ .topo-node.selected .topo-icon { stroke: var(--accent); }
486
+ .topo-node.dim { opacity: .16; }
487
+
488
+ /* Opaque: it floats over the diagram, and a translucent panel over moving
489
+ edges is unreadable. */
490
+ .topo-detail { position: absolute; z-index: 5; top: 12px; right: 12px; width: 340px; display: grid; gap: 4px; max-height: calc(100% - 24px); overflow: auto; background: #0e131d; box-shadow: 0 22px 60px rgba(0,0,0,0.6); }
491
+ .topo-detail-head { display: flex; justify-content: space-between; gap: 12px; align-items: start; margin-bottom: 8px; }
492
+ .topo-detail-head h2 { font-size: 19px; margin-top: 6px; }
493
+ .topo-detail-head p { color: var(--txt3); font-size: 12px; }
494
+ .topo-detail h3 { margin: 16px 0 4px; font-size: 12px; letter-spacing: .09em; text-transform: uppercase; color: var(--txt3); }
495
+ .topo-detail .kv b { font-family: var(--mono); font-size: 12.5px; text-align: right; word-break: break-all; }
496
+ .topo-empty-facts { color: var(--txt3); font-size: 12px; padding: 8px 0; }
497
+ .topo-conn { display: flex; align-items: center; gap: 9px; padding: 6px 0; color: var(--txt2); font-size: 12px; }
498
+ .topo-conn .chip { cursor: default; padding: 2px 9px; font-size: 11px; }
499
+ .topo-detail .btn { margin-top: 14px; text-align: center; text-decoration: none; }
500
+
501
+ .topo-legend { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 14px 4px 0; color: var(--txt3); font-size: 12px; }
502
+ .topo-legend span { display: inline-flex; align-items: center; gap: 7px; }
503
+ .topo-legend .swatch { width: 9px; height: 9px; border-radius: 50%; background: var(--txt3); }
504
+ .topo-legend .swatch.ok { background: var(--ok); }
505
+ .topo-legend .swatch.warn { background: var(--warn); }
506
+ .topo-legend .swatch.bad { background: var(--bad); }
507
+ .topo-legend .swatch.idle { background: var(--txt3); opacity: .5; }
508
+ .topo-legend .stroke { width: 20px; height: 0; border-top: 2px solid var(--accent); }
509
+ .topo-legend .stroke.async { border-top-style: dashed; border-color: #c08ae8; }
510
+ .topo-legend .stroke.data { border-color: var(--ok); }
511
+ .topo-legend .stroke.backup { border-top-style: dotted; border-color: var(--txt3); }
512
+ .topo-legend-sep { width: 1px; height: 14px; background: var(--panel-br); }
513
+
514
+ @media (max-width: 1100px) { .topo-detail { position: static; width: auto; max-height: none; margin-top: 12px; } .topo-summary { grid-template-columns: repeat(2, 1fr); } }
515
+ @media (max-width: 640px) { .topo-summary { grid-template-columns: 1fr; } .topo-controls > * { width: 100%; } }
516
+ </style>
517
+ </body></html>
@@ -95,6 +95,7 @@ const isActive = (...keys) => keys.includes(active ?? '')
95
95
  @if (canView('data:read') || canView('fleet:read') || canView('security:read') || canView('runtime:terminal'))
96
96
  <section class="nav-group">
97
97
  <h2>Infrastructure</h2>
98
+ @if (canView('fleet:read'))<a class="{{ isActive('topology') ? 'active' : '' }}" href="{{ envHref('/infrastructure/topology') }}">Infrastructure map</a>@endif
98
99
  @if (canView('data:read'))<a class="{{ isActive('data-services') ? 'active' : '' }}" href="{{ envHref('/data/services') }}">Data services</a>@endif
99
100
  @if (canView('data:read'))<a class="{{ isActive('volumes') ? 'active' : '' }}" href="{{ envHref('/data/volumes') }}">Persistent volumes</a>@endif
100
101
  @if (canView('fleet:read'))<a href="{{ envHref('/server/firewall') }}">Network & firewall</a><a href="{{ envHref('/server/ssh-keys') }}">SSH keys</a>@endif
@@ -125,6 +126,7 @@ const isActive = (...keys) => keys.includes(active ?? '')
125
126
  @if (canView('secrets:read') || canView('fleet:read') || canView('runtime:read'))
126
127
  <section class="nav-group">
127
128
  <h2>Settings</h2>
129
+ @if (canView('fleet:read'))<a class="{{ isActive('topology') ? 'active' : '' }}" href="{{ envHref('/infrastructure/topology') }}">Infrastructure map</a>@endif
128
130
  @if (canView('config:read'))<a class="{{ isActive('configuration') ? 'active' : '' }}" href="{{ envHref('/operations/configuration') }}">Environment &amp; secrets</a>@endif
129
131
  @if (canView('fleet:read'))<a href="{{ envHref('/serverless/firewall') }}">Network</a>@endif
130
132
  @if (canView('runtime:read'))<a href="{{ envHref('/serverless/alarms') }}">Alerts</a>@endif