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 <key>" 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 <noreply@anthropic.com>feat/v1-foundation
parent
40383261bd
commit
b7e44ca3c2
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 <key>" 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 <key>" 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 = [
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
['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') }}">
|
||||
<div class="flex items-center gap-1 overflow-x-auto no-scrollbar 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' }}"
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
</div>
|
||||
|
||||
{{-- Nav (navBadges polls /shell/badges.json and updates the alert/threat/update chips live) --}}
|
||||
<nav class="flex-1 space-y-1 overflow-y-auto p-3" x-data="navBadges">
|
||||
<nav class="flex-1 space-y-1 overflow-y-auto no-scrollbar p-3" x-data="navBadges">
|
||||
{{-- 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>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
</div>
|
||||
|
||||
{{-- 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). --}}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<x-kpi
|
||||
:label="__('threats.kpi_login_attempts')"
|
||||
|
|
@ -35,11 +36,22 @@
|
|||
:value="$honeytoken_trips"
|
||||
:tone="$honeytoken_trips > 0 ? 'offline' : 'ink'"
|
||||
icon="alert" />
|
||||
<x-kpi
|
||||
:label="__('threats.kpi_top_ip')"
|
||||
:value="$top_ip ?? '—'"
|
||||
:tone="$top_ip ? 'warning' : 'ink'"
|
||||
icon="activity" />
|
||||
</div>
|
||||
|
||||
{{-- 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. --}}
|
||||
<div class="flex items-center gap-3 rounded-lg border border-line bg-surface p-4 shadow-panel">
|
||||
<span @class([
|
||||
'grid h-9 w-9 shrink-0 place-items-center rounded-md',
|
||||
'border border-warning/25 bg-warning/10 text-warning' => (bool) $top_ip,
|
||||
'bg-raised text-ink-4' => ! $top_ip,
|
||||
])>
|
||||
<x-icon name="activity" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('threats.kpi_top_ip') }}</p>
|
||||
<p @class(['tabular mt-0.5 truncate font-mono text-xl font-semibold', 'text-warning' => (bool) $top_ip, 'text-ink-3' => ! $top_ip])>{{ $top_ip ?? '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Threat feed --}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue