docs(wg): SP2 Phase 1 plan — live-status dashboard page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
19fe1b8915
commit
dfdb0ce732
|
|
@ -0,0 +1,752 @@
|
||||||
|
# WireGuard dashboard — Phase 1 (live status) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** A read-only `/wireguard` dashboard page showing live WireGuard status — gate state, server info, and connected peers (handshake, endpoint, rx/tx) — fed by a periodic host collector through the `./run` bridge.
|
||||||
|
|
||||||
|
**Architecture:** A new `clusev-wg.sh collect` subcommand (run by a systemd `.timer` every 5 s) writes `run/wg-status.json`. A PHP `WgStatus` reader parses + validates that file; a full-page Livewire component renders it and `wire:poll`s every 5 s. No writes, no DB, no chart (those are P2–P4). Container never touches the host — same constraint as SP1.
|
||||||
|
|
||||||
|
**Tech Stack:** bash + `wg`/`iptables`/`systemctl` (host), systemd `.timer`/`.service`, Laravel 13 + Livewire 3, Tailwind v4 tokens, Pint, shellcheck. Spec: `docs/superpowers/specs/2026-06-20-wireguard-dashboard-sp2-design.md` (§0 Bridge 1, §1, §2).
|
||||||
|
|
||||||
|
**Run tooling (R8):** tests `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'mkdir -p /tmp/views-test && VIEW_COMPILED_PATH=/tmp/views-test php artisan test --filter=<X>'`; Pint `… vendor/bin/pint <files>`; shellcheck `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable <script>`. Commit on `feat/v1-foundation`; do not push until the release task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **`docker/wg/clusev-wg.sh`** (modify) — gains a `collect` subcommand + a `RUN_DIR` derivation (project root from the script location, override via `$CLUSEV_DIR`). Writes `run/wg-status.json` atomically; no secrets in it.
|
||||||
|
- **`docker/wg/clusev-wg-collect.service`** + **`docker/wg/clusev-wg-collect.timer`** (new) — periodic collector (oneshot every 5 s).
|
||||||
|
- **`install.sh`** (modify) — render + enable the collect timer inside `install_host_watchers()`.
|
||||||
|
- **`app/Services/WgStatus.php`** (new) — reads + validates `run/wg-status.json` → a typed array; the single source for both the page and the route. One responsibility: parse the status file.
|
||||||
|
- **`routes/web.php`** (modify) — `GET /wg-status.json` (auth-gated; has peer names/endpoints) using `WgStatus`.
|
||||||
|
- **`app/Livewire/Wireguard/Index.php`** + **`resources/views/livewire/wireguard/index.blade.php`** (new) — the page.
|
||||||
|
- **`resources/views/components/sidebar.blade.php`** (modify) — nav entry; **`lang/{de,en}/shell.php`** — `nav_wireguard`.
|
||||||
|
- **`lang/{de,en}/wireguard.php`** (new) — page strings (identical keys, R9/R16).
|
||||||
|
- **Tests:** `tests/Feature/WgStatusTest.php`, `tests/Feature/WireguardPageTest.php`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `clusev-wg.sh collect` subcommand
|
||||||
|
|
||||||
|
**Files:** Modify `docker/wg/clusev-wg.sh`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `RUN_DIR` derivation** near the other constants (after the `PANEL_PORTS="80,443"` line). It resolves the project root from the script's own location (`docker/wg/clusev-wg.sh` → `../..`), overridable by the systemd unit's `$CLUSEV_DIR`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Project root (for the run/ bridge dir). The collect timer sets CLUSEV_DIR; otherwise derive it
|
||||||
|
# from this script's location (docker/wg/clusev-wg.sh -> ../..).
|
||||||
|
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CLUSEV_DIR="${CLUSEV_DIR:-$(cd "${SELF_DIR}/../.." && pwd)}"
|
||||||
|
RUN_DIR="${CLUSEV_DIR}/run"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the `cmd_collect` function** (place it just before the `usage()` function). It emits `run/wg-status.json` atomically and never errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Emit run/wg-status.json (atomic temp+mv). Public, NO secrets. If WG isn't configured, emits
|
||||||
|
# {"configured":false}. Run every few seconds by clusev-wg-collect.timer; the dashboard reads it.
|
||||||
|
cmd_collect() {
|
||||||
|
mkdir -p "$RUN_DIR" 2>/dev/null || true
|
||||||
|
local now tmp dest
|
||||||
|
now="$(date +%s 2>/dev/null || echo 0)"
|
||||||
|
dest="${RUN_DIR}/wg-status.json"
|
||||||
|
tmp="$(mktemp "${RUN_DIR}/.wg-status.XXXXXX" 2>/dev/null)" || tmp="${RUN_DIR}/.wg-status.tmp"
|
||||||
|
|
||||||
|
if [ ! -f "$WG_CONF" ]; then
|
||||||
|
printf '{"at":%s,"configured":false}\n' "$now" > "$tmp"
|
||||||
|
mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; }
|
||||||
|
chmod 644 "$dest" 2>/dev/null || true
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
[ -f "$WG_ENV" ] && . "$WG_ENV"
|
||||||
|
local gate_marker=false gate_chain=false wgq
|
||||||
|
[ -f "$GATE_MARKER" ] && gate_marker=true
|
||||||
|
iptables -nL "$GATE_CHAIN" >/dev/null 2>&1 && gate_chain=true
|
||||||
|
wgq="$(systemctl is-active "wg-quick@${WG_IF}" 2>/dev/null || echo inactive)"
|
||||||
|
|
||||||
|
# Peers from `wg show wg0 dump`, named via the wg0.conf `# clusev-peer:` comments. Peer names are
|
||||||
|
# valid_name-restricted and pubkeys/endpoints are from safe charsets, so the values are JSON-safe.
|
||||||
|
local peers_json
|
||||||
|
peers_json="$({ wg show "$WG_IF" dump 2>/dev/null || true; } | awk -v conf="$WG_CONF" '
|
||||||
|
BEGIN {
|
||||||
|
name=""
|
||||||
|
while ((getline line < conf) > 0) {
|
||||||
|
if (line ~ /^# clusev-peer:/) { sub(/^# clusev-peer:[ \t]*/,"",line); name=line }
|
||||||
|
else if (name != "" && line ~ /^PublicKey[ \t]*=/) { p=line; sub(/^PublicKey[ \t]*=[ \t]*/,"",p); names[p]=name; name="" }
|
||||||
|
}
|
||||||
|
close(conf); out=""; first=1
|
||||||
|
}
|
||||||
|
NR==1 { next } # interface line
|
||||||
|
{
|
||||||
|
pk=$1; endp=$3; hs=$5+0; rx=$6+0; tx=$7+0
|
||||||
|
if (endp=="(none)") endp=""
|
||||||
|
nm=(pk in names)?names[pk]:""
|
||||||
|
rec="{\"name\":\"" nm "\",\"pubkey\":\"" pk "\",\"endpoint\":\"" endp "\",\"latest_handshake\":" hs ",\"rx\":" rx ",\"tx\":" tx "}"
|
||||||
|
if (first) { out=rec; first=0 } else { out=out "," rec }
|
||||||
|
}
|
||||||
|
END { printf "[%s]", out }
|
||||||
|
')"
|
||||||
|
[ -n "$peers_json" ] || peers_json="[]"
|
||||||
|
|
||||||
|
printf '{"at":%s,"configured":true,"gate":{"marker":%s,"chain":%s},"wg_quick":"%s","server":{"subnet":"%s","server_ip":"%s","port":%s,"endpoint":"%s","pubkey":"%s"},"peers":%s}\n' \
|
||||||
|
"$now" "$gate_marker" "$gate_chain" "$wgq" \
|
||||||
|
"${WG_SUBNET:-}" "${WG_SERVER_IP:-}" "${WG_PORT:-0}" "${WG_ENDPOINT:-}" "${WG_SERVER_PUBKEY:-}" \
|
||||||
|
"$peers_json" > "$tmp"
|
||||||
|
mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; }
|
||||||
|
chmod 644 "$dest" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the dispatch arm.** In the `case "$cmd"` switch, add after the `gate-apply)` arm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
collect) cmd_collect ;; # called by clusev-wg-collect.timer (root not required: read-only)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: bash -n + shellcheck**
|
||||||
|
|
||||||
|
Run: `bash -n docker/wg/clusev-wg.sh` → exit 0.
|
||||||
|
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/wg/clusev-wg.sh` → clean apart from the existing `# shellcheck disable=SC1090` lines.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Smoke `collect` (safe — read-only, no WG on this host).** It must produce a `configured:false` file without error:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLUSEV_DIR="$PWD" bash docker/wg/clusev-wg.sh collect; echo "exit=$?"; cat run/wg-status.json; rm -f run/wg-status.json
|
||||||
|
```
|
||||||
|
Expected: `exit=0` and `{"at":<number>,"configured":false}` (no `/etc/wireguard/wg0.conf` on the dev host). Then remove the test artifact.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker/wg/clusev-wg.sh
|
||||||
|
git commit -m "feat(wg): clusev-wg.sh collect — emit run/wg-status.json for the dashboard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: collect timer + service units
|
||||||
|
|
||||||
|
**Files:** Create `docker/wg/clusev-wg-collect.service`, `docker/wg/clusev-wg-collect.timer`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the service** (`docker/wg/clusev-wg-collect.service`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# Clusev WireGuard status collector — oneshot (HOST-side). Run every few seconds by the paired
|
||||||
|
# .timer; writes run/wg-status.json (no secrets) which the dashboard reads. Cheap: `wg show` is
|
||||||
|
# instant and an unconfigured host exits immediately. install.sh rewrites the /home/nexxo/clusev paths.
|
||||||
|
[Unit]
|
||||||
|
Description=Clusev WireGuard status collector
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
Environment=CLUSEV_DIR=/home/nexxo/clusev
|
||||||
|
WorkingDirectory=/home/nexxo/clusev
|
||||||
|
ExecStart=/home/nexxo/clusev/docker/wg/clusev-wg.sh collect
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the timer** (`docker/wg/clusev-wg-collect.timer`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# Fires clusev-wg-collect.service every 5 s (after a 10 s boot delay). The dashboard treats a
|
||||||
|
# wg-status.json older than ~20 s as "collector offline".
|
||||||
|
[Unit]
|
||||||
|
Description=Clusev WireGuard status collector — periodic trigger
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=10s
|
||||||
|
OnUnitActiveSec=5s
|
||||||
|
AccuracySec=1s
|
||||||
|
Unit=clusev-wg-collect.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify** `systemd-analyze verify docker/wg/clusev-wg-collect.timer docker/wg/clusev-wg-collect.service 2>&1` — no key/section errors (ExecStart-not-found is fine; path rendered at install). If `systemd-analyze` is absent, eyeball that the `[Unit]`/`[Timer]`/`[Install]` and `[Unit]`/`[Service]` sections + directives match the sibling units in `docker/restart-sentinel/`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker/wg/clusev-wg-collect.service docker/wg/clusev-wg-collect.timer
|
||||||
|
git commit -m "feat(wg): clusev-wg-collect timer+service — 5s status collection"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: install.sh — render + enable the collect timer
|
||||||
|
|
||||||
|
**Files:** Modify `install.sh` (`install_host_watchers()`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Render + enable the timer.** In `install_host_watchers()`, mirror the gate-unit handling. After the line `local tmp_gate; tmp_gate="$(mktemp)"` add:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
local tmp_csvc tmp_ctimer; tmp_csvc="$(mktemp)"; tmp_ctimer="$(mktemp)"
|
||||||
|
```
|
||||||
|
|
||||||
|
After the gate sed line (`sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-gate.service" > "$tmp_gate"`) add:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-collect.service" > "$tmp_csvc"
|
||||||
|
sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-collect.timer" > "$tmp_ctimer"
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `if install … && systemctl …; then` chain, add the two installs after the gate install line (`&& install -m 0644 "$tmp_gate" "${dst}/clusev-wg-gate.service" \`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
&& install -m 0644 "$tmp_csvc" "${dst}/clusev-wg-collect.service" \
|
||||||
|
&& install -m 0644 "$tmp_ctimer" "${dst}/clusev-wg-collect.timer" \
|
||||||
|
```
|
||||||
|
|
||||||
|
and add the enable+start after the gate enable line (`&& systemctl enable clusev-wg-gate.service \`) — the collector is safe to start now (read-only, emits `configured:false` until WG is set up):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
&& systemctl enable --now clusev-wg-collect.timer \
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the two temps to the cleanup `rm -f` line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f "$tmp_path" "$tmp_svc" "$tmp_upath" "$tmp_usvc" "$tmp_gate" "$tmp_csvc" "$tmp_ctimer"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify.** `bash -n install.sh` → exit 0. shellcheck `install.sh` → no NEW findings vs the committed version (capture the baseline first: `git show HEAD:install.sh > /tmp/i.sh; docker run --rm -v /tmp:/mnt -w /mnt koalaman/shellcheck:stable i.sh`). Eyeball: `daemon-reload` still runs after all installs; the timer enable is among the enables.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add install.sh
|
||||||
|
git commit -m "feat(wg): install + enable the WireGuard status collector timer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `WgStatus` reader service + `/wg-status.json` route
|
||||||
|
|
||||||
|
**Files:** Create `app/Services/WgStatus.php`; Modify `routes/web.php`; Test `tests/Feature/WgStatusTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (`tests/Feature/WgStatusTest.php`):
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\WgStatus;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class WgStatusTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private string $file;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$dir = storage_path('app/restart-signal');
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
$this->file = $dir.'/wg-status.json';
|
||||||
|
@unlink($this->file);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
@unlink($this->file);
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_absent_file_reads_as_unconfigured(): void
|
||||||
|
{
|
||||||
|
$s = app(WgStatus::class)->read();
|
||||||
|
$this->assertFalse($s['configured']);
|
||||||
|
$this->assertTrue($s['stale']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_parses_a_configured_status_with_peers(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->file, json_encode([
|
||||||
|
'at' => time(),
|
||||||
|
'configured' => true,
|
||||||
|
'gate' => ['marker' => true, 'chain' => true],
|
||||||
|
'wg_quick' => 'active',
|
||||||
|
'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => '1.2.3.4:51820', 'pubkey' => 'srvpub'],
|
||||||
|
'peers' => [
|
||||||
|
['name' => 'laptop', 'pubkey' => 'p1', 'endpoint' => '5.6.7.8:41820', 'latest_handshake' => time(), 'rx' => 100, 'tx' => 200],
|
||||||
|
['name' => 'phone', 'pubkey' => 'p2', 'endpoint' => '', 'latest_handshake' => 0, 'rx' => 0, 'tx' => 0],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$s = app(WgStatus::class)->read();
|
||||||
|
$this->assertTrue($s['configured']);
|
||||||
|
$this->assertFalse($s['stale']);
|
||||||
|
$this->assertTrue($s['gate']['marker']);
|
||||||
|
$this->assertSame(51820, $s['server']['port']);
|
||||||
|
$this->assertCount(2, $s['peers']);
|
||||||
|
$this->assertTrue($s['peers'][0]['online']); // fresh handshake
|
||||||
|
$this->assertFalse($s['peers'][1]['online']); // handshake 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stale_when_at_is_old(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->file, json_encode(['at' => time() - 999, 'configured' => true, 'peers' => []]));
|
||||||
|
$this->assertTrue(app(WgStatus::class)->read()['stale']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_route_requires_auth_and_returns_status(): void
|
||||||
|
{
|
||||||
|
$this->get('/wg-status.json')->assertRedirect('/login'); // gated
|
||||||
|
|
||||||
|
file_put_contents($this->file, json_encode(['at' => time(), 'configured' => false]));
|
||||||
|
$this->actingAs(User::factory()->create(['must_change_password' => false]))
|
||||||
|
->getJson('/wg-status.json')->assertOk()->assertJson(['configured' => false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it → fails** (`WgStatus` + route missing).
|
||||||
|
|
||||||
|
Run: `… php artisan test --filter=WgStatusTest`
|
||||||
|
Expected: FAIL (class not found / route 404).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `app/Services/WgStatus.php`:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads + validates the host-written status file run/wg-status.json (bind-mounted to
|
||||||
|
* storage/app/restart-signal). Single source for the WireGuard page + the /wg-status.json route.
|
||||||
|
* Contains NO secrets — the collector never writes a private key.
|
||||||
|
*/
|
||||||
|
class WgStatus
|
||||||
|
{
|
||||||
|
/** Status older than this (seconds) means the host collector is offline. */
|
||||||
|
private const STALE_AFTER = 20;
|
||||||
|
|
||||||
|
/** A peer with a handshake within this many seconds is considered online. */
|
||||||
|
private const ONLINE_WITHIN = 180;
|
||||||
|
|
||||||
|
private function path(): string
|
||||||
|
{
|
||||||
|
return storage_path('app/restart-signal/wg-status.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{configured:bool, at:int, stale:bool, gate?:array, wg_quick?:string, server?:array, peers?:array}
|
||||||
|
*/
|
||||||
|
public function read(): array
|
||||||
|
{
|
||||||
|
$path = $this->path();
|
||||||
|
$data = is_file($path) ? json_decode((string) @file_get_contents($path), true) : null;
|
||||||
|
|
||||||
|
if (! is_array($data)) {
|
||||||
|
return ['configured' => false, 'at' => 0, 'stale' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
$at = (int) ($data['at'] ?? 0);
|
||||||
|
$stale = $at < (time() - self::STALE_AFTER);
|
||||||
|
|
||||||
|
if (empty($data['configured'])) {
|
||||||
|
return ['configured' => false, 'at' => $at, 'stale' => $stale];
|
||||||
|
}
|
||||||
|
|
||||||
|
$peers = [];
|
||||||
|
foreach ((array) ($data['peers'] ?? []) as $p) {
|
||||||
|
if (! is_array($p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hs = (int) ($p['latest_handshake'] ?? 0);
|
||||||
|
$peers[] = [
|
||||||
|
'name' => (string) ($p['name'] ?? ''),
|
||||||
|
'pubkey' => (string) ($p['pubkey'] ?? ''),
|
||||||
|
'endpoint' => (string) ($p['endpoint'] ?? ''),
|
||||||
|
'latest_handshake' => $hs,
|
||||||
|
'rx' => (int) ($p['rx'] ?? 0),
|
||||||
|
'tx' => (int) ($p['tx'] ?? 0),
|
||||||
|
'online' => $hs > (time() - self::ONLINE_WITHIN),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'configured' => true,
|
||||||
|
'at' => $at,
|
||||||
|
'stale' => $stale,
|
||||||
|
'gate' => [
|
||||||
|
'marker' => (bool) ($data['gate']['marker'] ?? false),
|
||||||
|
'chain' => (bool) ($data['gate']['chain'] ?? false),
|
||||||
|
],
|
||||||
|
'wg_quick' => (string) ($data['wg_quick'] ?? 'inactive'),
|
||||||
|
'server' => [
|
||||||
|
'subnet' => (string) ($data['server']['subnet'] ?? ''),
|
||||||
|
'server_ip' => (string) ($data['server']['server_ip'] ?? ''),
|
||||||
|
'port' => (int) ($data['server']['port'] ?? 0),
|
||||||
|
'endpoint' => (string) ($data['server']['endpoint'] ?? ''),
|
||||||
|
'pubkey' => (string) ($data['server']['pubkey'] ?? ''),
|
||||||
|
],
|
||||||
|
'peers' => $peers,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the route.** In `routes/web.php`, inside the `EnsureSecurityOnboarded` panel group (next to the other authed routes — it carries peer names/endpoints, so it is NOT public like `/version.json`), add:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::get('/wg-status.json', fn (\App\Services\WgStatus $wg) => response()->json($wg->read()))->name('wg.status');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the test → passes.**
|
||||||
|
|
||||||
|
Run: `… php artisan test --filter=WgStatusTest` → PASS. Pint `app/Services/WgStatus.php tests/Feature/WgStatusTest.php`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/Services/WgStatus.php routes/web.php tests/Feature/WgStatusTest.php
|
||||||
|
git commit -m "feat(wg): WgStatus reader + auth-gated /wg-status.json route"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: the `/wireguard` page
|
||||||
|
|
||||||
|
**Files:** Create `app/Livewire/Wireguard/Index.php`, `resources/views/livewire/wireguard/index.blade.php`, `lang/de/wireguard.php`, `lang/en/wireguard.php`; Modify `routes/web.php`, `resources/views/components/sidebar.blade.php`, `lang/de/shell.php`, `lang/en/shell.php`; Test `tests/Feature/WireguardPageTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (`tests/Feature/WireguardPageTest.php`):
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Wireguard\Index;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class WireguardPageTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private string $file;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$dir = storage_path('app/restart-signal');
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
$this->file = $dir.'/wg-status.json';
|
||||||
|
@unlink($this->file);
|
||||||
|
$this->actingAs(User::factory()->create(['must_change_password' => false]));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
@unlink($this->file);
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_renders_unconfigured_state(): void
|
||||||
|
{
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertViewHas('status', fn ($s) => $s['configured'] === false)
|
||||||
|
->assertSee(__('wireguard.not_configured_title'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_renders_peers_when_configured(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->file, json_encode([
|
||||||
|
'at' => time(), 'configured' => true,
|
||||||
|
'gate' => ['marker' => true, 'chain' => true], 'wg_quick' => 'active',
|
||||||
|
'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => '1.2.3.4:51820', 'pubkey' => 'srvpub'],
|
||||||
|
'peers' => [['name' => 'laptop', 'pubkey' => 'p1', 'endpoint' => '5.6.7.8:41820', 'latest_handshake' => time(), 'rx' => 100, 'tx' => 200]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertViewHas('status', fn ($s) => $s['configured'] === true && count($s['peers']) === 1)
|
||||||
|
->assertSee('laptop')
|
||||||
|
->assertSee('10.99.0.0/24');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it → fails** (component + view + lang missing).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the component** (`app/Livewire/Wireguard/Index.php`):
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Wireguard;
|
||||||
|
|
||||||
|
use App\Services\WgStatus;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WireGuard dashboard — live status (read-only, Phase 1). Renders the host-collected
|
||||||
|
* run/wg-status.json (via WgStatus) and wire:polls every 5s. No writes here (P3/P4).
|
||||||
|
*/
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public function render(WgStatus $wg)
|
||||||
|
{
|
||||||
|
return view('livewire.wireguard.index', [
|
||||||
|
'status' => $wg->read(),
|
||||||
|
])->title(__('wireguard.title'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the lang files.** `lang/de/wireguard.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'WireGuard — Clusev',
|
||||||
|
'eyebrow' => 'Netzwerk',
|
||||||
|
'heading' => 'WireGuard',
|
||||||
|
'subtitle' => 'Tunnel-Zugang zum Panel — Live-Status.',
|
||||||
|
|
||||||
|
// Not-configured empty state
|
||||||
|
'not_configured_title' => 'WireGuard ist nicht eingerichtet',
|
||||||
|
'not_configured_body' => 'Auf dem Host einrichten: per SSH „sudo clusev wg setup". Danach erscheint hier der Live-Status.',
|
||||||
|
|
||||||
|
// Collector
|
||||||
|
'stale' => 'Status veraltet — der Host-Collector antwortet nicht. Läuft der Stack?',
|
||||||
|
|
||||||
|
// Gate
|
||||||
|
'gate' => 'Gate (Panel-Sperre)',
|
||||||
|
'gate_on' => 'aktiv — Panel nur über den Tunnel',
|
||||||
|
'gate_off' => 'aus — Panel öffentlich erreichbar',
|
||||||
|
'gate_escape' => 'Notausgang per SSH: „clusev wg down".',
|
||||||
|
|
||||||
|
// Server card
|
||||||
|
'server_title' => 'Server',
|
||||||
|
'subnet' => 'Subnetz',
|
||||||
|
'server_ip' => 'Server-IP',
|
||||||
|
'port' => 'Port',
|
||||||
|
'endpoint' => 'Endpoint',
|
||||||
|
'server_key' => 'Server-Schlüssel',
|
||||||
|
'service' => 'Dienst',
|
||||||
|
|
||||||
|
// Peers
|
||||||
|
'peers_title' => 'Peers',
|
||||||
|
'peers_count' => ':count Peer|:count Peers',
|
||||||
|
'no_peers' => 'Noch keine Peers — per SSH „clusev wg add-peer <name>".',
|
||||||
|
'online' => 'online',
|
||||||
|
'offline' => 'offline',
|
||||||
|
'last_handshake' => 'letzter Handshake',
|
||||||
|
'never' => 'nie',
|
||||||
|
'transfer' => 'Übertragung',
|
||||||
|
'down_up' => '↓ :rx · ↑ :tx',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
`lang/en/wireguard.php` (identical keys, R9/R16):
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'WireGuard — Clusev',
|
||||||
|
'eyebrow' => 'Network',
|
||||||
|
'heading' => 'WireGuard',
|
||||||
|
'subtitle' => 'Tunnel access to the panel — live status.',
|
||||||
|
|
||||||
|
'not_configured_title' => 'WireGuard is not set up',
|
||||||
|
'not_configured_body' => 'Set it up on the host: over SSH run "sudo clusev wg setup". The live status appears here afterwards.',
|
||||||
|
|
||||||
|
'stale' => 'Status is stale — the host collector is not responding. Is the stack running?',
|
||||||
|
|
||||||
|
'gate' => 'Gate (panel lock)',
|
||||||
|
'gate_on' => 'on — panel only via the tunnel',
|
||||||
|
'gate_off' => 'off — panel publicly reachable',
|
||||||
|
'gate_escape' => 'Escape over SSH: "clusev wg down".',
|
||||||
|
|
||||||
|
'server_title' => 'Server',
|
||||||
|
'subnet' => 'Subnet',
|
||||||
|
'server_ip' => 'Server IP',
|
||||||
|
'port' => 'Port',
|
||||||
|
'endpoint' => 'Endpoint',
|
||||||
|
'server_key' => 'Server key',
|
||||||
|
'service' => 'Service',
|
||||||
|
|
||||||
|
'peers_title' => 'Peers',
|
||||||
|
'peers_count' => ':count peer|:count peers',
|
||||||
|
'no_peers' => 'No peers yet — over SSH run "clusev wg add-peer <name>".',
|
||||||
|
'online' => 'online',
|
||||||
|
'offline' => 'offline',
|
||||||
|
'last_handshake' => 'last handshake',
|
||||||
|
'never' => 'never',
|
||||||
|
'transfer' => 'Transfer',
|
||||||
|
'down_up' => '↓ :rx · ↑ :tx',
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write the view** (`resources/views/livewire/wireguard/index.blade.php`). Token utilities only (R3), no inline style (R4), responsive (R7), DE/EN via `__()`, no emoji except the ↓/↑ glyphs which are text arrows (allowed — not status emoji), reuse `x-panel`/`x-badge`/`x-status-dot`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@php
|
||||||
|
$fmtBytes = function (int $n): string {
|
||||||
|
$u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
$i = 0;
|
||||||
|
$v = (float) $n;
|
||||||
|
while ($v >= 1024 && $i < count($u) - 1) { $v /= 1024; $i++; }
|
||||||
|
return ($i === 0 ? (string) $n : number_format($v, 1)).' '.$u[$i];
|
||||||
|
};
|
||||||
|
$ago = function (int $ts): string {
|
||||||
|
if ($ts <= 0) return __('wireguard.never');
|
||||||
|
$d = max(0, time() - $ts);
|
||||||
|
if ($d < 60) return $d.'s';
|
||||||
|
if ($d < 3600) return intdiv($d, 60).'m';
|
||||||
|
if ($d < 86400) return intdiv($d, 3600).'h';
|
||||||
|
return intdiv($d, 86400).'d';
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-5" wire:poll.5s>
|
||||||
|
{{-- Header --}}
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('wireguard.eyebrow') }}</p>
|
||||||
|
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('wireguard.heading') }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-ink-3">{{ __('wireguard.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! $status['configured'])
|
||||||
|
{{-- Not-configured empty state --}}
|
||||||
|
<x-panel :title="__('wireguard.not_configured_title')">
|
||||||
|
<p class="text-sm leading-relaxed text-ink-2">{{ __('wireguard.not_configured_body') }}</p>
|
||||||
|
<pre class="mt-3 overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] text-ink-2">sudo clusev wg setup</pre>
|
||||||
|
</x-panel>
|
||||||
|
@else
|
||||||
|
@if ($status['stale'])
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/10 px-4 py-3">
|
||||||
|
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||||||
|
<span class="text-sm text-ink-2">{{ __('wireguard.stale') }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
{{-- Peers --}}
|
||||||
|
<x-panel :title="__('wireguard.peers_title')" :subtitle="trans_choice('wireguard.peers_count', count($status['peers']), ['count' => count($status['peers'])])" :padded="false">
|
||||||
|
<div class="divide-y divide-line">
|
||||||
|
@forelse ($status['peers'] as $peer)
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 sm:px-5" wire:key="peer-{{ $peer['pubkey'] }}">
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<x-status-dot :status="$peer['online'] ? 'online' : 'offline'" />
|
||||||
|
<span class="font-mono text-sm font-semibold text-ink">{{ $peer['name'] !== '' ? $peer['name'] : Str::limit($peer['pubkey'], 10, '…') }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-[11px] text-ink-4">{{ $peer['online'] ? __('wireguard.online') : __('wireguard.offline') }}</span>
|
||||||
|
<span class="ml-auto font-mono text-[11px] text-ink-3">{{ __('wireguard.last_handshake') }}: {{ $ago($peer['latest_handshake']) }}</span>
|
||||||
|
<span class="w-full font-mono text-[11px] text-ink-3">
|
||||||
|
{{ $peer['endpoint'] !== '' ? $peer['endpoint'] : '—' }}
|
||||||
|
<span class="ml-2 text-ink-4">{{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="px-4 py-4 font-mono text-[11px] text-ink-3 sm:px-5">{{ __('wireguard.no_peers') }}</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</x-panel>
|
||||||
|
|
||||||
|
{{-- Aside: gate + server --}}
|
||||||
|
<div class="space-y-5">
|
||||||
|
<x-panel :title="__('wireguard.gate')">
|
||||||
|
@if ($status['gate']['marker'] && $status['gate']['chain'])
|
||||||
|
<x-badge tone="cyan">{{ __('wireguard.gate_on') }}</x-badge>
|
||||||
|
@else
|
||||||
|
<x-badge tone="neutral">{{ __('wireguard.gate_off') }}</x-badge>
|
||||||
|
@endif
|
||||||
|
<p class="mt-2 font-mono text-[11px] text-ink-4">{{ __('wireguard.gate_escape') }}</p>
|
||||||
|
</x-panel>
|
||||||
|
|
||||||
|
<x-panel :title="__('wireguard.server_title')" :padded="false">
|
||||||
|
<dl class="space-y-2 px-4 py-4 font-mono text-[11px] sm:px-5">
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.subnet') }}</dt><dd class="text-ink-2">{{ $status['server']['subnet'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.server_ip') }}</dt><dd class="text-ink-2">{{ $status['server']['server_ip'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.port') }}</dt><dd class="text-ink-2">{{ $status['server']['port'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.endpoint') }}</dt><dd class="truncate text-ink-2">{{ $status['server']['endpoint'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.service') }}</dt><dd class="text-ink-2">{{ $status['wg_quick'] }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</x-panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add the route.** In `routes/web.php`, in the onboarded panel group (near `/system`), add:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::get('/wireguard', Wireguard\Index::class)->name('wireguard');
|
||||||
|
```
|
||||||
|
|
||||||
|
and ensure the `use App\Livewire\Wireguard;` import is present at the top (add it alphabetically near the other `App\Livewire\*` imports).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Add the sidebar nav + shell lang.** In `resources/views/components/sidebar.blade.php`, in the Fleet group, after the Audit nav item, add:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item>
|
||||||
|
```
|
||||||
|
|
||||||
|
In `lang/de/shell.php` and `lang/en/shell.php`, add (both files, identical key):
|
||||||
|
|
||||||
|
```php
|
||||||
|
'nav_wireguard' => 'WireGuard',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run the test → passes.** Run: `… php artisan test --filter=WireguardPageTest` → PASS. Pint the new PHP files.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/Livewire/Wireguard/ resources/views/livewire/wireguard/ lang/de/wireguard.php lang/en/wireguard.php routes/web.php resources/views/components/sidebar.blade.php lang/de/shell.php lang/en/shell.php tests/Feature/WireguardPageTest.php
|
||||||
|
git commit -m "feat(wg): /wireguard live-status page (read-only) + nav (DE/EN)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: verify + release
|
||||||
|
|
||||||
|
- [ ] **Step 1: shellcheck + Pint + full suite.** shellcheck `docker/wg/clusev-wg.sh install.sh docker/clusev/clusev` → clean. Pint `--dirty` clean. `… php artisan test` → all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: R12 browser-verify** `/wireguard` (login via the gkonrad temp-password + puppeteer flow, CLAUDE.md R12). Because the dev host runs no collector, FIRST stub a populated status file so the configured state renders, then also test the unconfigured state by removing it:
|
||||||
|
- Stub: write a populated `storage/app/restart-signal/wg-status.json` (a configured payload with 1–2 peers, fresh `at`), load `/wireguard` at 1280 + 375 → HTTP 200, peers + gate + server render, zero console errors, no leaked tokens; screenshot.
|
||||||
|
- Then `rm` the file, reload → the "not configured" empty state renders 200, no errors.
|
||||||
|
- Switch locale to EN, reload → EN strings. Restore the gkonrad password + remove the stub file afterwards.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Release.** Bump `config/clusev.php` to `0.9.34`; CHANGELOG entry under `## [0.9.34]` (Hinzugefügt: WireGuard-Dashboard — Live-Status (read-only); host-side collector; weitere Phasen folgen). Commit `chore: release 0.9.34`, tag `v0.9.34`, push branch + tag (token from `/home/nexxo/.env.gitea`, sanitised).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage (P1 scope = spec §0 Bridge 1 + §1 + §2):**
|
||||||
|
- Collector subcommand + status JSON shape → Task 1. ✓
|
||||||
|
- `.timer`/`.service` periodic collection → Task 2. ✓
|
||||||
|
- install.sh render+enable → Task 3. ✓
|
||||||
|
- `/wg-status.json` route (auth-gated, whitelisted) → Task 4. ✓
|
||||||
|
- WgStatus reader (single source) → Task 4. ✓
|
||||||
|
- `/wireguard` page: gate badge + escape caveat, server card, peer list (online/handshake/endpoint/rx-tx), empty + stale states, `wire:poll`, sidebar nav, DE/EN → Task 5. ✓
|
||||||
|
- R12 + release → Task 6. ✓
|
||||||
|
- Out of P1 (deferred, per spec phases): write-bridge, peer add/remove, settings, history graph, chart lib. Not in this plan — correct.
|
||||||
|
|
||||||
|
**Placeholder scan:** none — every step has concrete code/commands.
|
||||||
|
|
||||||
|
**Type/name consistency:** `WgStatus::read()` returns the same array shape consumed by the route (Task 4), the component `render()` (Task 5), and asserted by both tests. The status JSON keys written by `cmd_collect` (Task 1: `at/configured/gate{marker,chain}/wg_quick/server{subnet,server_ip,port,endpoint,pubkey}/peers[{name,pubkey,endpoint,latest_handshake,rx,tx}]`) match exactly what `WgStatus::read()` parses (Task 4) and the view renders (Task 5). `cmd_collect`/`RUN_DIR` (Task 1) referenced consistently by the units (Task 2) + install (Task 3). `nav_wireguard`/`wireguard.*` lang keys used in the view exist in both lang files.
|
||||||
|
|
||||||
|
**Note (carried from spec):** the dev stack runs no collector, so P1's live state is only exercised in tests + the R12 stub; the real collector runs on the deployed host (verify there after `clusev update`). This mirrors the SP1 host-script reality.
|
||||||
Loading…
Reference in New Issue