From 83626352e2907faf0cbdcabd481d3032cff0815c Mon Sep 17 00:00:00 2001 From: boban Date: Thu, 25 Jun 2026 19:46:55 +0200 Subject: [PATCH] feat(terminal): Clusev-host SSH login tile + server search; drop the inline hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Clusev host" terminal was a shell inside the sidecar container (node@clusev, no sudo, not the host). It is now a REAL SSH login into the host machine, like any fleet server — so it shows the real root@ prompt with full rights. - HostCredential (encrypted singleton) holds the host SSH login; a HostShell modal lets the operator set host/port/user + password|key. The clear-text secret is never echoed back; on edit a blank secret keeps the stored one, and switching auth method requires a fresh secret (no password silently reused as a key). - open('host') mints a kind=host token only when a login is configured, else opens the setup modal; the internal resolve endpoint returns a server-shaped SSH spec (decrypted, internal-net only) for kind=host, or 404 when unconfigured. - compose: extra_hosts host.docker.internal:host-gateway so the sidecar reaches the host sshd; Caddy now 404s /_internal/* at the public edge (the sidecar uses app:80 directly). - rail gains a debounced search box past 5 servers; removed the now-obvious PTY hint line. Browser-verified (R12): host tile shows "not configured" + gear → modal → SSH login as root runs commands, zero console errors; search filters; resolve returns the host spec. 15 terminal tests pass. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Modals/HostShell.php | 146 ++++++++++++++++++ app/Livewire/Terminal/Index.php | 46 +++++- app/Models/HostCredential.php | 27 ++++ ...5_000003_create_host_credentials_table.php | 34 ++++ docker-compose.prod.yml | 6 +- docker-compose.yml | 6 +- docker/caddy/Caddyfile | 6 + lang/de/terminal.php | 27 +++- lang/en/terminal.php | 27 +++- .../livewire/modals/host-shell.blade.php | 88 +++++++++++ .../views/livewire/terminal/index.blade.php | 48 +++--- routes/web.php | 17 +- tests/Feature/TerminalTest.php | 117 +++++++++++++- 13 files changed, 560 insertions(+), 35 deletions(-) create mode 100644 app/Livewire/Modals/HostShell.php create mode 100644 app/Models/HostCredential.php create mode 100644 database/migrations/2026_06_25_000003_create_host_credentials_table.php create mode 100644 resources/views/livewire/modals/host-shell.blade.php diff --git a/app/Livewire/Modals/HostShell.php b/app/Livewire/Modals/HostShell.php new file mode 100644 index 0000000..c606f82 --- /dev/null +++ b/app/Livewire/Modals/HostShell.php @@ -0,0 +1,146 @@ +configured = true; + $this->host = $hc->host; + $this->port = $hc->port; + $this->username = $hc->username; + $this->authType = $hc->auth_type; + // secret/passphrase stay blank — never reveal the stored login back into the form. + } + } + + /** + * @return array> + */ + protected function rules(): array + { + return [ + 'host' => ['required', 'string', 'max:255'], + 'port' => ['required', 'integer', 'min:1', 'max:65535'], + 'username' => ['required', 'string', 'max:120'], + 'authType' => ['required', Rule::in(['password', 'key'])], + // On first setup the secret is required; when editing an existing login a blank + // secret means "keep the stored one". + 'secret' => [$this->configured ? 'nullable' : 'required', 'string'], + 'passphrase' => ['nullable', 'string'], + ]; + } + + /** + * @return array + */ + protected function validationAttributes(): array + { + return [ + 'host' => __('terminal.host_field_host'), + 'port' => __('terminal.host_field_port'), + 'username' => __('terminal.host_field_user'), + 'authType' => __('terminal.host_field_auth'), + 'secret' => $this->authType === 'key' ? __('terminal.host_field_key') : __('terminal.host_field_password'), + ]; + } + + public function save(): void + { + $data = $this->validate(); + + $existing = HostCredential::current(); + // Switching the auth method (password ↔ key) requires a fresh secret: keeping the stored one + // would feed e.g. an old password into key auth (or vice versa) and silently break the login. + if ($this->secret === '' && $existing !== null && $this->authType !== $existing->auth_type) { + throw ValidationException::withMessages([ + 'secret' => __('terminal.host_secret_required_for_auth_change'), + ]); + } + + $hc = $existing ?? new HostCredential; + $hc->host = trim($data['host']); + $hc->port = (int) $data['port']; + $hc->username = trim($data['username']); + $hc->auth_type = $data['authType'] === 'key' ? 'key' : 'password'; + // Only overwrite the secret when a new one was entered (blank = keep the stored one). + if ($this->secret !== '') { + $hc->secret = $this->secret; + $hc->passphrase = $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null; + } + $hc->save(); + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'host.terminal.configure', + 'target' => $hc->username.'@'.$hc->host.':'.$hc->port, + 'ip' => request()->ip(), + ]); + + $this->dispatch('hostShellSaved'); + $this->dispatch('notify', message: __('terminal.host_saved')); + $this->closeModal(); + } + + public function remove(): void + { + $hc = HostCredential::current(); + if ($hc !== null) { + $target = $hc->username.'@'.$hc->host.':'.$hc->port; + HostCredential::query()->delete(); // singleton — clear any/all rows, not just the first + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'host.terminal.remove', + 'target' => $target, + 'ip' => request()->ip(), + ]); + } + + $this->dispatch('hostShellSaved'); + $this->dispatch('notify', message: __('terminal.host_removed')); + $this->closeModal(); + } + + public function render() + { + return view('livewire.modals.host-shell'); + } +} diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 63082e8..c408808 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -2,11 +2,13 @@ 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; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Component; /** @@ -18,9 +20,15 @@ use Livewire\Component; #[Layout('layouts.app')] class Index extends Component { + /** Show the server search box once the fleet is large enough to warrant filtering. */ + private const SEARCH_THRESHOLD = 5; + /** '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 title(): string { return __('terminal.title'); @@ -33,7 +41,15 @@ class Index extends Component $label = __('terminal.host_label'); $this->activeKey = 'host'; - if ($kind !== 'host') { + 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) { @@ -57,10 +73,36 @@ class Index extends Component $this->dispatch('terminal-open', token: $token, title: $label); } + /** Open the modal that stores the Clusev host SSH login. */ + public function configureHost(): void + { + $this->dispatch('openModal', component: 'modals.host-shell'); + } + + /** The host-login modal saved/removed — re-render so the tile reflects the new state. */ + #[On('hostShellSaved')] + public function hostShellSaved(): void + { + // No state to set; the empty handler simply triggers a re-render. + } + 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' => Server::orderBy('name')->get(['uuid', 'name', 'status']), + 'servers' => $servers, + 'serverCount' => $serverCount, + 'showSearch' => $serverCount > self::SEARCH_THRESHOLD, + 'hostConfigured' => $host !== null, + 'hostTarget' => $host !== null ? $host->username.'@'.$host->host : null, ])->title($this->title()); } } diff --git a/app/Models/HostCredential.php b/app/Models/HostCredential.php new file mode 100644 index 0000000..00ed3a2 --- /dev/null +++ b/app/Models/HostCredential.php @@ -0,0 +1,27 @@ + 'integer', + 'secret' => 'encrypted', + 'passphrase' => 'encrypted', + ]; + + /** The single configured host login, or null when the operator hasn't set one up yet. */ + public static function current(): ?self + { + return static::query()->orderBy('id')->first(); + } +} diff --git a/database/migrations/2026_06_25_000003_create_host_credentials_table.php b/database/migrations/2026_06_25_000003_create_host_credentials_table.php new file mode 100644 index 0000000..9cf2fb2 --- /dev/null +++ b/database/migrations/2026_06_25_000003_create_host_credentials_table.php @@ -0,0 +1,34 @@ +id(); + // Reachable from the terminal sidecar container; defaults to the docker host gateway. + $table->string('host')->default('host.docker.internal'); + $table->unsignedSmallInteger('port')->default(22); + $table->string('username')->default('root'); + $table->string('auth_type')->default('password'); // 'password' | 'key' + $table->text('secret'); // encrypted (password or private key) + $table->text('passphrase')->nullable(); // encrypted (key passphrase) + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('host_credentials'); + } +}; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3c33b90..bf54d3d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -88,8 +88,10 @@ services: context: ./docker/terminal image: "${CLUSEV_TERMINAL_IMAGE:-clusev-terminal:prod}" restart: unless-stopped - # Clean hostname so the "Clusev host" terminal prompt reads node@clusev, not the container hash. - hostname: clusev + # Reach the Docker host's sshd from the sidecar so the "Clusev host" terminal can SSH into the + # real machine (host.docker.internal → the host gateway). + extra_hosts: + - "host.docker.internal:host-gateway" environment: TERMINAL_SIDECAR_SECRET: "${TERMINAL_SIDECAR_SECRET:-}" APP_INTERNAL_URL: "http://app:80" diff --git a/docker-compose.yml b/docker-compose.yml index a102bc7..1353cb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,8 +103,10 @@ services: context: ./docker/terminal image: clusev-terminal:dev restart: unless-stopped - # Clean hostname so the "Clusev host" terminal prompt reads node@clusev, not the container hash. - hostname: clusev + # Reach the Docker host's sshd from the sidecar so the "Clusev host" terminal can SSH into the + # real machine (host.docker.internal → the host gateway). + extra_hosts: + - "host.docker.internal:host-gateway" environment: TERMINAL_SIDECAR_SECRET: "${TERMINAL_SIDECAR_SECRET:-}" APP_INTERNAL_URL: "http://app:80" diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile index 5610f9f..07fd505 100644 --- a/docker/caddy/Caddyfile +++ b/docker/caddy/Caddyfile @@ -41,6 +41,12 @@ @terminal path /terminal/ws reverse_proxy @terminal terminal:3000 + # The internal terminal-resolve endpoint hands out DECRYPTED SSH credentials. It is for the sidecar + # only, which reaches it over the private network (app:80, bypassing Caddy). Refuse it at the public + # edge so a leaked sidecar secret can't be replayed from the internet (defence in depth). + @internal path /_internal/* + respond @internal 404 + reverse_proxy app:80 encode zstd gzip diff --git a/lang/de/terminal.php b/lang/de/terminal.php index 1c11a37..7dee464 100644 --- a/lang/de/terminal.php +++ b/lang/de/terminal.php @@ -8,9 +8,12 @@ return [ 'targets_title' => 'Ziele', 'targets_subtitle' => 'Wähle eine Sitzung', - 'host_label' => 'Clusev', - 'host_sub' => 'Lokale Shell', + 'host_label' => 'Clusev-Host', + 'host_not_configured' => 'Nicht eingerichtet', + 'host_configure' => 'Host-Login einrichten', 'servers_heading' => 'Server', + 'search_placeholder' => 'Server suchen…', + 'no_match' => 'Keine Treffer.', 'no_servers' => 'Noch keine Server angelegt.', 'no_target' => 'Kein Ziel gewählt', 'pick_target' => 'Ziel links wählen, um eine Sitzung zu starten.', @@ -22,5 +25,23 @@ return [ 'status_error' => 'Fehler', 'clear' => 'Leeren', - 'hint' => 'Echte PTY — dieselben Befehle wie im Terminal, inkl. Autovervollständigung (Tab / Shift-Tab). Eingaben gehen direkt an die Shell.', + // Host-Login-Einrichtung (Clusev-Host-Terminal) + 'host_setup_title' => 'Clusev-Host-Login', + 'host_setup_subtitle' => 'SSH-Zugang zum Clusev-Host. Damit wird das Terminal ein echtes Login auf der Maschine — kein Container.', + 'host_field_host' => 'Host / IP', + 'host_field_port' => 'Port', + 'host_field_user' => 'Benutzer', + 'host_field_auth' => 'Authentifizierung', + 'host_auth_password' => 'Passwort', + 'host_auth_key' => 'SSH-Key', + 'host_field_password' => 'Passwort', + 'host_field_key' => 'Privater SSH-Key', + 'host_field_passphrase' => 'Passphrase (optional)', + 'host_secret_keep' => 'Leer lassen, um das gespeicherte Passwort zu behalten', + 'host_secret_required_for_auth_change' => 'Bei Wechsel der Authentifizierungsmethode muss das Login neu eingegeben werden.', + 'host_security_note' => 'Dieses Login gibt dem Web-Terminal eine Shell auf dem Host. Verschlüsselt gespeichert, nur über das interne Netz genutzt.', + 'host_save' => 'Speichern', + 'host_remove' => 'Entfernen', + 'host_saved' => 'Host-Login gespeichert.', + 'host_removed' => 'Host-Login entfernt.', ]; diff --git a/lang/en/terminal.php b/lang/en/terminal.php index 0838dbc..05716ac 100644 --- a/lang/en/terminal.php +++ b/lang/en/terminal.php @@ -8,9 +8,12 @@ return [ 'targets_title' => 'Targets', 'targets_subtitle' => 'Pick a session', - 'host_label' => 'Clusev', - 'host_sub' => 'Local shell', + 'host_label' => 'Clusev host', + 'host_not_configured' => 'Not configured', + 'host_configure' => 'Set up host login', 'servers_heading' => 'Servers', + 'search_placeholder' => 'Search servers…', + 'no_match' => 'No matches.', 'no_servers' => 'No servers added yet.', 'no_target' => 'No target selected', 'pick_target' => 'Pick a target on the left to start a session.', @@ -22,5 +25,23 @@ return [ 'status_error' => 'Error', 'clear' => 'Clear', - 'hint' => 'A real PTY — the same commands as your terminal, including autocompletion (Tab / Shift-Tab). Input goes straight to the shell.', + // Host login setup (Clusev host terminal) + 'host_setup_title' => 'Clusev host login', + 'host_setup_subtitle' => 'SSH access to the Clusev host. This makes the terminal a real login on the machine — not a container.', + 'host_field_host' => 'Host / IP', + 'host_field_port' => 'Port', + 'host_field_user' => 'User', + 'host_field_auth' => 'Authentication', + 'host_auth_password' => 'Password', + 'host_auth_key' => 'SSH key', + 'host_field_password' => 'Password', + 'host_field_key' => 'Private SSH key', + 'host_field_passphrase' => 'Passphrase (optional)', + 'host_secret_keep' => 'Leave blank to keep the stored password', + 'host_secret_required_for_auth_change' => 'Switching the authentication method requires entering the login again.', + 'host_security_note' => 'This login gives the web terminal a shell on the host. Stored encrypted, used only over the internal network.', + 'host_save' => 'Save', + 'host_remove' => 'Remove', + 'host_saved' => 'Host login saved.', + 'host_removed' => 'Host login removed.', ]; diff --git a/resources/views/livewire/modals/host-shell.blade.php b/resources/views/livewire/modals/host-shell.blade.php new file mode 100644 index 0000000..305f0b0 --- /dev/null +++ b/resources/views/livewire/modals/host-shell.blade.php @@ -0,0 +1,88 @@ +
+
+ + + +
+

{{ __('terminal.host_setup_title') }}

+

{{ __('terminal.host_setup_subtitle') }}

+
+
+ +
+
+
+ + + @error('host')

{{ $message }}

@enderror +
+
+ + + @error('port')

{{ $message }}

@enderror +
+
+ +
+ + + @error('username')

{{ $message }}

@enderror +
+ +
+ + +
+ +
+ + @if ($authType === 'key') + + @else + + @endif + @error('secret')

{{ $message }}

@enderror +
+ + @if ($authType === 'key') +
+ + +
+ @endif + +

+ + {{ __('terminal.host_security_note') }} +

+
+ +
+
+ @if ($configured) + + + {{ __('terminal.host_remove') }} + + @endif +
+
+ {{ __('common.cancel') }} + + + + {{ __('terminal.host_save') }} + +
+
+
diff --git a/resources/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php index 901df4f..e914088 100644 --- a/resources/views/livewire/terminal/index.blade.php +++ b/resources/views/livewire/terminal/index.blade.php @@ -10,24 +10,38 @@ {{-- Target rail --}}
- {{-- Clusev host --}} - + {{-- Clusev host — a real SSH login into the host machine (configured by the operator). --}} +
$activeKey === 'host', + 'border-transparent hover:border-line hover:bg-raised/60' => $activeKey !== 'host', + ])> + + +
{{ __('terminal.servers_heading') }}
+ @if ($showSearch) +
+ + +
+ @endif + @forelse ($servers as $s) @empty -

{{ __('terminal.no_servers') }}

+

{{ $search !== '' ? __('terminal.no_match') : __('terminal.no_servers') }}

@endforelse
@@ -75,6 +89,4 @@ - -

{{ __('terminal.hint') }}

diff --git a/routes/web.php b/routes/web.php index 6ee1dd6..590c77e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,7 @@ use App\Livewire\System; use App\Livewire\Terminal; use App\Livewire\Versions; use App\Livewire\Wireguard; +use App\Models\HostCredential; use App\Models\Server; use App\Models\TerminalSession; use App\Services\DeploymentService; @@ -81,8 +82,22 @@ Route::post('/_internal/terminal/resolve', function (Request $request) { abort_if($burned !== 1, 404); $session = TerminalSession::where('token', $token)->firstOrFail(); + // The "Clusev host" terminal is a real SSH login into the host machine, configured by the operator + // (HostCredential). Hand the sidecar a server-shaped SSH spec — the decrypted login goes only over + // the internal network, never to the browser. No configured login → 404 (the UI gates this anyway). if ($session->kind === 'host') { - return response()->json(['kind' => 'host']); + $hc = HostCredential::current(); + abort_if($hc === null, 404); + + return response()->json([ + 'kind' => 'server', + 'host' => $hc->host, + 'port' => $hc->port ?: 22, + 'username' => $hc->username, + 'auth_type' => $hc->auth_type, + 'secret' => $hc->secret, // decrypted by the model cast; internal net only + 'passphrase' => $hc->passphrase, + ]); } $server = $session->server()->with('credential')->first(); diff --git a/tests/Feature/TerminalTest.php b/tests/Feature/TerminalTest.php index a532b95..2c4653b 100644 --- a/tests/Feature/TerminalTest.php +++ b/tests/Feature/TerminalTest.php @@ -2,7 +2,9 @@ namespace Tests\Feature; +use App\Livewire\Modals\HostShell; use App\Livewire\Terminal\Index; +use App\Models\HostCredential; use App\Models\Server; use App\Models\SshCredential; use App\Models\TerminalSession; @@ -30,6 +32,14 @@ class TerminalTest extends TestCase return $s; } + private function hostCredential(): HostCredential + { + return HostCredential::create([ + 'host' => 'host.docker.internal', 'port' => 2200, 'username' => 'root', + 'auth_type' => 'password', 'secret' => 'r00t-pw', + ]); + } + private function token(string $kind, ?int $serverId = null, ?\DateTimeInterface $expires = null): string { $token = 'tok-'.bin2hex(random_bytes(6)); @@ -53,8 +63,10 @@ class TerminalTest extends TestCase // ── token minting (Livewire) ────────────────────────────────────────────── - public function test_open_host_mints_a_single_use_host_session(): void + public function test_open_host_mints_a_single_use_host_session_when_configured(): void { + $this->hostCredential(); + Livewire::test(Index::class)->call('open', 'host')->assertSet('activeKey', 'host'); $s = TerminalSession::first(); @@ -65,6 +77,15 @@ class TerminalTest extends TestCase $this->assertTrue($s->expires_at->isFuture()); } + public function test_open_host_without_a_login_opens_the_setup_modal_and_mints_nothing(): void + { + Livewire::test(Index::class) + ->call('open', 'host') + ->assertDispatched('openModal', component: 'modals.host-shell'); + + $this->assertSame(0, TerminalSession::count()); + } + public function test_open_server_mints_a_session_for_that_server(): void { $server = $this->server(); @@ -88,12 +109,28 @@ class TerminalTest extends TestCase $this->resolve($token, 'wrong-secret')->assertForbidden(); } - public function test_resolve_returns_host_kind_and_burns_the_token(): void + public function test_resolve_returns_the_host_ssh_spec_and_burns_the_token(): void + { + $this->hostCredential(); + $token = $this->token('host'); + + $this->resolve($token)->assertOk()->assertExactJson([ + 'kind' => 'server', // the sidecar SSHes into the host exactly like a fleet server + 'host' => 'host.docker.internal', + 'port' => 2200, + 'username' => 'root', + 'auth_type' => 'password', + 'secret' => 'r00t-pw', // decrypted by the model cast, only over the internal net + 'passphrase' => null, + ]); + // single use → second resolve fails + $this->resolve($token)->assertNotFound(); + } + + public function test_resolve_host_404s_when_no_host_login_is_configured(): void { $token = $this->token('host'); - $this->resolve($token)->assertOk()->assertExactJson(['kind' => 'host']); - // single use → second resolve fails $this->resolve($token)->assertNotFound(); } @@ -135,4 +172,76 @@ class TerminalTest extends TestCase $this->server(); $this->get(route('terminal'))->assertOk()->assertSee(__('terminal.heading')); } + + // ── host-login modal + server search ─────────────────────────────────────── + + public function test_host_shell_modal_saves_the_singleton(): void + { + Livewire::test(HostShell::class) + ->set('host', '10.0.0.1') + ->set('port', 22) + ->set('username', 'root') + ->set('authType', 'password') + ->set('secret', 'hunter2pw') + ->call('save') + ->assertDispatched('hostShellSaved'); + + $hc = HostCredential::current(); + $this->assertNotNull($hc); + $this->assertSame('10.0.0.1', $hc->host); + $this->assertSame('root', $hc->username); + $this->assertSame('hunter2pw', $hc->secret); // round-trips through the encrypted cast + } + + public function test_host_shell_edit_keeps_the_stored_secret_when_left_blank(): void + { + $this->hostCredential(); // secret 'r00t-pw' + + Livewire::test(HostShell::class) + ->assertSet('configured', true) + ->set('username', 'admin') // change something, leave secret blank + ->call('save'); + + $hc = HostCredential::current(); + $this->assertSame('admin', $hc->username); + $this->assertSame('r00t-pw', $hc->secret); // unchanged + } + + public function test_host_shell_requires_a_new_secret_when_switching_auth_method(): void + { + $this->hostCredential(); // auth_type 'password', secret 'r00t-pw' + + Livewire::test(HostShell::class) + ->set('authType', 'key') // switch method but leave the secret blank + ->set('secret', '') + ->call('save') + ->assertHasErrors('secret'); + + // The stored login is untouched — no password silently reused as a key. + $hc = HostCredential::current(); + $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' => 'beta-db', 'ip' => '10.0.0.2', 'status' => 'online']); + + Livewire::test(Index::class) + ->set('search', 'alpha') + ->assertSee('alpha-web') + ->assertDontSee('beta-db'); + } + + public function test_search_box_appears_only_past_the_threshold(): void + { + $this->get(route('terminal'))->assertDontSee(__('terminal.search_placeholder')); + + foreach (range(1, 6) as $i) { + Server::create(['name' => 'srv-'.$i, 'ip' => '10.0.0.'.$i, 'status' => 'online']); + } + + $this->get(route('terminal'))->assertSee(__('terminal.search_placeholder')); + } }