@stacksjs/ts-cloud 0.5.25 → 0.5.27
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 +1216 -7359
- package/dist/deploy/dashboard-data-server.d.ts +27 -5
- package/dist/deploy/dashboard-database.d.ts +38 -0
- package/dist/deploy/dashboard-database.test.d.ts +1 -0
- package/dist/deploy/dashboard-operations.d.ts +48 -0
- package/dist/deploy/dashboard-operations.test.d.ts +1 -0
- package/dist/deploy/index.d.ts +1 -0
- package/dist/deploy/local-dashboard-server.d.ts +31 -0
- package/dist/deploy/management-dashboard.d.ts +21 -1
- package/dist/deploy/site-config-editor.d.ts +37 -0
- package/dist/deploy/site-target.d.ts +3 -1
- package/dist/deploy/ssh-config-editor.d.ts +22 -0
- package/dist/deploy/ssh-config-editor.test.d.ts +1 -0
- package/dist/drivers/factory.d.ts +4 -0
- package/dist/drivers/index.d.ts +1 -0
- package/dist/drivers/local-box/driver.d.ts +20 -0
- package/dist/drivers/shared/compute-deploy.d.ts +2 -0
- package/dist/drivers/shared/deploy-script.d.ts +28 -12
- package/dist/drivers/shared/rpx-gateway.d.ts +99 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3378 -207
- package/dist/ui/index.html +83 -1
- package/dist/ui/server/actions.html +1193 -0
- package/dist/ui/server/activity.html +1132 -0
- package/dist/ui/server/backups.html +57 -1
- package/dist/ui/server/database.html +1208 -0
- package/dist/ui/server/deployments.html +79 -1
- package/dist/ui/server/diagnostics.html +1132 -0
- package/dist/ui/server/logs.html +1165 -0
- package/dist/ui/server/metrics.html +1 -1
- package/dist/ui/server/security.html +1132 -0
- package/dist/ui/server/services.html +57 -1
- package/dist/ui/server/sites.html +141 -1
- package/dist/ui/server/ssh-keys.html +58 -1
- package/dist/ui/server/workers.html +57 -1
- package/dist/ui/serverless/assets.html +1 -1
- package/dist/ui/serverless/cost.html +1 -1
- package/dist/ui/serverless/data.html +1 -1
- package/dist/ui/serverless/deployments.html +1 -1
- package/dist/ui/serverless/firewall.html +1 -1
- package/dist/ui/serverless/functions.html +1 -1
- package/dist/ui/serverless/logs.html +1165 -0
- package/dist/ui/serverless/metrics.html +1 -1
- package/dist/ui/serverless/queues.html +1 -1
- package/dist/ui/serverless/scheduler.html +1 -1
- package/dist/ui/serverless/secrets.html +1 -1
- package/dist/ui/serverless.html +1 -1
- package/dist/ui-src/package.json +5 -0
- package/dist/ui-src/pages/index.stx +308 -0
- package/dist/ui-src/pages/partials/head.stx +294 -0
- package/dist/ui-src/pages/partials/nav.stx +54 -0
- package/dist/ui-src/pages/partials/op-confirm.stx +7 -0
- package/dist/ui-src/pages/server/actions.stx +60 -0
- package/dist/ui-src/pages/server/activity.stx +55 -0
- package/dist/ui-src/pages/server/backups.stx +91 -0
- package/dist/ui-src/pages/server/database.stx +119 -0
- package/dist/ui-src/pages/server/deployments.stx +106 -0
- package/dist/ui-src/pages/server/diagnostics.stx +88 -0
- package/dist/ui-src/pages/server/logs.stx +84 -0
- package/dist/ui-src/pages/server/metrics.stx +183 -0
- package/dist/ui-src/pages/server/security.stx +127 -0
- package/dist/ui-src/pages/server/services.stx +82 -0
- package/dist/ui-src/pages/server/sites.stx +143 -0
- package/dist/ui-src/pages/server/ssh-keys.stx +93 -0
- package/dist/ui-src/pages/server/workers.stx +99 -0
- package/dist/ui-src/pages/serverless/assets.stx +66 -0
- package/dist/ui-src/pages/serverless/cost.stx +149 -0
- package/dist/ui-src/pages/serverless/data.stx +76 -0
- package/dist/ui-src/pages/serverless/deployments.stx +56 -0
- package/dist/ui-src/pages/serverless/firewall.stx +58 -0
- package/dist/ui-src/pages/serverless/functions.stx +118 -0
- package/dist/ui-src/pages/serverless/logs.stx +84 -0
- package/dist/ui-src/pages/serverless/metrics.stx +76 -0
- package/dist/ui-src/pages/serverless/queues.stx +81 -0
- package/dist/ui-src/pages/serverless/scheduler.stx +60 -0
- package/dist/ui-src/pages/serverless/secrets.stx +55 -0
- package/dist/ui-src/pages/serverless.stx +222 -0
- package/dist/ui-src/src/charts.ts +17333 -0
- package/package.json +3 -3
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
let __d = null
|
|
3
|
+
try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
|
|
4
|
+
const server = __d?.server ?? { name: 'stacks-production-app' }
|
|
5
|
+
const initialLogs = __d?.serverLogs ?? []
|
|
6
|
+
const emptyReason = __d?.serverLogsEmptyReason ?? 'No server logs are available yet.'
|
|
7
|
+
</script>
|
|
8
|
+
<script client>
|
|
9
|
+
// Reactive log viewer: seeded from build-time data (stx server→client bridge),
|
|
10
|
+
// then filters by level + source and auto-refreshes — all via signals +
|
|
11
|
+
// directives (no manual DOM).
|
|
12
|
+
const logs = state(initialLogs)
|
|
13
|
+
const level = state('all')
|
|
14
|
+
const source = state('all')
|
|
15
|
+
const sources = derived(() => [...new Set(logs().map(l => l.source).filter(Boolean))])
|
|
16
|
+
const filtered = derived(() => logs().filter(l =>
|
|
17
|
+
(level() === 'all' || l.level === level()) && (source() === 'all' || l.source === source())))
|
|
18
|
+
function tone(l) { return l === 'error' ? 'bad' : (l === 'warn' ? 'warn' : 'ok') }
|
|
19
|
+
async function refresh() {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch('/api/dashboard-data')
|
|
22
|
+
if (!res.ok) return
|
|
23
|
+
const data = await res.json()
|
|
24
|
+
if (Array.isArray(data.serverLogs)) logs.set(data.serverLogs)
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
onMount(() => { refresh(); const id = setInterval(refresh, 8000); return () => clearInterval(id) })
|
|
28
|
+
</script>
|
|
29
|
+
<!DOCTYPE html>
|
|
30
|
+
<html lang="en" data-theme="dark">
|
|
31
|
+
<head>@include('../partials/head', { title: 'ts-cloud - Server logs' })</head>
|
|
32
|
+
<body>
|
|
33
|
+
@include('../partials/nav', { active: 'server', name: server.name, env: 'production' })
|
|
34
|
+
<div class="wrap">
|
|
35
|
+
<div class="crumbs"><a href="/">Server</a> <span class="sep">/</span> Logs</div>
|
|
36
|
+
<header class="page">
|
|
37
|
+
<div>
|
|
38
|
+
<h1>Logs</h1>
|
|
39
|
+
<p>Live journal entries from every managed service on {{ server.name }}.</p>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="cmds header-actions"><span class="accent cmd">cloud server:logs {{ server.name }}</span></div>
|
|
42
|
+
</header>
|
|
43
|
+
|
|
44
|
+
<div class="section">
|
|
45
|
+
<div class="panel">
|
|
46
|
+
<div class="filters">
|
|
47
|
+
<div class="segmented" role="group" aria-label="Filter log level">
|
|
48
|
+
<button type="button" :class="{ active: level() === 'all' }" @click="level.set('all')">All</button>
|
|
49
|
+
<button type="button" :class="{ active: level() === 'error' }" @click="level.set('error')">Errors</button>
|
|
50
|
+
<button type="button" :class="{ active: level() === 'warn' }" @click="level.set('warn')">Warnings</button>
|
|
51
|
+
<button type="button" :class="{ active: level() === 'info' }" @click="level.set('info')">Info</button>
|
|
52
|
+
</div>
|
|
53
|
+
<label class="select-filter">
|
|
54
|
+
<span>Source</span>
|
|
55
|
+
<select :value="source()" @change="source.set($event.target.value)">
|
|
56
|
+
<option value="all">All sources</option>
|
|
57
|
+
<template :for="s in sources()">
|
|
58
|
+
<option :value="s">{{ s }}</option>
|
|
59
|
+
</template>
|
|
60
|
+
</select>
|
|
61
|
+
</label>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="log-list">
|
|
65
|
+
<template :for="log in filtered()">
|
|
66
|
+
<article class="log-row" :class="log.level">
|
|
67
|
+
<div class="log-meta">
|
|
68
|
+
<span class="pill" :class="tone(log.level)">{{ log.level }}</span>
|
|
69
|
+
<span class="mono">{{ log.source }}</span>
|
|
70
|
+
<span>{{ log.when }}</span>
|
|
71
|
+
</div>
|
|
72
|
+
<pre>{{ log.message }}</pre>
|
|
73
|
+
</article>
|
|
74
|
+
</template>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="compact empty" @show="logs().length > 0 && filtered().length === 0"><strong>No matching logs</strong><span>Try a different level or source filter.</span></div>
|
|
77
|
+
<div class="empty" @show="logs().length === 0"><strong>No logs available</strong><span>{{ emptyReason }}</span></div>
|
|
78
|
+
|
|
79
|
+
<p class="note">Live <span class="mono">journalctl</span> for the web server, every managed service, and each app's service, <b>queue workers</b>, and daemons — plus the backup runner. Auto-refreshes every 8s.</p>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</body>
|
|
84
|
+
</html>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
import { renderResourceBars } from '../../src/charts.ts'
|
|
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
|
+
// values below render the design when nothing is injected.
|
|
7
|
+
let __d = null
|
|
8
|
+
try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
|
|
9
|
+
|
|
10
|
+
const server = __d?.server ?? {
|
|
11
|
+
name: 'acme-production-app', provider: 'Hetzner', region: 'fsn1',
|
|
12
|
+
os: 'Ubuntu 24.04', uptime: '14d 6h', ip: '203.0.113.42',
|
|
13
|
+
}
|
|
14
|
+
const m = __d?.systemMetrics ?? {
|
|
15
|
+
load: 0.42, cpus: 4,
|
|
16
|
+
memUsedMb: 2480, memTotalMb: 7860,
|
|
17
|
+
diskUsedPct: 38, diskUsedGb: 19, diskTotalGb: 50,
|
|
18
|
+
}
|
|
19
|
+
const live = __d ? !!__d?._serverReachable && !__d?.metricsUnavailable : true
|
|
20
|
+
|
|
21
|
+
// ── Derived percentages ───────────────────────────────────────────────
|
|
22
|
+
const memPct = live ? Math.min(100, Math.round((m.memUsedMb / Math.max(1, m.memTotalMb)) * 100)) : 0
|
|
23
|
+
const loadPct = live ? Math.min(100, Math.round((m.load / Math.max(1, m.cpus || 1)) * 100)) : 0
|
|
24
|
+
const diskPct = live ? Math.min(100, Math.round(m.diskUsedPct)) : 0
|
|
25
|
+
|
|
26
|
+
// Human-friendly sizes.
|
|
27
|
+
const gb = mb => (mb / 1024).toFixed(1)
|
|
28
|
+
const memUsedGb = gb(m.memUsedMb)
|
|
29
|
+
const memTotalGb = gb(m.memTotalMb)
|
|
30
|
+
const memFreeGb = gb(Math.max(0, m.memTotalMb - m.memUsedMb))
|
|
31
|
+
const diskFreeGb = Math.max(0, m.diskTotalGb - m.diskUsedGb)
|
|
32
|
+
const loadPerCpu = (m.load / Math.max(1, m.cpus || 1)).toFixed(2)
|
|
33
|
+
const unavailableNote = 'Live metrics are unavailable because the dashboard probe could not reach the server.'
|
|
34
|
+
const loadPerCpuNote = `${loadPerCpu} per core`
|
|
35
|
+
const memTotalNote = `of ${memTotalGb} GB total`
|
|
36
|
+
const diskTotalNote = `of ${m.diskTotalGb} GB total`
|
|
37
|
+
|
|
38
|
+
// ── Threshold tone: green < 70%, amber 70–90%, red ≥ 90% ───────────────
|
|
39
|
+
function toneOf(p) { return p >= 90 ? 'bad' : p >= 70 ? 'warn' : 'ok' }
|
|
40
|
+
function word(p) { return p >= 90 ? 'Critical' : p >= 70 ? 'Elevated' : 'Healthy' }
|
|
41
|
+
const cpuTone = toneOf(loadPct), memTone = toneOf(memPct), diskTone = toneOf(diskPct)
|
|
42
|
+
|
|
43
|
+
// Overall verdict = worst of the three vitals.
|
|
44
|
+
const ranks = { ok: 0, warn: 1, bad: 2 }
|
|
45
|
+
const worst = [cpuTone, memTone, diskTone].sort((a, b) => ranks[b] - ranks[a])[0]
|
|
46
|
+
const measuredOverall = {
|
|
47
|
+
bad: { tone: 'bad', title: 'Action needed', note: 'One or more vitals are in the critical range.' },
|
|
48
|
+
warn: { tone: 'warn', title: 'Running warm', note: 'Resources are elevated — keep an eye on it.' },
|
|
49
|
+
ok: { tone: 'ok', title: 'All systems healthy', note: 'CPU, memory, and disk are well within limits.' },
|
|
50
|
+
}[worst]
|
|
51
|
+
const overall = live ? measuredOverall : { tone: 'warn', title: 'Probe unavailable', note: unavailableNote }
|
|
52
|
+
|
|
53
|
+
// One descriptor per gauge — rendered with @foreach below.
|
|
54
|
+
const gaugeCards = [
|
|
55
|
+
{ 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 },
|
|
56
|
+
{ 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 },
|
|
57
|
+
{ 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 },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const resourceChart = renderResourceBars([
|
|
61
|
+
{ label: 'CPU', value: loadPct, detail: live ? `${m.load} load / ${m.cpus} vCPU` : unavailableNote },
|
|
62
|
+
{ label: 'Memory', value: memPct, detail: live ? `${memUsedGb} / ${memTotalGb} GB` : unavailableNote },
|
|
63
|
+
{ label: 'Disk', value: diskPct, detail: live ? `${m.diskUsedGb} / ${m.diskTotalGb} GB` : unavailableNote },
|
|
64
|
+
], { disabled: !live })
|
|
65
|
+
|
|
66
|
+
// ── Load trend (sample only — not part of the live metrics payload) ────
|
|
67
|
+
const loadTrend = __d?.loadTrend ?? [22, 28, 31, 26, 40, 38, 33, 45, 41, 30, 27, 35, 48, 52, 39, 33, 29, 44, 50, 47, 36, 31, 28, 34]
|
|
68
|
+
const maxLoad = Math.max(...loadTrend, 1)
|
|
69
|
+
const trendPeak = Math.max(...loadTrend)
|
|
70
|
+
function barPct(v) { return Math.round((v / maxLoad) * 100) }
|
|
71
|
+
function barTone(v) { const r = v / maxLoad; return r >= 0.9 ? 'peak' : r >= 0.7 ? 'hot' : '' }
|
|
72
|
+
</script>
|
|
73
|
+
<!DOCTYPE html>
|
|
74
|
+
<html lang="en" data-theme="dark">
|
|
75
|
+
<head>@include('../partials/head', { title: 'ts-cloud — Server health' })</head>
|
|
76
|
+
<body>
|
|
77
|
+
@include('../partials/nav', { active: 'server', name: server.name, env: 'production' })
|
|
78
|
+
<div class="wrap">
|
|
79
|
+
<div class="crumbs"><a href="/">Server</a> <span class="sep">/</span> Health</div>
|
|
80
|
+
<header class="page">
|
|
81
|
+
<div>
|
|
82
|
+
<h1>Server health</h1>
|
|
83
|
+
<p>CPU, memory, disk & vital signs for {{ server.name }}.</p>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="header-actions">
|
|
86
|
+
@if (live)<span class="ok pill">live</span>@else<span class="pill warn">probe unavailable</span>@endif
|
|
87
|
+
</div>
|
|
88
|
+
</header>
|
|
89
|
+
|
|
90
|
+
<!-- Verdict banner: the one-glance "is the box OK?" answer. -->
|
|
91
|
+
<div class="health-hero {{ overall.tone }}">
|
|
92
|
+
<div class="status">
|
|
93
|
+
<span class="led"></span>
|
|
94
|
+
<span>{{ overall.title }}<small>{{ overall.note }}</small></span>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="hmeta">
|
|
97
|
+
<div class="item"><span>OS</span><b>{{ server.os }}</b></div>
|
|
98
|
+
<div class="item"><span>Region</span><b>{{ server.provider }} · {{ server.region }}</b></div>
|
|
99
|
+
<div class="item"><span>Uptime</span><b>{{ live ? server.uptime : 'unavailable' }}</b></div>
|
|
100
|
+
<div class="item"><span>vCPUs</span><b>{{ m.cpus }}</b></div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<!-- Centerpiece: radial gauges for the three vitals. -->
|
|
105
|
+
<div class="gauges">
|
|
106
|
+
@foreach (gaugeCards as g)
|
|
107
|
+
<div class="gauge {{ g.tone }}">
|
|
108
|
+
<div class="ghead">
|
|
109
|
+
@if (g.icon === 'cpu')
|
|
110
|
+
<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>
|
|
111
|
+
@elseif (g.icon === 'mem')
|
|
112
|
+
<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>
|
|
113
|
+
@else
|
|
114
|
+
<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>
|
|
115
|
+
@endif
|
|
116
|
+
{{ g.label }}
|
|
117
|
+
</div>
|
|
118
|
+
<div class="ring" style="--pct: {{ g.pct }}">
|
|
119
|
+
<div class="center">
|
|
120
|
+
<div class="pct">{{ g.big }}@if (live)<sup>%</sup>@endif</div>
|
|
121
|
+
<div class="punit">{{ g.unit }}</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="gsub">{{ g.sub }}</div>
|
|
125
|
+
<div class="gtag">{{ g.tag }}</div>
|
|
126
|
+
</div>
|
|
127
|
+
@endforeach
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<!-- Secondary vital signs. -->
|
|
131
|
+
<div class="section">
|
|
132
|
+
<h2>Vital signs</h2>
|
|
133
|
+
<div class="vitals">
|
|
134
|
+
<div class="vital">
|
|
135
|
+
<div class="vlabel">Load average</div>
|
|
136
|
+
<div class="vval">{{ live ? m.load : 'Unavailable' }} @if (live)<small>/ {{ m.cpus }} cores</small>@endif</div>
|
|
137
|
+
<div class="vnote">{{ live ? loadPerCpuNote : unavailableNote }}</div>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="vital">
|
|
140
|
+
<div class="vlabel">Memory free</div>
|
|
141
|
+
<div class="vval">{{ live ? memFreeGb : 'Unavailable' }} @if (live)<small>GB</small>@endif</div>
|
|
142
|
+
<div class="vnote">{{ live ? memTotalNote : unavailableNote }}</div>
|
|
143
|
+
</div>
|
|
144
|
+
<div class="vital">
|
|
145
|
+
<div class="vlabel">Disk free</div>
|
|
146
|
+
<div class="vval">{{ live ? diskFreeGb : 'Unavailable' }} @if (live)<small>GB</small>@endif</div>
|
|
147
|
+
<div class="vnote">{{ live ? diskTotalNote : unavailableNote }}</div>
|
|
148
|
+
</div>
|
|
149
|
+
<div class="vital">
|
|
150
|
+
<div class="vlabel">Uptime</div>
|
|
151
|
+
<div class="vval">{{ live ? server.uptime : 'Unavailable' }}</div>
|
|
152
|
+
<div class="vnote">{{ server.os }}</div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<!-- Load over recent snapshots. -->
|
|
158
|
+
<div class="section">
|
|
159
|
+
<h2>Resource chart</h2>
|
|
160
|
+
<div class="chart-panel panel">
|
|
161
|
+
{{{ resourceChart }}}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div class="section">
|
|
166
|
+
<h2>CPU load trend</h2>
|
|
167
|
+
<div class="chart panel">
|
|
168
|
+
@if (live)
|
|
169
|
+
<div class="spark">
|
|
170
|
+
@foreach (loadTrend as v)
|
|
171
|
+
<i class="{{ barTone(v) }}" style="height: {{ barPct(v) }}%"></i>
|
|
172
|
+
@endforeach
|
|
173
|
+
</div>
|
|
174
|
+
<div class="xaxis"><span>-24 snapshots</span><span>peak {{ trendPeak }}% · now</span></div>
|
|
175
|
+
<p class="note">% of CPU capacity · refresh with <span class="mono">cloud server:monitoring {{ server.name }}</span></p>
|
|
176
|
+
@else
|
|
177
|
+
<div class="compact empty"><strong>No live trend</strong><span>{{ unavailableNote }}</span></div>
|
|
178
|
+
@endif
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</body>
|
|
183
|
+
</html>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
let __d = null
|
|
3
|
+
try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
|
|
4
|
+
const server = __d?.server ?? { name: 'stacks-production-app' }
|
|
5
|
+
const security = __d?.security ?? {
|
|
6
|
+
ports: [
|
|
7
|
+
{ proto: 'tcp', listen: '0.0.0.0:22', processName: 'sshd', exposure: 'public', tone: 'ok' },
|
|
8
|
+
{ proto: 'tcp', listen: '0.0.0.0:443', processName: 'rpx-gateway', exposure: 'public', tone: 'ok' },
|
|
9
|
+
],
|
|
10
|
+
firewall: { status: 'configured', summary: 'host firewall configured declaratively', rules: ['ALLOW 22/tcp', 'ALLOW 80/tcp', 'ALLOW 443/tcp'] },
|
|
11
|
+
tlsCertificates: [],
|
|
12
|
+
authEvents: [],
|
|
13
|
+
}
|
|
14
|
+
const checks = __d?.diagnostics ?? []
|
|
15
|
+
function tone(status) {
|
|
16
|
+
if (status === 'active' || status === 'configured' || status === 'ok' || status === 'pass') return 'ok'
|
|
17
|
+
if (status === 'bad' || status === 'fail' || status === 'inactive' || status === 'disabled') return 'bad'
|
|
18
|
+
return 'warn'
|
|
19
|
+
}
|
|
20
|
+
function certLabel(cert) {
|
|
21
|
+
if (cert.daysRemaining == null) return cert.expires
|
|
22
|
+
return `${cert.daysRemaining}d left`
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
<!DOCTYPE html>
|
|
26
|
+
<html lang="en" data-theme="dark">
|
|
27
|
+
<head>@include('../partials/head', { title: 'ts-cloud - Security' })</head>
|
|
28
|
+
<body>
|
|
29
|
+
@include('../partials/nav', { active: 'server', name: server.name, env: 'production' })
|
|
30
|
+
<div class="wrap">
|
|
31
|
+
<div class="crumbs"><a href="/">Server</a> <span class="sep">/</span> Security</div>
|
|
32
|
+
<header class="page">
|
|
33
|
+
<div><h1>Security</h1><p>Firewall state, listening ports, TLS expiry, and recent SSH auth events.</p></div>
|
|
34
|
+
<div class="cmds header-actions"><span class="accent cmd">cloud server:secure {{ server.name }}</span></div>
|
|
35
|
+
</header>
|
|
36
|
+
|
|
37
|
+
<div class="grid cards-3">
|
|
38
|
+
<div class="panel">
|
|
39
|
+
<h3>Firewall</h3>
|
|
40
|
+
<div class="stat"><span class="pill {{ tone(security.firewall.status) }}">{{ security.firewall.status }}</span></div>
|
|
41
|
+
<p class="note">{{ security.firewall.summary }}</p>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="panel">
|
|
44
|
+
<h3>Open listeners</h3>
|
|
45
|
+
<div class="stat">{{ security.ports.length }}</div>
|
|
46
|
+
<p class="note">Loopback listeners are shown but not treated as public exposure.</p>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="panel">
|
|
49
|
+
<h3>TLS certificates</h3>
|
|
50
|
+
<div class="stat">{{ security.tlsCertificates.length }}</div>
|
|
51
|
+
<p class="note">Renew with <span class="mono">cloud server:ssl:renew <domain></span>.</p>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="section">
|
|
56
|
+
<h2>Listening ports</h2>
|
|
57
|
+
@if (security.ports.length)
|
|
58
|
+
<div class="port-grid">
|
|
59
|
+
@foreach (security.ports as port)
|
|
60
|
+
<div class="port-card">
|
|
61
|
+
<div class="top"><span class="tag">{{ port.proto }}</span><span class="pill {{ port.tone }}">{{ port.exposure }}</span></div>
|
|
62
|
+
<div class="listen mono">{{ port.listen }}</div>
|
|
63
|
+
<div class="proc">{{ port.processName }}</div>
|
|
64
|
+
</div>
|
|
65
|
+
@endforeach
|
|
66
|
+
</div>
|
|
67
|
+
@else
|
|
68
|
+
<div class="empty"><strong>No port data</strong><span>Live listener data appears after the dashboard can reach the box.</span></div>
|
|
69
|
+
@endif
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="cols section">
|
|
73
|
+
<div>
|
|
74
|
+
<h2>Firewall rules</h2>
|
|
75
|
+
<div class="panel">
|
|
76
|
+
@if (security.firewall.rules.length)
|
|
77
|
+
@foreach (security.firewall.rules as rule)
|
|
78
|
+
<div class="kv"><span class="mono">{{ rule }}</span><b></b></div>
|
|
79
|
+
@endforeach
|
|
80
|
+
@else
|
|
81
|
+
<div class="compact empty"><strong>No rules reported</strong><span>{{ security.firewall.summary }}</span></div>
|
|
82
|
+
@endif
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
<div>
|
|
86
|
+
<h2>TLS certificates</h2>
|
|
87
|
+
<div class="panel">
|
|
88
|
+
@if (security.tlsCertificates.length)
|
|
89
|
+
@foreach (security.tlsCertificates as cert)
|
|
90
|
+
<div class="kv"><span class="mono">{{ cert.domain }}</span><b><span class="pill {{ cert.status }}">{{ certLabel(cert) }}</span></b></div>
|
|
91
|
+
@endforeach
|
|
92
|
+
@else
|
|
93
|
+
<div class="compact empty"><strong>No domains with TLS</strong><span>Add a domain-backed site to see certificate status here.</span></div>
|
|
94
|
+
@endif
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div class="section">
|
|
100
|
+
<h2>Recent SSH auth</h2>
|
|
101
|
+
<div class="panel">
|
|
102
|
+
@if (security.authEvents.length)
|
|
103
|
+
<div class="log-list">
|
|
104
|
+
@foreach (security.authEvents as event)
|
|
105
|
+
<div class="log-row {{ event.level }}">
|
|
106
|
+
<div class="log-meta"><span>{{ event.when }}</span><span>{{ event.host }}</span><span class="pill {{ tone(event.level) }}">{{ event.level }}</span></div>
|
|
107
|
+
<pre>{{ event.message }}</pre>
|
|
108
|
+
</div>
|
|
109
|
+
@endforeach
|
|
110
|
+
</div>
|
|
111
|
+
@else
|
|
112
|
+
<div class="compact empty"><strong>No auth events</strong><span>Recent sshd journal entries will appear here when the live probe can read them.</span></div>
|
|
113
|
+
@endif
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div class="section">
|
|
118
|
+
<h2>Security checks</h2>
|
|
119
|
+
<div class="check-list panel">
|
|
120
|
+
@foreach (checks as check)
|
|
121
|
+
<div class="check {{ check.status === 'pass' ? '' : check.status }}"><span class="mark"></span><div><div class="name">{{ check.name }}</div><div class="detail">{{ check.detail }}</div></div></div>
|
|
122
|
+
@endforeach
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</body>
|
|
127
|
+
</html>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
let __d = null
|
|
3
|
+
try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
|
|
4
|
+
const server = __d?.server ?? { name: 'acme-production-app' }
|
|
5
|
+
const services = __d?.servicesDetail ?? [
|
|
6
|
+
{ name: 'nginx', status: 'running', since: '14d', memMb: 42, auto: true },
|
|
7
|
+
{ name: 'php8.3-fpm', status: 'running', since: '14d', memMb: 318, auto: true },
|
|
8
|
+
{ name: 'mysql', status: 'running', since: '14d', memMb: 612, auto: true },
|
|
9
|
+
{ name: 'redis', status: 'running', since: '14d', memMb: 28, auto: true },
|
|
10
|
+
{ name: 'meilisearch', status: 'running', since: '6d', memMb: 96, auto: true },
|
|
11
|
+
]
|
|
12
|
+
function tone(s) { return s === 'running' ? 'ok' : (s === 'stopped' || s === 'failed' ? 'bad' : 'warn') }
|
|
13
|
+
</script>
|
|
14
|
+
<script client>
|
|
15
|
+
// Reactive server-operation runner: a typed-confirmation bar + output, driven by
|
|
16
|
+
// signals + directives (no manual DOM). Buttons call askOp(...) to stage an op.
|
|
17
|
+
const opOutput = state('')
|
|
18
|
+
const opShown = state(false)
|
|
19
|
+
const pending = state(null)
|
|
20
|
+
const typed = state('')
|
|
21
|
+
const confirmTok = derived(() => { const p = pending(); return p ? p.confirm : '' })
|
|
22
|
+
const confirmVerb = derived(() => { const p = pending(); return p ? p.verb : '' })
|
|
23
|
+
const canRun = derived(() => { const p = pending(); return !!p && typed() === p.confirm })
|
|
24
|
+
function askOp(operation, confirm, verb, to) { pending.set({ operation, confirm, verb, to: to || null }); typed.set('') }
|
|
25
|
+
function cancelOp() { pending.set(null) }
|
|
26
|
+
async function runOp() {
|
|
27
|
+
const p = pending()
|
|
28
|
+
if (!p || typed() !== p.confirm) return
|
|
29
|
+
pending.set(null); typed.set('')
|
|
30
|
+
opShown.set(true); opOutput.set('Running ' + p.operation + '...')
|
|
31
|
+
try {
|
|
32
|
+
const payload = p.to ? { operation: p.operation, confirm: p.confirm, to: p.to } : { operation: p.operation, confirm: p.confirm }
|
|
33
|
+
const res = await fetch('/api/server/operations/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
|
|
34
|
+
const body = await res.json()
|
|
35
|
+
opOutput.set((body.ok ? 'OK ' : 'FAILED ') + (body.command || p.operation) + '\n\n' + (body.stdout || '') + (body.stderr ? '\n' + body.stderr : '') + (body.error ? '\n' + body.error : ''))
|
|
36
|
+
} catch (e) { opOutput.set('FAILED ' + p.operation + '\n\n' + ((e && e.message) || e)) }
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
<!DOCTYPE html>
|
|
40
|
+
<html lang="en" data-theme="dark">
|
|
41
|
+
<head>@include('../partials/head', { title: 'ts-cloud — Services' })</head>
|
|
42
|
+
<body>
|
|
43
|
+
@include('../partials/nav', { active: 'server', name: server.name, env: 'production' })
|
|
44
|
+
<div class="wrap">
|
|
45
|
+
<div class="crumbs"><a href="/">Server</a> <span class="sep">/</span> Services</div>
|
|
46
|
+
<header class="page">
|
|
47
|
+
<div><h1>Services</h1><p>systemd / pantry services managed on the box.</p></div>
|
|
48
|
+
<div class="header-actions"><a class="btn ghost" href="/server/logs">View logs</a></div>
|
|
49
|
+
</header>
|
|
50
|
+
<div class="section">
|
|
51
|
+
<div class="panel">
|
|
52
|
+
<table>
|
|
53
|
+
<thead><tr><th>Service</th><th>Status</th><th>Uptime</th><th>Memory</th><th>Autostart</th><th>Manage</th></tr></thead>
|
|
54
|
+
<tbody>
|
|
55
|
+
@foreach (services as s)
|
|
56
|
+
<tr>
|
|
57
|
+
<td class="mono"><b>{{ s.name }}</b></td>
|
|
58
|
+
<td><span class="pill {{ tone(s.status) }}">{{ s.status }}</span></td>
|
|
59
|
+
<td style="color:var(--txt2)">{{ s.since }}</td>
|
|
60
|
+
<td>{{ s.memMb }} MB</td>
|
|
61
|
+
<td>@if (s.auto)<span class="ok pill">enabled</span>@else<span class="pill warn">disabled</span>@endif</td>
|
|
62
|
+
<td>
|
|
63
|
+
<div class="action-runner">
|
|
64
|
+
<button class="btn ghost sm" type="button" @click="askOp('restart:{{ s.name }}', '{{ s.name }}', 'restart')">Restart</button>
|
|
65
|
+
<button class="btn ghost sm" type="button" @click="askOp('reload:{{ s.name }}', '{{ s.name }}', 'reload')">Reload</button>
|
|
66
|
+
<button class="btn ghost sm" type="button" @click="askOp('start:{{ s.name }}', '{{ s.name }}', 'start')">Start</button>
|
|
67
|
+
<button class="btn danger sm" type="button" @click="askOp('stop:{{ s.name }}', '{{ s.name }}', 'stop')">Stop</button>
|
|
68
|
+
<button class="btn ghost sm" type="button" @click="askOp('enable:{{ s.name }}', '{{ s.name }}', 'enable')">Enable</button>
|
|
69
|
+
<button class="btn ghost sm" type="button" @click="askOp('disable:{{ s.name }}', '{{ s.name }}', 'disable')">Disable</button>
|
|
70
|
+
</div>
|
|
71
|
+
</td>
|
|
72
|
+
</tr>
|
|
73
|
+
@endforeach
|
|
74
|
+
</tbody>
|
|
75
|
+
</table>
|
|
76
|
+
@include('../partials/op-confirm')
|
|
77
|
+
<p class="note">Install software with <span class="mono">cloud server:recipe {{ server.name }} <recipe></span>.</p>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</body>
|
|
82
|
+
</html>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
let __d = null
|
|
3
|
+
try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
|
|
4
|
+
const server = __d?.server ?? { name: 'stacks-production-app' }
|
|
5
|
+
const sites = __d?.sitesDetail ?? [
|
|
6
|
+
{ name: 'main', route: 'stacksjs.com', href: 'https://stacksjs.com', kind: 'stacks', runtime: 'bun', tls: 'https', deploy: 'service', root: '/var/www/main/current', branch: 'main', status: 'live' },
|
|
7
|
+
{ name: 'api', route: 'internal', kind: 'api', runtime: 'bun', tls: 'loopback', deploy: 'service', root: '/var/www/api/current', branch: 'main', status: 'live' },
|
|
8
|
+
{ name: 'docs', route: 'stacksjs.com/docs', href: 'https://stacksjs.com/docs', kind: 'bunpress', runtime: 'static/bun', tls: 'https', deploy: 'server static', root: '/var/www/docs', branch: 'build artifact', status: 'live' },
|
|
9
|
+
]
|
|
10
|
+
function tone(s) { return s === 'live' ? 'ok' : (s === 'failed' ? 'bad' : 'warn') }
|
|
11
|
+
</script>
|
|
12
|
+
<script client>
|
|
13
|
+
// Reactive site management — add, per-site deploy/edit/remove — via signals +
|
|
14
|
+
// directives. Mutations re-render the server-built page (location.reload) once
|
|
15
|
+
// the cloud config has changed.
|
|
16
|
+
const addName = state(''); const addRoot = state(''); const addDomain = state(''); const addPath = state('')
|
|
17
|
+
const addDeploy = state('server'); const addPort = state(''); const addBuild = state(''); const addStart = state('')
|
|
18
|
+
const addMsg = state(''); const addMsgKind = state('')
|
|
19
|
+
async function addSite() {
|
|
20
|
+
addMsg.set('Adding site...'); addMsgKind.set('')
|
|
21
|
+
const payload = { name: addName(), root: addRoot(), domain: addDomain(), path: addPath(), deploy: addDeploy(), port: addPort(), build: addBuild(), start: addStart() }
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch('/api/sites', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
|
|
24
|
+
const b = await res.json()
|
|
25
|
+
if (!b.ok) throw new Error(b.error || 'Could not add site')
|
|
26
|
+
addMsg.set('Added ' + b.site + '. Refreshing...'); addMsgKind.set('ok'); location.reload()
|
|
27
|
+
} catch (e) { addMsg.set((e && e.message) || String(e)); addMsgKind.set('bad') }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Deploy / remove run through the shared op-confirm bar.
|
|
31
|
+
const opOutput = state(''); const opShown = state(false); const pending = state(null); const typed = state('')
|
|
32
|
+
const confirmTok = derived(() => { const p = pending(); return p ? p.confirm : '' })
|
|
33
|
+
const confirmVerb = derived(() => { const p = pending(); return p ? p.verb : '' })
|
|
34
|
+
const canRun = derived(() => { const p = pending(); return !!p && typed() === p.confirm })
|
|
35
|
+
function askDeploy(name) { pending.set({ kind: 'deploy', name, confirm: name, verb: 'deploy ' + name }); typed.set('') }
|
|
36
|
+
function askRemove(name) { pending.set({ kind: 'remove', name, confirm: name, verb: 'remove ' + name }); typed.set('') }
|
|
37
|
+
function cancelOp() { pending.set(null) }
|
|
38
|
+
async function runOp() {
|
|
39
|
+
const p = pending(); if (!p || typed() !== p.confirm) return
|
|
40
|
+
pending.set(null); typed.set('')
|
|
41
|
+
opShown.set(true); opOutput.set((p.kind === 'deploy' ? 'Deploying ' : 'Removing ') + p.name + '...')
|
|
42
|
+
try {
|
|
43
|
+
if (p.kind === 'deploy') {
|
|
44
|
+
const res = await fetch('/api/sites/deploy', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: p.name, confirm: p.name }) })
|
|
45
|
+
const b = await res.json()
|
|
46
|
+
opOutput.set((b.ok ? 'OK ' : 'FAILED ') + (b.command || ('deploy ' + p.name)) + '\n\n' + (b.stdout || '') + (b.stderr ? '\n' + b.stderr : '') + (b.error ? '\n' + b.error : ''))
|
|
47
|
+
} else {
|
|
48
|
+
const res = await fetch('/api/sites', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: p.name }) })
|
|
49
|
+
const b = await res.json()
|
|
50
|
+
if (b.ok) { opOutput.set('Removed ' + p.name + ' from cloud config. Refreshing...'); location.reload() } else opOutput.set(b.error || 'Could not remove site')
|
|
51
|
+
}
|
|
52
|
+
} catch (e) { opOutput.set('FAILED ' + p.name + '\n\n' + ((e && e.message) || e)) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Inline per-site edit (TLS / domain / env).
|
|
56
|
+
const editing = state(''); const editSsl = state(''); const editDomain = state(''); const editEnv = state('')
|
|
57
|
+
function toggleEdit(name) { editing.set(editing() === name ? '' : name); editSsl.set(''); editDomain.set(''); editEnv.set('') }
|
|
58
|
+
async function saveEdit(name) {
|
|
59
|
+
const payload = { name }
|
|
60
|
+
if (editSsl() === 'false') payload.ssl = false; else if (editSsl()) payload.ssl = { provider: editSsl() }
|
|
61
|
+
if (editDomain()) payload.domain = editDomain()
|
|
62
|
+
if (editEnv()) { payload.env = {}; for (const line of editEnv().split('\n')) { const i = line.indexOf('='); if (i > 0) payload.env[line.slice(0, i).trim()] = line.slice(i + 1).trim() } }
|
|
63
|
+
opShown.set(true); opOutput.set('Updating ' + name + '...')
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch('/api/sites', { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
|
|
66
|
+
const b = await res.json()
|
|
67
|
+
if (b.ok) { opOutput.set('Updated ' + name + '. Refreshing...'); location.reload() } else opOutput.set(b.error || 'Could not update site')
|
|
68
|
+
} catch (e) { opOutput.set('FAILED ' + name + '\n\n' + ((e && e.message) || e)) }
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
<!DOCTYPE html>
|
|
72
|
+
<html lang="en" data-theme="dark">
|
|
73
|
+
<head>@include('../partials/head', { title: 'ts-cloud - Sites' })</head>
|
|
74
|
+
<body>
|
|
75
|
+
@include('../partials/nav', { active: 'server', name: server.name, env: 'production' })
|
|
76
|
+
<div class="wrap">
|
|
77
|
+
<div class="crumbs"><a href="/">Server</a> <span class="sep">/</span> Sites</div>
|
|
78
|
+
<header class="page">
|
|
79
|
+
<div><h1>Sites</h1><p>Server routes served from the box, each deployed as an atomic release.</p></div>
|
|
80
|
+
<div class="cmds header-actions"><span class="accent cmd">cloud site:add <name></span></div>
|
|
81
|
+
</header>
|
|
82
|
+
<div class="section">
|
|
83
|
+
<h2>Add site</h2>
|
|
84
|
+
<div class="panel">
|
|
85
|
+
<form class="form-grid" @submit.prevent="addSite()">
|
|
86
|
+
<div class="field-block"><label>Name</label><input placeholder="docs" :value="addName()" @input="addName.set($event.target.value)"></div>
|
|
87
|
+
<div class="field-block"><label>Root</label><input placeholder="dist/docs" :value="addRoot()" @input="addRoot.set($event.target.value)"></div>
|
|
88
|
+
<div class="field-block"><label>Domain</label><input placeholder="example.com" :value="addDomain()" @input="addDomain.set($event.target.value)"></div>
|
|
89
|
+
<div class="field-block"><label>Path</label><input placeholder="/docs" :value="addPath()" @input="addPath.set($event.target.value)"></div>
|
|
90
|
+
<div class="field-block"><label>Deploy</label><input :value="addDeploy()" @input="addDeploy.set($event.target.value)"></div>
|
|
91
|
+
<div class="field-block"><label>Port</label><input placeholder="3000" :value="addPort()" @input="addPort.set($event.target.value)"></div>
|
|
92
|
+
<div class="field-block wide"><label>Build command</label><input placeholder="bun run build" :value="addBuild()" @input="addBuild.set($event.target.value)"></div>
|
|
93
|
+
<div class="field-block wide"><label>Start command</label><input placeholder="bun run start" :value="addStart()" @input="addStart.set($event.target.value)"></div>
|
|
94
|
+
<div class="form-actions"><button class="btn" type="submit">Add site</button><span class="msg" :class="addMsgKind()">{{ addMsg() }}</span></div>
|
|
95
|
+
</form>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
@foreach (sites as site)
|
|
99
|
+
<div class="section">
|
|
100
|
+
<h2>{{ site.name }} <span class="more"><span class="pill {{ tone(site.status) }}">{{ site.status }}</span></span></h2>
|
|
101
|
+
<div class="cols">
|
|
102
|
+
<div class="panel">
|
|
103
|
+
<div class="kv"><span>route</span> <b class="mono">@if (site.href)<a href="{{ site.href }}">{{ site.route }}</a>@else{{ site.route }}@endif</b></div>
|
|
104
|
+
<div class="kv"><span>kind</span> <b><span class="tag">{{ site.kind ?? site.type }}</span></b></div>
|
|
105
|
+
<div class="kv"><span>runtime</span> <b class="mono">{{ site.runtime }}</b></div>
|
|
106
|
+
<div class="kv"><span>tls</span> <b><span class="{{ site.tls === 'https' ? 'ok' : 'warn' }} pill">{{ site.tls }}</span></b></div>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="panel">
|
|
109
|
+
<div class="kv"><span>web root</span> <b class="mono" style="font-size:11px">{{ site.root }}</b></div>
|
|
110
|
+
<div class="kv"><span>source</span> <b class="mono">{{ site.branch }}</b></div>
|
|
111
|
+
<div class="kv"><span>deploy</span> <b>{{ site.deploy }}</b></div>
|
|
112
|
+
@if (site.envKeys && site.envKeys.length)
|
|
113
|
+
<div class="kv"><span>env vars</span> <b class="mono">{{ site.envKeys.join(', ') }}</b></div>
|
|
114
|
+
@endif
|
|
115
|
+
@if (site.shadowedBy)
|
|
116
|
+
<div class="kv"><span>shadowed by</span> <b class="mono">{{ site.shadowedBy }}</b></div>
|
|
117
|
+
@endif
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="row-actions" style="margin-top:12px;">
|
|
121
|
+
<button class="btn sm" type="button" @click="askDeploy('{{ site.name }}')">Deploy</button>
|
|
122
|
+
<button class="btn ghost sm" type="button" @click="toggleEdit('{{ site.name }}')">Edit</button>
|
|
123
|
+
<button class="btn danger sm" type="button" @click="askRemove('{{ site.name }}')">Remove</button>
|
|
124
|
+
</div>
|
|
125
|
+
<form class="form-grid" style="margin-top:14px;" @show="editing() === '{{ site.name }}'" @submit.prevent="saveEdit('{{ site.name }}')">
|
|
126
|
+
<div class="field-block">
|
|
127
|
+
<label>TLS</label>
|
|
128
|
+
<select :value="editSsl()" @change="editSsl.set($event.target.value)">
|
|
129
|
+
<option value="">(unchanged)</option>
|
|
130
|
+
<option value="letsencrypt">letsencrypt</option>
|
|
131
|
+
<option value="false">disable</option>
|
|
132
|
+
</select>
|
|
133
|
+
</div>
|
|
134
|
+
<div class="field-block"><label>Domain</label><input placeholder="{{ site.domain }}" :value="editDomain()" @input="editDomain.set($event.target.value)"></div>
|
|
135
|
+
<div class="field-block wide"><label>Env vars (KEY=value per line)</label><textarea rows="3" placeholder="NODE_ENV=production" :value="editEnv()" @input="editEnv.set($event.target.value)"></textarea></div>
|
|
136
|
+
<div class="form-actions"><button class="btn" type="submit">Save changes</button></div>
|
|
137
|
+
</form>
|
|
138
|
+
</div>
|
|
139
|
+
@endforeach
|
|
140
|
+
@include('../partials/op-confirm')
|
|
141
|
+
</div>
|
|
142
|
+
</body>
|
|
143
|
+
</html>
|