From b7e44ca3c22468c31c211bf4c998b6288a3c91de Mon Sep 17 00:00:00 2001 From: boban Date: Mon, 6 Jul 2026 22:15:57 +0200 Subject: [PATCH] UI polish + honest service-action reporting Command palette: rebuild the nav list from a role-gated table so every navigable page (Docker, Terminal, WireGuard, Commands, Security posture, Updates, Certificates, Uptime, Alerts, Threats) appears with a role-gated "g " chord. The help panel is derived from the resulting list, so a gated-out page has neither an entry nor a live shortcut (no 403 dead-end). New chords k/t/w/c/p/u/r/a resolve via nav.find; no collision with the hardcoded CMDK_GO map (d/s/i/f/l/e/y/v). Scrollbars: add a .no-scrollbar utility and apply it to the server-detail tab strip and the sidebar nav, so macOS "always show scroll bars" no longer paints a permanent track where nothing overflows. Threats KPIs: replace the orphaned 5-box row with a 4-box even grid of the count metrics + a full-width Top-Attacker spotlight card (an IP, not a count). serviceAction: a start/restart can EXIT 0 (systemd accepted the request) yet the unit dies on launch (e.g. monit crashing on a bad config) -> is-active reports failed. Verify the real post-action state and, when the unit did not come up, report failure WITH the reason (systemctl status tail) so the operator sees why instead of a false "gestartet". Harden all three systemctl calls against argument injection: unit must start alphanumeric, passed after `--` and escapeshellarg'd (Codex-reviewed, clean). Co-Authored-By: Claude Fable 5 --- app/Services/FleetService.php | 35 +++++++++- resources/css/app.css | 10 +++ .../components/command-palette.blade.php | 70 ++++++++++++------- .../views/components/server-tabs.blade.php | 2 +- resources/views/components/sidebar.blade.php | 2 +- .../views/livewire/threats/index.blade.php | 22 ++++-- 6 files changed, 104 insertions(+), 37 deletions(-) diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index 14e9017..d1f81b7 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -606,18 +606,47 @@ class FleetService if (! in_array($op, ['start', 'stop', 'restart'], true)) { throw new InvalidArgumentException('Unbekannte Aktion.'); } - if (! preg_match('/^[\w@.:-]+$/', $unit)) { + // Unit MUST start alphanumeric (blocks a leading-dash value that systemctl would parse as an + // option, e.g. `-H host` / `--signal=`) — mirrors the DockerService::ref() discipline. Every + // systemctl call below ALSO passes the unit after `--` (end-of-options) and escapeshellarg'd, + // so a hostile unit can never become a flag even if the pattern were ever loosened. + if (! preg_match('/\A[a-zA-Z0-9][\w@.:-]*\z/', $unit)) { throw new InvalidArgumentException('Ungueltige Unit.'); } + $safe = escapeshellarg($unit); $ssh = $this->client($server); try { [$out, $code] = $ssh->run( - 'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl {$op} {$unit} 2>&1" + 'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl {$op} -- {$safe} 2>&1" ); + $out = trim($out); + $ok = $code === 0; - return ['ok' => $code === 0, 'output' => trim($out)]; + // A `start`/`restart` can EXIT 0 (systemd accepted the request) yet the unit dies on + // launch → is-active=failed a moment later (e.g. monit crashing on a bad config). Verify + // the real post-action state; if it never came up, report failure WITH the reason (last + // journal lines) so the operator sees *why* — not a false "gestartet". (stop = the unit + // going inactive IS success, so skip the check there.) + if ($ok && $op !== 'stop') { + [$state] = $ssh->run( + 'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl is-active -- {$safe} 2>&1" + ); + $state = trim($state); + if ($state !== 'active') { + $ok = false; + [$why] = $ssh->run( + 'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl status --no-pager --lines=12 -- {$safe} 2>&1 || true" + ); + $detail = trim($why); + $out = trim(($out !== '' ? $out."\n" : '') + .$unit.': '.$state.' ('.$op.')' + .($detail !== '' ? "\n".$detail : '')); + } + } + + return ['ok' => $ok, 'output' => $out]; } finally { $ssh->disconnect(); } diff --git a/resources/css/app.css b/resources/css/app.css index 2692eaf..a12d55c 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -169,6 +169,16 @@ .tabular { font-variant-numeric: tabular-nums; } + + /* Keep scroll capability but hide the track — used on the sidebar nav + the server-detail tab + strip so macOS "always show scroll bars" doesn't paint a permanent bar where nothing overflows. */ + .no-scrollbar { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* old Edge */ + } + .no-scrollbar::-webkit-scrollbar { + display: none; /* Chromium / WebKit (macOS) */ + } } /* ── Touch devices: no zoom-on-focus ────────────────────────────────────── diff --git a/resources/views/components/command-palette.blade.php b/resources/views/components/command-palette.blade.php index e2efdbc..c75719d 100644 --- a/resources/views/components/command-palette.blade.php +++ b/resources/views/components/command-palette.blade.php @@ -4,38 +4,54 @@ Strg/⌘-K opens it; "g" + a key navigates; "/" focuses the page search; "?" shows help. --}} @php + // Every navigable page (mirrors the 3-group sidebar), each ROLE-GATED so the palette + its + // "g " shortcut only ever offer pages the user can actually open — go() resolves a chord + // via this list, so a gated-out page has neither an entry nor a live shortcut (no 403 dead-end). + // [ lang key, href, chord letter (null = search-only), required ability (null = always) ] + $u = auth()->user(); + $navDefs = [ + // Fleet + ['shell.nav_dashboard', '/', 'd', null], + ['shell.nav_servers', '/servers', 's', null], + ['shell.nav_services', '/services', 'i', null], + ['shell.nav_commands', '/commands', 'c', 'operate'], + ['shell.nav_files', '/files', 'f', null], + ['shell.nav_audit', '/audit', 'l', null], + // Host + ['shell.nav_docker', '/docker', 'k', 'manage-fleet'], + ['shell.nav_terminal', '/terminal', 't', 'manage-fleet'], + ['shell.nav_wireguard', '/wireguard', 'w', 'manage-network'], + ['shell.nav_system', '/system', 'y', null], + ['shell.nav_posture', '/posture', 'p', 'operate'], + ['shell.nav_patch', '/patch', 'u', 'operate'], + ['shell.nav_certs', '/certs', 'r', 'operate'], + ['shell.nav_health', '/health', null, 'operate'], + // Account + ['shell.nav_settings', '/settings', 'e', null], + ['shell.nav_alerts', '/alerts', 'a', 'manage-panel'], + ['shell.nav_threats', '/threats', null, 'manage-panel'], + ['shell.nav_versions', '/versions', 'v', null], + ['shell.nav_help', config('clusev.docs_url'), 'h', null], + ]; + + $nav = []; + foreach ($navDefs as [$key, $href, $chord, $gate]) { + if ($gate !== null && ! ($u?->can($gate) ?? false)) { + continue; + } + $nav[] = ['label' => __($key), 'href' => $href, 'hint' => $chord ? 'g '.$chord : '']; + } + + // Help panel: the fixed utility keys + one "g " row per shortcut the user actually has. $chords = [ ['Strg / ⌘ K', __('shell.cmdk_open')], ['/', __('shell.cmdk_focus_search')], ['?', __('shell.cmdk_show_help')], - ['g d', __('shell.nav_dashboard')], - ['g s', __('shell.nav_servers')], - ['g i', __('shell.nav_services')], - ['g f', __('shell.nav_files')], - ['g l', __('shell.nav_audit')], - ['g e', __('shell.nav_settings')], - ['g y', __('shell.nav_system')], - ['g v', __('shell.nav_versions')], - ['g h', __('shell.nav_help')], ]; - - $nav = [ - ['label' => __('shell.nav_dashboard'), 'href' => '/', 'hint' => 'g d'], - ['label' => __('shell.nav_servers'), 'href' => '/servers', 'hint' => 'g s'], - ['label' => __('shell.nav_services'), 'href' => '/services', 'hint' => 'g i'], - ['label' => __('shell.nav_files'), 'href' => '/files', 'hint' => 'g f'], - ['label' => __('shell.nav_audit'), 'href' => '/audit', 'hint' => 'g l'], - ['label' => __('shell.nav_settings'), 'href' => '/settings', 'hint' => 'g e'], - ['label' => __('shell.nav_system'), 'href' => '/system', 'hint' => 'g y'], - ['label' => __('shell.nav_versions'), 'href' => '/versions', 'hint' => 'g v'], - ['label' => __('shell.nav_help'), 'href' => config('clusev.docs_url'), 'hint' => 'g h'], - ]; - - // Terminal is admin-only (manage-fleet) — add its "g t" chord + palette entry ONLY for admins, - // so lower roles neither see it nor dead-end on the now-gated /terminal route. - if (auth()->user()?->can('manage-fleet')) { - $chords[] = ['g t', __('shell.nav_terminal')]; - $nav[] = ['label' => __('shell.nav_terminal'), 'href' => '/terminal', 'hint' => 'g t']; + foreach ($nav as $n) { + if ($n['hint'] !== '') { + $chords[] = [str_replace('g ', 'g ', $n['hint']), $n['label']]; + } } $actions = [ diff --git a/resources/views/components/server-tabs.blade.php b/resources/views/components/server-tabs.blade.php index 4a9fe2e..0d29eec 100644 --- a/resources/views/components/server-tabs.blade.php +++ b/resources/views/components/server-tabs.blade.php @@ -7,7 +7,7 @@ ['key' => 'terminal', 'label' => __('servers.tab_terminal'), 'icon' => 'terminal', 'show' => (bool) auth()->user()?->can('operate')], ], fn ($t) => $t['show']); @endphp -
+
@foreach ($tabs as $t)
{{-- KPI strip: auto-fit — login attempts first (the "someone tried to log in" signal). --}} + {{-- Four homogeneous count metrics (even 2-/4-up grid, no orphan). --}}
- +
+ + {{-- Top attacker is a single IP (not a count), so a full-width spotlight reads far better than a + lone fifth KPI orphaned on its own grid row. --}} +
+ (bool) $top_ip, + 'bg-raised text-ink-4' => ! $top_ip, + ])> + + +
+

{{ __('threats.kpi_top_ip') }}

+

(bool) $top_ip, 'text-ink-3' => ! $top_ip])>{{ $top_ip ?? '—' }}

+
{{-- Threat feed --}}