feat: publish standalone worker

Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
Gleb Tv
2026-07-13 17:55:14 +03:00
Коммит 2c7a0236da
309 изменённых файлов: 44004 добавлений и 0 удалений

20
internal/webapp/templates/app_detail.html Обычный файл
Просмотреть файл

@@ -0,0 +1,20 @@
{{define "body"}}<section class="card">
<h1>{{.App.Name}}</h1>
<dl>
<dt>Source</dt><dd>{{.App.Source}}</dd>
<dt>PID</dt><dd>{{.App.PID}}</dd>
<dt>Open ports</dt>
<dd>
{{if .App.Ports}}
<ul>
{{range .App.Ports}}<li>{{.}}</li>{{end}}
</ul>
{{else}}<span class="muted">none</span>{{end}}
</dd>
<dt>Cmdline</dt><dd><code>{{.App.Cmdline}}</code></dd>
<dt>Cwd</dt><dd><code>{{.App.CWD}}</code></dd>
<dt>Last seen</dt><dd>{{fmtTime .App.LastSeen}}</dd>
<dt>Last check</dt><dd><span class="muted">(Phase 1: no per-app probes)</span></dd>
</dl>
<p><a href="/apps">← All apps</a></p>
</section>{{end}}

32
internal/webapp/templates/apps.html Обычный файл
Просмотреть файл

@@ -0,0 +1,32 @@
{{define "body"}}<section class="card">
<h1>Discovered apps</h1>
<p class="muted">Refreshed every 60s from <code>/proc</code>. Docker Compose / nginx / systemd discoveries land with the deploymentd integration (see docs/plans/inventory-management.md §7.2); once the host runs <code>deploymentd</code> and posts to RSMon, the matching Site + Deployment rows appear here side-by-side with the per-process list.</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Source</th>
<th>PID</th>
<th>Ports</th>
<th>Uptime</th>
<th>Last check</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Apps}}
<tr>
<td>{{.Name}}</td>
<td>{{.Source}}</td>
<td>{{.PID}}</td>
<td>{{range .Ports}}{{.}}<br>{{end}}</td>
<td>{{fmtTime .LastSeen}}</td>
<td></td>
<td><a href="/apps/{{.PID}}">details</a></td>
</tr>
{{else}}
<tr><td colspan="7" class="muted">No apps discovered yet — first refresh runs within 60s of start.</td></tr>
{{end}}
</tbody>
</table>
</section>{{end}}

Просмотреть файл

@@ -0,0 +1,21 @@
{{define "body"}}<section class="card">
<h1>Change password</h1>
<p class="muted">The operator must change the first-run password before they can use the worker web app.</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form action="/web/change-password" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>
Current password
<input type="password" name="current_password" required>
</label>
<label>
New password (min {{.MinStrength}} chars)
<input type="password" name="new_password" required minlength="{{.MinStrength}}">
</label>
<label>
Confirm new password
<input type="password" name="new_password_confirm" required minlength="{{.MinStrength}}">
</label>
<button type="submit">Save</button>
</form>
</section>{{end}}

34
internal/webapp/templates/checks.html Обычный файл
Просмотреть файл

@@ -0,0 +1,34 @@
{{define "body"}}<section class="card">
<h1>Recent checks</h1>
<p class="muted">Last 50 results produced by this worker.</p>
<p><button type="button" disabled title="{{.RunNowTooltip}}">Run now</button>
<span class="muted">{{.RunNowTooltip}}</span></p>
<table>
<thead>
<tr>
<th>Monitor</th>
<th>Check</th>
<th>Kind</th>
<th>Host</th>
<th>State</th>
<th>Duration (ms)</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td>{{.MonitorID}}</td>
<td>{{.CheckID}}</td>
<td>{{.Kind}}</td>
<td>{{.Host}}</td>
<td>{{.State}}</td>
<td>{{.DurationMs}}</td>
<td>{{.Error}}</td>
</tr>
{{else}}
<tr><td colspan="7" class="muted">No checks yet. Once the main app assigns jobs to this worker, results will appear here.</td></tr>
{{end}}
</tbody>
</table>
</section>{{end}}

39
internal/webapp/templates/layout.html Обычный файл
Просмотреть файл

@@ -0,0 +1,39 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">RSMon worker</div>
{{if not .IsLogin}}
<nav>
<a href="/overview">Overview</a>
<a href="/apps">Apps</a>
<a href="/checks">Checks</a>
<a href="/notifications">Notifications</a>
<a href="/logs">Logs</a>
<a href="/status">Status</a>
<a href="/settings">Settings</a>
<a href="/updates">Updates</a>
</nav>
<div class="who">
<form action="/web/logout" method="post" class="logout-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="link">Logout</button>
</form>
</div>
{{end}}
</header>
<main>
{{template "body" .}}
</main>
<footer class="footer">
<span>RSMon worker v{{.Version}} · built {{.BuildDate}}</span>
</footer>
</body>
</html>{{end}}

33
internal/webapp/templates/login.html Обычный файл
Просмотреть файл

@@ -0,0 +1,33 @@
{{define "body"}}<section class="card login">
<h1>RSMon worker login</h1>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form action="/web/login" method="post" autocomplete="off">
<input type="hidden" name="next" value="{{.NextURL}}">
{{if .BasicAuth}}
<label>
Username
<input type="text" name="login" autocomplete="username" autofocus required value="">
</label>
{{end}}
<label>
Password
<input type="password" name="password" autocomplete="current-password" {{if not .BasicAuth}}autofocus{{end}} required minlength="1">
</label>
<button type="submit">Sign in</button>
</form>
{{if .BasicAuth}}
<p class="muted">
This worker is configured with HTTP basic auth
(<code>WORKER_LOGIN</code>/<code>WORKER_PASSWORD</code>). Use the
credentials from your environment. You can also pass them as
<code>Authorization: Basic</code> on
<code>/web/api/*</code> for scripted access.
</p>
{{else}}
<p class="muted">
On first start the worker printed a one-time password to its log.
Read it via <code>journalctl -u rsmon-worker</code> or your
container runtime.
</p>
{{end}}
</section>{{end}}

17
internal/webapp/templates/logs.html Обычный файл
Просмотреть файл

@@ -0,0 +1,17 @@
{{define "body"}}<section class="card">
<h1>Worker logs</h1>
<p class="muted">In-memory ring buffer (capacity {{.BufferCap}}, holding {{.BufferSize}} lines). Phase 1 shows worker-binary logs only; stack and host logs land in later phases.</p>
<form method="get" action="/logs" class="tail-form">
<label>Show last
<select name="tail" onchange="this.form.submit()">
<option value="200" {{if eq .Tail 200}}selected{{end}}>200</option>
<option value="500" {{if eq .Tail 500}}selected{{end}}>500</option>
<option value="1000" {{if eq .Tail 1000}}selected{{end}}>1000</option>
<option value="5000" {{if eq .Tail 5000}}selected{{end}}>5000</option>
</select>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<pre class="log">{{range .Lines}}{{.}}
{{end}}</pre>
</section>{{end}}

Просмотреть файл

@@ -0,0 +1,32 @@
{{define "body"}}<section class="card">
<h1>Recent notifications</h1>
<p class="muted">Last 50 notifications emitted by this worker (Phase 1: selfcheck alerts only).</p>
<p><button type="button" disabled title="{{.ResendTooltip}}">Resend selected</button>
<span class="muted">{{.ResendTooltip}}</span></p>
<table>
<thead>
<tr>
<th>Channel</th>
<th>Subject</th>
<th>Body</th>
<th>Status</th>
<th>Error</th>
<th>When</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td>{{.Channel}} ({{.Kind}})</td>
<td>{{.Subject}}</td>
<td><code>{{.Body}}</code></td>
<td>{{if .OK}}<span class="ok">delivered</span>{{else}}<span class="error">failed</span>{{end}}</td>
<td>{{.Error}}</td>
<td>{{fmtTime .At}}</td>
</tr>
{{else}}
<tr><td colspan="6" class="muted">No notifications yet.</td></tr>
{{end}}
</tbody>
</table>
</section>{{end}}

56
internal/webapp/templates/overview.html Обычный файл
Просмотреть файл

@@ -0,0 +1,56 @@
{{define "body"}}<section class="card">
<h1>Overview</h1>
<div class="cards">
<div class="metric">
<div class="label">Worker ID</div>
<div class="value">{{.WorkerID}}</div>
</div>
<div class="metric">
<div class="label">Region</div>
<div class="value">{{.RegionCode}}</div>
</div>
<div class="metric">
<div class="label">State</div>
<div class="value">{{.WorkerState}}</div>
</div>
<div class="metric">
<div class="label">Last heartbeat ack</div>
<div class="value">{{fmtTime .LastAckAt}}</div>
</div>
<div class="metric">
<div class="label">Discovered apps</div>
<div class="value">{{.DiscoveredCount}}</div>
</div>
<div class="metric">
<div class="label">Checks (last 1000)</div>
<div class="value">{{.ResultCount}}</div>
</div>
<div class="metric">
<div class="label">Notifications (last 1000)</div>
<div class="value">{{.NotifCount}}</div>
</div>
<div class="metric">
<div class="label">CPU</div>
<div class="value">{{fmtPercent .Snapshot.CPU.TotalPct}}</div>
</div>
<div class="metric">
<div class="label">Memory used</div>
<div class="value">{{fmtPercent .Snapshot.Memory.UsedPct}}</div>
</div>
<div class="metric">
<div class="label">Uptime</div>
<div class="value">{{fmtDuration .Snapshot.Uptime}}</div>
</div>
<div class="metric">
<div class="label">Load (1/5/15)</div>
<div class="value">{{printf "%.2f / %.2f / %.2f" .Snapshot.Load.One .Snapshot.Load.Five .Snapshot.Load.Fifteen}}</div>
</div>
</div>
</section>
<section class="card">
<h2>Recent log lines</h2>
<pre class="log">{{range .LogTail}}{{.}}
{{end}}</pre>
<p><a href="/logs">View all logs</a></p>
</section>{{end}}

31
internal/webapp/templates/settings.html Обычный файл
Просмотреть файл

@@ -0,0 +1,31 @@
{{define "body"}}<section class="card">
<h1>Settings</h1>
<p class="muted">Phase 1 exposes only the worker fields; web-app settings (theme, session timeout, basic-auth password rotate) land in later phases.</p>
<h2>Worker</h2>
<dl>
<dt>Worker ID</dt><dd>{{.WorkerID}}</dd>
<dt>Region</dt><dd>{{.RegionCode}}</dd>
<dt>Version</dt><dd>{{.WorkerVersion}}</dd>
<dt>Capabilities</dt><dd>
{{if .Capabilities}}
<ul>{{range .Capabilities}}<li><code>{{.}}</code></li>{{end}}</ul>
{{else}}<span class="muted"></span>{{end}}
</dd>
<dt>Last heartbeat ack</dt><dd>{{fmtTime .LastAckAt}}</dd>
</dl>
<h2>Token</h2>
<p>
Current token: <code>{{.TokenMasked}}</code><br>
Last rotated: {{fmtTime .TokenRotatedAt}}
</p>
<form action="/settings/rotate-token" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="danger"
onclick="return confirm('Rotate the worker token? The old token will be invalidated immediately and the websocket will reconnect with the new one.')">
Rotate token
</button>
<span class="muted">Issues a fresh token via the main app API and updates the in-memory runner config. On failure the old token is kept.</span>
</form>
</section>{{end}}

82
internal/webapp/templates/status.html Обычный файл
Просмотреть файл

@@ -0,0 +1,82 @@
{{define "body"}}<section class="card">
<h1>Server status</h1>
<p class="muted">Sampled every 5s from <code>/proc</code> and statfs. CLI tools (lsblk, smartctl, sensors) are not bundled with the worker; install them on the host separately if you want richer hardware data. SMART self-tests are out of scope.</p>
<h2>CPU</h2>
<div class="cards">
<div class="metric"><div class="label">Total busy</div><div class="value">{{fmtPercent .Snapshot.CPU.TotalPct}}</div></div>
<div class="metric"><div class="label">User</div><div class="value">{{fmtPercent .Snapshot.CPU.UserPct}}</div></div>
<div class="metric"><div class="label">System</div><div class="value">{{fmtPercent .Snapshot.CPU.SystemPct}}</div></div>
<div class="metric"><div class="label">Idle</div><div class="value">{{fmtPercent .Snapshot.CPU.IdlePct}}</div></div>
<div class="metric"><div class="label">I/O wait</div><div class="value">{{fmtPercent .Snapshot.CPU.IOWaitPct}}</div></div>
</div>
<h2>Load</h2>
<div class="cards">
<div class="metric"><div class="label">1 min</div><div class="value">{{printf "%.2f" .Snapshot.Load.One}}</div></div>
<div class="metric"><div class="label">5 min</div><div class="value">{{printf "%.2f" .Snapshot.Load.Five}}</div></div>
<div class="metric"><div class="label">15 min</div><div class="value">{{printf "%.2f" .Snapshot.Load.Fifteen}}</div></div>
</div>
<h2>Memory</h2>
<table>
<tbody>
<tr><th>Total</th><td>{{fmtBytes .Snapshot.Memory.Total}}</td></tr>
<tr><th>Available</th><td>{{fmtBytes .Snapshot.Memory.Available}} ({{fmtPercent .Snapshot.Memory.AvailablePct}})</td></tr>
<tr><th>Free</th><td>{{fmtBytes .Snapshot.Memory.Free}}</td></tr>
<tr><th>Buffers</th><td>{{fmtBytes .Snapshot.Memory.Buffers}}</td></tr>
<tr><th>Cached</th><td>{{fmtBytes .Snapshot.Memory.Cached}}</td></tr>
<tr><th>Swap total</th><td>{{fmtBytes .Snapshot.Memory.SwapTotal}}</td></tr>
<tr><th>Swap free</th><td>{{fmtBytes .Snapshot.Memory.SwapFree}}</td></tr>
</tbody>
</table>
<h2>Uptime</h2>
<div class="cards">
<div class="metric"><div class="label">Uptime</div><div class="value">{{fmtDuration .Snapshot.Uptime}}</div></div>
<div class="metric"><div class="label">Boot at</div><div class="value">{{fmtTime .Snapshot.BootAt}}</div></div>
<div class="metric"><div class="label">Last sample</div><div class="value">{{fmtTime .SnapshotAt}}</div></div>
</div>
<h2>Network</h2>
<table>
<thead><tr><th>Iface</th><th>RX bytes</th><th>TX bytes</th><th>RX pkts</th><th>TX pkts</th><th>RX errs</th><th>TX errs</th><th>RX drops</th><th>TX drops</th></tr></thead>
<tbody>
{{range .Snapshot.Networks}}
<tr>
<td>{{.Name}}</td>
<td>{{fmtBytes .RxBytes}}</td>
<td>{{fmtBytes .TxBytes}}</td>
<td>{{.RxPkt}}</td>
<td>{{.TxPkt}}</td>
<td>{{.RxErr}}</td>
<td>{{.TxErr}}</td>
<td>{{.RxDrop}}</td>
<td>{{.TxDrop}}</td>
</tr>
{{else}}
<tr><td colspan="9" class="muted">No network interfaces detected.</td></tr>
{{end}}
</tbody>
</table>
<h2>Mount points</h2>
<table>
<thead><tr><th>Mount</th><th>Device</th><th>FS</th><th>Total</th><th>Free</th><th>Used</th><th>Use%</th></tr></thead>
<tbody>
{{range .Snapshot.Disks}}
<tr>
<td>{{.Mount}}</td>
<td>{{.Device}}</td>
<td>{{.FSType}}</td>
<td>{{fmtBytes .Total}}</td>
<td>{{fmtBytes .Free}}</td>
<td>{{fmtBytes .Used}}</td>
<td>{{fmtPercent .UsedPct}}</td>
</tr>
{{else}}
<tr><td colspan="7" class="muted">No mount points parsed from /proc/mounts.</td></tr>
{{end}}
</tbody>
</table>
</section>{{end}}

17
internal/webapp/templates/updates.html Обычный файл
Просмотреть файл

@@ -0,0 +1,17 @@
{{define "body"}}<section class="card">
<h1>Updates</h1>
<p class="muted">Current binary version compared against the latest release. When <code>WORKER_RELEASE_URL</code> is unset the "latest known" cell shows the placeholder <code>v1 (dev)</code>. Pull and restart stays disabled until Docker management lands (requires sudo / docker socket access).</p>
<dl>
<dt>Current version</dt><dd><code>{{.CurrentVersion}}</code></dd>
<dt>Latest known</dt><dd><code>{{.LatestKnown}}</code></dd>
<dt>In sync?</dt><dd>
{{if eq .CurrentVersion .LatestKnown}}
<span class="ok">yes</span>
{{else}}
<span class="warn">update available</span>
{{end}}
</dd>
</dl>
<button type="button" disabled title="{{.PullTooltip}}">Pull and restart</button>
<p class="muted">{{.PullTooltip}}</p>
</section>{{end}}