219 lines
7.9 KiB
PHP
219 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Services;
|
|
|
|
use App\Livewire\Concerns\WithFleetContext;
|
|
use App\Models\AuditEvent;
|
|
use App\Services\FleetService;
|
|
use App\Support\Confirm\ConfirmToken;
|
|
use App\Support\Confirm\InvalidConfirmToken;
|
|
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
|
|
{
|
|
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.<op>); 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<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'));
|
|
}
|
|
}
|