*/ public array $services = []; /** @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 ?? '—'; } /** Lazy: the SSH read runs after the shell renders (wire:init), behind a skeleton. */ public function load(FleetService $fleet): void { $active = $this->activeServer(); if ($active && $active->credential_exists) { try { $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; } } $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 * applyService() handles. SSH exec (systemctl) is wired in behind this later. */ public function confirm(string $op, int $index): void { abort_unless(auth()->user()?->can('operate'), 403); $name = $this->filteredServices[$index]['name'] ?? null; if ($name === null) { return; } [$heading, $phrase, $label, $action, $icon] = match ($op) { 'start' => [__('services.confirm_start_heading'), __('services.confirm_start_phrase'), __('services.confirm_start_label'), 'service.start', 'power'], 'stop' => [__('services.confirm_stop_heading'), __('services.confirm_stop_phrase'), __('services.confirm_stop_label'), 'service.stop', 'power'], default => [__('services.confirm_restart_heading'), __('services.confirm_restart_phrase'), __('services.confirm_restart_label'), 'service.restart', 'rotate'], }; $this->dispatch('openModal', component: 'modals.confirm-action', arguments: [ 'heading' => $heading, 'body' => __('services.confirm_body', ['name' => $name, 'phrase' => $phrase, 'server' => $this->server]), 'confirmLabel' => $label, 'danger' => $op === 'stop', 'icon' => $icon, 'notify' => __('services.confirm_notify', ['name' => $name, 'label' => $label]), 'token' => ConfirmToken::issue( 'serviceConfirmed', ['op' => $op, 'name' => $name], $action, "{$name} · {$this->server}", $this->activeServer()?->id, ), ], ); } /** Performs the confirmed systemctl action over SSH, then reloads real state. */ #[On('serviceConfirmed')] public function applyService(string $confirmToken, FleetService $fleet): void { abort_unless(auth()->user()?->can('operate'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'serviceConfirmed'); } catch (InvalidConfirmToken) { return; // forged / replayed / direct-bypass attempt — no-op } $op = $payload['params']['op']; $name = $payload['params']['name']; $active = $this->activeServer(); if (! $active || ($payload['serverId'] ?? null) !== $active->id || ! $active->credential_exists) { return; // wrong/absent server (retarget attempt) or no credential — no-op } try { $res = $fleet->serviceAction($active, $op, $name); } catch (Throwable $e) { $this->dispatch('notify', message: __('services.action_error', ['name' => $name, 'error' => $e->getMessage()])); return; } if ($res['ok']) { $this->dispatch('notify', message: __('services.action_done', ['name' => $name, 'op' => $op])); } else { // A toast is ephemeral and truncated — persist the FULL failure reason so the operator // can re-read WHY a start failed (e.g. a service that crashes on launch). The confirm // modal already logged the neutral attempt (service.); this red entry records the // outcome with the complete systemctl output in meta, shown expandable in the audit log. AuditEvent::create([ 'user_id' => auth()->id(), 'server_id' => $active->id, 'actor' => auth()->user()?->name ?? 'system', 'action' => 'service.'.$op.'.failed', 'target' => $name.' · '.$active->name, 'ip' => request()->ip(), 'meta' => ['output' => mb_substr(mb_scrub(trim((string) ($res['output'] ?: __('services.no_permission')))), 0, 4000)], ]); $this->dispatch('notify', message: __('services.action_failed_short', ['name' => $name]), level: 'error'); } try { $this->services = $fleet->systemd($active)['services']; } catch (Throwable) { // keep current list on reload failure } } /** @return array */ public function getFilteredServicesProperty(): array { $q = trim(mb_strtolower($this->search)); if ($q === '') { return $this->services; } return array_values(array_filter( $this->services, fn (array $s): bool => str_contains(mb_strtolower($s['name']), $q) || str_contains(mb_strtolower($s['desc']), $q) )); } public function render() { return view('livewire.services.index')->title(__('services.title')); } }