@stacksjs/ts-cloud 0.7.63 → 0.7.65

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 (73) hide show
  1. package/dist/bin/cli.js +1556 -1284
  2. package/dist/{chunk-0zbqbrqg.js → chunk-0z7ry8fm.js} +889 -89
  3. package/dist/{chunk-t092y37p.js → chunk-267f6qd0.js} +525 -145
  4. package/dist/{chunk-zvk0f89t.js → chunk-28vh1h91.js} +1 -1
  5. package/dist/{chunk-tq46tnxv.js → chunk-ecsyc3p0.js} +4 -1
  6. package/dist/{chunk-rxhqny6z.js → chunk-zcvdzvmz.js} +2 -2
  7. package/dist/deploy/dashboard-data-server.d.ts +27 -1
  8. package/dist/deploy/index.js +4 -4
  9. package/dist/deploy/telemetry-collection.d.ts +2 -0
  10. package/dist/drivers/hetzner/client.d.ts +84 -2
  11. package/dist/drivers/hetzner/host-optimization.d.ts +33 -0
  12. package/dist/drivers/hetzner/monitoring.d.ts +46 -0
  13. package/dist/drivers/hetzner/resize-remote.d.ts +38 -0
  14. package/dist/drivers/hetzner/resize-state.d.ts +24 -0
  15. package/dist/drivers/hetzner/resize.d.ts +86 -0
  16. package/dist/drivers/index.d.ts +8 -0
  17. package/dist/drivers/index.js +35 -3
  18. package/dist/drivers/shared/compute-deploy.d.ts +15 -0
  19. package/dist/drivers/shared/ufw.d.ts +4 -2
  20. package/dist/index.js +4 -4
  21. package/dist/security/pre-deploy-scanner.test.d.ts +1 -0
  22. package/dist/ui/access-denied.html +2 -2
  23. package/dist/ui/account/automation.html +4 -4
  24. package/dist/ui/account/security.html +2 -2
  25. package/dist/ui/applications/compose.html +4 -4
  26. package/dist/ui/applications/new.html +2 -2
  27. package/dist/ui/data/backups.html +4 -4
  28. package/dist/ui/data/services.html +4 -4
  29. package/dist/ui/data/volumes.html +4 -4
  30. package/dist/ui/index.html +12 -7
  31. package/dist/ui/integrations.html +2 -2
  32. package/dist/ui/operations/alerts.html +4 -4
  33. package/dist/ui/operations/configuration.html +4 -4
  34. package/dist/ui/operations/jobs.html +4 -4
  35. package/dist/ui/operations/maintenance.html +3 -3
  36. package/dist/ui/operations/observability.html +13 -7
  37. package/dist/ui/operations/previews.html +4 -4
  38. package/dist/ui/operations/queue.html +4 -4
  39. package/dist/ui/operations/regions.html +4 -4
  40. package/dist/ui/operations/releases.html +4 -4
  41. package/dist/ui/operations/workloads.html +4 -4
  42. package/dist/ui/security.html +2 -2
  43. package/dist/ui/server/actions.html +3 -3
  44. package/dist/ui/server/activity.html +2 -2
  45. package/dist/ui/server/capacity.html +4 -4
  46. package/dist/ui/server/database.html +4 -4
  47. package/dist/ui/server/deployments.html +4 -4
  48. package/dist/ui/server/firewall.html +3 -3
  49. package/dist/ui/server/fleet.html +4 -4
  50. package/dist/ui/server/logs.html +4 -4
  51. package/dist/ui/server/metrics.html +203 -3
  52. package/dist/ui/server/services.html +2 -2
  53. package/dist/ui/server/sites.html +102 -6
  54. package/dist/ui/server/ssh-keys.html +4 -4
  55. package/dist/ui/server/team.html +4 -4
  56. package/dist/ui/server/terminal.html +2 -2
  57. package/dist/ui/serverless/alarms.html +4 -4
  58. package/dist/ui/serverless/assets.html +2 -2
  59. package/dist/ui/serverless/cost.html +2 -2
  60. package/dist/ui/serverless/data.html +4 -4
  61. package/dist/ui/serverless/firewall.html +2 -2
  62. package/dist/ui/serverless/functions.html +4 -4
  63. package/dist/ui/serverless/logs.html +3 -3
  64. package/dist/ui/serverless/metrics.html +2 -2
  65. package/dist/ui/serverless/queues.html +4 -4
  66. package/dist/ui/serverless/secrets.html +4 -4
  67. package/dist/ui/serverless/traces.html +4 -4
  68. package/dist/ui/serverless.html +4 -4
  69. package/dist/ui-src/pages/index.stx +12 -10
  70. package/dist/ui-src/pages/operations/observability.stx +6 -4
  71. package/dist/ui-src/pages/server/metrics.stx +262 -106
  72. package/dist/ui-src/pages/server/sites.stx +98 -6
  73. package/package.json +3 -3
@@ -6,7 +6,7 @@ const range = state(new URLSearchParams(location.search).get('range') || '1h')
6
6
  const timezone = state(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC')
7
7
  const compare = state(false), paused = state(false), textQuery = state(''), level = state(''), source = state('')
8
8
  const status = state({ sources: [], errors: [], retention: {} })
9
- const traffic = state([]), errors = state([]), latency = state([]), saturation = state([]), records = state([])
9
+ const traffic = state([]), errors = state([]), latency = state([]), saturation = state([]), infrastructure = state([]), siteSignals = state([]), records = state([])
10
10
  const nextCursor = state(''), savedQueries = state([]), correlation = state(null), message = state(''), savedName = state(''), tailState = state('connecting')
11
11
  let tailSource = null
12
12
 
@@ -22,12 +22,14 @@ async function load() {
22
22
  if (paused()) return
23
23
  const duration = ranges[range()] || ranges['1h'], bucket = Math.max(60000, Math.ceil(duration / 120 / 60000) * 60000), params = windowParams()
24
24
  params.set('kinds', 'log,request,event,trace'); params.set('limit', '300'); if (textQuery()) params.set('text', textQuery()); if (level()) params.set('levels', level()); if (source()) params.set('sources', source())
25
- const [statusBody, trafficBody, errorBody, latencyBody, saturationBody, recordBody, savedBody] = await Promise.all([
25
+ const [statusBody, trafficBody, errorBody, latencyBody, saturationBody, infrastructureBody, siteBody, recordBody, savedBody] = await Promise.all([
26
26
  json('/api/telemetry/status'), json(seriesUrl(['request.count','traffic.requests'], 'sum', bucket)), json(seriesUrl(['request.error','errors.count'], 'sum', bucket)),
27
27
  json(seriesUrl(['request.duration','latency.p95'], 'p95', bucket)), json(seriesUrl(['queue.depth','saturation.restarts','saturation.throttles','saturation.concurrency','host.disk.used_percent','runtime.memory.used'], 'max', bucket)),
28
+ json(seriesUrl(['host.cpu.used_percent','host.memory.used_percent','host.disk.used_percent','host.swap.used_percent','host.disk.inode_used_percent','host.load'], 'avg', bucket)),
29
+ json(seriesUrl(['site.health.up','site.response.time','site.http.status','site.tls.days_remaining'], 'avg', bucket)),
28
30
  json('/api/telemetry/query?' + params), json('/api/telemetry/saved-queries'),
29
31
  ])
30
- status.set(statusBody); traffic.set(trafficBody.series || []); errors.set(errorBody.series || []); latency.set(latencyBody.series || []); saturation.set(saturationBody.series || []); records.set(recordBody.records || []); nextCursor.set(recordBody.nextCursor || ''); savedQueries.set(savedBody.queries || [])
32
+ status.set(statusBody); traffic.set(trafficBody.series || []); errors.set(errorBody.series || []); latency.set(latencyBody.series || []); saturation.set(saturationBody.series || []); infrastructure.set(infrastructureBody.series || []); siteSignals.set(siteBody.series || []); records.set(recordBody.records || []); nextCursor.set(recordBody.nextCursor || ''); savedQueries.set(savedBody.queries || [])
31
33
  if (!tailSource && !paused()) connectTail()
32
34
  }
33
35
  const { pollingState, pollingError, pollingLabel, lastUpdatedAt, refresh } = usePolling(load, 15000)
@@ -65,7 +67,7 @@ addEventListener('pagehide', closeTail)
65
67
  <details class="panel policy"><summary>Collection, retention, and estimated cost</summary><div class="cost-summary"><span>~{{ fmt(status().estimatedMonthlyBytes, ' B/month') }}</span><span>{{ status().costEstimateConfigured ? '$' + status().estimatedMonthlyCostUsd + '/month at configured storage rate' : 'Cost unavailable until a storage $/GB-month rate is configured' }}</span></div><form @submit="savePolicy($event)"><label>Raw retention days<input name="rawDays" type="number" min="1" max="3650" :value="status().policy?.rawDays || 30"></label><label>Downsample after days<input name="downsampleAfterDays" type="number" min="1" max="3650" :value="status().policy?.downsampleAfterDays || 7"></label><label>Maximum local records<input name="maxRecords" type="number" min="1000" :value="status().policy?.maxRecords || 1000000"></label><label>Sample rate (0.01–1)<input name="samplingRate" type="number" min="0.01" max="1" step="0.01" :value="status().policy?.samplingRate || 1"></label><label>Storage $/GB-month<input name="estimatedStorageUsdPerGbMonth" type="number" min="0" step="0.001" :value="status().policy?.estimatedStorageUsdPerGbMonth || 0"></label><label class="check"><input name="collectLogs" type="checkbox" :checked="status().policy?.collectLogs !== false"> Logs</label><label class="check"><input name="collectTraces" type="checkbox" :checked="status().policy?.collectTraces !== false"> Traces</label><label class="check"><input name="collectRequestAnalytics" type="checkbox" :checked="status().policy?.collectRequestAnalytics !== false"> Request analytics</label><button class="btn sm" type="submit">Save policy</button></form><p class="note">Lower sampling and retention reduce local ingestion/storage, but can hide rare events. Cost is an estimate using your configured storage rate; provider allocation is never invented.</p></details>
66
68
  <section class="source-grid" aria-label="Telemetry collection sources"><template :for="item in status().sources || []"><article class="panel source-card"><div><b>{{ item.source }}</b><span class="pill" :class="freshnessTone(item.freshness)">{{ item.freshness }}</span></div><small>last {{ item.lastObservedAt ? new Date(item.lastObservedAt).toLocaleString() : 'never' }} · lag {{ item.lagSeconds ?? '—' }}s</small><small>{{ item.retentionDays }}d retention · {{ Math.round(item.samplingRate * 100) }}% sampled · ~{{ fmt(item.estimatedDailyBytes, ' B/day') }}</small><p class="note" @show="!!item.message">{{ item.message }}</p></article></template><article class="panel source-card" @show="!(status().sources || []).length"><b>No collection sources</b><small>Refresh to collect live provider data. Empty data is never replaced with samples.</small></article></section>
67
69
  <section class="golden"><article class="panel"><h3>Traffic</h3><strong>{{ fmt(requestTotal()) }}</strong><small>requests · summed buckets</small></article><article class="panel"><h3>Errors</h3><strong>{{ fmt(errorRate(), '%') }}</strong><small>{{ fmt(errorTotal()) }} errors</small></article><article class="panel"><h3>Latency</h3><strong>{{ fmt(p95(), ' ms') }}</strong><small>latest p95 · worst available source</small></article><article class="panel"><h3>Saturation</h3><strong>{{ fmt(saturationSignal()?.value, saturationSignal()?.unit ? ' ' + saturationSignal().unit : '') }}</strong><small>{{ saturationSignal()?.name || 'unsupported in this window' }}</small></article></section>
68
- <section class="chart-grid"><template :for="series in [...traffic(), ...errors(), ...latency(), ...saturation()].slice(0, 8)"><article class="chart-card panel"><div class="chart-head"><b>{{ series.name }}</b><span>{{ series.source }} · {{ series.aggregation }} / {{ Math.round(series.bucketMs / 60000) }}m</span></div><div class="telemetry-bars" :aria-label="series.name + ' time series'"><template :for="point in series.points"><i :class="{ gap: point.gap }" :style="'height:' + barHeight(series, point) + '%'" :title="point.label + ': ' + (point.gap ? 'gap' : point.value)"></i></template></div><small>{{ timezone() }} · gaps shown as dim baseline bars <span @show="comparisonDelta(series) != null">· {{ comparisonDelta(series) > 0 ? '+' : '' }}{{ comparisonDelta(series)?.toFixed(1) }}% vs previous</span></small></article></template><article class="chart-card panel" @show="![...traffic(), ...errors(), ...latency(), ...saturation()].length"><b>No metric series</b><p class="note">The selected sources have not emitted these golden signals in this window, or the provider reports them as unsupported.</p></article></section>
70
+ <section class="chart-grid"><template :for="series in [...traffic(), ...errors(), ...latency(), ...saturation(), ...infrastructure(), ...siteSignals()].slice(0, 24)"><article class="chart-card panel"><div class="chart-head"><b>{{ series.name }}</b><span>{{ series.source }} · {{ series.aggregation }} / {{ Math.round(series.bucketMs / 60000) }}m</span></div><div class="telemetry-bars" :aria-label="series.name + ' time series'"><template :for="point in series.points"><i :class="{ gap: point.gap }" :style="'height:' + barHeight(series, point) + '%'" :title="point.label + ': ' + (point.gap ? 'gap' : point.value)"></i></template></div><small>{{ timezone() }} · gaps shown as dim baseline bars <span @show="comparisonDelta(series) != null">· {{ comparisonDelta(series) > 0 ? '+' : '' }}{{ comparisonDelta(series)?.toFixed(1) }}% vs previous</span></small></article></template><article class="chart-card panel" @show="![...traffic(), ...errors(), ...latency(), ...saturation(), ...infrastructure(), ...siteSignals()].length"><b>No metric series</b><p class="note">The selected sources have not emitted these golden signals in this window, or the provider reports them as unsupported.</p></article></section>
69
71
  <section class="section"><div class="section-title"><div><h2>Logs, requests, traces, and events</h2><p class="note">Bodies, authorization, cookies, query values, and configured PII are removed before persistence.</p></div><div class="header-actions"><input class="save-name" :value="savedName()" @input="savedName.set($event.target.value)" placeholder="Query name" aria-label="Saved query name"><button class="btn ghost sm" type="button" @click="saveQuery()">Save query</button><button class="btn ghost sm" type="button" @click="exportData('json')">JSON</button><button class="btn ghost sm" type="button" @click="exportData('csv')">CSV</button></div></div><div class="log-filters panel"><label>Search<input type="search" :value="textQuery()" @input="textQuery.set($event.target.value)" @change="applyFilters()" placeholder="Message, request, release…"></label><label>Level<select :value="level()" @change="level.set($event.target.value); applyFilters()"><option value="">All</option><option value="debug">debug</option><option value="info">info</option><option value="warning">warning</option><option value="error">error</option></select></label><label>Source<input :value="source()" @change="source.set($event.target.value); applyFilters()" placeholder="cloudwatch:http"></label></div><div class="saved" @show="savedQueries().length"><span>Saved:</span><template :for="item in savedQueries()"><button type="button" class="btn ghost sm" @click="useSaved(item)">{{ item.name }}</button></template></div><div class="panel telemetry-table-wrap"><table class="telemetry-table"><thead><tr><th scope="col">Time</th><th scope="col">Kind</th><th scope="col">Source</th><th scope="col">Signal</th><th scope="col">Message / value</th><th scope="col">Correlation</th></tr></thead><tbody><template :for="record in records()"><tr tabindex="0" @click="openCorrelation(record)" @keydown.enter="openCorrelation(record)"><td>{{ new Date(record.timestamp).toLocaleString([], {timeZone: timezone()}) }}</td><td><span class="tag">{{ record.kind }}</span></td><td>{{ record.source }}</td><td><span class="pill" :class="record.level === 'error' ? 'bad' : record.level === 'warning' ? 'warn' : ''">{{ record.level || record.name }}</span></td><td class="mono">{{ record.message || (record.value != null ? record.value + ' ' + (record.unit || '') : record.durationMs != null ? record.durationMs + 'ms' : record.name) }}</td><td class="mono">{{ record.requestId || record.traceId || record.releaseId || record.deploymentId || '—' }}</td></tr></template></tbody></table><div class="empty" @show="!records().length"><strong>No telemetry in this window</strong><span>Empty, unavailable, and filtered results remain explicit; no demo rows are shown.</span></div><button class="btn ghost load-more sm" type="button" @show="!!nextCursor()" @click="loadMore()">Load older records</button></div></section>
70
72
  <aside class="correlation panel" @show="!!correlation()"><button class="detail-close" type="button" aria-label="Close correlation detail" @click="correlation.set(null)">×</button><h2>Correlation</h2><p class="mono">{{ correlation()?.key }}</p><div class="timeline"><template :for="item in correlation()?.records || []"><article><span>{{ new Date(item.timestamp).toLocaleString() }}</span><b>{{ item.name }}</b><small>{{ item.source }} · {{ item.kind }} · {{ item.message || item.value || '' }}</small><nav><a @show="!!item.releaseId" :href="'/operations/releases?release=' + encodeURIComponent(item.releaseId)">Release</a><a @show="!!item.workloadId" :href="'/operations/workloads?search=' + encodeURIComponent(item.workloadId)">Workload</a></nav></article></template></div></aside>
71
73
  </main>
@@ -1,66 +1,179 @@
1
1
  <script server>
2
2
  import { renderResourceBars } from '../../src/charts.ts'
3
3
 
4
- // ts-cloud server health view. Real data is injected at build time via
5
- // TSCLOUD_DASHBOARD_DATA (see deploy/dashboard-data-server.ts); the sample
6
- // No fallback samples: missing data must remain visibly unavailable.
7
4
  let __d = null
8
5
  try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
9
6
 
10
7
  const server = __d?.server ?? { name: 'Server', provider: 'unavailable', region: 'unavailable', os: 'unavailable', uptime: 'unavailable' }
11
- const m = __d?.systemMetrics ?? { load: 0, cpus: 0, memUsedMb: 0, memTotalMb: 0, diskUsedPct: 0, diskUsedGb: 0, diskTotalGb: 0 }
8
+ const m = __d?.systemMetrics ?? {
9
+ load: 0, load5: 0, load15: 0, cpuUsedPct: 0, cpus: 0,
10
+ memUsedMb: 0, memTotalMb: 0, memAvailableMb: 0, memCacheMb: 0,
11
+ swapUsedMb: 0, swapTotalMb: 0, diskUsedPct: 0, diskUsedGb: 0,
12
+ diskTotalGb: 0, inodeUsedPct: 0, networkRxBytes: 0, networkTxBytes: 0,
13
+ processes: 0,
14
+ }
12
15
  const live = !!__d && !!__d?._serverReachable && !__d?.metricsUnavailable
13
-
14
- // ── Derived percentages ───────────────────────────────────────────────
15
16
  const memPct = live ? Math.min(100, Math.round((m.memUsedMb / Math.max(1, m.memTotalMb)) * 100)) : 0
16
17
  const loadPct = live ? Math.min(100, Math.round((m.load / Math.max(1, m.cpus || 1)) * 100)) : 0
18
+ const cpuPct = live ? Math.min(100, Math.round(m.cpuUsedPct ?? loadPct)) : 0
17
19
  const diskPct = live ? Math.min(100, Math.round(m.diskUsedPct)) : 0
18
-
19
- // Human-friendly sizes.
20
+ const swapPct = live && m.swapTotalMb ? Math.min(100, Math.round((m.swapUsedMb / m.swapTotalMb) * 100)) : 0
20
21
  const gb = mb => (mb / 1024).toFixed(1)
22
+ const bytes = value => {
23
+ const n = Number(value || 0)
24
+ if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB'
25
+ if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'
26
+ return Math.round(n / 1024) + ' KB'
27
+ }
21
28
  const memUsedGb = gb(m.memUsedMb)
22
29
  const memTotalGb = gb(m.memTotalMb)
23
- const memFreeGb = gb(Math.max(0, m.memTotalMb - m.memUsedMb))
30
+ const memAvailableGb = gb(m.memAvailableMb || Math.max(0, m.memTotalMb - m.memUsedMb))
24
31
  const diskFreeGb = Math.max(0, m.diskTotalGb - m.diskUsedGb)
25
- const loadPerCpu = (m.load / Math.max(1, m.cpus || 1)).toFixed(2)
26
32
  const unavailableNote = 'Live metrics are unavailable because the dashboard probe could not reach the server.'
27
- const loadPerCpuNote = `${loadPerCpu} per core`
28
- const memTotalNote = `of ${memTotalGb} GB total`
29
- const diskTotalNote = `of ${m.diskTotalGb} GB total`
30
-
31
- // ── Threshold tone: green < 70%, amber 70-90%, red ≥ 90% ───────────────
32
33
  function toneOf(p) { return p >= 90 ? 'bad' : p >= 70 ? 'warn' : 'ok' }
33
34
  function word(p) { return p >= 90 ? 'Critical' : p >= 70 ? 'Elevated' : 'Healthy' }
34
- const cpuTone = toneOf(loadPct), memTone = toneOf(memPct), diskTone = toneOf(diskPct)
35
-
36
- // Overall verdict = worst of the three vitals.
35
+ const cpuTone = toneOf(cpuPct), memTone = toneOf(memPct), diskTone = toneOf(diskPct)
37
36
  const ranks = { ok: 0, warn: 1, bad: 2 }
38
37
  const worst = [cpuTone, memTone, diskTone].sort((a, b) => ranks[b] - ranks[a])[0]
39
38
  const measuredOverall = {
40
- bad: { tone: 'bad', title: 'Action needed', note: 'One or more vitals are in the critical range.' },
41
- warn: { tone: 'warn', title: 'Running warm', note: 'Resources are elevated - keep an eye on it.' },
42
- ok: { tone: 'ok', title: 'All systems healthy', note: 'CPU, memory, and disk are well within limits.' },
39
+ bad: { tone: 'bad', title: 'Action needed', note: 'One or more host resources are in the critical range.' },
40
+ warn: { tone: 'warn', title: 'Running warm', note: 'At least one host resource is elevated.' },
41
+ ok: { tone: 'ok', title: 'All systems healthy', note: 'CPU, memory, and disk are within normal limits.' },
43
42
  }[worst]
44
43
  const overall = live ? measuredOverall : { tone: 'warn', title: 'Probe unavailable', note: unavailableNote }
45
-
46
- // One descriptor per gauge - rendered with @foreach below.
47
44
  const gaugeCards = [
48
- { label: 'CPU Load', icon: 'cpu', tone: live ? cpuTone : 'warn', tag: live ? word(loadPct) : 'Unavailable', big: live ? loadPct : '-', pct: loadPct, unit: live ? 'of capacity' : 'probe offline', sub: live ? `${loadPerCpu} load / core · ${m.load} avg` : unavailableNote },
49
- { label: 'Memory', icon: 'mem', tone: live ? memTone : 'warn', tag: live ? word(memPct) : 'Unavailable', big: live ? memPct : '-', pct: memPct, unit: live ? 'memory used' : 'probe offline', sub: live ? `${memUsedGb} / ${memTotalGb} GB · ${memFreeGb} free` : unavailableNote },
50
- { label: 'Disk', icon: 'disk', tone: live ? diskTone : 'warn', tag: live ? word(diskPct) : 'Unavailable', big: live ? diskPct : '-', pct: diskPct, unit: live ? 'disk used' : 'probe offline', sub: live ? `${m.diskUsedGb} / ${m.diskTotalGb} GB · ${diskFreeGb} GB free` : unavailableNote },
45
+ { label: 'CPU utilization', icon: 'cpu', tone: live ? cpuTone : 'warn', tag: live ? word(cpuPct) : 'Unavailable', big: live ? cpuPct : '-', pct: cpuPct, unit: live ? 'processor busy' : 'probe offline', sub: live ? `${m.load} load across ${m.cpus} vCPU` : unavailableNote },
46
+ { label: 'Memory', icon: 'mem', tone: live ? memTone : 'warn', tag: live ? word(memPct) : 'Unavailable', big: live ? memPct : '-', pct: memPct, unit: live ? 'memory used' : 'probe offline', sub: live ? `${memUsedGb} / ${memTotalGb} GB, ${memAvailableGb} GB available` : unavailableNote },
47
+ { label: 'Disk', icon: 'disk', tone: live ? diskTone : 'warn', tag: live ? word(diskPct) : 'Unavailable', big: live ? diskPct : '-', pct: diskPct, unit: live ? 'disk used' : 'probe offline', sub: live ? `${m.diskUsedGb} / ${m.diskTotalGb} GB, ${diskFreeGb} GB free` : unavailableNote },
51
48
  ]
52
-
53
49
  const resourceChart = renderResourceBars([
54
- { label: 'CPU', value: loadPct, detail: live ? `${m.load} load / ${m.cpus} vCPU` : unavailableNote },
50
+ { label: 'CPU', value: cpuPct, detail: live ? `${cpuPct}% busy, ${m.load} one-minute load` : unavailableNote },
55
51
  { label: 'Memory', value: memPct, detail: live ? `${memUsedGb} / ${memTotalGb} GB` : unavailableNote },
56
52
  { label: 'Disk', value: diskPct, detail: live ? `${m.diskUsedGb} / ${m.diskTotalGb} GB` : unavailableNote },
53
+ { label: 'Swap', value: swapPct, detail: live ? `${gb(m.swapUsedMb)} / ${gb(m.swapTotalMb)} GB` : unavailableNote },
57
54
  ], { disabled: !live })
55
+ </script>
56
+ <script client>
57
+ import { usePolling } from '../../functions/usePolling.ts'
58
58
 
59
- const loadTrend = __d?.loadTrend ?? []
60
- const maxLoad = Math.max(...loadTrend, 1)
61
- const trendPeak = loadTrend.length ? Math.max(...loadTrend) : 0
62
- function barPct(v) { return Math.round((v / maxLoad) * 100) }
63
- function barTone(v) { const r = v / maxLoad; return r >= 0.9 ? 'peak' : r >= 0.7 ? 'hot' : '' }
59
+ const ranges = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '7d': 604800000, '30d': 2592000000 }
60
+ const historyRange = state('24h')
61
+ const historySeries = state([])
62
+ const historyError = state('')
63
+ const currentData = state(null)
64
+ function historyParams() {
65
+ const duration = ranges[historyRange()] || ranges['24h']
66
+ const to = new Date()
67
+ const from = new Date(to.getTime() - duration)
68
+ const bucketMs = Math.max(60000, Math.ceil(duration / 96 / 60000) * 60000)
69
+ return new URLSearchParams({
70
+ from: from.toISOString(),
71
+ to: to.toISOString(),
72
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
73
+ names: [
74
+ 'host.cpu.used_percent', 'host.load', 'host.cpu.capacity',
75
+ 'host.memory.used_percent', 'host.memory.used', 'host.memory.total',
76
+ 'host.disk.used_percent', 'host.swap.used_percent',
77
+ 'host.disk.inode_used_percent',
78
+ ].join(','),
79
+ aggregation: 'avg',
80
+ bucketMs: String(bucketMs),
81
+ })
82
+ }
83
+ async function loadHistory() {
84
+ const response = await fetch('/api/telemetry/series?' + historyParams())
85
+ const body = await response.json()
86
+ if (!response.ok || body.ok === false) throw new Error(body.error || 'Historical metrics could not be loaded.')
87
+ historySeries.set(body.series || [])
88
+ historyError.set('')
89
+ }
90
+ async function loadCurrent() {
91
+ const response = await fetch('/api/dashboard-data')
92
+ const body = await response.json()
93
+ if (!response.ok || body._serverReachable !== true)
94
+ throw new Error(body.error || 'Current server metrics could not be loaded.')
95
+ currentData.set(body)
96
+ }
97
+ const { pollingState, pollingError, lastUpdatedAt, refresh } = usePolling(async () => {
98
+ try { await Promise.all([loadCurrent(), loadHistory()]) }
99
+ catch (error) { historyError.set(error?.message || String(error)); throw error }
100
+ }, 60000)
101
+ function liveMetric() { return currentData()?.systemMetrics || {} }
102
+ function percent(value) { return Math.max(0, Math.min(100, Math.round(Number(value) || 0))) }
103
+ function ratio(used, total) { return total ? percent(Number(used) / Number(total) * 100) : 0 }
104
+ function liveCpu() { return percent(liveMetric().cpuUsedPct) }
105
+ function liveMemory() { return ratio(liveMetric().memUsedMb, liveMetric().memTotalMb) }
106
+ function liveDisk() { return percent(liveMetric().diskUsedPct) }
107
+ function liveSwap() { return ratio(liveMetric().swapUsedMb, liveMetric().swapTotalMb) }
108
+ function liveTone() {
109
+ const peak = Math.max(liveCpu(), liveMemory(), liveDisk())
110
+ return peak >= 90 ? 'bad' : peak >= 70 ? 'warn' : 'ok'
111
+ }
112
+ function liveStatus() { return liveTone() === 'bad' ? 'Action needed' : liveTone() === 'warn' ? 'Running warm' : 'All systems healthy' }
113
+ function liveGb(value) { return (Number(value || 0) / 1024).toFixed(1) }
114
+ function liveBytes(value) {
115
+ const n = Number(value || 0)
116
+ if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB'
117
+ if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'
118
+ return Math.round(n / 1024) + ' KB'
119
+ }
120
+ const liveCards = derived(() => [
121
+ { label: 'CPU utilization', pct: liveCpu(), detail: `${liveMetric().load} load across ${liveMetric().cpus} vCPU` },
122
+ { label: 'Memory', pct: liveMemory(), detail: `${liveGb(liveMetric().memUsedMb)} / ${liveGb(liveMetric().memTotalMb)} GB` },
123
+ { label: 'Disk', pct: liveDisk(), detail: `${liveMetric().diskUsedGb} / ${liveMetric().diskTotalGb} GB` },
124
+ { label: 'Swap', pct: liveSwap(), detail: `${liveGb(liveMetric().swapUsedMb)} / ${liveGb(liveMetric().swapTotalMb)} GB` },
125
+ ])
126
+ function series(name) { return historySeries().find(item => item.name === name) }
127
+ function points(name) { return (series(name)?.points || []).filter(point => !point.gap && point.value != null) }
128
+ function ratioPoints(primary, numerator, denominator, multiplier = 100) {
129
+ const direct = points(primary)
130
+ if (direct.length) return direct.map(point => ({ ...point, value: Math.max(0, Math.min(100, Number(point.value))) }))
131
+ const den = new Map(points(denominator).map(point => [point.timestamp, Number(point.value)]))
132
+ return points(numerator)
133
+ .filter(point => Number(den.get(point.timestamp)) > 0)
134
+ .map(point => ({ ...point, value: Math.max(0, Math.min(100, Number(point.value) / Number(den.get(point.timestamp)) * multiplier)) }))
135
+ }
136
+ function metricPoints(name) {
137
+ if (name === 'cpu') return ratioPoints('host.cpu.used_percent', 'host.load', 'host.cpu.capacity')
138
+ if (name === 'memory') return ratioPoints('host.memory.used_percent', 'host.memory.used', 'host.memory.total')
139
+ if (name === 'disk') return points('host.disk.used_percent')
140
+ if (name === 'swap') return points('host.swap.used_percent')
141
+ if (name === 'inodes') return points('host.disk.inode_used_percent')
142
+ return points('host.load')
143
+ }
144
+ function linePath(metric) {
145
+ const values = metricPoints(metric)
146
+ if (!values.length) return ''
147
+ const max = metric === 'load' ? Math.max(1, ...values.map(point => Number(point.value))) : 100
148
+ return values.map((point, index) => {
149
+ const x = values.length === 1 ? 50 : index / (values.length - 1) * 100
150
+ const y = 42 - Math.max(0, Math.min(1, Number(point.value) / max)) * 38
151
+ return `${index ? 'L' : 'M'} ${x.toFixed(2)} ${y.toFixed(2)}`
152
+ }).join(' ')
153
+ }
154
+ function areaPath(metric) {
155
+ const line = linePath(metric)
156
+ return line ? `${line} L 100 44 L 0 44 Z` : ''
157
+ }
158
+ function latest(metric) {
159
+ const values = metricPoints(metric)
160
+ return values.length ? Number(values.at(-1).value) : null
161
+ }
162
+ function peak(metric) {
163
+ const values = metricPoints(metric)
164
+ return values.length ? Math.max(...values.map(point => Number(point.value))) : null
165
+ }
166
+ function display(value, suffix = '%') { return value == null ? 'No data' : `${value.toFixed(value >= 10 ? 0 : 1)}${suffix}` }
167
+ function samples(metric) { return metricPoints(metric).length }
168
+ const historyCards = [
169
+ { key: 'cpu', label: 'CPU utilization', note: 'Actual busy time, with load-per-core fallback for older samples', tone: 'blue' },
170
+ { key: 'memory', label: 'Memory usage', note: 'Used RAM as a percentage of installed memory', tone: 'green' },
171
+ { key: 'disk', label: 'Disk usage', note: 'Root filesystem capacity consumed', tone: 'amber' },
172
+ { key: 'swap', label: 'Swap usage', note: 'Swap pressure across the selected period', tone: 'violet' },
173
+ { key: 'load', label: 'Load average', note: 'One-minute runnable and waiting task average', tone: 'cyan', suffix: '' },
174
+ { key: 'inodes', label: 'Inode usage', note: 'Root filesystem inode capacity consumed', tone: 'rose' },
175
+ ]
176
+ function setRange(value) { historyRange.set(value); refresh() }
64
177
  </script>
65
178
  <!DOCTYPE html>
66
179
  <html lang="en" data-theme="dark">
@@ -70,21 +183,55 @@ function barTone(v) { const r = v / maxLoad; return r >= 0.9 ? 'peak' : r >= 0.7
70
183
  <div class="wrap">
71
184
  <div class="crumbs"><a href="/">Server</a> <span class="sep">/</span> Health</div>
72
185
  <header class="page">
73
- <div>
74
- <h1>Server health</h1>
75
- <p>CPU, memory, disk &amp; vital signs for {{ server.name }}.</p>
186
+ <div><h1>Server health</h1><p>Current and historical resource pressure for {{ server.name }}.</p></div>
187
+ <div class="header-actions"><span class="pill" :class="pollingState() === 'live' ? 'ok' : 'warn'">{{ pollingState() }}</span><small>{{ lastUpdatedAt()?.toLocaleTimeString() || 'connecting' }}</small></div>
188
+ </header>
189
+
190
+ <div class="health-hero" :class="liveTone()" @show="!!currentData()">
191
+ <div class="status"><span class="led"></span><span>{{ liveStatus() }}<small>Live host probe, refreshed automatically.</small></span></div>
192
+ <div class="hmeta">
193
+ <div class="item"><span>OS</span><b>{{ currentData()?.server?.os || 'unavailable' }}</b></div>
194
+ <div class="item"><span>Region</span><b>{{ currentData()?.server?.provider || 'unavailable' }} · {{ currentData()?.server?.region || 'unavailable' }}</b></div>
195
+ <div class="item"><span>Uptime</span><b>{{ currentData()?.server?.uptime || 'unavailable' }}</b></div>
196
+ <div class="item"><span>vCPUs</span><b>{{ liveMetric().cpus }}</b></div>
76
197
  </div>
77
- <div class="header-actions">
78
- @if (live)<span class="ok pill">live</span>@else<span class="pill warn">probe unavailable</span>@endif
198
+ </div>
199
+
200
+ <div class="gauges live-gauges" @show="!!currentData()">
201
+ <template :for="card in liveCards()">
202
+ <div class="gauge" :class="card.pct >= 90 ? 'bad' : card.pct >= 70 ? 'warn' : 'ok'">
203
+ <div class="ghead">{{ card.label }}</div>
204
+ <div class="ring" :style="'--pct:' + card.pct"><div class="center"><div class="pct">{{ card.pct }}<sup>%</sup></div><div class="punit">used</div></div></div>
205
+ <div class="gsub">{{ card.detail }}</div>
206
+ </div>
207
+ </template>
208
+ </div>
209
+
210
+ <div class="section" @show="!!currentData()">
211
+ <h2>Vital signs</h2>
212
+ <div class="host-vitals vitals">
213
+ <div class="vital"><div class="vlabel">Load averages</div><div class="vval">{{ liveMetric().load }} / {{ liveMetric().load5 }} / {{ liveMetric().load15 }}</div><div class="vnote">1, 5, and 15 minutes</div></div>
214
+ <div class="vital"><div class="vlabel">Memory available</div><div class="vval">{{ liveGb(liveMetric().memAvailableMb) }} <small>GB</small></div><div class="vnote">{{ liveGb(liveMetric().memCacheMb) }} GB cache</div></div>
215
+ <div class="vital"><div class="vlabel">Disk inodes</div><div class="vval">{{ liveMetric().inodeUsedPct }}<small>% used</small></div><div class="vnote">Filesystem entry capacity</div></div>
216
+ <div class="vital"><div class="vlabel">Processes</div><div class="vval">{{ liveMetric().processes }}</div><div class="vnote">Current process count</div></div>
217
+ <div class="vital"><div class="vlabel">Network received</div><div class="vval">{{ liveBytes(liveMetric().networkRxBytes) }}</div><div class="vnote">Since host boot</div></div>
218
+ <div class="vital"><div class="vlabel">Network sent</div><div class="vval">{{ liveBytes(liveMetric().networkTxBytes) }}</div><div class="vnote">Since host boot</div></div>
219
+ <div class="vital"><div class="vlabel">Disk free</div><div class="vval">{{ Math.max(0, Number(liveMetric().diskTotalGb) - Number(liveMetric().diskUsedGb)) }} <small>GB</small></div><div class="vnote">{{ liveMetric().diskTotalGb }} GB total</div></div>
220
+ <div class="vital"><div class="vlabel">Swap</div><div class="vval">{{ liveSwap() }}<small>% used</small></div><div class="vnote">{{ liveGb(liveMetric().swapUsedMb) }} / {{ liveGb(liveMetric().swapTotalMb) }} GB</div></div>
79
221
  </div>
80
- </header>
222
+ </div>
81
223
 
82
- <!-- Verdict banner: the one-glance "is the box OK?" answer. -->
83
- <div class="health-hero {{ overall.tone }}">
84
- <div class="status">
85
- <span class="led"></span>
86
- <span>{{ overall.title }}<small>{{ overall.note }}</small></span>
224
+ <div class="section" @show="!!currentData()">
225
+ <h2>Current resource shape</h2>
226
+ <div class="live-bars panel">
227
+ <template :for="card in liveCards()">
228
+ <div class="live-bar"><div><span>{{ card.label }}</span><strong>{{ card.pct }}%</strong></div><div class="live-bar-track"><i :style="'width:' + card.pct + '%'"></i></div><small>{{ card.detail }}</small></div>
229
+ </template>
87
230
  </div>
231
+ </div>
232
+
233
+ <div class="health-hero {{ overall.tone }}" @show="!currentData()">
234
+ <div class="status"><span class="led"></span><span>{{ overall.title }}<small>{{ overall.note }}</small></span></div>
88
235
  <div class="hmeta">
89
236
  <div class="item"><span>OS</span><b>{{ server.os }}</b></div>
90
237
  <div class="item"><span>Region</span><b>{{ server.provider }} · {{ server.region }}</b></div>
@@ -93,83 +240,92 @@ function barTone(v) { const r = v / maxLoad; return r >= 0.9 ? 'peak' : r >= 0.7
93
240
  </div>
94
241
  </div>
95
242
 
96
- <!-- Centerpiece: radial gauges for the three vitals. -->
97
- <div class="gauges">
243
+ <div class="gauges" @show="!currentData()">
98
244
  @foreach (gaugeCards as g)
99
245
  <div class="gauge {{ g.tone }}">
100
- <div class="ghead">
101
- @if (g.icon === 'cpu')
102
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/></svg>
103
- @elseif (g.icon === 'mem')
104
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="10" rx="2"/><path d="M7 7v-2M12 7v-2M17 7v-2M7 21v-2M12 21v-2M17 21v-2"/></svg>
105
- @else
106
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/></svg>
107
- @endif
108
- {{ g.label }}
109
- </div>
110
- <div class="ring" style="--pct: {{ g.pct }}">
111
- <div class="center">
112
- <div class="pct">{{ g.big }}@if (live)<sup>%</sup>@endif</div>
113
- <div class="punit">{{ g.unit }}</div>
114
- </div>
115
- </div>
116
- <div class="gsub">{{ g.sub }}</div>
117
- <div class="gtag">{{ g.tag }}</div>
246
+ <div class="ghead">{{ g.label }}</div>
247
+ <div class="ring" style="--pct: {{ g.pct }}"><div class="center"><div class="pct">{{ g.big }}@if (live)<sup>%</sup>@endif</div><div class="punit">{{ g.unit }}</div></div></div>
248
+ <div class="gsub">{{ g.sub }}</div><div class="gtag">{{ g.tag }}</div>
118
249
  </div>
119
250
  @endforeach
120
251
  </div>
121
252
 
122
- <!-- Secondary vital signs. -->
123
- <div class="section">
253
+ <div class="section" @show="!currentData()">
124
254
  <h2>Vital signs</h2>
125
- <div class="vitals">
126
- <div class="vital">
127
- <div class="vlabel">Load average</div>
128
- <div class="vval">{{ live ? m.load : 'Unavailable' }} @if (live)<small>/ {{ m.cpus }} cores</small>@endif</div>
129
- <div class="vnote">{{ live ? loadPerCpuNote : unavailableNote }}</div>
130
- </div>
131
- <div class="vital">
132
- <div class="vlabel">Memory free</div>
133
- <div class="vval">{{ live ? memFreeGb : 'Unavailable' }} @if (live)<small>GB</small>@endif</div>
134
- <div class="vnote">{{ live ? memTotalNote : unavailableNote }}</div>
135
- </div>
136
- <div class="vital">
137
- <div class="vlabel">Disk free</div>
138
- <div class="vval">{{ live ? diskFreeGb : 'Unavailable' }} @if (live)<small>GB</small>@endif</div>
139
- <div class="vnote">{{ live ? diskTotalNote : unavailableNote }}</div>
140
- </div>
141
- <div class="vital">
142
- <div class="vlabel">Uptime</div>
143
- <div class="vval">{{ live ? server.uptime : 'Unavailable' }}</div>
144
- <div class="vnote">{{ server.os }}</div>
145
- </div>
255
+ <div class="host-vitals vitals">
256
+ <div class="vital"><div class="vlabel">Load averages</div><div class="vval">{{ live ? `${m.load} / ${m.load5} / ${m.load15}` : 'Unavailable' }}</div><div class="vnote">1, 5, and 15 minutes</div></div>
257
+ <div class="vital"><div class="vlabel">Memory available</div><div class="vval">{{ live ? memAvailableGb : 'Unavailable' }} @if (live)<small>GB</small>@endif</div><div class="vnote">{{ live ? `${gb(m.memCacheMb)} GB cache` : unavailableNote }}</div></div>
258
+ <div class="vital"><div class="vlabel">Swap</div><div class="vval">{{ live ? swapPct : 'Unavailable' }}@if (live)<small>% used</small>@endif</div><div class="vnote">{{ live ? `${gb(m.swapUsedMb)} / ${gb(m.swapTotalMb)} GB` : unavailableNote }}</div></div>
259
+ <div class="vital"><div class="vlabel">Disk inodes</div><div class="vval">{{ live ? m.inodeUsedPct : 'Unavailable' }}@if (live)<small>% used</small>@endif</div><div class="vnote">Filesystem entry capacity</div></div>
260
+ <div class="vital"><div class="vlabel">Network received</div><div class="vval">{{ live ? bytes(m.networkRxBytes) : 'Unavailable' }}</div><div class="vnote">Since host boot</div></div>
261
+ <div class="vital"><div class="vlabel">Network sent</div><div class="vval">{{ live ? bytes(m.networkTxBytes) : 'Unavailable' }}</div><div class="vnote">Since host boot</div></div>
262
+ <div class="vital"><div class="vlabel">Processes</div><div class="vval">{{ live ? m.processes : 'Unavailable' }}</div><div class="vnote">Current process count</div></div>
263
+ <div class="vital"><div class="vlabel">Disk free</div><div class="vval">{{ live ? diskFreeGb : 'Unavailable' }} @if (live)<small>GB</small>@endif</div><div class="vnote">{{ live ? `${m.diskTotalGb} GB total` : unavailableNote }}</div></div>
146
264
  </div>
147
265
  </div>
148
266
 
149
- <!-- Load over recent snapshots. -->
150
- <div class="section">
151
- <h2>Resource chart</h2>
152
- <div class="chart-panel panel">
153
- {{{ resourceChart }}}
154
- </div>
267
+ <div class="section" @show="!currentData()">
268
+ <h2>Current resource shape</h2>
269
+ <div class="chart-panel panel">{{{ resourceChart }}}</div>
155
270
  </div>
156
271
 
157
272
  <div class="section">
158
- <h2>CPU load trend</h2>
159
- <div class="chart panel">
160
- @if (live && loadTrend.length)
161
- <div class="spark">
162
- @foreach (loadTrend as v)
163
- <i class="{{ barTone(v) }}" style="height: {{ barPct(v) }}%"></i>
164
- @endforeach
165
- </div>
166
- <div class="xaxis"><span>-24 snapshots</span><span>peak {{ trendPeak }}% · now</span></div>
167
- <p class="note">% of CPU capacity · refresh with <span class="mono">cloud server:monitoring {{ server.name }}</span></p>
168
- @else
169
- <div class="compact empty"><strong>No historical trend</strong><span>Open <a href="/operations/observability">Observability</a> for persisted host metrics, source freshness, timezone, and gaps.</span></div>
170
- @endif
273
+ <div class="history-heading">
274
+ <div><h2>Historical performance</h2><p>Persisted independently of browser sessions. Gaps remain visible as missing samples.</p></div>
275
+ <div class="segmented" role="group" aria-label="Historical metric range">
276
+ <template :for="value in ['1h','6h','24h','7d','30d']"><button type="button" :class="{active:historyRange()===value}" @click="setRange(value)">{{ value }}</button></template>
277
+ </div>
278
+ </div>
279
+ <p class="bad notice" role="status" @show="!!historyError() || !!pollingError()">{{ historyError() || pollingError() }}</p>
280
+ <div class="history-grid">
281
+ <template :for="card in historyCards">
282
+ <article class="history-card panel" :class="card.tone">
283
+ <div class="history-card-head"><div><h3>{{ card.label }}</h3><p>{{ card.note }}</p></div><strong>{{ display(latest(card.key), card.suffix ?? '%') }}</strong></div>
284
+ <svg viewBox="0 0 100 46" preserveAspectRatio="none" role="img" :aria-label="card.label + ' history'">
285
+ <line x1="0" x2="100" y1="23" y2="23" class="history-gridline"></line>
286
+ <path :d="areaPath(card.key)" class="history-area"></path>
287
+ <path :d="linePath(card.key)" class="history-line"></path>
288
+ </svg>
289
+ <div class="history-meta"><span>{{ samples(card.key) }} samples</span><span>peak {{ display(peak(card.key), card.suffix ?? '%') }}</span></div>
290
+ <div class="compact empty" @show="samples(card.key) === 0"><strong>Collecting history</strong><span>The first persisted point appears within one minute.</span></div>
291
+ </article>
292
+ </template>
171
293
  </div>
294
+ <p class="note">Retention, source freshness, comparisons, logs, and exports are available in <a href="/operations/observability">Observability</a>.</p>
172
295
  </div>
173
296
  </div>
297
+ <style>
298
+ .host-vitals { grid-template-columns: repeat(4, minmax(0, 1fr)); }
299
+ .live-gauges { grid-template-columns: repeat(4, minmax(0, 1fr)); }
300
+ .live-bars { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px 24px; }
301
+ .live-bar > div:first-child { display:flex; align-items:center; justify-content:space-between; gap:12px; font-size:12px; }
302
+ .live-bar strong { color:var(--txt); }
303
+ .live-bar small { display:block; margin-top:7px; color:var(--txt3); font-size:11px; }
304
+ .live-bar-track { height:7px; margin-top:8px; overflow:hidden; border-radius:999px; background:rgba(255,255,255,.07); }
305
+ .live-bar-track i { display:block; height:100%; border-radius:inherit; background:linear-gradient(90deg,var(--accent),#22d3ee); transition:width .35s ease; }
306
+ .history-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:20px; margin-bottom:14px; }
307
+ .history-heading h2 { font-size:15px; }
308
+ .history-heading p { margin-top:5px; color:var(--txt3); font-size:12px; }
309
+ .history-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }
310
+ .history-card { --series:var(--accent); position:relative; overflow:hidden; min-height:244px; }
311
+ .history-card.green { --series:var(--ok); }
312
+ .history-card.amber { --series:var(--warn); }
313
+ .history-card.violet { --series:#a78bfa; }
314
+ .history-card.cyan { --series:#22d3ee; }
315
+ .history-card.rose { --series:#fb7185; }
316
+ .history-card-head { display:flex; justify-content:space-between; gap:18px; align-items:flex-start; }
317
+ .history-card-head h3 { color:var(--txt); font-size:13px; text-transform:none; letter-spacing:0; }
318
+ .history-card-head p { color:var(--txt3); font-size:11px; line-height:1.45; margin-top:4px; max-width:44ch; }
319
+ .history-card-head strong { color:var(--series); font-size:22px; white-space:nowrap; }
320
+ .history-card svg { width:100%; height:126px; margin-top:14px; overflow:visible; }
321
+ .history-gridline { stroke:rgba(255,255,255,.07); stroke-width:.35; stroke-dasharray:2 3; vector-effect:non-scaling-stroke; }
322
+ .history-area { fill:color-mix(in srgb,var(--series) 16%,transparent); }
323
+ .history-line { fill:none; stroke:var(--series); stroke-width:1.35; stroke-linecap:round; stroke-linejoin:round; vector-effect:non-scaling-stroke; }
324
+ .history-meta { display:flex; justify-content:space-between; color:var(--txt3); font-size:11px; }
325
+ .history-card .empty { position:absolute; inset:86px 18px 38px; min-height:0; background:rgba(12,16,23,.92); }
326
+ @media(max-width:900px){.host-vitals,.history-grid,.live-gauges{grid-template-columns:repeat(2,minmax(0,1fr));}.history-heading{align-items:flex-start;flex-direction:column;}}
327
+ @media(max-width:560px){.host-vitals,.history-grid,.live-gauges,.live-bars{grid-template-columns:1fr}.history-heading .segmented{overflow-x:auto}.history-heading .segmented button{min-width:48px}}
328
+ @media(prefers-reduced-motion:reduce){.health-hero .status .led{animation:none}.gauge{transition:none}}
329
+ </style>
174
330
  </body>
175
331
  </html>