fix(services): live journal poll + command-palette server search

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/<uuid>.

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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 20:13:08 +02:00
parent bdec68f661
commit 44d4046526
11 changed files with 344 additions and 37 deletions

View File

@ -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<int, array{time:string,unit:string,level:string,text:string}> */
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

View File

@ -315,14 +315,14 @@ class FleetService
// ── systemd services + journal (Services page) ──────────────────────
/** @return array{services:array<int,array>,journal:array<int,array>} */
/** @return array{services:array<int,array>,journal:array<int,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<int,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;
}

View File

@ -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',

View File

@ -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',

View File

@ -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) {

View File

@ -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
<div x-data="cmdk(@js($nav), @js($actions))"
<div x-data="cmdk(@js($nav), @js($actions), @js($servers))"
@keydown.window="onKey($event)"
@keydown.escape.window="close()"
@cmdk-open.window="toggle(true)">
@ -58,7 +69,7 @@
</div>
<ul class="max-h-80 overflow-y-auto py-1.5">
<template x-for="(item, idx) in items" :key="item.label">
<template x-for="(item, idx) in items" :key="item.href || item.label">
<li>
<button type="button"
@click="run(item)"

View File

@ -93,7 +93,7 @@
<x-badge tone="cyan">{{ __('services.journal_live') }}</x-badge>
</x-slot:actions>
<div class="overflow-x-auto">
<div @if ($ready && $connected) wire:poll.5s="pollJournal" @endif class="overflow-x-auto">
<div class="min-w-[640px] divide-y divide-line-soft font-mono text-[11px] leading-relaxed sm:min-w-0">
@foreach ($journal as $line)
<div class="flex items-start gap-3 px-4 py-1.5 sm:px-5">

View File

@ -0,0 +1,32 @@
<?php
namespace Tests\Feature;
use App\Models\Server;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
use Tests\TestCase;
/**
* The command palette (Strg/ K) must let you jump to a server. The Blade side feeds the
* fleet into the Alpine `cmdk` x-data; the JS filters it by name OR IP.
*/
class CommandPaletteServerSearchTest extends TestCase
{
use RefreshDatabase;
public function test_palette_payload_includes_fleet_servers_with_details_href(): void
{
$server = Server::create(['name' => 'mars', 'ip' => '10.9.9.9', 'ssh_port' => 22, 'status' => 'online']);
$html = Blade::render('<x-command-palette />');
// Name (label + search), IP (hint + search) and the details href all reach the x-data.
$this->assertStringContainsString('mars', $html);
$this->assertStringContainsString('10.9.9.9', $html);
// @js() emits a JS-escaped string literal, so assert on the (unescaped) uuid that
// the details href is built from, plus the route segment it lands on.
$this->assertStringContainsString($server->uuid, $html);
$this->assertStringContainsString('servers', $html);
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace Tests\Feature;
use App\Livewire\Services\Index;
use App\Models\Server;
use App\Models\User;
use App\Services\FleetService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
/**
* The journal is "live" via an incremental poll: each tick appends entries AFTER the
* stored cursor (no gaps), advances the cursor, and caps the retained rows.
*/
class ServicesJournalPollTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function activeServer(): Server
{
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
session(['active_server_id' => $server->id]);
return $server;
}
public function test_poll_appends_new_entries_and_advances_the_cursor(): void
{
$this->actingAs(User::factory()->create());
$this->activeServer();
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('journalSince')
->once()
->with(Mockery::type(Server::class), 'cur-0', 300)
->andReturn([
'journal' => [['time' => '10:00:05', 'unit' => 'nginx', 'level' => 'info', 'text' => 'new line']],
'cursor' => 'cur-1',
]);
app()->instance(FleetService::class, $fleet);
Livewire::test(Index::class)
->set('ready', true)
->set('connected', true)
->set('journalCursor', 'cur-0')
->set('journal', [['time' => '10:00:01', 'unit' => 'sshd', 'level' => 'info', 'text' => 'old line']])
->call('pollJournal')
->assertSet('journalCursor', 'cur-1')
->assertSet('journal', [
['time' => '10:00:01', 'unit' => 'sshd', 'level' => 'info', 'text' => 'old line'],
['time' => '10:00:05', 'unit' => 'nginx', 'level' => 'info', 'text' => 'new line'],
]);
}
public function test_poll_caps_retained_rows_at_the_ceiling(): void
{
$this->actingAs(User::factory()->create());
$this->activeServer();
$fresh = array_map(
fn (int $i) => ['time' => '10:00:'.$i, 'unit' => 'u', 'level' => 'info', 'text' => 'n'.$i],
range(1, 350),
);
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('journalSince')->once()->andReturn(['journal' => $fresh, 'cursor' => 'c']);
app()->instance(FleetService::class, $fleet);
$component = Livewire::test(Index::class)
->set('ready', true)
->set('connected', true)
->set('journal', [['time' => '09:59:59', 'unit' => 'old', 'level' => 'info', 'text' => 'oldest']])
->call('pollJournal');
$journal = $component->get('journal');
$this->assertCount(300, $journal);
// The oldest row is evicted; the newest survives.
$this->assertSame('n350', end($journal)['text']);
$this->assertNotSame('oldest', $journal[0]['text']);
}
public function test_poll_is_a_noop_until_connected(): void
{
$this->actingAs(User::factory()->create());
$this->activeServer();
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldNotReceive('journalSince');
app()->instance(FleetService::class, $fleet);
Livewire::test(Index::class)
->set('ready', true)
->set('connected', false)
->set('journal', [['time' => 't', 'unit' => 'u', 'level' => 'info', 'text' => 'unchanged']])
->call('pollJournal')
->assertSet('journal', [['time' => 't', 'unit' => 'u', 'level' => 'info', 'text' => 'unchanged']]);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace Tests\Unit;
use App\Services\FleetService;
use ReflectionMethod;
use Tests\TestCase;
/**
* The journal poll resumes from the journald cursor that `--show-cursor` appends as a
* trailing `-- cursor: X` line. These cover the two pure helpers that make that work.
*/
class JournalCursorTest extends TestCase
{
private function invokePrivate(string $method, string $body)
{
$m = new ReflectionMethod(FleetService::class, $method);
$m->setAccessible(true);
return $m->invoke(app(FleetService::class), $body);
}
public function test_split_pulls_the_trailing_cursor_off_the_body(): void
{
$body = "2026-06-14T10:00:01+02:00 host nginx[1]: started\n-- cursor: s=abc123;i=4f;b=9";
[$kept, $cursor] = $this->invokePrivate('splitJournalCursor', $body);
$this->assertSame('s=abc123;i=4f;b=9', $cursor);
$this->assertStringNotContainsString('-- cursor:', $kept);
$this->assertStringContainsString('nginx[1]: started', $kept);
}
public function test_split_returns_null_cursor_when_absent(): void
{
[, $cursor] = $this->invokePrivate('splitJournalCursor', '2026-06-14T10:00:01+02:00 host nginx[1]: started');
$this->assertNull($cursor);
}
public function test_parse_lines_classifies_levels_and_never_injects_a_placeholder(): void
{
$body = implode("\n", [
'2026-06-14T10:00:01+02:00 host nginx[1]: connection refused',
'2026-06-14T10:00:02+02:00 host cron[9]: warning clock skew detected',
'2026-06-14T10:00:03+02:00 host sshd[7]: accepted publickey',
]);
$rows = $this->invokePrivate('parseJournalLines', $body);
$this->assertCount(3, $rows);
$this->assertSame('error', $rows[0]['level']);
$this->assertSame('warn', $rows[1]['level']);
$this->assertSame('info', $rows[2]['level']);
$this->assertSame('2026-06-14 10:00:01', $rows[0]['time']);
}
public function test_parse_lines_on_empty_input_returns_empty_no_placeholder(): void
{
// Empty = "nothing new since the cursor", which must NOT look like a permission error.
$this->assertSame([], $this->invokePrivate('parseJournalLines', ''));
$this->assertSame([], $this->invokePrivate('parseJournalLines', '-- No entries --'));
}
}