From 44d4046526313ecf7c433f5b5b27dff3841221e9 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 20:13:08 +0200 Subject: [PATCH] fix(services): live journal poll + command-palette server search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Journal was a one-time snapshot mislabeled "journalctl -f": it loaded the last N lines once and never refreshed, so it was neither live nor complete. - FleetService: `journalctl --show-cursor` + new journalSince() reads only entries after the stored journald cursor (no gaps, no dup appends), capped to a bounded live tail; parseJournal split into splitJournalCursor + parseJournalLines. - Services\Index: pollJournal() (wire:poll.5s) appends new entries since the cursor and caps retained rows at 300 (fetch ceiling == display ceiling). - Honest labels: drop the misleading "-f"; badge stays "live" (now true). Command palette (Strg/⌘ K) can now jump to a server: the fleet feeds the Alpine cmdk x-data; servers surface only while searching, matched on name OR IP, and selecting one navigates to /servers/. Cursor is validated (^[a-z0-9;=]+$) and single-quoted before the remote shell — no injection (Codex-reviewed: no P1, no security findings). Removes stock ExampleTest (asserted 200 on an auth-gated route → always 302). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Services/Index.php | 41 +++++++ app/Services/FleetService.php | 81 +++++++++++-- lang/de/services.php | 4 +- lang/en/services.php | 4 +- resources/js/app.js | 11 +- .../components/command-palette.blade.php | 15 ++- .../views/livewire/services/index.blade.php | 2 +- .../CommandPaletteServerSearchTest.php | 32 ++++++ tests/Feature/ExampleTest.php | 19 --- tests/Feature/ServicesJournalPollTest.php | 108 ++++++++++++++++++ tests/Unit/JournalCursorTest.php | 64 +++++++++++ 11 files changed, 344 insertions(+), 37 deletions(-) create mode 100644 tests/Feature/CommandPaletteServerSearchTest.php delete mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/ServicesJournalPollTest.php create mode 100644 tests/Unit/JournalCursorTest.php diff --git a/app/Livewire/Services/Index.php b/app/Livewire/Services/Index.php index 3c3ea0d..3d26b54 100644 --- a/app/Livewire/Services/Index.php +++ b/app/Livewire/Services/Index.php @@ -15,6 +15,9 @@ class Index extends Component { use WithFleetContext; + /** Cap on retained journal rows so the live poll never grows the payload unbounded. */ + private const JOURNAL_MAX = 300; + /** Active server name (from fleet context). */ public string $server = '—'; @@ -31,6 +34,9 @@ class Index extends Component /** @var array */ public array $journal = []; + /** journald cursor to resume the live poll from (null until the first read). */ + public ?string $journalCursor = null; + public function mount(): void { $this->server = $this->activeServer()?->name ?? '—'; @@ -46,6 +52,7 @@ class Index extends Component $data = $fleet->systemd($active); $this->services = $data['services']; $this->journal = $data['journal']; + $this->journalCursor = $data['cursor'] ?? null; $this->connected = true; } catch (Throwable) { $this->connected = false; @@ -55,6 +62,40 @@ class Index extends Component $this->ready = true; } + /** + * Live journal: poll for entries since the last cursor and APPEND them, so no line + * is dropped between ticks (a true `journalctl -f` stream needs a PTY — deferred to + * the web terminal). Caps the retained rows and survives a transient SSH hiccup. + */ + public function pollJournal(FleetService $fleet): void + { + if (! $this->ready || ! $this->connected) { + return; + } + + $active = $this->activeServer(); + + if (! $active || ! $active->credential_exists) { + return; + } + + try { + // Fetch ceiling == display ceiling: never under-fill, never carry more than we show. + $data = $fleet->journalSince($active, $this->journalCursor, self::JOURNAL_MAX); + } catch (Throwable) { + return; // keep the current view; retry on the next tick + } + + if ($data['journal'] !== []) { + $this->journal = array_slice( + array_merge($this->journal, $data['journal']), + -self::JOURNAL_MAX + ); + } + + $this->journalCursor = $data['cursor']; + } + /** * Service control (R5): opens the wire-elements/modal confirm dialog. The * modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index 0f6ceed..a7d1d83 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -315,14 +315,14 @@ class FleetService // ── systemd services + journal (Services page) ────────────────────── - /** @return array{services:array,journal:array} */ + /** @return array{services:array,journal:array,cursor:?string} */ public function systemd(Server $server, int $journalLines = 25): array { $ssh = $this->client($server); $cmd = 'export LC_ALL=C; ' .'echo '.self::MARK.'units===; systemctl list-units --type=service --all --plain --no-legend --no-pager; ' .'echo '.self::MARK.'files===; systemctl list-unit-files --type=service --no-legend --no-pager; ' - .'echo '.self::MARK.'journal===; '.$this->sudoFn($server).'priv journalctl -n '.(int) $journalLines.' --no-pager -o short-iso 2>&1'; + .'echo '.self::MARK.'journal===; '.$this->sudoFn($server).'priv journalctl -n '.(int) $journalLines.' --no-pager -o short-iso --show-cursor 2>&1'; try { $s = $this->sections($ssh->exec($cmd)); @@ -330,9 +330,56 @@ class FleetService $ssh->disconnect(); } + [$body, $cursor] = $this->splitJournalCursor($s['journal'] ?? ''); + $journal = $this->parseJournalLines($body); + if ($journal === []) { + $journal[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => __('backend.journal_needs_privileges')]; + } + return [ 'services' => $this->parseUnits($s['units'] ?? '', $s['files'] ?? ''), - 'journal' => $this->parseJournal($s['journal'] ?? ''), + 'journal' => $journal, + 'cursor' => $cursor, + ]; + } + + /** + * Incremental journal read for the live poll: only entries AFTER $cursor, plus + * the new cursor to resume from. Without a valid cursor (first call) it returns + * the last $tail entries like systemd(). Empty result = nothing new since the + * cursor (NOT a permission problem), so no placeholder row is injected. + * + * This is a bounded LIVE TAIL, not a complete log shipper: `-n $tail` keeps the + * NEWEST $tail entries since the cursor, so a single tick that bursts past $tail + * lines shows the most recent ones (what "live" means) and drops the older overflow + * — which the capped on-screen window would evict anyway. Set $tail to the display + * ceiling so the visible window is always fully filled. Replaying a full backlog is + * the deferred web terminal's job, not this poll's. + * + * @return array{journal:array,cursor:?string} + */ + public function journalSince(Server $server, ?string $cursor, int $tail = 300): array + { + // Journald cursors are `key=hex` segments joined by `;` — reject anything else + // before it reaches the shell, then single-quote it. + $valid = is_string($cursor) && preg_match('/^[a-z0-9;=]+$/i', $cursor) === 1; + $selector = $valid ? "--after-cursor='".$cursor."'" : ''; + + $ssh = $this->client($server); + $cmd = 'export LC_ALL=C; '.$this->sudoFn($server) + .'priv journalctl '.$selector.' -n '.(int) $tail.' --no-pager -o short-iso --show-cursor 2>&1'; + + try { + $out = $ssh->exec($cmd); + } finally { + $ssh->disconnect(); + } + + [$body, $next] = $this->splitJournalCursor($out); + + return [ + 'journal' => $this->parseJournalLines($body), + 'cursor' => $next ?? $cursor, // keep the old cursor when nothing new arrived ]; } @@ -760,7 +807,29 @@ class FleetService return $out; } - private function parseJournal(string $body): array + /** + * Pull the trailing `-- cursor: X` line (journalctl --show-cursor) off the body. + * + * @return array{0:string,1:?string} [body without the cursor line, cursor or null] + */ + private function splitJournalCursor(string $body): array + { + $cursor = null; + $kept = []; + foreach ($this->lines($body) as $line) { + if (preg_match('/^-- cursor:\s*(\S+)/', $line, $m)) { + $cursor = $m[1]; + + continue; + } + $kept[] = $line; + } + + return [implode("\n", $kept), $cursor]; + } + + /** Parse journalctl `short-iso` lines into structured rows. No placeholder on empty. */ + private function parseJournalLines(string $body): array { $out = []; foreach ($this->lines($body) as $line) { @@ -782,10 +851,6 @@ class FleetService ]; } - if ($out === []) { - $out[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => __('backend.journal_needs_privileges')]; - } - return $out; } diff --git a/lang/de/services.php b/lang/de/services.php index 14d94fb..e295e34 100644 --- a/lang/de/services.php +++ b/lang/de/services.php @@ -29,9 +29,9 @@ return [ // Journal panel 'journal_title' => 'Journal', - 'journal_subtitle' => 'journalctl -f · :server', + 'journal_subtitle' => 'journalctl · :server', 'journal_live' => 'live', - 'journal_follow' => 'Folge Journal · :count Zeilen', + 'journal_follow' => 'Live · automatische Aktualisierung · :count Zeilen', // Confirm modal — operations 'confirm_start_heading' => 'Dienst starten', diff --git a/lang/en/services.php b/lang/en/services.php index 6fc9353..ee17381 100644 --- a/lang/en/services.php +++ b/lang/en/services.php @@ -29,9 +29,9 @@ return [ // Journal panel 'journal_title' => 'Journal', - 'journal_subtitle' => 'journalctl -f · :server', + 'journal_subtitle' => 'journalctl · :server', 'journal_live' => 'live', - 'journal_follow' => 'Following journal · :count lines', + 'journal_follow' => 'Live · auto-refresh · :count lines', // Confirm modal — operations 'confirm_start_heading' => 'Start service', diff --git a/resources/js/app.js b/resources/js/app.js index dade033..1fd36a0 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -87,9 +87,10 @@ document.addEventListener('alpine:init', () => { // e=Einstellungen, y=sYstem — d/s already taken) and stays in JS. const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' }; - window.Alpine.data('cmdk', (nav = [], actions = []) => ({ + window.Alpine.data('cmdk', (nav = [], actions = [], servers = []) => ({ nav, actions, + servers, open: false, help: false, query: '', @@ -115,8 +116,12 @@ document.addEventListener('alpine:init', () => { get items() { const q = this.query.trim().toLowerCase(); - const all = [...this.nav, ...this.actions]; - return q === '' ? all : all.filter((i) => i.label.toLowerCase().includes(q)); + // Empty query stays compact (nav + actions). Servers join the pool only while + // searching, matched on name OR IP (the `search` field), so the fleet never + // clutters the default list but is one keystroke away. + const base = [...this.nav, ...this.actions]; + if (q === '') return base; + return [...base, ...this.servers].filter((i) => (i.search || i.label).toLowerCase().includes(q)); }, toggle(force) { diff --git a/resources/views/components/command-palette.blade.php b/resources/views/components/command-palette.blade.php index b83ed39..b2aeeb6 100644 --- a/resources/views/components/command-palette.blade.php +++ b/resources/views/components/command-palette.blade.php @@ -32,9 +32,20 @@ $actions = [ ['label' => __('servers.add_server'), 'action' => 'create-server', 'hint' => ''], ]; + + // Fleet servers become palette entries so "Strg/⌘ K → name/IP" jumps straight to a + // server's details. `search` carries name + IP so either matches; the hint shows the IP. + $servers = \App\Models\Server::orderBy('name')->get(['uuid', 'name', 'ip']) + ->map(fn ($s) => [ + 'label' => $s->name, + 'href' => '/servers/'.$s->uuid, + 'hint' => $s->ip, + 'search' => $s->name.' '.$s->ip, + ])->all(); + $kbd = 'rounded border border-line bg-inset px-1.5 py-0.5 font-mono text-[10px] text-ink-2'; @endphp -
@@ -58,7 +69,7 @@
    -