feat(help): in-panel help page scaffold + overview topic

New /help full-page Livewire component with left topic nav (like Settings),
bilingual content via per-locale Blade partials, sidebar + command-palette
entries (g h). First topic: Überblick / Overview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-19 22:14:15 +02:00
parent ca693219bf
commit 28c28df868
14 changed files with 264 additions and 1 deletions

View File

@ -0,0 +1,55 @@
<?php
namespace App\Livewire\Help;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* In-panel help/documentation. Pure display: a left topic nav (like Settings) and a per-locale
* content partial. No persistence, no external calls. Topic keys are also the content-partial
* filenames at resources/views/livewire/help/content/{de,en}/<topic>.blade.php.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** Help topics in display order. */
private const TOPICS = [
'overview', 'domain-tls', 'security', 'updates',
'servers', 'sessions', 'email', 'audit', 'recovery',
];
public string $topic = 'overview';
public function mount(?string $topic = null): void
{
if ($topic !== null) {
$this->topic = $topic; // clamped to a known topic in render()
}
}
public function render()
{
// Authoritative guard: any unknown topic (deep link, tampered property) → overview, so
// the view never @includes a missing content partial.
if (! in_array($this->topic, self::TOPICS, true)) {
$this->topic = 'overview';
}
$labels = [
'overview' => __('help.topic_overview'),
'domain-tls' => __('help.topic_domain_tls'),
'security' => __('help.topic_security'),
'updates' => __('help.topic_updates'),
'servers' => __('help.topic_servers'),
'sessions' => __('help.topic_sessions'),
'email' => __('help.topic_email'),
'audit' => __('help.topic_audit'),
'recovery' => __('help.topic_recovery'),
];
$topics = array_map(fn (string $key): array => ['key' => $key, 'label' => $labels[$key]], self::TOPICS);
return view('livewire.help.index', ['topics' => $topics])->title(__('help.title'));
}
}

18
lang/de/help.php Normal file
View File

@ -0,0 +1,18 @@
<?php
return [
'title' => 'Hilfe — Clusev',
'eyebrow' => 'Hilfe',
'heading' => 'Hilfe & Dokumentation',
'subtitle' => 'Einstellungen und Abläufe erklärt.',
'topic_overview' => 'Überblick',
'topic_domain_tls' => 'Domain, TLS & Reverse-Proxy',
'topic_security' => 'Sicherheit & 2FA',
'topic_updates' => 'Updates & Versionen',
'topic_servers' => 'Server & SSH',
'topic_sessions' => 'Sitzungen & Benutzer',
'topic_email' => 'E-Mail (SMTP)',
'topic_audit' => 'Audit-Log',
'topic_recovery' => 'Konto-Wiederherstellung',
];

View File

@ -21,6 +21,7 @@ return [
'nav_settings' => 'Einstellungen',
'nav_system' => 'System',
'nav_versions' => 'Version',
'nav_help' => 'Hilfe',
// Sidebar — user / 2FA badge
'twofa_on' => '2FA aktiv',

18
lang/en/help.php Normal file
View File

@ -0,0 +1,18 @@
<?php
return [
'title' => 'Help — Clusev',
'eyebrow' => 'Help',
'heading' => 'Help & documentation',
'subtitle' => 'Settings and procedures explained.',
'topic_overview' => 'Overview',
'topic_domain_tls' => 'Domain, TLS & reverse proxy',
'topic_security' => 'Security & 2FA',
'topic_updates' => 'Updates & versions',
'topic_servers' => 'Servers & SSH',
'topic_sessions' => 'Sessions & users',
'topic_email' => 'Email (SMTP)',
'topic_audit' => 'Audit log',
'topic_recovery' => 'Account recovery',
];

View File

@ -21,6 +21,7 @@ return [
'nav_settings' => 'Settings',
'nav_system' => 'System',
'nav_versions' => 'Version',
'nav_help' => 'Help',
// Sidebar — user / 2FA badge
'twofa_on' => '2FA on',

View File

@ -253,7 +253,7 @@ document.addEventListener('alpine:init', () => {
// translated via __()), so the palette stays in sync with the active locale.
// CMDK_GO maps leader-key mnemonics to routes (i=dIenste, l=Log,
// e=Einstellungen, y=sYstem — d/s already taken) and stays in JS.
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' };
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', h: '/help' };
window.Alpine.data('cmdk', (nav = [], actions = [], servers = [], failMsg = '') => ({
nav,

View File

@ -16,6 +16,7 @@
['g e', __('shell.nav_settings')],
['g y', __('shell.nav_system')],
['g v', __('shell.nav_versions')],
['g h', __('shell.nav_help')],
];
$nav = [
@ -27,6 +28,7 @@
['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' => '/help', 'hint' => 'g h'],
];
$actions = [

View File

@ -5,6 +5,7 @@
'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>',
'chevron-left' => '<path d="m15 18-6-6 6-6"/>',
'tag' => '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
'help-circle' => '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
'lock' => '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'lock-open' => '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>',
'git-branch' => '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',

View File

@ -34,6 +34,7 @@
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item>
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')">{{ __('shell.nav_versions') }}</x-nav-item>
<x-nav-item icon="help-circle" href="/help" :active="request()->is('help*')">{{ __('shell.nav_help') }}</x-nav-item>
</nav>
{{-- User --}}

View File

@ -0,0 +1,46 @@
@php
$h = 'font-display text-base font-semibold text-ink';
$p = 'text-sm leading-relaxed text-ink-2';
$li = 'text-sm leading-relaxed text-ink-2';
$code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text';
@endphp
<div class="space-y-3">
<h3 class="{{ $h }}">Was ist Clusev?</h3>
<p class="{{ $p }}">Clusev ist ein selbst-gehostetes Control-Panel, um eine Flotte von Linux-Servern aus einem
Dashboard zu verwalten agentenlos über SSH. Es installiert nichts auf den Zielservern, sondern steuert sie
direkt (Befehle + Dateien über SSH). Die ganze Oberfläche ist sicherheitsorientiert: 2FA, verschlüsselter
SSH-Zugangsdaten-Tresor und ein vollständiges Audit-Log.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Erster Login</h3>
<p class="{{ $p }}">Nach der Installation meldest du dich mit der Admin-E-Mail und dem Standard-Passwort
<code class="{{ $code }}">clusev</code> an (die Standard-E-Mail ist <code class="{{ $code }}">admin@clusev.local</code>,
sofern beim Installieren keine andere gesetzt wurde). Das Panel <span class="text-ink">erzwingt sofort einen
Passwortwechsel</span> `clusev` ist ein bekanntes Standard-Passwort und nur bis zur Änderung gültig. Ändere es
gleich beim ersten Login.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Zugriff: Bare-IP oder Domain</h3>
<ul class="ml-4 list-disc space-y-1.5">
<li class="{{ $li }}"><span class="text-ink">Ohne Domain (Bare-IP):</span> erreichbar über
<code class="{{ $code }}">http://&lt;Server-IP&gt;</code> per Klartext-HTTP. Diese Adresse bleibt immer als
Wiederherstellungsweg erreichbar, auch wenn später eine Domain eingerichtet wird.</li>
<li class="{{ $li }}"><span class="text-ink">Mit Domain:</span> entweder mit eingebautem TLS (Let's Encrypt,
automatisch) oder hinter einem externen Reverse-Proxy. Details im Thema „Domain, TLS &amp; Reverse-Proxy".</li>
</ul>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Wo finde ich was?</h3>
<ul class="ml-4 list-disc space-y-1.5">
<li class="{{ $li }}"><span class="text-ink">Dashboard:</span> Live-Metriken der Flotte.</li>
<li class="{{ $li }}"><span class="text-ink">Server / Dienste / Dateien:</span> Server-Details, systemd-Dienste, SFTP-Dateimanager.</li>
<li class="{{ $li }}"><span class="text-ink">Audit-Log:</span> wer hat wann was gemacht.</li>
<li class="{{ $li }}"><span class="text-ink">Einstellungen:</span> Profil, Sicherheit/2FA, Benutzer, Sitzungen, E-Mail.</li>
<li class="{{ $li }}"><span class="text-ink">System:</span> Domain, TLS &amp; Neustart. <span class="text-ink">Version:</span> Updates. <span class="text-ink">Hilfe:</span> diese Seite.</li>
</ul>
<p class="{{ $p }}">Tipp: <code class="{{ $code }}">Strg/ K</code> öffnet die Befehls-Suche; mit <code class="{{ $code }}">g</code> + Buchstabe springst du direkt (z. B. <code class="{{ $code }}">g h</code> = Hilfe).</p>
</div>

View File

@ -0,0 +1,44 @@
@php
$h = 'font-display text-base font-semibold text-ink';
$p = 'text-sm leading-relaxed text-ink-2';
$li = 'text-sm leading-relaxed text-ink-2';
$code = 'rounded bg-inset px-1.5 py-0.5 font-mono text-[12px] text-accent-text';
@endphp
<div class="space-y-3">
<h3 class="{{ $h }}">What is Clusev?</h3>
<p class="{{ $p }}">Clusev is a self-hosted control panel for managing a fleet of Linux servers from one dashboard
agentless, over SSH. It installs nothing on the target servers; it drives them directly (commands + files over
SSH). The whole interface is security-first: 2FA, an encrypted SSH-credential vault and a complete audit log.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">First login</h3>
<p class="{{ $p }}">After installation, sign in with the admin e-mail and the default password
<code class="{{ $code }}">clusev</code> (the default e-mail is <code class="{{ $code }}">admin@clusev.local</code>
unless another was set at install time). The panel <span class="text-ink">forces a password change immediately</span>
`clusev` is a known default and only valid until you change it. Change it right away on first login.</p>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Access: bare IP or a domain</h3>
<ul class="ml-4 list-disc space-y-1.5">
<li class="{{ $li }}"><span class="text-ink">No domain (bare IP):</span> reachable at
<code class="{{ $code }}">http://&lt;server-ip&gt;</code> over plain HTTP. This address always stays reachable
as a recovery path, even after a domain is configured.</li>
<li class="{{ $li }}"><span class="text-ink">With a domain:</span> either built-in TLS (Let's Encrypt,
automatic) or behind an external reverse proxy. See the "Domain, TLS &amp; reverse proxy" topic.</li>
</ul>
</div>
<div class="space-y-3">
<h3 class="{{ $h }}">Where to find things</h3>
<ul class="ml-4 list-disc space-y-1.5">
<li class="{{ $li }}"><span class="text-ink">Dashboard:</span> live fleet metrics.</li>
<li class="{{ $li }}"><span class="text-ink">Servers / Services / Files:</span> server details, systemd services, SFTP file manager.</li>
<li class="{{ $li }}"><span class="text-ink">Audit log:</span> who did what, when.</li>
<li class="{{ $li }}"><span class="text-ink">Settings:</span> profile, security/2FA, users, sessions, e-mail.</li>
<li class="{{ $li }}"><span class="text-ink">System:</span> domain, TLS &amp; restart. <span class="text-ink">Version:</span> updates. <span class="text-ink">Help:</span> this page.</li>
</ul>
<p class="{{ $p }}">Tip: <code class="{{ $code }}">Ctrl/ K</code> opens the command search; <code class="{{ $code }}">g</code> + a letter jumps directly (e.g. <code class="{{ $code }}">g h</code> = Help).</p>
</div>

View File

@ -0,0 +1,35 @@
@php
// Resolve the content partial for the active locale; fall back to German if an EN partial
// is missing, so a not-yet-translated topic never breaks the page.
$locale = in_array(app()->getLocale(), ['de', 'en'], true) ? app()->getLocale() : 'de';
$partial = 'livewire.help.content.'.$locale.'.'.$topic;
if (! view()->exists($partial)) {
$partial = 'livewire.help.content.de.'.$topic;
}
@endphp
<div class="space-y-5">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('help.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('help.heading') }}</h2>
<p class="mt-1 font-mono text-xs text-ink-3">{{ __('help.subtitle') }}</p>
</div>
<div class="grid gap-5 lg:grid-cols-[15rem_1fr]">
{{-- Topic navigation (wraps above the content on small screens) --}}
<nav class="flex flex-row flex-wrap gap-1 lg:flex-col">
@foreach ($topics as $t)
<button type="button" wire:click="$set('topic', '{{ $t['key'] }}')" @class([
'min-h-11 rounded-md px-3 py-2 text-left font-mono text-[12px] transition-colors',
'bg-accent/10 text-accent-text shadow-[inset_2px_0_0_var(--color-accent)]' => $topic === $t['key'],
'text-ink-2 hover:bg-raised hover:text-ink' => $topic !== $t['key'],
])>{{ $t['label'] }}</button>
@endforeach
</nav>
{{-- Content --}}
<article class="min-w-0 space-y-4 rounded-xl border border-line bg-surface p-5 shadow-panel">
@include($partial)
</article>
</div>
</div>

View File

@ -6,6 +6,7 @@ use App\Livewire\Audit;
use App\Livewire\Auth;
use App\Livewire\Dashboard;
use App\Livewire\Files;
use App\Livewire\Help;
use App\Livewire\Servers;
use App\Livewire\Services;
use App\Livewire\Settings;
@ -104,5 +105,6 @@ Route::middleware('auth')->group(function () {
Route::get('/settings', Settings\Index::class)->name('settings');
Route::get('/versions', Versions\Index::class)->name('versions');
Route::get('/system', System\Index::class)->name('system');
Route::get('/help', Help\Index::class)->name('help');
});
});

View File

@ -0,0 +1,39 @@
<?php
namespace Tests\Feature;
use App\Livewire\Help\Index;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class HelpPageTest extends TestCase
{
use RefreshDatabase;
private function actAsAdmin(): void
{
$this->actingAs(User::factory()->create(['must_change_password' => false]));
}
public function test_help_route_loads_for_an_authenticated_admin(): void
{
$this->actAsAdmin();
$this->get('/help')->assertOk();
}
public function test_default_topic_is_overview(): void
{
$this->actAsAdmin();
Livewire::test(Index::class)
->assertSet('topic', 'overview')
->assertSee(__('help.topic_overview'));
}
public function test_unknown_topic_falls_back_to_overview(): void
{
$this->actAsAdmin();
Livewire::test(Index::class, ['topic' => 'bogus'])->assertSet('topic', 'overview');
}
}