feat: short clusev commands in versions panel, lang strings, MOTD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-19 23:09:35 +02:00
parent 437a22592b
commit 8d2b7087a9
12 changed files with 97 additions and 22 deletions

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Clusev themed MOTD — printed at host login (Debian/Ubuntu run-parts MOTD).
# Installed by install.sh, which substitutes __CLUSEV_URL__, __CLUSEV_VERSION__ and
# __CLUSEV_COMPOSE__ (absolute path to docker-compose.prod.yml). Signal-orange brand,
# __CLUSEV_COMPOSE__ (absolute path to the production compose file). Signal-orange brand,
# live stack status, no emoji (status = colour + dot). Idempotent: install.sh only ever
# overwrites this single 00-clusev file, never other MOTD parts. Never fails a login
# (every dynamic lookup degrades to "unbekannt").
@ -40,8 +40,8 @@ printf ' %s%s▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀ %s %sFlee
printf ' %s──────────────────────────────────────────────────────────%s\n' "$D" "$R"
printf ' %sDashboard%s %s%s%s\n' "$D" "$R" "$O" "$URL" "$R"
printf ' %sStack%s %s\n' "$D" "$R" "$(stack_status)"
printf ' %sLogin%s %sStandard-Passwort: clusev — beim ersten Login aendern · Reset: clusev:reset-admin%s\n' "$D" "$R" "$D" "$R"
printf ' %sVerwalten%s %sdocker compose -f docker-compose.prod.yml ps | logs -f | restart%s\n' "$D" "$R" "$D" "$R"
printf ' %sUpdate%s %ssudo ./update.sh (in %s)%s\n' "$D" "$R" "$D" "$(dirname "$COMPOSE")" "$R"
printf ' %sLogin%s %sStandard-Passwort: clusev — beim ersten Login aendern · Reset: clusev reset-admin%s\n' "$D" "$R" "$D" "$R"
printf ' %sVerwalten%s %sclusev ps | logs | restart%s\n' "$D" "$R" "$D" "$R"
printf ' %sUpdate%s %ssudo clusev update%s\n' "$D" "$R" "$D" "$R"
printf ' %s──────────────────────────────────────────────────────────%s\n' "$D" "$R"
printf '%s\n' ""

View File

@ -41,7 +41,7 @@ return [
'twofa_remove_totp' => 'Authenticator entfernen',
// Account recovery (last-resort) note — admin-only, post-login
'recovery_note' => 'Konto-Wiederherstellung (letzter Ausweg): per SSH auf den Host einloggen und `clusev:reset-admin` ausführen.',
'recovery_note' => 'Konto-Wiederherstellung (letzter Ausweg): per SSH auf den Host einloggen und `clusev reset-admin` ausführen.',
// Notifications
'profile_saved' => 'Profil gespeichert.',

View File

@ -31,7 +31,7 @@ return [
'domain_invalid' => 'Bitte eine gültige Domain eingeben (z. B. panel.example.com) — ohne http://, Port oder Pfad.',
'access_address' => 'Zugriffsadresse',
'access_bare_ip' => 'HTTP (Bare-IP)',
'ssh_reset_hint' => 'Komplett ausgesperrt? Per SSH auf den Host einloggen und `clusev:reset-admin` ausführen, um den Zugang zurückzusetzen.',
'ssh_reset_hint' => 'Komplett ausgesperrt? Per SSH auf den Host einloggen und `clusev reset-admin` ausführen, um den Zugang zurückzusetzen.',
// Change-domain confirmation modal
'change_domain_heading' => 'Domain ändern',
@ -45,7 +45,7 @@ return [
'restart_body' => 'Die Änderung ist gespeichert. Starte den Stack neu, damit sie übernommen wird.',
'restart_now' => 'Jetzt neu starten',
'restart_running' => 'Neustart wird ausgeführt …',
'restart_write_failed' => 'Neustart konnte nicht angestoßen werden (Sentinel nicht schreibbar). Bitte einmalig per SSH „sudo ./update.sh" aktualisieren.',
'restart_write_failed' => 'Neustart konnte nicht angestoßen werden (Sentinel nicht schreibbar). Bitte einmalig per SSH „sudo clusev update" aktualisieren.',
'restart_watcher_hint' => 'Falls nach ~30 s nichts passiert, ist der Host-Watcher evtl. nicht installiert — siehe Doku (docker/restart-sentinel).',
'restart_lockout_hint' => 'Nach dem Neustart eventuell neu anmelden. Falls die neue Domain (DNS/Zertifikat) noch nicht erreichbar ist, bleibt das Panel über http://<Server-IP> als Rückfallweg erreichbar.',

View File

@ -44,7 +44,7 @@ return [
// Update panel
'update_title' => 'Aktualisierung',
'update_hint' => 'Updates kommen über getaggte Releases im Deploy — neues Image ziehen und Migrationen anwenden:',
'update_hint' => 'Updates kommen über getaggte Releases. Auf dem Host genügt ein Befehl — er baut das Image neu und wendet Migrationen an:',
// Dashboard-triggered update (one-click; a host watcher runs update.sh)
'update_now' => 'Jetzt aktualisieren',
@ -52,7 +52,7 @@ return [
'update_running' => 'Update läuft — Stack wird neu gebaut. Bitte gleich neu laden.',
'update_not_available' => 'Kein Update verfügbar — die installierte Version ist bereits aktuell.',
'update_throttled' => 'Zu viele Update-Anfragen — bitte in :seconds Sekunden erneut versuchen.',
'update_write_failed' => 'Update konnte nicht angestoßen werden (Sentinel nicht schreibbar). Bitte einmalig per SSH „sudo ./update.sh" aktualisieren.',
'update_write_failed' => 'Update konnte nicht angestoßen werden (Sentinel nicht schreibbar). Bitte einmalig per SSH „sudo clusev update" aktualisieren.',
'update_done' => 'Update abgeschlossen — jetzt auf v:version.',
'update_stalled' => 'Update dauert ungewöhnlich lange — Logs prüfen (journalctl -u clusev-update.service) oder Seite neu laden.',

View File

@ -41,7 +41,7 @@ return [
'twofa_remove_totp' => 'Remove authenticator',
// Account recovery (last-resort) note — admin-only, post-login
'recovery_note' => 'Account recovery (last resort): sign in to the host over SSH and run `clusev:reset-admin`.',
'recovery_note' => 'Account recovery (last resort): sign in to the host over SSH and run `clusev reset-admin`.',
// Notifications
'profile_saved' => 'Profile saved.',

View File

@ -31,7 +31,7 @@ return [
'domain_invalid' => 'Please enter a valid domain (e.g. panel.example.com) — without http://, port or path.',
'access_address' => 'Access address',
'access_bare_ip' => 'HTTP (bare IP)',
'ssh_reset_hint' => 'Fully locked out? SSH into the host and run `clusev:reset-admin` to reset dashboard access.',
'ssh_reset_hint' => 'Fully locked out? SSH into the host and run `clusev reset-admin` to reset dashboard access.',
// Change-domain confirmation modal
'change_domain_heading' => 'Change domain',
@ -45,7 +45,7 @@ return [
'restart_body' => 'The change is saved. Restart the stack to apply it.',
'restart_now' => 'Restart now',
'restart_running' => 'Restart is running …',
'restart_write_failed' => 'Could not start the restart (sentinel not writable). Please update once via SSH with "sudo ./update.sh".',
'restart_write_failed' => 'Could not start the restart (sentinel not writable). Please update once via SSH with "sudo clusev update".',
'restart_watcher_hint' => 'If nothing happens after ~30 s, the host watcher may not be installed — see the docs (docker/restart-sentinel).',
'restart_lockout_hint' => 'You may need to sign in again after the restart. If the new domain (DNS/certificate) is not reachable yet, the panel stays available at http://<server-IP> as a fallback.',

View File

@ -44,7 +44,7 @@ return [
// Update panel
'update_title' => 'Update',
'update_hint' => 'Updates arrive via tagged releases in the deploy — pull the new image and apply migrations:',
'update_hint' => 'Updates arrive via tagged releases. On the host a single command rebuilds the image and applies migrations:',
// Dashboard-triggered update (one-click; a host watcher runs update.sh)
'update_now' => 'Update now',
@ -52,7 +52,7 @@ return [
'update_running' => 'Update in progress — the stack is rebuilding. Reload shortly.',
'update_not_available' => 'No update available — the installed version is already current.',
'update_throttled' => 'Too many update requests — try again in :seconds seconds.',
'update_write_failed' => 'Could not start the update (sentinel not writable). Please update once via SSH with "sudo ./update.sh".',
'update_write_failed' => 'Could not start the update (sentinel not writable). Please update once via SSH with "sudo clusev update".',
'update_done' => 'Update complete — now on v:version.',
'update_stalled' => 'Update is taking unusually long — check the logs (journalctl -u clusev-update.service) or reload.',

View File

@ -43,7 +43,7 @@ WebSocket: aktivieren (für Realtime: /app/* und /apps/*)</pre>
<pre class="{{ $pre }}">TRUSTED_PROXY_CIDR=&lt;Proxy-IP&gt;/32
# danach einmal anwenden:
sudo ./update.sh</pre>
sudo clusev update</pre>
<p class="{{ $p }}"><span class="text-ink">4. Firewall:</span> sichere den HTTP-Port (80) des Clusev-Servers so ab, dass er <span class="text-ink">nur vom Proxy</span> erreichbar ist.</p>

View File

@ -43,7 +43,7 @@ WebSocket: enable (for realtime: /app/* and /apps/*)</pre>
<pre class="{{ $pre }}">TRUSTED_PROXY_CIDR=&lt;proxy-ip&gt;/32
# then apply once:
sudo ./update.sh</pre>
sudo clusev update</pre>
<p class="{{ $p }}"><span class="text-ink">4. Firewall:</span> secure the Clusev server's HTTP port (80) so it is reachable <span class="text-ink">only from the proxy</span>.</p>

View File

@ -159,9 +159,7 @@
<x-panel :title="__('versions.update_title')" :padded="false">
<div class="space-y-3 px-4 py-4 sm:px-5">
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ __('versions.update_hint') }}</p>
<pre class="overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
artisan migrate --force</pre>
<pre class="overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">sudo clusev update</pre>
</div>
</x-panel>

View File

@ -0,0 +1,77 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class CommandShortcutsTest extends TestCase
{
/** Files shown to the operator — none may print the long compose filename. */
private const DISPLAY_SURFACES = [
'resources/views/livewire/versions/index.blade.php',
'resources/views/livewire/help/content/de/recovery.blade.php',
'resources/views/livewire/help/content/en/recovery.blade.php',
'resources/views/livewire/help/content/de/updates.blade.php',
'resources/views/livewire/help/content/en/updates.blade.php',
'resources/views/livewire/help/content/de/commands.blade.php',
'resources/views/livewire/help/content/en/commands.blade.php',
'resources/views/livewire/help/content/de/domain-tls.blade.php',
'resources/views/livewire/help/content/en/domain-tls.blade.php',
'docker/motd/00-clusev',
];
/** Operator-facing files that must use the short `sudo clusev update`, never `sudo ./update.sh`. */
private const UPDATE_COMMAND_SURFACES = [
'resources/views/livewire/help/content/de/domain-tls.blade.php',
'resources/views/livewire/help/content/en/domain-tls.blade.php',
'resources/views/livewire/help/content/de/updates.blade.php',
'resources/views/livewire/help/content/en/updates.blade.php',
'lang/de/system.php',
'lang/en/system.php',
'lang/de/versions.php',
'lang/en/versions.php',
];
public function test_no_display_surface_leaks_the_long_compose_filename(): void
{
foreach (self::DISPLAY_SURFACES as $rel) {
$contents = file_get_contents(base_path($rel));
$this->assertNotFalse($contents, "Could not read {$rel}");
$this->assertStringNotContainsString('docker-compose.prod.yml', $contents, "{$rel} still shows docker-compose.prod.yml");
}
}
public function test_operator_update_instructions_use_the_short_command(): void
{
foreach (self::UPDATE_COMMAND_SURFACES as $rel) {
$contents = file_get_contents(base_path($rel));
$this->assertNotFalse($contents, "Could not read {$rel}");
$this->assertStringNotContainsString('sudo ./update.sh', $contents, "{$rel} still shows the long update command");
}
}
public function test_versions_panel_shows_the_short_update_command(): void
{
$blade = file_get_contents(base_path('resources/views/livewire/versions/index.blade.php'));
$this->assertStringContainsString('sudo clusev update', $blade);
$this->assertStringNotContainsString('docker compose -f', $blade);
}
public function test_reset_hints_use_the_short_host_command(): void
{
foreach (['de', 'en'] as $locale) {
$settings = require base_path("lang/{$locale}/settings.php");
$system = require base_path("lang/{$locale}/system.php");
$this->assertStringContainsString('clusev reset-admin', $settings['recovery_note']);
$this->assertStringContainsString('clusev reset-admin', $system['ssh_reset_hint']);
}
}
public function test_motd_uses_short_commands(): void
{
$motd = file_get_contents(base_path('docker/motd/00-clusev'));
$this->assertStringContainsString('clusev ps | logs | restart', $motd);
$this->assertStringContainsString('sudo clusev update', $motd);
$this->assertStringContainsString('clusev reset-admin', $motd);
}
}

View File

@ -136,12 +136,12 @@ class ForgotPasswordSmtpAwareTest extends TestCase
config(['mail.default' => 'log']);
Livewire::test(ForgotPassword::class)
->assertDontSee('clusev:reset-admin');
->assertDontSee('clusev reset-admin');
config(['mail.default' => 'smtp']);
Livewire::test(ForgotPassword::class)
->assertDontSee('clusev:reset-admin');
->assertDontSee('clusev reset-admin');
}
public function test_settings_security_shows_the_recovery_note_with_the_command(): void
@ -150,6 +150,6 @@ class ForgotPasswordSmtpAwareTest extends TestCase
Livewire::actingAs($user)->test(Security::class)
->assertSee(__('settings.recovery_note'))
->assertSee('clusev:reset-admin');
->assertSee('clusev reset-admin');
}
}