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
parent
ca693219bf
commit
28c28df868
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"/>',
|
||||
|
|
|
|||
|
|
@ -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 --}}
|
||||
|
|
|
|||
|
|
@ -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://<Server-IP></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 & 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 & 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>
|
||||
|
|
@ -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://<server-ip></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 & 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 & 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue