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
parent
096ba3ca98
commit
b4c309bd54
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ return [
|
|||
|
||||
// Sidebar — nav group labels
|
||||
'group_fleet' => 'Flotte',
|
||||
'group_host' => 'Host',
|
||||
'group_account' => 'Konto',
|
||||
|
||||
// Sidebar — nav items
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'eyebrow' => 'Fleet',
|
||||
'eyebrow' => 'Host',
|
||||
'title' => 'Docker',
|
||||
'containers_title' => 'Containers',
|
||||
'connected' => 'Connected',
|
||||
'disconnected' => 'Disconnected',
|
||||
|
||||
'host_subtitle' => 'Containers on the Clusev host. A specific server’s containers live on its detail page under the “Docker” tab.',
|
||||
'target_label' => 'Target',
|
||||
'host_target' => 'Clusev host',
|
||||
'server_target' => 'Server',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ return [
|
|||
|
||||
// Sidebar — nav group labels
|
||||
'group_fleet' => 'Fleet',
|
||||
'group_host' => 'Host',
|
||||
'group_account' => 'Account',
|
||||
|
||||
// Sidebar — nav items
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@
|
|||
|
||||
return [
|
||||
'title' => 'Terminal — Clusev',
|
||||
'eyebrow' => 'Operations',
|
||||
'eyebrow' => 'Host',
|
||||
'heading' => 'Terminal',
|
||||
'subtitle' => 'Interactive shell — per server over SSH, or the Clusev environment itself.',
|
||||
'host_heading' => 'Host terminal',
|
||||
'host_page_subtitle' => 'A real SSH shell on the machine Clusev runs on. A server’s shell lives on its detail page under the “Terminal” tab.',
|
||||
'connect' => 'Connect',
|
||||
'connect_host' => 'Connect to host',
|
||||
'no_permission_title' => 'No access',
|
||||
'no_permission_hint' => 'You don’t have permission to open this server’s terminal.',
|
||||
|
||||
'targets_title' => 'Targets',
|
||||
'targets_subtitle' => 'Pick a session',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue