@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.
- package/dist/bin/cli.js +784 -785
- package/dist/bin/dashboard-server.js +292 -292
- package/dist/{chunk-dmzez5f8.js → chunk-33sy72r8.js} +52 -3
- package/dist/{chunk-vkd2y0fs.js → chunk-eg8ck25v.js} +858 -150
- package/dist/deploy/dashboard-bundle-encoding.test.d.ts +1 -0
- package/dist/deploy/dashboard-payload.d.ts +20 -0
- package/dist/deploy/dashboard-payload.test.d.ts +1 -0
- package/dist/deploy/dashboard-topology.d.ts +85 -0
- package/dist/deploy/dashboard-topology.test.d.ts +1 -0
- package/dist/deploy/egress-collection.d.ts +60 -0
- package/dist/deploy/index.d.ts +2 -0
- package/dist/deploy/index.js +14 -2
- package/dist/drivers/index.js +1 -1
- package/dist/index.js +2 -2
- package/dist/ui/404.html +1 -0
- package/dist/ui/access-denied.html +1 -1
- package/dist/ui/account/automation.html +4 -4
- package/dist/ui/account/security.html +2 -2
- package/dist/ui/applications/compose.html +4 -4
- package/dist/ui/applications/new.html +2 -2
- package/dist/ui/data/backups.html +22 -9
- package/dist/ui/data/services.html +22 -9
- package/dist/ui/data/volumes.html +22 -9
- package/dist/ui/index.html +22 -9
- package/dist/ui/infrastructure/topology.html +2223 -0
- package/dist/ui/integrations.html +2 -2
- package/dist/ui/operations/alerts.html +22 -9
- package/dist/ui/operations/configuration.html +4 -4
- package/dist/ui/operations/jobs.html +22 -9
- package/dist/ui/operations/maintenance.html +4 -4
- package/dist/ui/operations/observability.html +22 -9
- package/dist/ui/operations/previews.html +4 -4
- package/dist/ui/operations/queue.html +4 -4
- package/dist/ui/operations/regions.html +4 -4
- package/dist/ui/operations/releases.html +4 -4
- package/dist/ui/operations/workloads.html +22 -9
- package/dist/ui/security.html +2 -2
- package/dist/ui/server/actions.html +3 -3
- package/dist/ui/server/activity.html +1 -1
- package/dist/ui/server/capacity.html +4 -4
- package/dist/ui/server/database.html +4 -4
- package/dist/ui/server/deployments.html +4 -4
- package/dist/ui/server/diagnostics.html +2 -2
- package/dist/ui/server/firewall.html +4 -4
- package/dist/ui/server/fleet.html +22 -9
- package/dist/ui/server/logs.html +22 -9
- package/dist/ui/server/metrics.html +130 -14
- package/dist/ui/server/security.html +2 -2
- package/dist/ui/server/services.html +2 -2
- package/dist/ui/server/sites.html +22 -9
- package/dist/ui/server/ssh-keys.html +4 -4
- package/dist/ui/server/team.html +4 -4
- package/dist/ui/server/terminal.html +2 -2
- package/dist/ui/serverless/alarms.html +3 -3
- package/dist/ui/serverless/assets.html +2 -2
- package/dist/ui/serverless/cost.html +2 -2
- package/dist/ui/serverless/data.html +4 -4
- package/dist/ui/serverless/deployments.html +2 -2
- package/dist/ui/serverless/firewall.html +2 -2
- package/dist/ui/serverless/functions.html +4 -4
- package/dist/ui/serverless/logs.html +21 -8
- package/dist/ui/serverless/metrics.html +2 -2
- package/dist/ui/serverless/queues.html +4 -4
- package/dist/ui/serverless/secrets.html +4 -4
- package/dist/ui/serverless/traces.html +4 -4
- package/dist/ui/serverless.html +4 -4
- package/dist/ui-src/functions/requestJson.ts +41 -0
- package/dist/ui-src/functions/useOpConfirm.ts +132 -0
- package/dist/ui-src/functions/usePolling.ts +98 -0
- package/dist/ui-src/functions/useTerminal.ts +79 -0
- package/dist/ui-src/pages/infrastructure/topology.stx +517 -0
- package/dist/ui-src/pages/partials/nav.stx +2 -0
- package/dist/ui-src/pages/server/metrics.stx +140 -6
- package/dist/ui-src/src/topology.ts +485 -0
- 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 & 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
|