clusev/app/Livewire/Services/Index.php

197 lines
6.8 KiB
PHP

<?php
namespace App\Livewire\Services;
use App\Livewire\Concerns\WithFleetContext;
use App\Services\FleetService;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
#[Layout('layouts.app')]
class Index extends Component
{
use WithFleetContext;
/** Cap on retained journal rows so the live poll never grows the payload unbounded. */
private const JOURNAL_MAX = 200;
/** Active server name (from fleet context). */
public string $server = '—';
public bool $connected = false;
public bool $ready = false;
/** wire:model search over service name + description */
public string $search = '';
/** @var array<int, array{name:string,status:string,sub:string,enabled:bool,desc:string}> */
public array $services = [];
/** @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 ?? '—';
}
/** 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
{
$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
{
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;
}
$this->dispatch('notify', message: $res['ok']
? __('services.action_done', ['name' => $name, 'op' => $op])
: __('services.action_failed', ['name' => $name, 'output' => Str::limit($res['output'] ?: __('services.no_permission'), 90)]));
try {
$this->services = $fleet->systemd($active)['services'];
} catch (Throwable) {
// keep current list on reload failure
}
}
/** @return array<int, array{name:string,status:string,sub:string,enabled:bool,desc:string}> */
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'));
}
}