clusev/app/Livewire/Docker/Index.php

178 lines
6.3 KiB
PHP

<?php
namespace App\Livewire\Docker;
use App\Livewire\Concerns\WithFleetContext;
use App\Models\AuditEvent;
use App\Models\HostCredential;
use App\Models\Server;
use App\Services\DockerService;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Throwable;
/**
* Container management over SSH. Two targets: the CLUSEV HOST itself (the machine Clusev runs on,
* reached via the stored HostCredential over the local Docker gateway) and any fleet server. The
* host is the default when configured — that is where the operator's own containers live. Viewing
* the list is open to any role; container actions (start/stop/restart) require `operate`, matching
* systemd service gating. Loads lazily via wire:init like Files/Services.
*/
#[Layout('layouts.app')]
class Index extends Component
{
use WithFleetContext;
/** '' = auto (host when a host login exists, else the active fleet server); or 'host' / 'server'. */
public string $target = '';
/** @var array<int, array<string, string>> */
public array $containers = [];
public bool $connected = false;
public bool $ready = false;
/** Docker binary absent on the target (e.g. a native mail server) — an honest state, not an error. */
public bool $notInstalled = false;
/** Host target selected but no host SSH login configured yet — points the operator to setup. */
public bool $hostNotConfigured = false;
/** The docker error (daemon down / permission / rootless), surfaced so "empty" isn't misleading. */
public ?string $error = null;
public function title(): string
{
return __('docker.title');
}
/** Whether a Clusev-host SSH login exists (controls the target switch + the default target). */
public function hostConfigured(): bool
{
return HostCredential::current() !== null;
}
/** Resolve '' to a concrete target: the host when one is configured, otherwise a fleet server. */
public function effectiveTarget(): string
{
if ($this->target === 'host' || $this->target === 'server') {
return $this->target;
}
return $this->hostConfigured() ? 'host' : 'server';
}
/** The Server the current target resolves to (transient host server, or the active fleet server). */
private function targetServer(): ?Server
{
return $this->effectiveTarget() === 'host'
? HostCredential::current()?->toServer()
: $this->activeServer();
}
/** Switch between the host and the active fleet server, then reload. */
public function setTarget(string $target, DockerService $docker): void
{
$this->target = in_array($target, ['host', 'server'], true) ? $target : '';
$this->ready = false;
$this->load($docker);
}
public function load(DockerService $docker): void
{
$this->containers = [];
$this->connected = false;
$this->notInstalled = false;
$this->hostNotConfigured = false;
$this->error = null;
$isHost = $this->effectiveTarget() === 'host';
$server = $this->targetServer();
if ($isHost && ! $server) {
$this->hostNotConfigured = true;
$this->ready = true;
return;
}
// Fleet servers need a stored credential; the host server always carries the host login.
if ($server && ($isHost || $server->credential_exists)) {
try {
// Probe first: a target with no docker binary (native mail/web server) gets a clean
// "not installed" panel instead of the raw "sh: docker: not found" shell error.
if (! $docker->available($server)) {
$this->notInstalled = true;
} else {
$this->containers = $docker->containers($server);
$this->connected = true;
}
} catch (Throwable $e) {
$this->connected = false;
$this->error = mb_scrub(mb_strcut($e->getMessage(), 0, 300), 'UTF-8');
}
}
$this->ready = true;
}
public function action(string $id, string $op, DockerService $docker): void
{
abort_unless(Auth::user()?->can('operate'), 403);
$isHost = $this->effectiveTarget() === 'host';
$server = $this->targetServer();
if (! $server || (! $isHost && ! $server->credential_exists)) {
return;
}
try {
$res = $docker->containerAction($server, $id, $op);
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id, // null for the host (transient) — the target label names it
'actor' => Auth::user()?->name ?? 'system',
'action' => 'docker.action',
'target' => "{$op} {$id} · {$server->name}",
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: $res['ok']
? __('docker.action_ok', ['op' => $op, 'ref' => $id])
: __('docker.action_failed', ['error' => $res['output']]));
} catch (InvalidArgumentException) {
$this->dispatch('notify', message: __('docker.invalid_ref'));
}
$this->load($docker);
}
/** Open the read-only logs modal for a container (the ref is re-validated in DockerService). */
public function viewLogs(string $id): void
{
// Container logs can contain secrets (env dumps, tokens in traces), so reading them is an
// operate action — consistent with gating file CONTENT reads. The list itself stays open.
abort_unless(Auth::user()?->can('operate'), 403);
$isHost = $this->effectiveTarget() === 'host';
$server = $this->targetServer();
if (! $server) {
return;
}
$this->dispatch('openModal', component: 'modals.container-logs', arguments: [
// 0 is the sentinel the logs modal resolves to the Clusev host (no persisted id).
'serverId' => $isHost ? 0 : (int) $server->id,
'ref' => $id,
]);
}
public function render(): View
{
return view('livewire.docker.index')->title($this->title());
}
}