@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.
- package/dist/bin/cli.js +1556 -1284
- package/dist/{chunk-0zbqbrqg.js → chunk-0z7ry8fm.js} +889 -89
- package/dist/{chunk-t092y37p.js → chunk-267f6qd0.js} +525 -145
- package/dist/{chunk-zvk0f89t.js → chunk-28vh1h91.js} +1 -1
- package/dist/{chunk-tq46tnxv.js → chunk-ecsyc3p0.js} +4 -1
- package/dist/{chunk-rxhqny6z.js → chunk-zcvdzvmz.js} +2 -2
- package/dist/deploy/dashboard-data-server.d.ts +27 -1
- package/dist/deploy/index.js +4 -4
- package/dist/deploy/telemetry-collection.d.ts +2 -0
- package/dist/drivers/hetzner/client.d.ts +84 -2
- package/dist/drivers/hetzner/host-optimization.d.ts +33 -0
- package/dist/drivers/hetzner/monitoring.d.ts +46 -0
- package/dist/drivers/hetzner/resize-remote.d.ts +38 -0
- package/dist/drivers/hetzner/resize-state.d.ts +24 -0
- package/dist/drivers/hetzner/resize.d.ts +86 -0
- package/dist/drivers/index.d.ts +8 -0
- package/dist/drivers/index.js +35 -3
- package/dist/drivers/shared/compute-deploy.d.ts +15 -0
- package/dist/drivers/shared/ufw.d.ts +4 -2
- package/dist/index.js +4 -4
- package/dist/security/pre-deploy-scanner.test.d.ts +1 -0
- package/dist/ui/access-denied.html +2 -2
- 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 +4 -4
- package/dist/ui/data/services.html +4 -4
- package/dist/ui/data/volumes.html +4 -4
- package/dist/ui/index.html +12 -7
- package/dist/ui/integrations.html +2 -2
- package/dist/ui/operations/alerts.html +4 -4
- package/dist/ui/operations/configuration.html +4 -4
- package/dist/ui/operations/jobs.html +4 -4
- package/dist/ui/operations/maintenance.html +3 -3
- package/dist/ui/operations/observability.html +13 -7
- 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 +4 -4
- package/dist/ui/security.html +2 -2
- package/dist/ui/server/actions.html +3 -3
- package/dist/ui/server/activity.html +2 -2
- 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/firewall.html +3 -3
- package/dist/ui/server/fleet.html +4 -4
- package/dist/ui/server/logs.html +4 -4
- package/dist/ui/server/metrics.html +203 -3
- package/dist/ui/server/services.html +2 -2
- package/dist/ui/server/sites.html +102 -6
- 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 +4 -4
- 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/firewall.html +2 -2
- package/dist/ui/serverless/functions.html +4 -4
- package/dist/ui/serverless/logs.html +3 -3
- 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/pages/index.stx +12 -10
- package/dist/ui-src/pages/operations/observability.stx +6 -4
- package/dist/ui-src/pages/server/metrics.stx +262 -106
- package/dist/ui-src/pages/server/sites.stx +98 -6
- 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,
|
|
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 ?? {
|
|
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
|
|
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(
|
|
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
|
|
41
|
-
warn: { tone: 'warn', title: 'Running warm', note: '
|
|
42
|
-
ok: { tone: 'ok', title: 'All systems healthy', note: 'CPU, memory, and disk are
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
</
|
|
222
|
+
</div>
|
|
81
223
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<div class="
|
|
85
|
-
<
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
123
|
-
<div class="section">
|
|
253
|
+
<div class="section" @show="!currentData()">
|
|
124
254
|
<h2>Vital signs</h2>
|
|
125
|
-
|
|
126
|
-
<div class="vital">
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
</div>
|
|
131
|
-
<div class="vital">
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
<
|
|
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
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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>
|