feat(nav): 3-group sidebar; split Docker + Terminal into host page + per-server tabs

Restructure the navigation so host-level tools are grouped separately and
the host vs per-server split is explicit.

Sidebar — three groups instead of two:
  • Flotte: Dashboard, Servers, Services, Commands, Files, Audit
  • Host:   Docker, Terminal, WireGuard, System, Security posture, Patch,
            Certificates, Uptime  (things that act on the Clusev host)
  • Konto:  Settings, Alerts, Threats, Versions, Release, Help

Docker & Terminal are now split by target:
  • Sidebar Docker = the CLUSEV HOST's containers; sidebar Terminal = the
    host shell. Both admin-only (manage-fleet, route + mount guarded) — the
    control-plane machine, same bar the host shell already used.
  • Per-server Docker + Terminal move to the server-details page as tabs
    (Übersicht | Docker | Terminal, ?tab= deep-linkable). New lean
    components Servers\ServerDocker + Servers\ServerTerminal; the metrics
    poll is suppressed off the overview tab. Viewing a server's containers
    is open to any role; actions/logs/shell require operate.

Also: Docker\Index + Terminal\Index reduced to host-only (no target
toggle / server rail); ServerDocker checks credential()->exists() (the
withExists attribute does not survive Livewire hydration); container rows
drop the noisy port list. Docs at ~/clusev-site updated to match; a
.gitignore guard keeps the separate marketing site out of this repo.

Tests: Docker/Terminal component + RBAC gate tests reworked for the split;
new ServerDockerTest + ServerTerminalTest. 747 pass. R12-verified in a
browser (3-group sidebar, host Docker/Terminal, server tabs switch, real
per-server containers, zero console errors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-06 19:21:13 +02:00
parent 096ba3ca98
commit b4c309bd54
27 changed files with 729 additions and 545 deletions

5
.gitignore vendored
View File

@ -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/

View File

@ -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<int, array<string, string>> */
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,
]);
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Livewire\Servers;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\DockerService;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
use Livewire\Component;
use Throwable;
/**
* Per-server container management, rendered as the "Docker" tab of the server-details page (the
* sidebar Docker page targets the Clusev host instead). Viewing the list is open to any role;
* actions (start/stop/restart) require `operate`, matching systemd service gating. Loads lazily.
*/
class ServerDocker extends Component
{
public Server $server;
/** @var array<int, array<string, string>> */
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');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Livewire\Servers;
use App\Models\Server;
use App\Models\TerminalSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Component;
/**
* Per-server web terminal, rendered as the "Terminal" tab of the server-details page (the sidebar
* Terminal page is the Clusev HOST shell instead). Mints a single-use token that the xterm.js island
* uses to open a WebSocket to the terminal sidecar; this component never touches SSH or credentials.
* A server shell is a day-to-day operator action (the host shell is the admin-only sidebar page).
*/
class ServerTerminal extends Component
{
public Server $server;
/** Mint a single-use token for this server and hand it to the xterm island via an event. */
public function open(): void
{
abort_unless(Auth::user()?->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');
}
}

View File

@ -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 <title>; 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();
}

View File

@ -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());
}

View File

@ -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',

View File

@ -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',

View File

@ -10,6 +10,7 @@ return [
// Sidebar — nav group labels
'group_fleet' => 'Flotte',
'group_host' => 'Host',
'group_account' => 'Konto',
// Sidebar — nav items

View File

@ -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',

View File

@ -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 servers containers live on its detail page under the “Docker” tab.',
'target_label' => 'Target',
'host_target' => 'Clusev host',
'server_target' => 'Server',

View File

@ -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',

View File

@ -10,6 +10,7 @@ return [
// Sidebar — nav group labels
'group_fleet' => 'Fleet',
'group_host' => 'Host',
'group_account' => 'Account',
// Sidebar — nav items

View File

@ -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 servers 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 dont have permission to open this servers terminal.',
'targets_title' => 'Targets',
'targets_subtitle' => 'Pick a session',

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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');
}
}

View File

@ -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

View File

@ -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();
}
}

View File

@ -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());
}
}

View File

@ -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
}
}