diff --git a/.gitignore b/.gitignore index 00df2c6..a20788e 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,8 @@ yarn-error.log # The release image pin is written ONLY into the promoted public tree by CI (git add -f there); # a copy must never be committed to the source tree, or a dev install would wrongly switch to pull. /release-images.lock + +# The marketing site + docs (clusev.com / docs.clusev.com) are a SEPARATE project at ~/clusev-site +# with their own compose — they must NEVER land in the panel repo (else anyone could self-host the +# site). Belt-and-suspenders guard in case site files are ever copied in here by mistake. +/clusev-site/ diff --git a/app/Livewire/Docker/Index.php b/app/Livewire/Docker/Index.php index 4c6562e..80c3343 100644 --- a/app/Livewire/Docker/Index.php +++ b/app/Livewire/Docker/Index.php @@ -2,7 +2,6 @@ namespace App\Livewire\Docker; -use App\Livewire\Concerns\WithFleetContext; use App\Models\AuditEvent; use App\Models\HostCredential; use App\Models\Server; @@ -15,20 +14,14 @@ 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. + * Docker for the CLUSEV HOST — the machine Clusev runs on, reached via the stored HostCredential + * over the local Docker gateway. Admin-only (manage-fleet), the same bar as the host terminal: a + * `Stop` on a control-plane container takes Clusev down. Per-SERVER container management lives on + * the server-details page (the "Docker" tab / App\Livewire\Servers\ServerDocker). Loads lazily. */ #[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> */ public array $containers = []; @@ -36,71 +29,30 @@ class Index extends Component public bool $ready = false; - /** Docker binary absent on the target (e.g. a native mail server) — an honest state, not an error. */ + /** Docker binary absent on the host — 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. */ + /** No host SSH login configured yet — point 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 mount(): void + { + // The host is the control-plane machine — admin-only, like the host terminal. + abort_unless(Auth::user()?->can('manage-fleet'), 403); + } + public function title(): string { return __('docker.title'); } - /** Whether a Clusev-host SSH login exists (controls the default target + the setup hint). */ - public function hostConfigured(): bool + /** A transient Server pointing at the Clusev host (built from the host login), or null if unset. */ + private function hostServer(): ?Server { - return HostCredential::current() !== null; - } - - /** - * Only admins (manage-fleet) may target the Clusev host — the same bar as the host terminal. - * The host is the control-plane machine: a `Stop` on clusev-mariadb would take Clusev down, so - * a viewer/operator must never reach it (they only ever see fleet servers). - */ - public function mayUseHost(): bool - { - return (bool) Auth::user()?->can('manage-fleet'); - } - - /** Show the host/server switch only to users allowed on the host. */ - public function canToggleHost(): bool - { - return $this->mayUseHost(); - } - - /** Resolve the target: the host (admins only) or a fleet server; '' auto-picks the host default. */ - public function effectiveTarget(): string - { - // An explicit host selection is honoured only for permitted users; everyone else is pinned - // to a fleet server so a viewer/operator can never reach the control-plane machine. - if ($this->target === 'host' && $this->mayUseHost()) { - return 'host'; - } - if ($this->target === 'server') { - return 'server'; - } - - return ($this->mayUseHost() && $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); + return HostCredential::current()?->toServer(); } public function load(DockerService $docker): void @@ -111,31 +63,26 @@ class Index extends Component $this->hostNotConfigured = false; $this->error = null; - $isHost = $this->effectiveTarget() === 'host'; - $server = $this->targetServer(); - - if ($isHost && ! $server) { + $server = $this->hostServer(); + if (! $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'); + try { + // Probe first: a host with no docker binary 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; @@ -143,16 +90,10 @@ class Index extends Component public function action(string $id, string $op, DockerService $docker): void { - abort_unless(Auth::user()?->can('operate'), 403); + abort_unless(Auth::user()?->can('manage-fleet'), 403); - $isHost = $this->effectiveTarget() === 'host'; - if ($isHost) { - // Host container actions match the host-terminal bar: admin-only (stopping a - // control-plane container is as destructive as a root shell on the host). - abort_unless(Auth::user()?->can('manage-fleet'), 403); - } - $server = $this->targetServer(); - if (! $server || (! $isHost && ! $server->credential_exists)) { + $server = $this->hostServer(); + if (! $server) { return; } @@ -160,7 +101,7 @@ class Index extends Component $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 + 'server_id' => null, // the host is transient (no fleet row); the target label names it 'actor' => Auth::user()?->name ?? 'system', 'action' => 'docker.action', 'target' => "{$op} {$id} · {$server->name}", @@ -176,25 +117,14 @@ class Index extends Component $this->load($docker); } - /** Open the read-only logs modal for a container (the ref is re-validated in DockerService). */ + /** Open the read-only logs modal for a host container (ref 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'; - if ($isHost) { - abort_unless(Auth::user()?->can('manage-fleet'), 403); // host logs: admin-only, like the host shell - } - $server = $this->targetServer(); - if (! $server) { - return; - } + abort_unless(Auth::user()?->can('manage-fleet'), 403); $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, + 'serverId' => 0, 'ref' => $id, ]); } diff --git a/app/Livewire/Servers/ServerDocker.php b/app/Livewire/Servers/ServerDocker.php new file mode 100644 index 0000000..9530e3b --- /dev/null +++ b/app/Livewire/Servers/ServerDocker.php @@ -0,0 +1,104 @@ +> */ + public array $containers = []; + + public bool $connected = false; + + public bool $ready = false; + + /** Docker binary absent on the server (e.g. a native mail server) — honest state, not an error. */ + public bool $notInstalled = false; + + /** The docker error (daemon down / permission / rootless), surfaced so "empty" isn't misleading. */ + public ?string $error = null; + + public function load(DockerService $docker): void + { + $this->containers = []; + $this->connected = false; + $this->notInstalled = false; + $this->error = null; + + // credential_exists is a withExists() query attribute that does NOT survive Livewire model + // hydration, so check the relation directly here. + if ($this->server->credential()->exists()) { + try { + if (! $docker->available($this->server)) { + $this->notInstalled = true; + } else { + $this->containers = $docker->containers($this->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); + + if (! $this->server->credential()->exists()) { + return; + } + + try { + $res = $docker->containerAction($this->server, $id, $op); + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $this->server->id, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'docker.action', + 'target' => "{$op} {$id} · {$this->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 on this server (ref re-validated in DockerService). */ + public function viewLogs(string $id): void + { + abort_unless(Auth::user()?->can('operate'), 403); + + $this->dispatch('openModal', component: 'modals.container-logs', arguments: [ + 'serverId' => (int) $this->server->id, + 'ref' => $id, + ]); + } + + public function render() + { + return view('livewire.servers.server-docker'); + } +} diff --git a/app/Livewire/Servers/ServerTerminal.php b/app/Livewire/Servers/ServerTerminal.php new file mode 100644 index 0000000..315c7fc --- /dev/null +++ b/app/Livewire/Servers/ServerTerminal.php @@ -0,0 +1,43 @@ +can('operate'), 403); + + $token = Str::random(48); + TerminalSession::create([ + 'token' => $token, + 'user_id' => Auth::id(), + 'kind' => 'server', + 'server_id' => $this->server->id, + 'expires_at' => now()->addSeconds(60), + 'created_at' => now(), + ]); + + $this->dispatch('terminal-open', token: $token, title: $this->server->name); + } + + public function render() + { + return view('livewire.servers.server-terminal'); + } +} diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index c9540cd..f275db3 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -15,6 +15,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; use Livewire\Attributes\Layout; use Livewire\Attributes\On; +use Livewire\Attributes\Url; use Livewire\Component; use Throwable; @@ -24,6 +25,10 @@ class Show extends Component /** Route-model-bound by uuid (R11). */ public Server $server; + /** Active detail tab: 'overview' | 'docker' | 'terminal' (deep-linkable via ?tab=). */ + #[Url] + public string $tab = 'overview'; + /** Page ; localized at runtime (attributes can't call __()). */ public function title(): string { @@ -156,6 +161,10 @@ class Show extends Component /** Refresh the live gauges from the poller-updated row (wire:poll). */ public function pollMetrics(): void { + // Only the Übersicht tab shows live metrics; skip the refresh/re-render on Docker/Terminal. + if ($this->tab !== 'overview') { + return; + } $this->server->refresh(); } diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 56ff651..b84c018 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -3,7 +3,6 @@ namespace App\Livewire\Terminal; use App\Models\HostCredential; -use App\Models\Server; use App\Models\TerminalSession; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; @@ -12,76 +11,54 @@ use Livewire\Attributes\On; use Livewire\Component; /** - * Terminal page. Lists the targets (the Clusev host + each server) and, on selection, mints a - * single-use session token that the xterm.js island uses to open a WebSocket to the terminal - * sidecar. The component itself never touches the SSH connection or credentials — it only authorizes - * the operator and hands out a short-lived token (resolved server-side over the internal network). + * Host terminal — a real SSH login into the CLUSEV HOST machine, admin-only (manage-fleet). Per-SERVER + * shells live on the server-details page (the "Terminal" tab / App\Livewire\Servers\ServerTerminal). + * On connect it mints a single-use token the xterm.js island uses to open a WebSocket to the terminal + * sidecar; this component never touches the SSH connection or credentials. */ #[Layout('layouts.app')] class Index extends Component { - /** Show the server search box once there is more than one server to filter between. */ - private const SEARCH_THRESHOLD = 1; - - /** 'host' or a server uuid — drives the active-target highlight in the rail. */ - public string $activeKey = ''; - - /** Live filter over the server rail (only shown past SEARCH_THRESHOLD servers). */ - public string $search = ''; + public function mount(): void + { + // The host shell is a real login into the Clusev machine — the most dangerous target. + abort_unless(Auth::user()?->can('manage-fleet'), 403); + } public function title(): string { return __('terminal.title'); } - /** Mint a single-use token for the chosen target and hand it to the xterm island via an event. */ - public function open(string $kind, ?string $uuid = null): void + /** Mint a single-use token for the host shell and hand it to the xterm island via an event. */ + public function open(): void { - // Split by target: the host shell is a real login into the Clusev machine (admin-only), - // while a server shell is a day-to-day operator action. - abort_unless(auth()->user()?->can($kind === 'host' ? 'manage-fleet' : 'operate'), 403); + abort_unless(Auth::user()?->can('manage-fleet'), 403); - $serverId = null; - $label = __('terminal.host_label'); - $this->activeKey = 'host'; + // With no host login configured yet, open the setup form instead of minting a dead session. + if (HostCredential::current() === null) { + $this->configureHost(); - if ($kind === 'host') { - // The host terminal is a real SSH login into the Clusev machine. With no login configured - // yet, open the setup form instead of minting a dead session. - if (HostCredential::current() === null) { - $this->configureHost(); - - return; - } - } else { - $kind = 'server'; - $server = Server::where('uuid', $uuid)->first(); - if ($server === null) { - return; - } - $serverId = $server->id; - $label = $server->name; - $this->activeKey = $server->uuid; + return; } $token = Str::random(48); TerminalSession::create([ 'token' => $token, 'user_id' => Auth::id(), - 'kind' => $kind, - 'server_id' => $serverId, + 'kind' => 'host', + 'server_id' => null, 'expires_at' => now()->addSeconds(60), 'created_at' => now(), ]); - $this->dispatch('terminal-open', token: $token, title: $label); + $this->dispatch('terminal-open', token: $token, title: __('terminal.host_label')); } /** Open the modal that stores the Clusev host SSH login. */ public function configureHost(): void { - // Configuring the host SSH login is a fleet-admin action (HostShell::mount re-gates too). - abort_unless(auth()->user()?->can('manage-fleet'), 403); + abort_unless(Auth::user()?->can('manage-fleet'), 403); $this->dispatch('openModal', component: 'modals.host-shell'); } @@ -95,21 +72,10 @@ class Index extends Component public function render() { - $serverCount = Server::count(); - - $servers = Server::query() - ->when($this->search !== '', fn ($q) => $q->where('name', 'like', '%'.$this->search.'%')) - ->orderBy('name') - ->get(['uuid', 'name', 'status']); - $host = HostCredential::current(); return view('livewire.terminal.index', [ - 'servers' => $servers, - 'serverCount' => $serverCount, - 'showSearch' => $serverCount > self::SEARCH_THRESHOLD, 'hostConfigured' => $host !== null, - // The host is always the local machine, so the tile shows just the login user. 'hostTarget' => $host?->username, ])->title($this->title()); } diff --git a/lang/de/docker.php b/lang/de/docker.php index 1e27631..62cb89e 100644 --- a/lang/de/docker.php +++ b/lang/de/docker.php @@ -1,12 +1,13 @@ <?php return [ - 'eyebrow' => 'Flotte', + 'eyebrow' => 'Host', 'title' => 'Docker', 'containers_title' => 'Container', 'connected' => 'Verbunden', 'disconnected' => 'Getrennt', + 'host_subtitle' => 'Container auf dem Clusev-Host. Container einzelner Server findest du auf deren Detailseite im Reiter „Docker".', 'target_label' => 'Ziel', 'host_target' => 'Clusev-Host', 'server_target' => 'Server', diff --git a/lang/de/servers.php b/lang/de/servers.php index 4df4f2d..fb3e20e 100644 --- a/lang/de/servers.php +++ b/lang/de/servers.php @@ -6,6 +6,12 @@ return [ 'index_title' => 'Server — Clusev', 'show_title' => 'Server-Details — Clusev', + // ── Detail-Reiter ───────────────────────────────────────────────────── + 'tabs_aria' => 'Server-Reiter', + 'tab_overview' => 'Übersicht', + 'tab_docker' => 'Docker', + 'tab_terminal' => 'Terminal', + // ── Status labels (status pills / KPIs) ─────────────────────────────── 'status_online' => 'Online', 'status_warning' => 'Warnung', diff --git a/lang/de/shell.php b/lang/de/shell.php index 69de19c..30cb060 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -10,6 +10,7 @@ return [ // Sidebar — nav group labels 'group_fleet' => 'Flotte', + 'group_host' => 'Host', 'group_account' => 'Konto', // Sidebar — nav items diff --git a/lang/de/terminal.php b/lang/de/terminal.php index 6f2e982..3b8bf52 100644 --- a/lang/de/terminal.php +++ b/lang/de/terminal.php @@ -2,9 +2,15 @@ return [ 'title' => 'Terminal — Clusev', - 'eyebrow' => 'Betrieb', + 'eyebrow' => 'Host', 'heading' => 'Terminal', 'subtitle' => 'Interaktive Shell — pro Server per SSH, oder die Clusev-Umgebung selbst.', + 'host_heading' => 'Host-Terminal', + 'host_page_subtitle' => 'Echte SSH-Shell auf der Maschine, auf der Clusev läuft. Server-Shells findest du auf der jeweiligen Server-Detailseite im Reiter „Terminal".', + 'connect' => 'Verbinden', + 'connect_host' => 'Mit Host verbinden', + 'no_permission_title' => 'Kein Zugriff', + 'no_permission_hint' => 'Für das Terminal dieses Servers fehlt dir die Berechtigung.', 'targets_title' => 'Ziele', 'targets_subtitle' => 'Wähle eine Sitzung', diff --git a/lang/en/docker.php b/lang/en/docker.php index 4319ccb..59e9e31 100644 --- a/lang/en/docker.php +++ b/lang/en/docker.php @@ -1,12 +1,13 @@ <?php return [ - 'eyebrow' => 'Fleet', + 'eyebrow' => 'Host', 'title' => 'Docker', 'containers_title' => 'Containers', 'connected' => 'Connected', 'disconnected' => 'Disconnected', + 'host_subtitle' => 'Containers on the Clusev host. A specific server’s containers live on its detail page under the “Docker” tab.', 'target_label' => 'Target', 'host_target' => 'Clusev host', 'server_target' => 'Server', diff --git a/lang/en/servers.php b/lang/en/servers.php index 835ef19..8716389 100644 --- a/lang/en/servers.php +++ b/lang/en/servers.php @@ -6,6 +6,12 @@ return [ 'index_title' => 'Servers — Clusev', 'show_title' => 'Server details — Clusev', + // ── Detail tabs ─────────────────────────────────────────────────────── + 'tabs_aria' => 'Server tabs', + 'tab_overview' => 'Overview', + 'tab_docker' => 'Docker', + 'tab_terminal' => 'Terminal', + // ── Status labels (status pills / KPIs) ─────────────────────────────── 'status_online' => 'Online', 'status_warning' => 'Warning', diff --git a/lang/en/shell.php b/lang/en/shell.php index 42d7588..816932b 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -10,6 +10,7 @@ return [ // Sidebar — nav group labels 'group_fleet' => 'Fleet', + 'group_host' => 'Host', 'group_account' => 'Account', // Sidebar — nav items diff --git a/lang/en/terminal.php b/lang/en/terminal.php index accbba5..a20daeb 100644 --- a/lang/en/terminal.php +++ b/lang/en/terminal.php @@ -2,9 +2,15 @@ return [ 'title' => 'Terminal — Clusev', - 'eyebrow' => 'Operations', + 'eyebrow' => 'Host', 'heading' => 'Terminal', 'subtitle' => 'Interactive shell — per server over SSH, or the Clusev environment itself.', + 'host_heading' => 'Host terminal', + 'host_page_subtitle' => 'A real SSH shell on the machine Clusev runs on. A server’s shell lives on its detail page under the “Terminal” tab.', + 'connect' => 'Connect', + 'connect_host' => 'Connect to host', + 'no_permission_title' => 'No access', + 'no_permission_hint' => 'You don’t have permission to open this server’s terminal.', 'targets_title' => 'Targets', 'targets_subtitle' => 'Pick a session', diff --git a/resources/views/components/server-tabs.blade.php b/resources/views/components/server-tabs.blade.php new file mode 100644 index 0000000..4a9fe2e --- /dev/null +++ b/resources/views/components/server-tabs.blade.php @@ -0,0 +1,23 @@ +@props(['tab' => 'overview']) +@php + // Übersicht + Docker are open to any role (read-only); the server shell is an operate action. + $tabs = array_filter([ + ['key' => 'overview', 'label' => __('servers.tab_overview'), 'icon' => 'server', 'show' => true], + ['key' => 'docker', 'label' => __('servers.tab_docker'), 'icon' => 'box', 'show' => true], + ['key' => 'terminal', 'label' => __('servers.tab_terminal'), 'icon' => 'terminal', 'show' => (bool) auth()->user()?->can('operate')], + ], fn ($t) => $t['show']); +@endphp +<div class="flex items-center gap-1 overflow-x-auto border-b border-line" role="tablist" aria-label="{{ __('servers.tabs_aria') }}"> + @foreach ($tabs as $t) + <button type="button" wire:click="$set('tab', '{{ $t['key'] }}')" role="tab" + aria-selected="{{ $tab === $t['key'] ? 'true' : 'false' }}" + @class([ + 'flex items-center gap-2 whitespace-nowrap border-b-2 px-3.5 py-2.5 font-mono text-[12px] transition-colors -mb-px', + 'border-accent text-accent-text' => $tab === $t['key'], + 'border-transparent text-ink-3 hover:text-ink' => $tab !== $t['key'], + ])> + <x-icon :name="$t['icon']" class="h-3.5 w-3.5" /> + {{ $t['label'] }} + </button> + @endforeach +</div> diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index fdb4258..fbd9a96 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -42,23 +42,26 @@ {{-- Nav --}} <nav class="flex-1 space-y-1 overflow-y-auto p-3"> + {{-- Flotte: per-server / fleet operations --}} <p class="px-3 pb-1 pt-2 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_fleet') }}</p> <x-nav-item icon="dashboard" href="/" :active="request()->is('/')" data-tour="dashboard">{{ __('shell.nav_dashboard') }}</x-nav-item> <x-nav-item icon="server" href="/servers" :active="request()->is('servers*')" data-tour="servers">{{ __('shell.nav_servers') }}</x-nav-item> <x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item> - <x-nav-item icon="box" href="/docker" :active="request()->is('docker*')">{{ __('shell.nav_docker') }}</x-nav-item> @can('operate') <x-nav-item icon="command" href="/commands" :active="request()->is('commands*')">{{ __('shell.nav_commands') }}</x-nav-item> @endcan <x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item> <x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item> + + {{-- Host: things that act on the Clusev host machine itself --}} + <p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_host') }}</p> + @can('manage-fleet') + <x-nav-item icon="box" href="/docker" :active="request()->is('docker*')">{{ __('shell.nav_docker') }}</x-nav-item> + <x-nav-item icon="terminal" href="/terminal" :active="request()->is('terminal*')" data-tour="terminal">{{ __('shell.nav_terminal') }}</x-nav-item> + @endcan @can('manage-network') <x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item> @endcan - <x-nav-item icon="terminal" href="/terminal" :active="request()->is('terminal*')" data-tour="terminal">{{ __('shell.nav_terminal') }}</x-nav-item> - - <p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p> - <x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')" data-tour="settings">{{ __('shell.nav_settings') }}</x-nav-item> <x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item> @can('operate') <x-nav-item icon="shield-check" href="/posture" :active="request()->is('posture*')">{{ __('shell.nav_posture') }}</x-nav-item> @@ -66,6 +69,10 @@ <x-nav-item icon="lock" href="/certs" :active="request()->is('certs*')">{{ __('shell.nav_certs') }}</x-nav-item> <x-nav-item icon="activity" href="/health" :active="request()->is('health*')">{{ __('shell.nav_health') }}</x-nav-item> @endcan + + {{-- Konto: account / panel-level --}} + <p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p> + <x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')" data-tour="settings">{{ __('shell.nav_settings') }}</x-nav-item> @can('manage-panel') <x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item> <x-nav-item icon="shield-alert" href="/threats" :active="request()->is('threats*')" :badge="$threatCount > 0 ? $threatCount : null" :badge-title="$threatCount > 0 ? __('shell.threats_badge', ['count' => $threatCount]) : null">{{ __('shell.nav_threats') }}</x-nav-item> diff --git a/resources/views/livewire/docker/index.blade.php b/resources/views/livewire/docker/index.blade.php index 2fa0010..c995640 100644 --- a/resources/views/livewire/docker/index.blade.php +++ b/resources/views/livewire/docker/index.blade.php @@ -6,10 +6,6 @@ in_array($s, ['created', 'restarting'], true) => 'pending', default => 'offline', // exited / dead / removing }; - $hostOn = $this->canToggleHost(); - $effTarget = $this->effectiveTarget(); - $active = $this->activeServer(); - $targetName = $effTarget === 'host' ? __('docker.host_target') : $active?->name; @endphp <div class="space-y-4" @if (! $ready) wire:init="load" @endif> @@ -18,35 +14,12 @@ <div> <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('docker.eyebrow') }}</p> <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('docker.title') }}</h2> + <p class="mt-1 text-sm text-ink-3">{{ __('docker.host_subtitle') }}</p> </div> - - <div class="flex flex-wrap items-center gap-3"> - {{-- Target switch: the Clusev host vs the active fleet server (only when a host login exists) --}} - @if ($hostOn) - <div class="inline-flex rounded-lg border border-line bg-surface p-0.5" role="tablist" aria-label="{{ __('docker.target_label') }}"> - <button type="button" wire:click="setTarget('host')" - @class([ - 'rounded-md px-3 py-1 font-mono text-[11px] transition-colors', - 'bg-accent/15 text-accent-text' => $effTarget === 'host', - 'text-ink-3 hover:text-ink' => $effTarget !== 'host', - ]) - aria-selected="{{ $effTarget === 'host' ? 'true' : 'false' }}">{{ __('docker.host_target') }}</button> - <button type="button" wire:click="setTarget('server')" @disabled(! $active) - @class([ - 'rounded-md px-3 py-1 font-mono text-[11px] transition-colors', - 'bg-accent/15 text-accent-text' => $effTarget === 'server', - 'text-ink-3 hover:text-ink' => $effTarget !== 'server', - 'cursor-not-allowed opacity-40' => ! $active, - ]) - aria-selected="{{ $effTarget === 'server' ? 'true' : 'false' }}">{{ $active?->name ?? __('docker.server_target') }}</button> - </div> - @endif - - <x-status-pill :status="$connected ? 'online' : 'offline'">{{ $connected ? __('docker.connected') : __('docker.disconnected') }}</x-status-pill> - </div> + <x-status-pill :status="$connected ? 'online' : 'offline'">{{ $connected ? __('docker.connected') : __('docker.disconnected') }}</x-status-pill> </div> - <x-panel :title="__('docker.containers_title')" :subtitle="$targetName" :padded="false"> + <x-panel :title="__('docker.containers_title')" :subtitle="__('docker.host_target')" :padded="false"> @if (! $ready) {{-- lazy skeleton --}} <div class="space-y-2 p-4 sm:p-5"> @@ -64,7 +37,7 @@ <p class="mx-auto mt-1 max-w-md font-mono text-[11px] text-ink-4">{{ __('docker.host_unconfigured_hint') }}</p> </div> @elseif ($notInstalled) - {{-- Honest, neutral state: the target simply has no docker (not a fault). --}} + {{-- Honest, neutral state: the host simply has no docker (not a fault). --}} <div class="px-4 py-10 text-center sm:px-5"> <div class="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-raised text-ink-3"> <x-icon name="box" class="h-5 w-5" /> @@ -92,21 +65,18 @@ <x-status-dot :status="$pill($c['state'])" :ping="$c['state'] === 'running'" class="h-2.5 w-2.5" /> <div class="min-w-0 flex-1"> <p class="truncate font-mono text-sm text-ink">{{ $c['name'] }}</p> - {{-- Just the image — the raw port list is noisy and rarely useful at a glance. --}} <p class="truncate font-mono text-[11px] text-ink-3">{{ $c['image'] }}</p> </div> <x-status-pill :status="$pill($c['state'])" class="hidden sm:inline-flex">{{ $c['state'] ?: '—' }}</x-status-pill> <div class="flex flex-wrap items-center gap-1.5 pl-5 sm:pl-0"> - @can('operate') - <x-btn variant="secondary" wire:click="viewLogs('{{ $c['id'] }}')">{{ __('docker.logs') }}</x-btn> - @if ($c['state'] === 'running') - <x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'restart')" wire:loading.attr="disabled">{{ __('docker.restart') }}</x-btn> - <x-btn variant="danger-soft" wire:click="action('{{ $c['id'] }}', 'stop')" wire:loading.attr="disabled">{{ __('docker.stop') }}</x-btn> - @else - <x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'start')" wire:loading.attr="disabled">{{ __('docker.start') }}</x-btn> - @endif - @endcan + <x-btn variant="secondary" wire:click="viewLogs('{{ $c['id'] }}')">{{ __('docker.logs') }}</x-btn> + @if ($c['state'] === 'running') + <x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'restart')" wire:loading.attr="disabled">{{ __('docker.restart') }}</x-btn> + <x-btn variant="danger-soft" wire:click="action('{{ $c['id'] }}', 'stop')" wire:loading.attr="disabled">{{ __('docker.stop') }}</x-btn> + @else + <x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'start')" wire:loading.attr="disabled">{{ __('docker.start') }}</x-btn> + @endif </div> </div> @endforeach diff --git a/resources/views/livewire/servers/server-docker.blade.php b/resources/views/livewire/servers/server-docker.blade.php new file mode 100644 index 0000000..265ee4d --- /dev/null +++ b/resources/views/livewire/servers/server-docker.blade.php @@ -0,0 +1,66 @@ +@php + $pill = fn (string $s) => match (true) { + $s === 'running' => 'online', + $s === 'paused' => 'warning', + in_array($s, ['created', 'restarting'], true) => 'pending', + default => 'offline', + }; +@endphp + +<div @if (! $ready) wire:init="load" @endif> + <x-panel :title="__('docker.containers_title')" :subtitle="$server->name" :padded="false"> + @if (! $ready) + <div class="space-y-2 p-4 sm:p-5"> + @for ($i = 0; $i < 3; $i++) + <div class="h-12 animate-pulse rounded-md bg-raised/50"></div> + @endfor + </div> + @elseif ($notInstalled) + <div class="px-4 py-10 text-center sm:px-5"> + <div class="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-raised text-ink-3"> + <x-icon name="box" class="h-5 w-5" /> + </div> + <p class="mt-3 text-sm text-ink-2">{{ __('docker.not_installed_title') }}</p> + <p class="mx-auto mt-1 max-w-md font-mono text-[11px] text-ink-4">{{ __('docker.not_installed_hint') }}</p> + </div> + @elseif (! $connected) + <div class="px-4 py-10 text-center sm:px-5"> + <p class="text-sm text-ink-2">{{ __('docker.unavailable_title') }}</p> + <p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('docker.unavailable_hint') }}</p> + @if ($error) + <pre class="mx-auto mt-3 max-w-xl overflow-auto rounded-md border border-offline/25 bg-offline/10 p-2.5 text-left font-mono text-[11px] text-offline">{{ $error }}</pre> + @endif + </div> + @elseif (empty($containers)) + <div class="px-4 py-10 text-center sm:px-5"> + <p class="text-sm text-ink-2">{{ __('docker.empty_title') }}</p> + <p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('docker.empty_hint') }}</p> + </div> + @else + <div class="divide-y divide-line"> + @foreach ($containers as $c) + <div wire:key="ctr-{{ $c['id'] }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5"> + <x-status-dot :status="$pill($c['state'])" :ping="$c['state'] === 'running'" class="h-2.5 w-2.5" /> + <div class="min-w-0 flex-1"> + <p class="truncate font-mono text-sm text-ink">{{ $c['name'] }}</p> + <p class="truncate font-mono text-[11px] text-ink-3">{{ $c['image'] }}</p> + </div> + <x-status-pill :status="$pill($c['state'])" class="hidden sm:inline-flex">{{ $c['state'] ?: '—' }}</x-status-pill> + + <div class="flex flex-wrap items-center gap-1.5 pl-5 sm:pl-0"> + @can('operate') + <x-btn variant="secondary" wire:click="viewLogs('{{ $c['id'] }}')">{{ __('docker.logs') }}</x-btn> + @if ($c['state'] === 'running') + <x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'restart')" wire:loading.attr="disabled">{{ __('docker.restart') }}</x-btn> + <x-btn variant="danger-soft" wire:click="action('{{ $c['id'] }}', 'stop')" wire:loading.attr="disabled">{{ __('docker.stop') }}</x-btn> + @else + <x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'start')" wire:loading.attr="disabled">{{ __('docker.start') }}</x-btn> + @endif + @endcan + </div> + </div> + @endforeach + </div> + @endif + </x-panel> +</div> diff --git a/resources/views/livewire/servers/server-terminal.blade.php b/resources/views/livewire/servers/server-terminal.blade.php new file mode 100644 index 0000000..eb481b2 --- /dev/null +++ b/resources/views/livewire/servers/server-terminal.blade.php @@ -0,0 +1,44 @@ +<div> + @can('operate') + {{-- Terminal island (self-contained; Livewire never touches it after the first render) --}} + <div wire:ignore x-data="terminalView()" x-on:terminal-open.window="open($event.detail)" + class="flex min-h-[28rem] flex-col overflow-hidden rounded-xl border border-line bg-void shadow-panel"> + {{-- Toolbar --}} + <div class="flex items-center gap-3 border-b border-line-soft bg-surface px-4 py-2.5"> + <span class="flex gap-1.5"> + <span class="h-2.5 w-2.5 rounded-full bg-offline/70"></span> + <span class="h-2.5 w-2.5 rounded-full bg-warning/70"></span> + <span class="h-2.5 w-2.5 rounded-full bg-online/70"></span> + </span> + <span class="truncate font-mono text-[12px] text-ink-2" x-text="title || '{{ $server->name }}'"></span> + <span class="ml-auto flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]"> + <span class="h-1.5 w-1.5 rounded-full" + :class="{ 'bg-ink-4': status==='idle', 'bg-warning': status==='connecting', 'bg-online': status==='connected', 'bg-offline': status==='error'||status==='closed' }"></span> + <span class="text-ink-3" + x-text="({idle:'{{ __('terminal.status_idle') }}',connecting:'{{ __('terminal.status_connecting') }}',connected:'{{ __('terminal.status_connected') }}',closed:'{{ __('terminal.status_closed') }}',error:'{{ __('terminal.status_error') }}'})[status]"></span> + </span> + <button type="button" x-on:click="clear()" class="rounded-md border border-line px-2.5 py-1 font-mono text-[11px] text-ink-3 transition-colors hover:border-accent/40 hover:text-accent-text">{{ __('terminal.clear') }}</button> + </div> + + {{-- Screen --}} + <div class="relative flex-1"> + <div x-ref="screen" class="absolute inset-0 p-2"></div> + {{-- Idle placeholder + connect action --}} + <div x-show="status==='idle'" class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-center"> + <x-icon name="terminal" class="h-7 w-7 text-ink-4" /> + <button type="button" wire:click="open" wire:loading.attr="disabled" + class="pointer-events-auto rounded-md border border-accent/40 bg-accent/10 px-4 py-1.5 font-mono text-[12px] text-accent-text transition-colors hover:bg-accent/20"> + {{ __('terminal.connect') }} + </button> + </div> + </div> + </div> + @else + <x-panel> + <div class="px-4 py-10 text-center sm:px-5"> + <p class="text-sm text-ink-2">{{ __('terminal.no_permission_title') }}</p> + <p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('terminal.no_permission_hint') }}</p> + </div> + </x-panel> + @endcan +</div> diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 7b2aa01..24f95d2 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -85,6 +85,14 @@ </div> </div> + <x-server-tabs :tab="$tab" /> + + @if ($tab === 'docker') + <livewire:servers.server-docker :server="$server" :key="'sd-'.$server->id" /> + @elseif ($tab === 'terminal') + <livewire:servers.server-terminal :server="$server" :key="'st-'.$server->id" /> + @else + @if ($ready && ! $connected) <div class="flex items-center gap-2.5 rounded-lg border border-warning/25 bg-warning/10 px-4 py-3"> <x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" /> @@ -699,4 +707,5 @@ <div class="space-y-3 p-4 sm:p-5"><x-skeleton class="h-3 w-2/3" /><x-skeleton class="h-3 w-1/2" /></div> </x-panel> @endif + @endif {{-- /tab === overview --}} </div> diff --git a/resources/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php index 30368da..028da8a 100644 --- a/resources/views/livewire/terminal/index.blade.php +++ b/resources/views/livewire/terminal/index.blade.php @@ -1,99 +1,61 @@ <div class="space-y-5"> {{-- Header --}} - <div> - <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('terminal.eyebrow') }}</p> - <h1 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('terminal.heading') }}</h1> - <p class="mt-1 text-sm text-ink-3">{{ __('terminal.subtitle') }}</p> + <div class="flex flex-wrap items-end justify-between gap-3"> + <div> + <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('terminal.eyebrow') }}</p> + <h1 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('terminal.host_heading') }}</h1> + <p class="mt-1 text-sm text-ink-3">{{ __('terminal.host_page_subtitle') }}</p> + </div> + <button type="button" wire:click="configureHost" + class="inline-flex items-center gap-2 rounded-md border border-line bg-surface px-3 py-1.5 font-mono text-[11px] text-ink-3 transition-colors hover:border-accent/40 hover:text-accent-text"> + <x-icon name="settings" class="h-3.5 w-3.5" /> + {{ __('terminal.host_configure') }} + </button> </div> - <div class="grid grid-cols-1 gap-4 lg:grid-cols-[260px_minmax(0,1fr)]"> - {{-- Target rail --}} - <x-panel :title="__('terminal.targets_title')" :subtitle="__('terminal.targets_subtitle')" :padded="false"> - <div class="space-y-1 p-2"> - {{-- Clusev host — a real SSH login into the host machine (configured by the operator). --}} - @can('manage-fleet') - <div @class([ - 'flex items-center rounded-md border transition-colors', - 'border-accent/30 bg-accent/10' => $activeKey === 'host', - 'border-transparent hover:border-line hover:bg-raised/60' => $activeKey !== 'host', - ])> - <button type="button" wire:click="open('host')" wire:loading.attr="disabled" - class="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left"> - <span class="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent"> - <x-icon name="command" class="h-4 w-4" /> - </span> - <span class="min-w-0 flex-1"> - <span @class(['block truncate font-mono text-sm', 'text-accent-text' => $activeKey === 'host', 'text-ink' => $activeKey !== 'host'])>{{ __('terminal.host_label') }}</span> - <span @class(['block truncate font-mono text-[11px]', 'text-ink-3' => $hostConfigured, 'text-warning' => ! $hostConfigured])>{{ $hostConfigured ? $hostTarget : __('terminal.host_not_configured') }}</span> - </span> - </button> - <button type="button" wire:click="configureHost" title="{{ __('terminal.host_configure') }}" - class="mr-1.5 grid h-7 w-7 shrink-0 place-items-center rounded-md text-ink-4 transition-colors hover:bg-raised hover:text-ink-2"> - <x-icon name="settings" class="h-3.5 w-3.5" /> - </button> - </div> - @endcan + {{-- Host login summary --}} + <div class="flex items-center gap-3 rounded-lg border border-line bg-surface px-4 py-3"> + <span class="grid h-9 w-9 shrink-0 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent"> + <x-icon name="command" class="h-4 w-4" /> + </span> + <div class="min-w-0 flex-1"> + <p class="font-mono text-sm text-ink">{{ __('terminal.host_label') }}</p> + <p @class(['truncate font-mono text-[11px]', 'text-ink-3' => $hostConfigured, 'text-warning' => ! $hostConfigured])> + {{ $hostConfigured ? $hostTarget.'@host.docker.internal' : __('terminal.host_not_configured') }} + </p> + </div> + </div> - <div class="my-1.5 px-3 font-mono text-[10px] uppercase tracking-[0.18em] text-ink-4">{{ __('terminal.servers_heading') }}</div> + {{-- Terminal island (self-contained; Livewire never touches it after the first render) --}} + <div wire:ignore x-data="terminalView()" x-on:terminal-open.window="open($event.detail)" + class="flex min-h-[28rem] flex-col overflow-hidden rounded-xl border border-line bg-void shadow-panel"> + {{-- Toolbar --}} + <div class="flex items-center gap-3 border-b border-line-soft bg-surface px-4 py-2.5"> + <span class="flex gap-1.5"> + <span class="h-2.5 w-2.5 rounded-full bg-offline/70"></span> + <span class="h-2.5 w-2.5 rounded-full bg-warning/70"></span> + <span class="h-2.5 w-2.5 rounded-full bg-online/70"></span> + </span> + <span class="truncate font-mono text-[12px] text-ink-2" x-text="title || '{{ __('terminal.host_label') }}'"></span> + <span class="ml-auto flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]"> + <span class="h-1.5 w-1.5 rounded-full" + :class="{ 'bg-ink-4': status==='idle', 'bg-warning': status==='connecting', 'bg-online': status==='connected', 'bg-offline': status==='error'||status==='closed' }"></span> + <span class="text-ink-3" + x-text="({idle:'{{ __('terminal.status_idle') }}',connecting:'{{ __('terminal.status_connecting') }}',connected:'{{ __('terminal.status_connected') }}',closed:'{{ __('terminal.status_closed') }}',error:'{{ __('terminal.status_error') }}'})[status]"></span> + </span> + <button type="button" x-on:click="clear()" class="rounded-md border border-line px-2.5 py-1 font-mono text-[11px] text-ink-3 transition-colors hover:border-accent/40 hover:text-accent-text">{{ __('terminal.clear') }}</button> + </div> - @if ($showSearch) - <div class="relative px-1 pb-1.5"> - <x-icon name="search" class="pointer-events-none absolute left-3.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" /> - <input wire:model.live.debounce.200ms="search" type="search" autocomplete="off" placeholder="{{ __('terminal.search_placeholder') }}" - class="h-8 w-full rounded-md border border-line bg-inset pl-8 pr-8 font-mono text-[12px] text-ink placeholder:text-ink-4 transition-colors focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/20 [&::-webkit-search-cancel-button]:appearance-none" /> - @if ($search !== '') - <button type="button" wire:click="$set('search', '')" aria-label="{{ __('terminal.search_clear') }}" - class="absolute right-2.5 top-1/2 grid h-5 w-5 -translate-y-1/2 place-items-center rounded text-ink-4 transition-colors hover:bg-raised hover:text-ink-2"> - <x-icon name="x" class="h-3 w-3" /> - </button> - @endif - </div> - @endif - - @forelse ($servers as $s) - <button type="button" wire:key="term-{{ $s->uuid }}" wire:click="open('server', '{{ $s->uuid }}')" wire:loading.attr="disabled" - @class([ - 'flex w-full items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors', - 'border-accent/30 bg-accent/10' => $activeKey === $s->uuid, - 'border-transparent hover:border-line hover:bg-raised/60' => $activeKey !== $s->uuid, - ])> - <x-status-dot :status="$s->status" class="shrink-0" /> - <span @class(['min-w-0 flex-1 truncate font-mono text-sm', 'text-accent-text' => $activeKey === $s->uuid, 'text-ink' => $activeKey !== $s->uuid])>{{ $s->name }}</span> - </button> - @empty - <p class="px-3 py-4 font-mono text-[11px] text-ink-4">{{ $search !== '' ? __('terminal.no_match') : __('terminal.no_servers') }}</p> - @endforelse - </div> - </x-panel> - - {{-- Terminal island (self-contained; Livewire never touches it after the first render) --}} - <div wire:ignore x-data="terminalView()" x-on:terminal-open.window="open($event.detail)" - class="flex min-h-[28rem] flex-col overflow-hidden rounded-xl border border-line bg-void shadow-panel"> - {{-- Toolbar --}} - <div class="flex items-center gap-3 border-b border-line-soft bg-surface px-4 py-2.5"> - <span class="flex gap-1.5"> - <span class="h-2.5 w-2.5 rounded-full bg-offline/70"></span> - <span class="h-2.5 w-2.5 rounded-full bg-warning/70"></span> - <span class="h-2.5 w-2.5 rounded-full bg-online/70"></span> - </span> - <span class="truncate font-mono text-[12px] text-ink-2" x-text="title || '{{ __('terminal.no_target') }}'"></span> - <span class="ml-auto flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]"> - <span class="h-1.5 w-1.5 rounded-full" - :class="{ 'bg-ink-4': status==='idle', 'bg-warning': status==='connecting', 'bg-online': status==='connected', 'bg-offline': status==='error'||status==='closed' }"></span> - <span class="text-ink-3" - x-text="({idle:'{{ __('terminal.status_idle') }}',connecting:'{{ __('terminal.status_connecting') }}',connected:'{{ __('terminal.status_connected') }}',closed:'{{ __('terminal.status_closed') }}',error:'{{ __('terminal.status_error') }}'})[status]"></span> - </span> - <button type="button" x-on:click="clear()" class="rounded-md border border-line px-2.5 py-1 font-mono text-[11px] text-ink-3 transition-colors hover:border-accent/40 hover:text-accent-text">{{ __('terminal.clear') }}</button> - </div> - - {{-- Screen --}} - <div class="relative flex-1"> - <div x-ref="screen" class="absolute inset-0 p-2"></div> - {{-- Idle placeholder --}} - <div x-show="status==='idle'" class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 text-center"> - <x-icon name="command" class="h-7 w-7 text-ink-4" /> - <p class="font-mono text-[12px] text-ink-4">{{ __('terminal.pick_target') }}</p> - </div> + {{-- Screen --}} + <div class="relative flex-1"> + <div x-ref="screen" class="absolute inset-0 p-2"></div> + {{-- Idle placeholder + connect action --}} + <div x-show="status==='idle'" class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-center"> + <x-icon name="command" class="h-7 w-7 text-ink-4" /> + <button type="button" wire:click="open" wire:loading.attr="disabled" + class="pointer-events-auto rounded-md border border-accent/40 bg-accent/10 px-4 py-1.5 font-mono text-[12px] text-accent-text transition-colors hover:bg-accent/20"> + {{ __('terminal.connect_host') }} + </button> </div> </div> </div> diff --git a/routes/web.php b/routes/web.php index 37a31cf..8397829 100644 --- a/routes/web.php +++ b/routes/web.php @@ -228,7 +228,9 @@ Route::middleware('auth')->group(function () { })->name('servers.history'); Route::get('/services', Services\Index::class)->name('services.index'); - Route::get('/docker', Docker\Index::class)->name('docker'); + // Host Docker view — admin-only (the control-plane machine; per-server Docker is a tab on the + // server-details page). Route guard + Docker\Index::mount() abort_unless both gate it. + Route::get('/docker', Docker\Index::class)->middleware('can:manage-fleet')->name('docker'); Route::get('/commands', Commands\Index::class)->middleware('can:operate')->name('commands'); Route::get('/files', Files\Index::class)->name('files.index'); Route::get('/audit', Audit\Index::class)->name('audit.index'); @@ -247,7 +249,9 @@ Route::middleware('auth')->group(function () { Route::get('/health', Health\Index::class)->middleware('can:operate')->name('health'); Route::get('/help', Help\Index::class)->name('help'); Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard'); - Route::get('/terminal', Terminal\Index::class)->name('terminal'); + // Host terminal — admin-only (real SSH login into the Clusev machine; per-server shells are a + // tab on the server-details page). Terminal\Index::mount() abort_unless gates it too. + Route::get('/terminal', Terminal\Index::class)->middleware('can:manage-fleet')->name('terminal'); // Dev-only Release page — first of the gating triple (spec): the route is registered ONLY when // the flag is on, so with it off /release does not exist (uncached route file is re-included diff --git a/tests/Feature/DockerComponentTest.php b/tests/Feature/DockerComponentTest.php index 4cd2f00..ab2ec77 100644 --- a/tests/Feature/DockerComponentTest.php +++ b/tests/Feature/DockerComponentTest.php @@ -13,6 +13,10 @@ use Livewire\Livewire; use Mockery; use Tests\TestCase; +/** + * The sidebar Docker page targets the CLUSEV HOST and is admin-only (manage-fleet). Per-server + * container management lives in ServerDockerTest. This also covers the shared ContainerLogs modal. + */ class DockerComponentTest extends TestCase { use RefreshDatabase; @@ -33,20 +37,22 @@ class DockerComponentTest extends TestCase return User::factory()->operator()->create(['must_change_password' => false]); } - private function viewer(): User - { - return User::factory()->viewer()->create(['must_change_password' => false]); - } - - private function activeServer(): Server + private function server(): 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; } + private function hostCred(): HostCredential + { + return HostCredential::create([ + 'host' => 'host.docker.internal', 'port' => 22, + 'username' => 'root', 'auth_type' => 'password', 'secret' => 'x', + ]); + } + private function stubDocker(array $containers = []): DockerService { $docker = Mockery::mock(DockerService::class); @@ -57,97 +63,60 @@ class DockerComponentTest extends TestCase return $docker; } - private function hostCred(): HostCredential + public function test_admin_sees_the_host_containers(): void { - return HostCredential::create([ - 'host' => 'host.docker.internal', 'port' => 22, - 'username' => 'root', 'auth_type' => 'password', 'secret' => 'x', - ]); - } - - public function test_admin_defaults_to_the_clusev_host_when_a_host_login_is_configured(): void - { - // The host is where the operator's own containers live, so it is the default target — but - // only for admins (manage-fleet); the control-plane machine is not exposed to lower roles. $this->actingAs($this->admin()); $this->hostCred(); - $this->activeServer(); // a fleet server also exists, but the host wins the default $this->stubDocker([['id' => 'h1', 'name' => 'clusev-app-1', 'image' => 'clusev-app:prod', 'state' => 'running', 'status' => 'Up', 'ports' => '']]); Livewire::test(Index::class) ->call('load') ->assertOk() ->assertSet('connected', true) - ->assertSee('clusev-app-1') - ->assertSee(__('docker.host_target')); + ->assertSee('clusev-app-1'); } - public function test_non_admin_never_targets_the_host_even_when_configured(): void + public function test_non_admin_is_refused_the_host_docker_route(): void { - // A viewer/operator must land on a fleet server, never the Clusev host — no toggle, no default. $this->actingAs($this->operator()); - $this->hostCred(); - $this->activeServer(); - $this->stubDocker([['id' => 's1', 'name' => 'fleet-web', 'image' => 'nginx', 'state' => 'running', 'status' => 'Up', 'ports' => '']]); - - Livewire::test(Index::class) - ->call('load') - ->assertOk() - ->assertSet('connected', true) - ->assertSee('fleet-web') - ->assertDontSee(__('docker.host_target')); // no host switch, no host subtitle + $this->get(route('docker'))->assertForbidden(); } - public function test_switching_to_host_without_a_login_shows_the_unconfigured_state(): void + public function test_host_not_configured_state_when_no_login_exists(): void { $this->actingAs($this->admin()); - $this->activeServer(); $this->stubDocker(); Livewire::test(Index::class) - ->call('setTarget', 'host') + ->call('load') ->assertOk() ->assertSet('hostNotConfigured', true) ->assertSet('connected', false) ->assertSee(__('docker.host_unconfigured_title')); } - public function test_can_switch_from_the_host_to_the_active_fleet_server(): void + public function test_shows_a_clean_not_installed_state_when_the_host_has_no_docker(): void { $this->actingAs($this->admin()); $this->hostCred(); - $this->activeServer(); - $this->stubDocker([['id' => 's1', 'name' => 'web', 'image' => 'nginx', 'state' => 'running', 'status' => 'Up', 'ports' => '80/tcp']]); + $docker = Mockery::mock(DockerService::class); + $docker->shouldReceive('available')->once()->andReturn(false); + $docker->shouldReceive('containers')->never(); + app()->instance(DockerService::class, $docker); Livewire::test(Index::class) - ->call('setTarget', 'server') + ->call('load') ->assertOk() - ->assertSet('connected', true) - ->assertSee('web'); - } - - public function test_operator_target_forced_to_host_still_hits_a_fleet_server_not_the_host(): void - { - // Even if a non-admin forces target=host, effectiveTarget() pins them to a fleet server, so - // the action runs against the server credential path — never the control-plane host. - $this->actingAs($this->operator()); - $this->hostCred(); - $this->activeServer(); - $docker = $this->stubDocker(); - $docker->shouldReceive('containerAction')->once() - ->with(Mockery::on(fn ($s) => $s->exists === true), 'web', 'restart') // a real fleet server, not the transient host - ->andReturn(['ok' => true, 'output' => '']); - - Livewire::test(Index::class) - ->set('target', 'host') - ->call('action', 'web', 'restart') - ->assertOk(); + ->assertSet('notInstalled', true) + ->assertSet('error', null) + ->assertSee(__('docker.not_installed_title')) + ->assertDontSee('not found'); } public function test_admin_can_action_a_host_container_and_it_is_audited(): void { $this->actingAs($this->admin()); - $this->hostCred(); // default target = host + $this->hostCred(); $docker = $this->stubDocker(); $docker->shouldReceive('containerAction')->once() ->with(Mockery::type(Server::class), 'clusev-redis-1', 'restart') @@ -161,6 +130,36 @@ class DockerComponentTest extends TestCase $this->assertDatabaseHas('audit_events', ['action' => 'docker.action', 'server_id' => null]); } + // ── ContainerLogs modal (shared by the host page + the server Docker tab) ── + + public function test_logs_modal_loads_a_server_container_tail(): void + { + $this->actingAs($this->operator()); + $server = $this->server(); + $docker = Mockery::mock(DockerService::class); + $docker->shouldReceive('logs')->once()->with(Mockery::type(Server::class), 'web', 200)->andReturn('log line 1'); + app()->instance(DockerService::class, $docker); + + Livewire::test(ContainerLogs::class, ['serverId' => $server->id, 'ref' => 'web']) + ->call('load') + ->assertOk() + ->assertSet('logs', 'log line 1'); + } + + public function test_logs_modal_loads_a_host_container_tail_for_admins(): void + { + $this->actingAs($this->admin()); + $this->hostCred(); + $docker = Mockery::mock(DockerService::class); + $docker->shouldReceive('logs')->once()->with(Mockery::type(Server::class), 'clusev-app-1', 200)->andReturn('host log line'); + app()->instance(DockerService::class, $docker); + + Livewire::test(ContainerLogs::class, ['serverId' => 0, 'ref' => 'clusev-app-1']) + ->call('load') + ->assertOk() + ->assertSet('logs', 'host log line'); + } + public function test_operator_cannot_open_host_logs(): void { // The host-logs sentinel (serverId 0) is admin-only; an operator is refused at the read. @@ -175,106 +174,10 @@ class DockerComponentTest extends TestCase ->assertForbidden(); } - public function test_logs_modal_loads_a_host_container_tail(): void + public function test_viewer_cannot_read_server_logs_in_the_modal(): void { - $this->actingAs($this->admin()); - $this->hostCred(); - $docker = Mockery::mock(DockerService::class); - $docker->shouldReceive('logs')->once()->with(Mockery::type(Server::class), 'clusev-app-1', 200)->andReturn('host log line'); - app()->instance(DockerService::class, $docker); - - Livewire::test(ContainerLogs::class, ['serverId' => 0, 'ref' => 'clusev-app-1']) - ->call('load') - ->assertOk() - ->assertSet('logs', 'host log line'); - } - - public function test_viewer_can_browse_the_container_list(): void - { - $this->actingAs($this->viewer()); - $this->activeServer(); - $this->stubDocker([['id' => 'abc', 'name' => 'web', 'image' => 'nginx', 'state' => 'running', 'status' => 'Up', 'ports' => '80/tcp']]); - - Livewire::test(Index::class) - ->call('load') - ->assertOk() - ->assertSet('connected', true) - ->assertSee('web'); - } - - public function test_shows_a_clean_not_installed_state_when_docker_is_absent(): void - { - // A host without a container runtime (e.g. a native postfix/dovecot mail server) must NOT - // render the raw "sh: 1: docker: not found" shell error — that reads as a broken feature. - // available() is probed first; false => a clean, honest "not installed" panel. - $this->actingAs($this->viewer()); - $this->activeServer(); - $docker = Mockery::mock(DockerService::class); - $docker->shouldReceive('available')->once()->andReturn(false); - $docker->shouldReceive('containers')->never(); // never even attempt ps when docker is absent - app()->instance(DockerService::class, $docker); - - Livewire::test(Index::class) - ->call('load') - ->assertOk() - ->assertSet('connected', false) - ->assertSet('notInstalled', true) - ->assertSet('error', null) - ->assertSee(__('docker.not_installed_title')) - ->assertDontSee('not found'); - } - - public function test_viewer_cannot_run_a_container_action(): void - { - $this->actingAs($this->viewer()); - $this->activeServer(); - $docker = $this->stubDocker(); - $docker->shouldReceive('containerAction')->never(); // guard fires first - - Livewire::test(Index::class) - ->call('action', 'web', 'stop') - ->assertForbidden(); - } - - public function test_operator_can_run_a_container_action_and_it_is_audited(): void - { - $this->actingAs($this->operator()); - $server = $this->activeServer(); - $docker = $this->stubDocker(); - $docker->shouldReceive('containerAction')->once() - ->with(Mockery::type(Server::class), 'web', 'restart') - ->andReturn(['ok' => true, 'output' => 'web']); - - Livewire::test(Index::class) - ->call('action', 'web', 'restart') - ->assertOk(); - - $this->assertDatabaseHas('audit_events', ['action' => 'docker.action', 'server_id' => $server->id]); - } - - public function test_admin_can_run_a_container_action(): void - { - $this->actingAs($this->admin()); - $this->activeServer(); - $docker = $this->stubDocker(); - $docker->shouldReceive('containerAction')->once()->andReturn(['ok' => true, 'output' => '']); - - Livewire::test(Index::class)->call('action', 'db', 'start')->assertOk(); - } - - public function test_viewer_cannot_open_logs(): void - { - $this->actingAs($this->viewer()); - $this->activeServer(); - $this->stubDocker(); - - Livewire::test(Index::class)->call('viewLogs', 'web')->assertForbidden(); - } - - public function test_viewer_cannot_read_logs_in_the_modal(): void - { - $this->actingAs($this->viewer()); - $server = $this->activeServer(); + $this->actingAs(User::factory()->viewer()->create(['must_change_password' => false])); + $server = $this->server(); $docker = Mockery::mock(DockerService::class); $docker->shouldReceive('logs')->never(); // guard fires before the read app()->instance(DockerService::class, $docker); @@ -283,18 +186,4 @@ class DockerComponentTest extends TestCase ->call('load') ->assertForbidden(); } - - public function test_logs_modal_loads_a_container_tail(): void - { - $this->actingAs($this->admin()); - $server = $this->activeServer(); - $docker = Mockery::mock(DockerService::class); - $docker->shouldReceive('logs')->once()->with(Mockery::type(Server::class), 'web', 200)->andReturn('log line 1'); - app()->instance(DockerService::class, $docker); - - Livewire::test(ContainerLogs::class, ['serverId' => $server->id, 'ref' => 'web']) - ->call('load') - ->assertOk() - ->assertSet('logs', 'log line 1'); - } } diff --git a/tests/Feature/RbacFleetGateTest.php b/tests/Feature/RbacFleetGateTest.php index 5b220e5..b549854 100644 --- a/tests/Feature/RbacFleetGateTest.php +++ b/tests/Feature/RbacFleetGateTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Livewire\Modals\CreateServer; +use App\Livewire\Servers\ServerTerminal; use App\Livewire\Servers\Show; use App\Livewire\System\Index as SystemIndex; use App\Livewire\Terminal\Index as TerminalIndex; @@ -149,7 +150,7 @@ class RbacFleetGateTest extends TestCase $this->assertTrue($server->credential()->exists()); } - // ── manage-fleet vs operate: Terminal\Index::open (split by target) ────────── + // ── manage-fleet (host shell) vs operate (server shell): the split terminals ── public function test_admin_can_open_host_and_server_shells(): void { @@ -159,26 +160,20 @@ class RbacFleetGateTest extends TestCase ]); $server = $this->server(); - Livewire::actingAs($this->admin()) - ->test(TerminalIndex::class) - ->call('open', 'host')->assertSet('activeKey', 'host') - ->call('open', 'server', $server->uuid)->assertSet('activeKey', $server->uuid); + // Host shell = the admin-only sidebar Terminal page. + Livewire::actingAs($this->admin())->test(TerminalIndex::class)->call('open'); + // Server shell = the operate-gated Terminal tab on the server-details page. + Livewire::actingAs($this->admin())->test(ServerTerminal::class, ['server' => $server])->call('open'); $this->assertSame(2, TerminalSession::count()); // both targets minted a session } - public function test_operator_cannot_open_the_host_shell(): void + public function test_operator_is_refused_the_host_terminal(): void { - HostCredential::create([ - 'host' => 'host.docker.internal', 'port' => 22, 'username' => 'root', - 'auth_type' => 'password', 'secret' => 'r00t-pw', - ]); - - Livewire::actingAs($this->operator()) - ->test(TerminalIndex::class) - ->call('open', 'host') - ->assertForbidden(); // host shell = manage-fleet - + // The host terminal page (manage-fleet) is out of reach for an operator — route + component + // mount both gate it, so they can never mint a host session or open the host-login setup. + $this->actingAs($this->operator()); + $this->get(route('terminal'))->assertForbidden(); $this->assertSame(0, TerminalSession::count()); } @@ -187,9 +182,9 @@ class RbacFleetGateTest extends TestCase $server = $this->server(); Livewire::actingAs($this->operator()) - ->test(TerminalIndex::class) - ->call('open', 'server', $server->uuid) - ->assertSet('activeKey', $server->uuid); // server shell = operate → allowed + ->test(ServerTerminal::class, ['server' => $server]) + ->call('open') + ->assertOk(); // server shell = operate → allowed $this->assertSame(1, TerminalSession::count()); } @@ -198,27 +193,17 @@ class RbacFleetGateTest extends TestCase { $server = $this->server(); - Livewire::actingAs($this->viewer()) - ->test(TerminalIndex::class) - ->call('open', 'host') - ->assertForbidden(); + $this->actingAs($this->viewer()); + $this->get(route('terminal'))->assertForbidden(); // host page = manage-fleet Livewire::actingAs($this->viewer()) - ->test(TerminalIndex::class) - ->call('open', 'server', $server->uuid) - ->assertForbidden(); + ->test(ServerTerminal::class, ['server' => $server]) + ->call('open') + ->assertForbidden(); // server shell = operate $this->assertSame(0, TerminalSession::count()); } - public function test_operator_cannot_configure_the_host_login(): void - { - Livewire::actingAs($this->operator()) - ->test(TerminalIndex::class) - ->call('configureHost') - ->assertForbidden(); - } - // ── manage-panel: System\Index domain/TLS mutations ───────────────────────── public function test_admin_can_apply_a_domain_change(): void diff --git a/tests/Feature/ServerDockerTest.php b/tests/Feature/ServerDockerTest.php new file mode 100644 index 0000000..cbe4d0c --- /dev/null +++ b/tests/Feature/ServerDockerTest.php @@ -0,0 +1,124 @@ +<?php + +namespace Tests\Feature; + +use App\Livewire\Servers\ServerDocker; +use App\Models\Server; +use App\Models\User; +use App\Services\DockerService; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; +use Mockery; +use Tests\TestCase; + +/** + * Per-server container management (the "Docker" tab of the server-details page). Viewing is open to + * any role; actions require `operate`. The host-targeted sidebar page is covered by DockerComponentTest. + */ +class ServerDockerTest extends TestCase +{ + use RefreshDatabase; + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + private function viewer(): User + { + return User::factory()->viewer()->create(['must_change_password' => false]); + } + + private function operator(): User + { + return User::factory()->operator()->create(['must_change_password' => false]); + } + + private function server(): 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']); + + // Re-load with the withExists('credential') attribute the component reads. + return Server::withExists('credential')->find($server->id); + } + + private function stubDocker(array $containers = []): DockerService + { + $docker = Mockery::mock(DockerService::class); + $docker->shouldReceive('available')->andReturn(true); + $docker->shouldReceive('containers')->andReturn($containers); + app()->instance(DockerService::class, $docker); + + return $docker; + } + + public function test_viewer_can_browse_the_server_container_list(): void + { + $this->actingAs($this->viewer()); + $server = $this->server(); + $this->stubDocker([['id' => 'abc', 'name' => 'web', 'image' => 'nginx', 'state' => 'running', 'status' => 'Up', 'ports' => '80/tcp']]); + + Livewire::test(ServerDocker::class, ['server' => $server]) + ->call('load') + ->assertOk() + ->assertSet('connected', true) + ->assertSee('web'); + } + + public function test_viewer_cannot_run_a_server_container_action(): void + { + $this->actingAs($this->viewer()); + $server = $this->server(); + $docker = $this->stubDocker(); + $docker->shouldReceive('containerAction')->never(); + + Livewire::test(ServerDocker::class, ['server' => $server]) + ->call('action', 'web', 'stop') + ->assertForbidden(); + } + + public function test_operator_can_run_a_server_container_action_and_it_is_audited(): void + { + $this->actingAs($this->operator()); + $server = $this->server(); + $docker = $this->stubDocker(); + $docker->shouldReceive('containerAction')->once() + ->with(Mockery::type(Server::class), 'web', 'restart') + ->andReturn(['ok' => true, 'output' => 'web']); + + Livewire::test(ServerDocker::class, ['server' => $server]) + ->call('action', 'web', 'restart') + ->assertOk(); + + $this->assertDatabaseHas('audit_events', ['action' => 'docker.action', 'server_id' => $server->id]); + } + + public function test_shows_a_clean_not_installed_state(): void + { + $this->actingAs($this->viewer()); + $server = $this->server(); + $docker = Mockery::mock(DockerService::class); + $docker->shouldReceive('available')->once()->andReturn(false); + $docker->shouldReceive('containers')->never(); + app()->instance(DockerService::class, $docker); + + Livewire::test(ServerDocker::class, ['server' => $server]) + ->call('load') + ->assertOk() + ->assertSet('notInstalled', true) + ->assertSee(__('docker.not_installed_title')); + } + + public function test_viewer_cannot_open_server_logs(): void + { + $this->actingAs($this->viewer()); + $server = $this->server(); + $this->stubDocker(); + + Livewire::test(ServerDocker::class, ['server' => $server]) + ->call('viewLogs', 'web') + ->assertForbidden(); + } +} diff --git a/tests/Feature/ServerTerminalTest.php b/tests/Feature/ServerTerminalTest.php new file mode 100644 index 0000000..137f96b --- /dev/null +++ b/tests/Feature/ServerTerminalTest.php @@ -0,0 +1,45 @@ +<?php + +namespace Tests\Feature; + +use App\Livewire\Servers\ServerTerminal; +use App\Models\Server; +use App\Models\TerminalSession; +use App\Models\User; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; +use Tests\TestCase; + +class ServerTerminalTest extends TestCase +{ + use RefreshDatabase; + + private function server(): Server + { + return Server::create(['name' => 'box', 'ip' => '10.0.0.9', 'ssh_port' => 22, 'status' => 'online']); + } + + public function test_operator_open_mints_a_single_use_server_session(): void + { + $this->actingAs(User::factory()->operator()->create(['must_change_password' => false])); + $server = $this->server(); + + Livewire::test(ServerTerminal::class, ['server' => $server])->call('open')->assertOk(); + + $s = TerminalSession::first(); + $this->assertNotNull($s); + $this->assertSame('server', $s->kind); + $this->assertSame($server->id, $s->server_id); + $this->assertNull($s->used_at); + $this->assertTrue($s->expires_at->isFuture()); + } + + public function test_viewer_cannot_open_a_server_terminal(): void + { + $this->actingAs(User::factory()->viewer()->create(['must_change_password' => false])); + $server = $this->server(); + + Livewire::test(ServerTerminal::class, ['server' => $server])->call('open')->assertForbidden(); + $this->assertSame(0, TerminalSession::count()); + } +} diff --git a/tests/Feature/TerminalTest.php b/tests/Feature/TerminalTest.php index 055808e..c5537f6 100644 --- a/tests/Feature/TerminalTest.php +++ b/tests/Feature/TerminalTest.php @@ -67,7 +67,7 @@ class TerminalTest extends TestCase { $this->hostCredential(); - Livewire::test(Index::class)->call('open', 'host')->assertSet('activeKey', 'host'); + Livewire::test(Index::class)->call('open'); $s = TerminalSession::first(); $this->assertNotNull($s); @@ -80,24 +80,16 @@ class TerminalTest extends TestCase public function test_open_host_without_a_login_opens_the_setup_modal_and_mints_nothing(): void { Livewire::test(Index::class) - ->call('open', 'host') + ->call('open') ->assertDispatched('openModal', component: 'modals.host-shell'); $this->assertSame(0, TerminalSession::count()); } - public function test_open_server_mints_a_session_for_that_server(): void + public function test_non_admin_is_refused_the_host_terminal_route(): void { - $server = $this->server(); - Livewire::test(Index::class)->call('open', 'server', $server->uuid)->assertSet('activeKey', $server->uuid); - - $this->assertSame($server->id, TerminalSession::first()->server_id); - } - - public function test_open_ignores_an_unknown_server(): void - { - Livewire::test(Index::class)->call('open', 'server', 'nope'); - $this->assertSame(0, TerminalSession::count()); + $this->actingAs(User::factory()->operator()->create(['must_change_password' => false])); + $this->get(route('terminal'))->assertForbidden(); } // ── resolve endpoint ────────────────────────────────────────────────────── @@ -178,10 +170,9 @@ class TerminalTest extends TestCase // ── page ────────────────────────────────────────────────────────────────── - public function test_terminal_page_renders_with_the_rail(): void + public function test_host_terminal_page_renders(): void { - $this->server(); - $this->get(route('terminal'))->assertOk()->assertSee(__('terminal.heading')); + $this->get(route('terminal'))->assertOk()->assertSee(__('terminal.host_heading')); } // ── host-login modal + server search ─────────────────────────────────────── @@ -232,24 +223,4 @@ class TerminalTest extends TestCase $this->assertSame('password', $hc->auth_type); $this->assertSame('r00t-pw', $hc->secret); } - - public function test_search_filters_the_server_rail(): void - { - Server::create(['name' => 'alpha-web', 'ip' => '10.0.0.1', 'status' => 'online']); - Server::create(['name' => 'delta-db', 'ip' => '10.0.0.2', 'status' => 'online']); - - Livewire::test(Index::class) - ->set('search', 'alpha') - ->assertSee('alpha-web') - ->assertDontSee('delta-db'); - } - - public function test_search_box_appears_once_there_is_more_than_one_server(): void - { - Server::create(['name' => 'only-one', 'ip' => '10.0.0.1', 'status' => 'online']); - $this->get(route('terminal'))->assertDontSee(__('terminal.search_placeholder')); // 1 server → no search - - Server::create(['name' => 'second', 'ip' => '10.0.0.2', 'status' => 'online']); - $this->get(route('terminal'))->assertSee(__('terminal.search_placeholder')); // 2 servers → search shows - } }