feat(i18n): bilingual foundation (DE/EN) + localize auth/dashboard/servers/fleet/account

Adds user-selectable German (default) + English, per new rule R16.

Foundation:
- config locale=de + fallback=de; User.locale column (+migration); SetLocale middleware
  (user → session → default; SUPPORTED=[de,en]); public /locale/{code} route; DE/EN
  switcher (x-lang-switch) in topbar + auth layout; <html lang> dynamic.
- Translation structure: lang/{de,en}/<group>.php (one group per feature + shared common),
  identical keys across both languages, :placeholder interpolation. Documented as R16 in
  rules.md + CLAUDE.md (R9 reconciled).
- Removed the "Warum Caddy" rationale block from the System page (per feedback).

Localized this pass (views + Livewire components, keys de↔en at parity):
- common, auth, dashboard, servers, services, files, audit, settings, system, versions.
- #[Title] attributes → dynamic title() (attributes can't call __()).

Remaining (next commit): modals components + a few modal views, shell (command palette,
nav-item, server-item, toaster), and backend service messages (app/Services/*, OsProfile).
App is fully functional in German throughout; English covers the groups above.

Boot-verified: /login HTTP 200 in DE + EN, titles + <html lang> localized, locale switch works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-13 22:36:16 +02:00
parent c34ced0c0e
commit 5691051ad8
69 changed files with 2175 additions and 452 deletions

View File

@ -144,8 +144,10 @@ number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups:
## 6. Conventions
- **UI copy: German**, terse/operational, **no emoji** (status = color/dots/pills). Native
technical tokens stay as-is (`nginx.service`, `chmod 600`, `SSH`, `2FA`). (R9)
- **UI copy is localized (DE default + EN)** — never hard-code visible strings; use
`__('group.key')` with translations in `lang/{de,en}/<group>.php` (one group per feature +
shared `common`), keys identical in both languages. Register: terse/operational, **no emoji**
(status = color/dots/pills). Native technical tokens stay as-is (`nginx.service`, `SSH`, `2FA`). (R9, R16)
- **Colors:** only `@theme` token utilities in markup — never raw hex/rgb. (R3)
- **Inline styles:** forbidden except a progress bar's `width`. (R4)
- **Naming:** Livewire class `App\Livewire\Servers\Show` ↔ view `livewire.servers.show`

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Resolve the active UI language for each request: the signed-in user's saved
* preference wins, then the session choice (so a guest on the login page can switch),
* then the app default. Only known locales are honoured.
*/
class SetLocale
{
/** Languages Clusev ships. Keep in sync with the lang/<code> directories + R16. */
public const SUPPORTED = ['de', 'en'];
public function handle(Request $request, Closure $next): Response
{
$locale = $request->user()?->locale
?? $request->session()->get('locale')
?? config('app.locale');
if (in_array($locale, self::SUPPORTED, true)) {
app()->setLocale($locale);
}
return $next($request);
}
}

View File

@ -5,11 +5,9 @@ namespace App\Livewire\Audit;
use App\Models\AuditEvent;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Audit-Log — Clusev')]
class Index extends Component
{
/** Freitext-Filter über Akteur / Aktion / Ziel / Server. */
@ -47,6 +45,6 @@ class Index extends Component
{
return view('livewire.audit.index', [
'events' => $this->events(),
]);
])->title(__('audit.title'));
}
}

View File

@ -8,12 +8,10 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.auth')]
#[Title('Anmelden — Clusev')]
class Login extends Component
{
#[Validate('required|email')]
@ -32,7 +30,7 @@ class Login extends Component
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'email' => 'Zu viele Versuche. Bitte in '.RateLimiter::availableIn($key).' Sekunden erneut versuchen.',
'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
@ -40,7 +38,7 @@ class Login extends Component
if (! $user || ! Hash::check($this->password, $user->password)) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['email' => 'Diese Zugangsdaten passen nicht.']);
throw ValidationException::withMessages(['email' => __('auth.invalid_credentials')]);
}
RateLimiter::clear($key);
@ -61,6 +59,6 @@ class Login extends Component
public function render()
{
return view('livewire.auth.login');
return view('livewire.auth.login')->title(__('auth.title_login'));
}
}

View File

@ -6,11 +6,9 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.auth')]
#[Title('Passwort ändern — Clusev')]
class PasswordChange extends Component
{
public string $current = '';
@ -25,8 +23,8 @@ class PasswordChange extends Component
'current' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()],
], attributes: [
'current' => 'aktuelles Passwort',
'password' => 'neues Passwort',
'current' => __('auth.attr_current_password'),
'password' => __('auth.attr_new_password'),
]);
Auth::user()->forceFill([
@ -40,6 +38,6 @@ class PasswordChange extends Component
public function render()
{
return view('livewire.auth.password-change');
return view('livewire.auth.password-change')->title(__('auth.title_password_change'));
}
}

View File

@ -7,13 +7,11 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
#[Title('Bestätigung — Clusev')]
class TwoFactorChallenge extends Component
{
#[Validate('required|string')]
@ -34,7 +32,7 @@ class TwoFactorChallenge extends Component
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => 'Zu viele Versuche. Bitte in '.RateLimiter::availableIn($key).' Sekunden erneut versuchen.',
'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
]);
}
@ -42,14 +40,14 @@ class TwoFactorChallenge extends Component
if (! $user || ! $user->hasTwoFactorEnabled()) {
session()->forget(['2fa.user', '2fa.remember']);
throw ValidationException::withMessages(['code' => 'Sitzung abgelaufen. Bitte erneut anmelden.']);
throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
}
$valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code));
if (! $valid) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages(['code' => 'Ungültiger Code.']);
throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
}
RateLimiter::clear($key);
@ -65,6 +63,6 @@ class TwoFactorChallenge extends Component
public function render()
{
return view('livewire.auth.two-factor-challenge');
return view('livewire.auth.two-factor-challenge')->title(__('auth.title_challenge'));
}
}

View File

@ -5,13 +5,11 @@ namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;
#[Layout('layouts.auth')]
#[Title('2FA einrichten — Clusev')]
class TwoFactorSetup extends Component
{
public string $secret = '';
@ -33,7 +31,7 @@ class TwoFactorSetup extends Component
$this->validate();
if (! (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) {
throw ValidationException::withMessages(['code' => 'Code stimmt nicht — prüfe die Uhrzeit der Authenticator-App.']);
throw ValidationException::withMessages(['code' => __('auth.code_mismatch')]);
}
Auth::user()->forceFill([
@ -51,6 +49,6 @@ class TwoFactorSetup extends Component
public function render()
{
return view('livewire.auth.two-factor-setup');
return view('livewire.auth.two-factor-setup')->title(__('auth.title_setup'));
}
}

View File

@ -8,18 +8,22 @@ use App\Models\Server;
use App\Services\FleetService;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Throwable;
#[Layout('layouts.app')]
#[Title('Clusev — Dashboard')]
class Dashboard extends Component
{
use WithFleetContext;
public bool $ready = false;
/** Page <title>; localized at runtime (attributes can't call __()). */
public function title(): string
{
return __('dashboard.page_title');
}
/** @var array<int, array> notable systemd units (lazy-loaded over SSH) */
public array $svcRows = [];
@ -124,7 +128,7 @@ class Dashboard extends Component
'diskSub' => isset($latest['disk_total']) && $latest['disk_total'] > 0
? $latest['disk_used'].' / '.$latest['disk_total'].' GB'
: null,
'loadSub' => $cores.' '.($cores === 1 ? 'Kern' : 'Kerne'),
'loadSub' => $cores.' '.($cores === 1 ? __('dashboard.core') : __('dashboard.cores')),
'services' => $this->svcRows,
'events' => AuditEvent::with('server')->latest()->limit(6)->get(),
]);

View File

@ -6,13 +6,11 @@ use App\Livewire\Concerns\WithFleetContext;
use App\Services\FleetService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithFileUploads;
use Throwable;
#[Layout('layouts.app')]
#[Title('Dateien — Clusev')]
class Index extends Component
{
use WithFileUploads, WithFleetContext;
@ -87,9 +85,9 @@ class Index extends Component
try {
$name = basename($this->upload->getClientOriginalName());
$fleet->uploadFile($active, rtrim($this->path, '/').'/'.$name, $this->upload->getRealPath());
$this->dispatch('notify', message: "{$name}“ hochgeladen.");
$this->dispatch('notify', message: __('files.uploaded', ['name' => $name]));
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Upload fehlgeschlagen: '.$e->getMessage());
$this->dispatch('notify', message: __('files.upload_failed', ['error' => $e->getMessage()]));
}
}
@ -175,16 +173,16 @@ class Index extends Component
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => 'Datei löschen',
'body' => "{$name}“ wird unwiderruflich aus {$this->path} entfernt.",
'confirmLabel' => 'Löschen',
'heading' => __('files.delete_heading'),
'body' => __('files.delete_body', ['name' => $name, 'path' => $this->path]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'auditAction' => 'file.delete',
'auditTarget' => rtrim($this->path, '/')."/{$name}",
'event' => 'fileConfirmed',
'params' => ['name' => $name],
'notify' => "{$name}“ gelöscht.",
'notify' => __('files.delete_notify', ['name' => $name]),
],
);
}
@ -199,7 +197,7 @@ class Index extends Component
try {
$fleet->deleteFile($active, rtrim($this->path, '/').'/'.$name);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Löschen fehlgeschlagen: '.$e->getMessage());
$this->dispatch('notify', message: __('files.delete_failed', ['error' => $e->getMessage()]));
return;
}
@ -210,6 +208,6 @@ class Index extends Component
public function render()
{
return view('livewire.files.index');
return view('livewire.files.index')->title(__('files.title'));
}
}

View File

@ -6,16 +6,20 @@ use App\Models\Server;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Server — Clusev')]
class Index extends Component
{
/** Live search over name / IP. */
public string $search = '';
/** Page <title>; localized at runtime (attributes can't call __()). */
public function title(): string
{
return __('servers.index_title');
}
/** A freshly added server should appear without a manual reload (render() re-queries). */
#[On('serverCreated')]
public function refreshServers(): void

View File

@ -13,17 +13,21 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
use Throwable;
#[Layout('layouts.app')]
#[Title('Server-Details — Clusev')]
class Show extends Component
{
/** Route-model-bound by uuid (R11). */
public Server $server;
/** Page <title>; localized at runtime (attributes can't call __()). */
public function title(): string
{
return __('servers.show_title');
}
public bool $connected = false;
public bool $ready = false;
@ -163,9 +167,9 @@ class Show extends Component
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => 'SSH-Schlüssel entfernen',
'body' => "Der Schlüssel „{$comment}“ verliert den Zugang zu {$this->server->name}.",
'confirmLabel' => 'Entfernen',
'heading' => __('servers.confirm_remove_key_heading'),
'body' => __('servers.confirm_remove_key_body', ['name' => $comment, 'server' => $this->server->name]),
'confirmLabel' => __('common.remove'),
'danger' => true,
'icon' => 'trash',
'auditAction' => 'ssh_key.remove',
@ -173,7 +177,7 @@ class Show extends Component
'serverId' => $this->server->id,
'event' => 'keyRemoved',
'params' => ['fingerprint' => $fingerprint],
'notify' => "Schlüssel „{$comment}“ entfernt.",
'notify' => __('servers.notify_key_removed', ['name' => $comment]),
],
);
}
@ -185,7 +189,7 @@ class Show extends Component
try {
$fleet->removeAuthorizedKey($this->server, $fingerprint);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.$e->getMessage());
$this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => $e->getMessage()]));
return;
}
@ -246,7 +250,7 @@ class Show extends Component
]);
$this->server->refresh();
$this->dispatch('notify', message: $disable ? 'Zugang gesperrt.' : 'Zugang entsperrt.');
$this->dispatch('notify', message: $disable ? __('servers.notify_access_locked') : __('servers.notify_access_unlocked'));
}
/**
@ -263,9 +267,9 @@ class Show extends Component
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => 'Zugang löschen',
'body' => "Der hinterlegte SSH-Zugang für {$this->server->name} wird unwiderruflich entfernt. Live-Steuerung ist danach nicht mehr möglich.",
'confirmLabel' => 'Löschen',
'heading' => __('servers.confirm_delete_credential_heading'),
'body' => __('servers.confirm_delete_credential_body', ['server' => $this->server->name]),
'confirmLabel' => __('common.delete'),
'danger' => true,
'icon' => 'trash',
'auditAction' => 'credential.delete',
@ -273,7 +277,7 @@ class Show extends Component
'serverId' => $this->server->id,
'event' => 'credentialDeleted',
'params' => [],
'notify' => 'Zugang gelöscht.',
'notify' => __('servers.notify_access_deleted'),
],
);
}
@ -314,9 +318,9 @@ class Show extends Component
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => 'Firewall-Regel entfernen',
'body' => "Die Regel „{$label}“ wird aus der Firewall von {$this->server->name} entfernt.",
'confirmLabel' => 'Entfernen',
'heading' => __('servers.confirm_remove_rule_heading'),
'body' => __('servers.confirm_remove_rule_body', ['label' => $label, 'server' => $this->server->name]),
'confirmLabel' => __('common.remove'),
'danger' => true,
'icon' => 'trash',
// No premature audit/notify — the handler records the real outcome
@ -335,21 +339,21 @@ class Show extends Component
try {
$res = $firewall->deleteRule($this->server, $spec);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.Str::limit($e->getMessage(), 90));
$this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
// The rule was already gone — inform, refresh, but do NOT audit a non-event.
if (! empty($res['notFound'])) {
$this->dispatch('notify', message: 'Regel existiert nicht mehr.');
$this->dispatch('notify', message: __('servers.notify_rule_not_found'));
$this->reloadFirewall($firewall);
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: 'Entfernen fehlgeschlagen: '.Str::limit($res['output'] ?: 'unbekannt', 90));
$this->dispatch('notify', message: __('servers.notify_remove_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)]));
return;
}
@ -363,7 +367,7 @@ class Show extends Component
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: 'Firewall-Regel entfernt.');
$this->dispatch('notify', message: __('servers.notify_firewall_rule_removed'));
$this->reloadFirewall($firewall);
}
@ -384,18 +388,18 @@ class Show extends Component
try {
$res = $fail2ban->unban($this->server, $jail, $ip);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Entsperren fehlgeschlagen: '.Str::limit($e->getMessage(), 90));
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: 'Entsperren fehlgeschlagen: '.Str::limit($res['output'] ?: 'unbekannt', 90));
$this->dispatch('notify', message: __('servers.notify_unban_failed', ['error' => Str::limit($res['output'] ?: __('servers.error_unknown'), 90)]));
return;
}
$this->audit('fail2ban.unban', $ip.' · '.$jail.' · '.$this->server->name);
$this->dispatch('notify', message: "IP {$ip} entsperrt.");
$this->dispatch('notify', message: __('servers.notify_ip_unbanned', ['ip' => $ip]));
$this->reloadFail2ban($fail2ban);
}
@ -410,12 +414,12 @@ class Show extends Component
try {
$res = $fail2ban->addIgnoreIp($this->server, $ip);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($e->getMessage(), 90));
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($res['output'] ?: 'fehlgeschlagen', 90));
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($res['output'] ?: __('servers.error_failed'), 90)]));
return;
}
@ -428,7 +432,7 @@ class Show extends Component
$this->audit('fail2ban.ignoreip_add', $ip.' · '.$this->server->name);
$this->newIgnoreIp = '';
$this->dispatch('notify', message: "{$ip} zur Whitelist hinzugefügt.");
$this->dispatch('notify', message: __('servers.notify_ip_whitelisted', ['ip' => $ip]));
$this->reloadFail2ban($fail2ban);
}
@ -438,12 +442,12 @@ class Show extends Component
try {
$res = $fail2ban->removeIgnoreIp($this->server, $ip);
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($e->getMessage(), 90));
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($e->getMessage(), 90)]));
return;
}
if (! $res['ok']) {
$this->dispatch('notify', message: 'Whitelist: '.Str::limit($res['output'] ?: 'fehlgeschlagen', 90));
$this->dispatch('notify', message: __('servers.notify_whitelist_error', ['error' => Str::limit($res['output'] ?: __('servers.error_failed'), 90)]));
return;
}
@ -454,7 +458,7 @@ class Show extends Component
}
$this->audit('fail2ban.ignoreip_remove', $ip.' · '.$this->server->name);
$this->dispatch('notify', message: "{$ip} von der Whitelist entfernt.");
$this->dispatch('notify', message: __('servers.notify_ip_unwhitelisted', ['ip' => $ip]));
$this->reloadFail2ban($fail2ban);
}

View File

@ -7,12 +7,10 @@ use App\Services\FleetService;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
use Throwable;
#[Layout('layouts.app')]
#[Title('Dienste — Clusev')]
class Index extends Component
{
use WithFleetContext;
@ -70,16 +68,16 @@ class Index extends Component
}
[$heading, $phrase, $label, $action, $icon] = match ($op) {
'start' => ['Dienst starten', 'wird gestartet', 'Starten', 'service.start', 'power'],
'stop' => ['Dienst stoppen', 'wird gestoppt', 'Stoppen', 'service.stop', 'power'],
default => ['Dienst neu starten', 'wird neu gestartet', 'Neu starten', 'service.restart', 'rotate'],
'start' => [__('services.confirm_start_heading'), __('services.confirm_start_phrase'), __('services.confirm_start_label'), 'service.start', 'power'],
'stop' => [__('services.confirm_stop_heading'), __('services.confirm_stop_phrase'), __('services.confirm_stop_label'), 'service.stop', 'power'],
default => [__('services.confirm_restart_heading'), __('services.confirm_restart_phrase'), __('services.confirm_restart_label'), 'service.restart', 'rotate'],
};
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => $heading,
'body' => "{$name} {$phrase}. Ausgeführt über systemctl auf {$this->server}.",
'body' => __('services.confirm_body', ['name' => $name, 'phrase' => $phrase, 'server' => $this->server]),
'confirmLabel' => $label,
'danger' => $op === 'stop',
'icon' => $icon,
@ -87,7 +85,7 @@ class Index extends Component
'auditTarget' => "{$name} · {$this->server}",
'event' => 'serviceConfirmed',
'params' => ['op' => $op, 'name' => $name],
'notify' => "{$name}: {$label} ausgeführt.",
'notify' => __('services.confirm_notify', ['name' => $name, 'label' => $label]),
],
);
}
@ -105,14 +103,14 @@ class Index extends Component
try {
$res = $fleet->serviceAction($active, $op, $name);
} catch (Throwable $e) {
$this->dispatch('notify', message: "{$name}: ".$e->getMessage());
$this->dispatch('notify', message: __('services.action_error', ['name' => $name, 'error' => $e->getMessage()]));
return;
}
$this->dispatch('notify', message: $res['ok']
? "{$name}: {$op} ausgeführt."
: "{$name}: fehlgeschlagen — ".Str::limit($res['output'] ?: 'keine Rechte', 90));
? __('services.action_done', ['name' => $name, 'op' => $op])
: __('services.action_failed', ['name' => $name, 'output' => Str::limit($res['output'] ?: __('services.no_permission'), 90)]));
try {
$this->services = $fleet->systemd($active)['services'];
@ -139,6 +137,6 @@ class Index extends Component
public function render()
{
return view('livewire.services.index');
return view('livewire.services.index')->title(__('services.title'));
}
}

View File

@ -8,11 +8,9 @@ use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Einstellungen — Clusev')]
class Index extends Component
{
public string $tab = 'profile';
@ -42,7 +40,7 @@ class Index extends Component
Auth::user()->update($data);
$this->audit('profile.update', $data['email']);
$this->dispatch('notify', message: 'Profil gespeichert.');
$this->dispatch('notify', message: __('settings.profile_saved'));
}
public function updatePassword(): void
@ -55,7 +53,7 @@ class Index extends Component
Auth::user()->update(['password' => $this->password, 'must_change_password' => false]);
$this->reset('current_password', 'password', 'password_confirmation');
$this->audit('password.change', Auth::user()->email);
$this->dispatch('notify', message: 'Passwort geändert.');
$this->dispatch('notify', message: __('settings.password_changed'));
}
public function confirmDisableTwoFactor(): void
@ -63,15 +61,15 @@ class Index extends Component
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => '2FA deaktivieren',
'body' => 'Die Zwei-Faktor-Authentifizierung wird entfernt. Dein Konto ist dann nur noch per Passwort geschützt.',
'confirmLabel' => 'Deaktivieren',
'heading' => __('settings.disable_2fa_heading'),
'body' => __('settings.disable_2fa_body'),
'confirmLabel' => __('common.disable'),
'danger' => true,
'icon' => 'shield',
'auditAction' => 'two_factor.disable',
'auditTarget' => Auth::user()?->email,
'event' => 'twoFactorDisabled',
'notify' => '2FA deaktiviert.',
'notify' => __('settings.disable_2fa_notify'),
],
);
}
@ -100,6 +98,6 @@ class Index extends Component
{
return view('livewire.settings.index', [
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
]);
])->title(__('settings.title'));
}
}

View File

@ -6,7 +6,6 @@ use App\Models\Setting;
use App\Services\DeploymentService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
@ -21,14 +20,19 @@ use Livewire\Component;
* config('clusev.channel')), audited on change.
*/
#[Layout('layouts.app')]
#[Title('System — Clusev')]
class Index extends Component
{
/** Valid user-facing release channels with their German descriptions. */
public const CHANNELS = [
'stable' => 'Nur getestete, vom Maintainer freigegebene Releases (getaggte Versionen). Empfohlen für den Produktivbetrieb.',
'beta' => 'Vorabversionen kommender Releases zum Testen neuer Funktionen. Kann instabil sein. Entwicklungs-Builds werden Nutzern nie angeboten.',
];
/** Valid user-facing release channels (descriptions are localized at render time). */
public const CHANNELS = ['stable', 'beta'];
/** Channel key => localized description, built per-request for the view. */
private function channelDescriptions(): array
{
return [
'stable' => __('system.channel_stable'),
'beta' => __('system.channel_beta'),
];
}
/** Install-time panel domain (read-only display). */
public string $domain = '';
@ -40,29 +44,29 @@ class Index extends Component
$this->domain = (string) ($deployment->domain() ?? '');
$channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable';
// Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable.
$this->channel = array_key_exists($channel, self::CHANNELS) ? $channel : 'stable';
$this->channel = in_array($channel, self::CHANNELS, true) ? $channel : 'stable';
}
/** Release channel changes are audited (confirmation via the shared modal). */
public function confirmChannel(string $channel): void
{
if (! array_key_exists($channel, self::CHANNELS) || $channel === $this->channel) {
if (! in_array($channel, self::CHANNELS, true) || $channel === $this->channel) {
return;
}
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => 'Release-Kanal wechseln',
'body' => 'Der Release-Kanal wird auf "'.$channel.'" gesetzt. Updates werden dann aus dieser Quelle bezogen.',
'confirmLabel' => 'Wechseln',
'heading' => __('system.change_channel_heading'),
'body' => __('system.change_channel_body', ['channel' => $channel]),
'confirmLabel' => __('system.change_channel_confirm'),
'danger' => false,
'icon' => 'git-branch',
'auditAction' => 'system.settings_updated',
'auditTarget' => 'release_channel='.$channel,
'event' => 'channelChanged',
'params' => ['channel' => $channel],
'notify' => 'Release-Kanal auf "'.$channel.'" gesetzt.',
'notify' => __('system.change_channel_notify', ['channel' => $channel]),
],
);
}
@ -70,7 +74,7 @@ class Index extends Component
#[On('channelChanged')]
public function applyChannel(string $channel): void
{
if (! array_key_exists($channel, self::CHANNELS)) {
if (! in_array($channel, self::CHANNELS, true)) {
return;
}
@ -83,7 +87,7 @@ class Index extends Component
return view('livewire.system.index', [
'hasTls' => $this->domain !== '',
'panelUrl' => $deployment->panelUrl(),
'channels' => self::CHANNELS,
]);
'channels' => $this->channelDescriptions(),
])->title(__('system.title'));
}
}

View File

@ -4,7 +4,6 @@ namespace App\Livewire\Versions;
use App\Models\Setting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
@ -16,7 +15,6 @@ use Livewire\Component;
* against the newest tag in the configured channel.
*/
#[Layout('layouts.app')]
#[Title('Version — Clusev')]
class Index extends Component
{
/** Only these channels are ever offered to users — there is no 'dev' channel. */
@ -43,13 +41,13 @@ class Index extends Component
if ($latest === null) {
$this->updateState = 'current';
$this->updateStatus = 'Noch kein Release getaggt — installierte Version v'.$installed.' (Kanal: '.$channel.').';
$this->updateStatus = __('versions.status_none_tagged', ['installed' => $installed, 'channel' => $channel]);
} elseif ($this->isNewer($latest, $installed)) {
$this->updateState = 'update';
$this->updateStatus = 'Update verfügbar: v'.$latest.' (Kanal: '.$channel.').';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
} else {
$this->updateState = 'current';
$this->updateStatus = 'Aktuell — v'.$installed.' (Kanal: '.$channel.').';
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
}
}
@ -228,6 +226,6 @@ class Index extends Component
'build' => $this->build(),
'releases' => $releases,
'latestTag' => $latestTag,
]);
])->title(__('versions.title'));
}
}

View File

@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password', 'must_change_password'])]
#[Fillable(['name', 'email', 'password', 'must_change_password', 'locale'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret'])]
class User extends Authenticatable
{

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\SecurityHeaders;
use App\Http\Middleware\SetLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -14,6 +15,7 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
SetLocale::class,
SecurityHeaders::class,
]);
})

View File

@ -78,9 +78,9 @@ return [
|
*/
'locale' => env('APP_LOCALE', 'en'),
'locale' => env('APP_LOCALE', 'de'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'de'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/** Per-user UI language preference (de|en). Null = follow the app default. */
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('locale', 5)->nullable()->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('locale');
});
}
};

23
lang/de/audit.php Normal file
View File

@ -0,0 +1,23 @@
<?php
// Audit log page strings (R16). Shared buttons live in common.php.
return [
// Header
'eyebrow' => 'Sicherheit',
'heading' => 'Audit-Log',
'event_count' => ':count Ereignisse',
// Events panel
'panel_title' => 'Ereignisse',
'panel_subtitle' => 'letzte 50 · neueste zuerst',
'search_label' => 'Audit-Log durchsuchen',
'search_placeholder' => 'Akteur, Aktion, Ziel …',
// Empty state
'empty_title' => 'Keine Ereignisse',
'empty_filtered' => 'Für „:query“ wurden keine Audit-Einträge gefunden.',
'empty_none' => 'Es liegen noch keine Audit-Einträge vor.',
// Page title
'title' => 'Audit-Log — Clusev',
];

74
lang/de/auth.php Normal file
View File

@ -0,0 +1,74 @@
<?php
// Auth feature strings (login, password change, 2FA challenge + setup, auth brand panel).
// Shared actions/status live in common.php (R16). de = original German verbatim.
return [
// ── Login ────────────────────────────────────────────────────────────
'control_plane' => 'Control Plane',
'login_heading' => 'Anmelden',
'login_subtitle' => 'Zugang zum Clusev-Panel.',
'email' => 'E-Mail',
'password' => 'Passwort',
'remember_me' => 'Angemeldet bleiben',
'login_submit' => 'Anmelden',
'checking' => 'Prüfe…',
'secured' => 'Gesichert',
'security_note' => 'Verbindung über SSH · 2FA erforderlich · jede Aktion landet im Audit-Log.',
// ── Password change ──────────────────────────────────────────────────
'security' => 'Sicherheit',
'password_change_heading' => 'Passwort ändern',
'password_change_subtitle' => 'Lege ein eigenes Passwort fest (min. 12 Zeichen, Groß-/Kleinschreibung + Zahl).',
'current_password' => 'Aktuelles Passwort',
'new_password' => 'Neues Passwort',
'confirm_new_password' => 'Neues Passwort bestätigen',
'save_and_continue' => 'Speichern & weiter',
'saving' => 'Speichere…',
// ── Two-factor (shared label) ────────────────────────────────────────
'two_factor' => 'Zwei-Faktor',
// ── 2FA challenge ────────────────────────────────────────────────────
'challenge_heading' => 'Bestätigung',
'challenge_subtitle' => '6-stelliger Code aus deiner Authenticator-App.',
'code' => 'Code',
'back_to_login' => 'Zurück zur Anmeldung',
// ── 2FA setup ────────────────────────────────────────────────────────
'setup_heading' => 'Einrichten',
'setup_subtitle' => 'Scanne den Code mit einer Authenticator-App (Aegis, Google Authenticator, …) und bestätige mit einem Code.',
'qr_alt' => '2FA-QR-Code',
'secret_manual' => 'Geheimnis (manuell)',
'confirmation_code' => 'Bestätigungs-Code',
'activate' => 'Aktivieren',
// ── Brand panel (auth layout) ────────────────────────────────────────
'brand_headline_lead' => 'Eine Konsole für deine',
'brand_headline_accent' => 'gesamte Flotte',
'brand_tagline' => 'Agentenlos über SSH. Metriken, Dienste, Dateien und ein lückenloses Audit-Log — sicher aus einem Panel gesteuert.',
'terminal_title' => 'clusev · sicherheitsmodell',
'terminal_ssh' => 'agentenlos · exec + SFTP über phpseclib',
'terminal_key' => 'Host-Keys via TOFU gepinnt',
'terminal_2fa' => 'erzwungen für jeden Login',
'terminal_log' => 'jede Aktion im Audit-Log',
'terminal_core' => 'Open Core · AGPL-lizenziert',
'footer_copyright' => '© :year Clusev · Fleet Control',
'footer_operational' => 'betriebsbereit',
// ── Page titles ──────────────────────────────────────────────────────
'title_login' => 'Anmelden — Clusev',
'title_password_change' => 'Passwort ändern — Clusev',
'title_challenge' => 'Bestätigung — Clusev',
'title_setup' => '2FA einrichten — Clusev',
// ── Validation / error messages ──────────────────────────────────────
'too_many_attempts' => 'Zu viele Versuche. Bitte in :seconds Sekunden erneut versuchen.',
'invalid_credentials' => 'Diese Zugangsdaten passen nicht.',
'session_expired' => 'Sitzung abgelaufen. Bitte erneut anmelden.',
'invalid_code' => 'Ungültiger Code.',
'code_mismatch' => 'Code stimmt nicht — prüfe die Uhrzeit der Authenticator-App.',
// ── Validation attribute names ───────────────────────────────────────
'attr_current_password' => 'aktuelles Passwort',
'attr_new_password' => 'neues Passwort',
];

43
lang/de/common.php Normal file
View File

@ -0,0 +1,43 @@
<?php
// Shared UI strings reused across the app (R16). Feature-specific strings live in
// their own group file (lang/<code>/<group>.php). Keep this list small and generic.
return [
// Actions
'save' => 'Speichern',
'cancel' => 'Abbrechen',
'close' => 'Schließen',
'delete' => 'Löschen',
'remove' => 'Entfernen',
'add' => 'Hinzufügen',
'edit' => 'Bearbeiten',
'enable' => 'Aktivieren',
'disable' => 'Deaktivieren',
'lock' => 'Sperren',
'unlock' => 'Entsperren',
'back' => 'Zurück',
'retry' => 'Erneut versuchen',
'confirm' => 'Bestätigen',
'open' => 'Öffnen',
'more' => 'mehr',
'apply' => 'Anwenden',
// Status / state
'online' => 'Online',
'offline' => 'Offline',
'warning' => 'Warnung',
'active' => 'aktiv',
'inactive' => 'inaktiv',
'secure' => 'sicher',
'unsupported' => 'n. v.',
'unknown' => 'unbekannt',
'loading' => 'Lädt…',
'none' => '—',
// Generic
'search' => 'Suchen',
'actions' => 'Aktionen',
'server_not_found' => 'Server nicht gefunden.',
'failed' => 'Fehlgeschlagen.',
'done' => 'Erledigt.',
];

33
lang/de/dashboard.php Normal file
View File

@ -0,0 +1,33 @@
<?php
// Dashboard / live metrics view (R16). German = source of truth.
return [
// Page
'page_title' => 'Clusev — Dashboard',
'heading' => 'Übersicht',
// Service state labels (systemd ActiveState, operator wording)
'svc_online' => 'aktiv',
'svc_warning' => 'degradiert',
'svc_offline' => 'gestoppt',
// Utilisation chart
'utilization' => 'Auslastung',
'utilization_subtitle' => 'CPU & Memory · 15 Min',
'axis_minus_15min' => '-15 Min',
'axis_now' => 'jetzt',
// systemd services panel
'services_title' => 'systemd-Dienste',
'col_state' => 'Zustand',
'no_service_data' => 'Keine Dienstdaten — Server nicht verbunden.',
// Audit panel
'audit_title' => 'Audit-Log',
'audit_subtitle' => 'letzte Ereignisse',
'no_events' => 'Keine Ereignisse.',
// Cores (CPU)
'core' => 'Kern',
'cores' => 'Kerne',
];

36
lang/de/files.php Normal file
View File

@ -0,0 +1,36 @@
<?php
// File manager (SFTP) page strings (R16). Shared buttons live in common.php.
return [
// Header
'eyebrow' => 'Dateien',
'heading' => 'Dateien',
'sftp_connected' => 'SFTP verbunden',
'sftp_disconnected' => 'nicht verbunden',
// Listing panel
'panel_subtitle' => ':dirs Ordner · :files Dateien',
'upload' => 'Hochladen',
// Column headers
'col_name' => 'Name',
'col_perms' => 'Rechte',
'col_size' => 'Größe',
'col_modified' => 'Geändert',
// Row actions
'download' => 'Download',
// Upload / notifications
'uploaded' => '„:name“ hochgeladen.',
'upload_failed' => 'Upload fehlgeschlagen: :error',
// Delete (confirm modal)
'delete_heading' => 'Datei löschen',
'delete_body' => '„:name“ wird unwiderruflich aus :path entfernt.',
'delete_notify' => '„:name“ gelöscht.',
'delete_failed' => 'Löschen fehlgeschlagen: :error',
// Page title
'title' => 'Dateien — Clusev',
];

170
lang/de/modals.php Normal file
View File

@ -0,0 +1,170 @@
<?php
// Modal dialogs (wire-elements/modal). German = source of truth (R16).
// Keys grouped by modal via sub-key prefixes. Shared strings (Speichern, Abbrechen,
// Schließen, Hinzufügen, Anwenden, „Server nicht gefunden.", …) come from common.*.
return [
// ── Add SSH key ───────────────────────────────────────────────────────
'add_ssh_key' => [
'title' => 'SSH-Schlüssel hinzufügen',
'subtitle_before' => 'Öffentlichen Schlüssel einfügen — er wird in',
'subtitle_after' => 'ergänzt.',
'public_key_label' => 'Öffentlicher Schlüssel',
'generate' => 'Neues Paar generieren',
'generating' => 'generiere…',
'public_key_placeholder' => 'ssh-ed25519 AAAA… kommentar — oder oben generieren',
'private_key_warning' => 'Privater Schlüssel — erscheint nur jetzt, sicher speichern.',
'notify_added' => 'SSH-Schlüssel hinzugefügt.',
],
// ── Create server ─────────────────────────────────────────────────────
'create_server' => [
'title' => 'Server hinzufügen',
'subtitle' => 'Neuen Host in die Flotte aufnehmen und den SSH-Zugang im verschlüsselten Tresor hinterlegen.',
'name_label' => 'Name',
'name_placeholder' => 'z. B. Prod-Web-01',
'ip_host_label' => 'IP/Host',
'ip_host_placeholder' => '10.10.90.10 oder host.example.com',
'ssh_port_label' => 'SSH-Port',
'user_label' => 'Benutzer',
'auth_label' => 'Authentifizierung',
'auth_password' => 'Passwort',
'auth_key' => 'Privater Schlüssel',
'secret_key_label' => 'Privater Schlüssel (PEM)',
'secret_password_label' => 'Passwort',
'key_placeholder' => '-----BEGIN OPENSSH PRIVATE KEY-----',
'passphrase_label' => 'Passphrase (optional)',
'passphrase_placeholder' => 'leer = keine',
'credential_name_label' => 'Zugangs-Name (optional)',
'credential_name_placeholder' => 'z. B. Root-Login · Deploy-User',
'validation_ip_or_host' => 'Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.',
'attr_name' => 'Name',
'attr_ip' => 'IP/Host',
'attr_ssh_port' => 'SSH-Port',
'attr_user' => 'Benutzer',
'attr_auth' => 'Authentifizierung',
'attr_secret_key' => 'Privater Schlüssel',
'attr_secret_password' => 'Passwort',
'attr_credential_name' => 'Zugangs-Name',
'notify_added' => 'Server hinzugefügt.',
],
// ── Edit credential ───────────────────────────────────────────────────
'edit_credential' => [
'title' => 'SSH-Zugang',
'subtitle_before' => 'Hier den Zugang hinterlegen — z. B.',
'subtitle_after' => 'für volle Steuerung (systemctl, Journal).',
'name_label' => 'Name (optional)',
'name_placeholder' => 'z. B. Prod-Server X · Backup-User',
'user_label' => 'Benutzer',
'auth_label' => 'Authentifizierung',
'auth_password' => 'Passwort',
'auth_key' => 'Privater Schlüssel',
'secret_key_label' => 'Privater Schlüssel (PEM)',
'secret_password_label' => 'Passwort',
'key_placeholder' => '-----BEGIN OPENSSH PRIVATE KEY-----',
'passphrase_label' => 'Passphrase (optional)',
'passphrase_placeholder' => 'leer = keine',
'error_required' => 'Benutzer und Passwort/Schlüssel sind erforderlich.',
'error_name_too_long' => 'Name darf höchstens 120 Zeichen lang sein.',
'notify_saved' => 'Zugangsdaten gespeichert.',
],
// ── fail2ban: ban IP ──────────────────────────────────────────────────
'fail2ban_ban' => [
'title' => 'IP manuell sperren',
'subtitle' => 'Sperrt eine IP-Adresse sofort im gewählten Jail. Loopback und der Clusev-Zugang können nicht gesperrt werden.',
'jail_label' => 'Jail',
'ip_label' => 'IP-Adresse',
'ip_placeholder' => 'z. B. 203.0.113.10',
'submit' => 'IP sperren',
'error_ban_failed' => 'Sperren fehlgeschlagen.',
'notify_banned' => 'IP :ip gesperrt.',
],
// ── fail2ban: config ──────────────────────────────────────────────────
'fail2ban_config' => [
'title' => 'fail2ban konfigurieren',
'subtitle' => 'Legt fest, wann fail2ban auf :server eine IP sperrt und wie lange. Wiederholte fehlgeschlagene Anmeldungen innerhalb des Zeitfensters führen zur Sperre.',
'this_server' => 'diesem Server',
'bantime_label' => 'Sperrdauer',
'bantime_placeholder' => 'z. B. 600, 10m, 1h, -1 = dauerhaft',
'maxretry_label' => 'Max. Fehlversuche',
'findtime_label' => 'Zeitfenster',
'findtime_placeholder' => 'z. B. 600, 10m, 1h',
'error_read_locked' => 'Aktuelle Konfiguration konnte nicht gelesen werden — Speichern ist gesperrt.',
'error_bantime_invalid' => 'Ungültige Dauer (z. B. 600, 10m, 1h, -1 für dauerhaft).',
'error_findtime_invalid' => 'Ungültige Dauer (z. B. 600, 10m, 1h).',
'error_save_failed' => 'Speichern fehlgeschlagen. :output',
'attr_bantime' => 'Sperrdauer',
'attr_maxretry' => 'Max. Fehlversuche',
'attr_findtime' => 'Zeitfenster',
'notify_saved' => 'fail2ban konfiguriert.',
],
// ── File editor ───────────────────────────────────────────────────────
'file_editor' => [
'too_big' => 'Datei zu groß zum Bearbeiten (über 256 KB) — bitte herunterladen.',
'binary' => 'Binärdatei — kann nicht im Editor angezeigt werden.',
'notify_saved' => '„:name“ gespeichert.',
],
// ── Firewall rule ─────────────────────────────────────────────────────
'firewall_rule' => [
'title' => 'Firewall-Regel hinzufügen',
'subtitle_firewalld' => 'Öffnet einen Port in der Standard-Zone von firewalld.',
'subtitle_ufw' => 'Neue UFW-Regel. Quelle leer lassen = von überall.',
'action_label' => 'Aktion',
'action_allow' => 'Erlauben (allow)',
'action_deny' => 'Verwerfen (deny)',
'action_reject' => 'Ablehnen (reject)',
'action_limit' => 'Begrenzen (limit)',
'port_label' => 'Port',
'port_optional' => '(optional)',
'port_placeholder' => 'z. B. 8080',
'proto_label' => 'Protokoll',
'proto_both' => 'Beide',
'source_label' => 'Quelle (optional)',
'source_placeholder' => 'IP oder CIDR, z. B. 10.0.0.0/8',
'error_add_failed' => 'Regel konnte nicht hinzugefügt werden.',
'summary_port_opened' => 'Port :port/:proto geöffnet',
'summary_from' => 'von :source',
'notify_added' => 'Firewall-Regel hinzugefügt.',
],
// ── Hardening action ──────────────────────────────────────────────────
'hardening_action' => [
'title_fallback' => 'Härtung anwenden',
'result_ok' => 'Erfolgreich angewendet.',
'result_failed' => 'Fehlgeschlagen.',
'error_unknown' => 'Unbekannter Fehler.',
'audit_failed_suffix' => '(fehlgeschlagen)',
'notify_applied' => ':action angewendet.',
'notify_failed' => ':action fehlgeschlagen.',
],
// ── System update ─────────────────────────────────────────────────────
'system_update' => [
'title' => 'System-Updates',
'subtitle' => 'Aktualisiert die installierten Pakete auf :server über die Paketverwaltung. Es werden keine neuen Versionssprünge erzwungen — nur verfügbare Updates eingespielt.',
'this_server' => 'diesem Server',
'unsupported_fallback' => 'Auf diesem System nicht verfügbar.',
'count_unknown' => 'Update-Anzahl unbekannt',
'count_one' => '1 Paket-Update verfügbar',
'count_many' => ':count Paket-Updates verfügbar',
'hint_unknown' => 'Konnte nicht ermittelt werden — Aktualisierung trotzdem möglich.',
'hint_recommended' => 'Aktualisierung empfohlen.',
'hint_up_to_date' => 'Das System ist auf dem aktuellen Stand.',
'result_ok' => 'System aktualisiert.',
'result_failed' => 'Aktualisierung fehlgeschlagen.',
'submit' => 'Jetzt aktualisieren',
'notify_updated' => 'System aktualisiert.',
'notify_failed' => 'Aktualisierung fehlgeschlagen.',
],
// ── Generic confirm dialog defaults (ConfirmAction) ───────────────────
'confirm_action' => [
'default_heading' => 'Aktion bestätigen',
'default_notify' => 'Aktion ausgeführt.',
],
];

174
lang/de/servers.php Normal file
View File

@ -0,0 +1,174 @@
<?php
// Servers area: fleet index, server detail, switcher (R16). German = source of truth.
return [
// ── Page titles ───────────────────────────────────────────────────────
'index_title' => 'Server — Clusev',
'show_title' => 'Server-Details — Clusev',
// ── Status labels (status pills / KPIs) ───────────────────────────────
'status_online' => 'Online',
'status_warning' => 'Warnung',
'status_offline' => 'Offline',
// ── Index: header ─────────────────────────────────────────────────────
'fleet_eyebrow' => 'Flotte',
'heading' => 'Server',
'add_server' => 'Server hinzufügen',
'online_of_total' => ':online / :total online',
// ── Index: KPI grid ───────────────────────────────────────────────────
'kpi_total' => 'Gesamt',
'kpi_online' => 'Online',
'kpi_warning' => 'Warnung',
'kpi_offline' => 'Offline',
// ── Index: fleet panel ────────────────────────────────────────────────
'fleet_title' => 'Flotte',
'in_cluster' => ':count im Cluster',
'search_label' => 'Server suchen',
'search_placeholder' => 'Name oder IP…',
'empty_title' => 'Keine Server gefunden.',
'empty_hint' => 'Suchbegriff anpassen oder Filter zurücksetzen.',
// ── Show: back link ───────────────────────────────────────────────────
'back_to_fleet' => 'Zurück zur Flotte',
// ── Show: hero header ─────────────────────────────────────────────────
'uptime' => 'Uptime :value',
'last_seen' => 'zuletzt :value',
'system_updates' => 'System-Updates',
'files' => 'Dateien',
// ── Show: offline banner ──────────────────────────────────────────────
'offline_banner' => 'Keine Live-Verbindung — gezeigt werden die zuletzt gespeicherten Werte. Zugang/Erreichbarkeit prüfen.',
// ── Show: SSH access (credential) ─────────────────────────────────────
'ssh_access_title' => 'SSH-Zugang',
'ssh_access_subtitle' => 'Hinterlegte Anmeldung für die Live-Steuerung',
'cred_locked' => 'Gesperrt',
'cred_active' => 'Aktiv',
'cred_auth_key' => 'SSH-Key',
'cred_auth_password' => 'Passwort',
'cred_none_title' => 'Kein Zugang hinterlegt',
'cred_none_hint' => 'Ohne SSH-Zugang ist keine Live-Steuerung möglich.',
'cred_deposit' => 'Zugang hinterlegen',
// ── Show: specifications ──────────────────────────────────────────────
'specs_title' => 'Spezifikationen',
'specs_subtitle' => 'Hardware-Profil',
'spec_arch' => 'Architektur',
'spec_kernel' => 'Kernel',
'spec_virt' => 'Virtualisierung',
'spec_cores' => 'Kerne',
'spec_ram' => 'RAM',
'spec_disk' => 'Disk',
'spec_system' => 'System',
'spec_package_manager' => 'Paketverwaltung',
'spec_firewall' => 'Firewall',
'gb' => ':value GB',
// ── Show: vitals ──────────────────────────────────────────────────────
'vital_cpu' => 'CPU',
'vital_ram' => 'RAM',
'vital_disk' => 'Disk',
'load' => 'Last :value',
'cores_count' => ':count Kerne',
'ram_used_of_total' => ':used / :total GB',
'disk_used_of_total' => ':used / :total GB',
// ── Show: security / hardening ────────────────────────────────────────
'security_title' => 'Sicherheit',
'security_subtitle' => 'Hardening-Checkliste',
'check_secure' => 'sicher',
'check_open' => 'offen',
'fail2ban_configure' => 'fail2ban konfigurieren',
// ── Show: firewall rules ──────────────────────────────────────────────
'firewall_title' => 'Firewall-Regeln',
'firewall_unavailable' => 'Nicht verfügbar',
'firewall_sub_active' => ' · aktiv',
'firewall_sub_inactive' => ' · inaktiv',
'firewall_sub_not_installed' => ' · nicht installiert',
'firewall_add_rule' => 'Regel',
'firewall_not_supported' => 'Firewall-Verwaltung ist auf diesem System nicht verfügbar.',
'firewall_read_error' => 'Firewall-Status konnte nicht gelesen werden (Verbindung/Rechte). Bitte später erneut laden.',
'firewall_tool_not_installed' => ':tool ist nicht installiert.',
'firewalld_inactive' => 'firewalld ist installiert, aber inaktiv.',
'firewall_default' => 'Standard',
'firewall_incoming' => 'eingehend: :value',
'firewall_outgoing' => 'ausgehend: :value',
'firewall_zone' => 'Zone',
'firewall_readonly' => 'firewalld: nur Anzeige — Regeln bitte direkt am Server verwalten.',
'firewall_rule_from' => 'von :value',
'firewall_remove_rule' => 'Regel entfernen',
'firewall_no_rules' => 'Keine Regeln definiert.',
// ── Show: fail2ban status ─────────────────────────────────────────────
'fail2ban_title' => 'fail2ban-Status',
'fail2ban_unavailable' => 'Nicht verfügbar',
'fail2ban_sub_active' => 'aktiv · :count gesperrt',
'fail2ban_sub_installed_inactive' => 'installiert · inaktiv',
'fail2ban_sub_not_installed' => 'nicht installiert',
'fail2ban_ban_ip' => 'IP sperren',
'fail2ban_not_supported' => 'fail2ban ist auf diesem System nicht verfügbar.',
'fail2ban_read_error' => 'fail2ban-Status konnte nicht gelesen werden. Bitte später erneut laden.',
'fail2ban_state_installed_inactive' => 'fail2ban ist installiert, aber inaktiv.',
'fail2ban_state_not_installed' => 'fail2ban ist nicht installiert.',
'fail2ban_banned' => 'gebannt: :count',
'fail2ban_failed' => 'Fehlversuche: :count',
'fail2ban_no_banned' => 'Keine gebannten IPs.',
'whitelist_label' => 'Whitelist (ignoreip)',
'whitelist_placeholder' => 'IP oder CIDR',
// ── Show: volumes ─────────────────────────────────────────────────────
'volumes_title' => 'Volumes',
'mountpoints' => ':count Mountpoints',
'no_volumes' => 'Keine Volumes gelesen.',
// ── Show: network interfaces ──────────────────────────────────────────
'interfaces_title' => 'Netzwerk-Interfaces',
'interfaces_count' => ':count Schnittstellen',
'col_name' => 'Name',
'col_ip' => 'IP',
'col_mac' => 'MAC',
'col_rx' => 'RX',
'col_tx' => 'TX',
// ── Show: SSH keys ────────────────────────────────────────────────────
'ssh_keys_title' => 'SSH-Schlüssel',
'ssh_keys_authorized' => ':count autorisiert',
'ssh_keys_add' => 'Schlüssel hinzufügen',
'ssh_keys_remove' => 'Schlüssel entfernen',
'ssh_keys_none' => 'Keine autorisierten Schlüssel.',
// ── Show: loading skeletons ───────────────────────────────────────────
'loading' => 'lädt…',
// ── Switcher ──────────────────────────────────────────────────────────
'switcher_no_server' => 'Kein Server',
// ── Notifications (Show component) ────────────────────────────────────
'notify_access_locked' => 'Zugang gesperrt.',
'notify_access_unlocked' => 'Zugang entsperrt.',
'notify_access_deleted' => 'Zugang gelöscht.',
'notify_remove_failed' => 'Entfernen fehlgeschlagen: :error',
'notify_key_removed' => 'Schlüssel „:name“ entfernt.',
'notify_rule_not_found' => 'Regel existiert nicht mehr.',
'notify_firewall_rule_removed' => 'Firewall-Regel entfernt.',
'notify_unban_failed' => 'Entsperren fehlgeschlagen: :error',
'notify_ip_unbanned' => 'IP :ip entsperrt.',
'notify_whitelist_error' => 'Whitelist: :error',
'notify_ip_whitelisted' => ':ip zur Whitelist hinzugefügt.',
'notify_ip_unwhitelisted' => ':ip von der Whitelist entfernt.',
'error_unknown' => 'unbekannt',
'error_failed' => 'fehlgeschlagen',
// ── Confirm modals (Show component) ───────────────────────────────────
'confirm_remove_key_heading' => 'SSH-Schlüssel entfernen',
'confirm_remove_key_body' => 'Der Schlüssel „:name“ verliert den Zugang zu :server.',
'confirm_delete_credential_heading' => 'Zugang löschen',
'confirm_delete_credential_body' => 'Der hinterlegte SSH-Zugang für :server wird unwiderruflich entfernt. Live-Steuerung ist danach nicht mehr möglich.',
'confirm_remove_rule_heading' => 'Firewall-Regel entfernen',
'confirm_remove_rule_body' => 'Die Regel „:label“ wird aus der Firewall von :server entfernt.',
];

57
lang/de/services.php Normal file
View File

@ -0,0 +1,57 @@
<?php
// Services (systemd) page strings (R16). Shared buttons live in common.php.
return [
// Header / overview
'eyebrow' => 'Dienste',
'heading' => 'Dienste',
'fleet_active' => ':active / :total aktiv',
// systemd panel
'panel_title' => 'systemd-Dienste',
'panel_subtitle' => ':total Units · :server',
'search_label' => 'Dienste durchsuchen',
'search_placeholder' => 'Dienst suchen…',
// Service status labels (status triad)
'status_online' => 'aktiv',
'status_warning' => 'degradiert',
'status_offline' => 'gestoppt',
// Service actions
'start' => 'Starten',
'stop' => 'Stopp',
'restart' => 'Neustart',
// Empty state
'empty_title' => 'Kein Dienst gefunden',
'empty_hint' => 'Suche „:search“ ergab keine Treffer.',
// Journal panel
'journal_title' => 'Journal',
'journal_subtitle' => 'journalctl -f · :server',
'journal_live' => 'live',
'journal_follow' => 'Folge Journal · :count Zeilen',
// Confirm modal — operations
'confirm_start_heading' => 'Dienst starten',
'confirm_start_phrase' => 'wird gestartet',
'confirm_start_label' => 'Starten',
'confirm_stop_heading' => 'Dienst stoppen',
'confirm_stop_phrase' => 'wird gestoppt',
'confirm_stop_label' => 'Stoppen',
'confirm_restart_heading' => 'Dienst neu starten',
'confirm_restart_phrase' => 'wird neu gestartet',
'confirm_restart_label' => 'Neu starten',
'confirm_body' => ':name :phrase. Ausgeführt über systemctl auf :server.',
'confirm_notify' => ':name: :label ausgeführt.',
// Action results / notifications
'action_error' => ':name: :error',
'action_done' => ':name: :op ausgeführt.',
'action_failed' => ':name: fehlgeschlagen — :output',
'no_permission' => 'keine Rechte',
// Page title
'title' => 'Dienste — Clusev',
];

49
lang/de/settings.php Normal file
View File

@ -0,0 +1,49 @@
<?php
// Settings (profile + security) page strings (R16). Shared buttons live in common.php.
return [
// Identity header
'account' => 'Konto',
'role_admin' => 'Administrator',
'two_factor_on' => '2FA aktiv',
'two_factor_off' => '2FA aus',
// Section nav tabs
'tab_profile' => 'Profil',
'tab_security' => 'Sicherheit',
// Profile panel
'profile_title' => 'Profil',
'profile_subtitle' => 'Name und E-Mail',
'name' => 'Name',
'email' => 'E-Mail',
// Password panel
'password_title' => 'Passwort',
'password_subtitle' => 'Mindestens 10 Zeichen',
'current_password' => 'Aktuelles Passwort',
'new_password' => 'Neues Passwort',
'repeat_password' => 'Wiederholen',
'change_password' => 'Passwort ändern',
// Two-factor panel
'twofa_title' => 'Zwei-Faktor-Authentifizierung',
'twofa_subtitle' => 'TOTP (Authenticator-App)',
'twofa_status_on' => '2FA ist aktiv',
'twofa_status_off' => '2FA ist nicht eingerichtet',
'twofa_hint_on' => 'Der Login erfordert einen TOTP-Code.',
'twofa_hint_off' => 'Empfohlen — schützt den Login mit einem zweiten Faktor.',
'twofa_setup' => 'Einrichten',
// Notifications
'profile_saved' => 'Profil gespeichert.',
'password_changed' => 'Passwort geändert.',
// Disable-2FA confirmation modal
'disable_2fa_heading' => '2FA deaktivieren',
'disable_2fa_body' => 'Die Zwei-Faktor-Authentifizierung wird entfernt. Dein Konto ist dann nur noch per Passwort geschützt.',
'disable_2fa_notify' => '2FA deaktiviert.',
// Page title
'title' => 'Einstellungen — Clusev',
];

49
lang/de/shell.php Normal file
View File

@ -0,0 +1,49 @@
<?php
// App shell: sidebar nav, topbar, command palette, layout chrome (R16).
return [
// Sidebar — accessibility / brand
'nav_aria' => 'Seitennavigation',
'menu_close' => 'Menü schließen',
'menu_open' => 'Menü öffnen',
'logout' => 'Abmelden',
// Sidebar — nav group labels
'group_fleet' => 'Flotte',
'group_account' => 'Konto',
// Sidebar — nav items
'nav_dashboard' => 'Dashboard',
'nav_servers' => 'Server',
'nav_services' => 'Dienste',
'nav_files' => 'Dateien',
'nav_audit' => 'Audit-Log',
'nav_settings' => 'Einstellungen',
'nav_system' => 'System',
'nav_versions' => 'Version',
// Sidebar — user / 2FA badge
'twofa_on' => '2FA aktiv',
'twofa_off' => '2FA aus',
// Topbar
'title_default' => 'Übersicht',
'commands_open_aria' => 'Befehle öffnen (Strg K)',
'commands_title' => 'Befehle (Strg K)',
'shortcut_ctrl_k' => 'Strg K',
'fleet_online' => ':online / :total online',
// Command palette
'cmdk_open' => 'Befehle öffnen',
'cmdk_focus_search' => 'Suche auf der Seite fokussieren',
'cmdk_show_help' => 'Diese Hilfe anzeigen',
'cmdk_placeholder' => 'Befehl oder Seite…',
'cmdk_no_match' => 'Kein Treffer.',
'cmdk_open_hint' => 'öffnen',
'cmdk_shortcuts_hint' => '? Tastenkürzel',
'cmdk_esc' => 'Esc',
'shortcuts_heading' => 'Tastenkürzel',
// Toaster fallback
'toast_done' => 'Erledigt',
];

47
lang/de/system.php Normal file
View File

@ -0,0 +1,47 @@
<?php
// System settings (Domain/TLS + release channel) page strings (R16). Shared buttons live in common.php.
return [
// Header
'eyebrow' => 'System',
'heading' => 'Domain, TLS & Release-Kanal',
'subheading' => 'Zugriffsadresse des Panels und Update-Quelle',
'https_active' => 'HTTPS aktiv',
'https_plain' => 'Klartext-HTTP',
// Domain & TLS panel
'tls_title' => 'Domain & TLS',
'tls_subtitle' => 'Erreichbarkeit des Panels und Let\'s-Encrypt-Zertifikat',
'tls_active_title' => 'TLS aktiv — Zertifikat automatisch über Let\'s Encrypt',
'tls_active_body_pre' => 'Erreichbar unter',
'tls_active_body_post' => 'wird automatisch ausgestellt und erneuert. Voraussetzung: die Domain zeigt per DNS auf diesen Server.',
'tls_bare_title' => 'Bare-IP-Modus: Klartext-HTTP',
'tls_bare_body' => 'Ohne Domain läuft das Panel inkl. 2FA und Audit über unverschlüsseltes HTTP. Domain eintragen — TLS wird dann automatisch eingerichtet, ohne weitere Schritte.',
// Read-only domain facts
'domain' => 'Domain',
'domain_via_ip' => '— (Zugriff per IP)',
'access_address' => 'Zugriffsadresse',
'access_bare_ip' => 'HTTP (Bare-IP)',
'domain_note_pre' => 'Die Panel-Domain wird bei der Installation gesetzt und bleibt mit URL, WebSocket und Cookie-Sicherheit konsistent. Zum Ändern',
'domain_note_post' => 'mit der neuen Domain erneut ausführen — TLS richtet sich danach automatisch ein.',
// Release channel panel
'channel_title' => 'Release-Kanal',
'channel_subtitle' => 'Quelle für Updates',
'channel_radiogroup_label' => 'Release-Kanal',
'channel_current' => 'Kanal:',
// Channel descriptions (CHANNELS array)
'channel_stable' => 'Nur getestete, vom Maintainer freigegebene Releases (getaggte Versionen). Empfohlen für den Produktivbetrieb.',
'channel_beta' => 'Vorabversionen kommender Releases zum Testen neuer Funktionen. Kann instabil sein. Entwicklungs-Builds werden Nutzern nie angeboten.',
// Change-channel confirmation modal
'change_channel_heading' => 'Release-Kanal wechseln',
'change_channel_body' => 'Der Release-Kanal wird auf „:channel“ gesetzt. Updates werden dann aus dieser Quelle bezogen.',
'change_channel_confirm' => 'Wechseln',
'change_channel_notify' => 'Release-Kanal auf „:channel“ gesetzt.',
// Page title
'title' => 'System — Clusev',
];

47
lang/de/versions.php Normal file
View File

@ -0,0 +1,47 @@
<?php
// Version & releases page strings (R16). Shared buttons live in common.php.
return [
// Header
'eyebrow' => 'Version',
'heading' => 'Version & Releases',
'check_updates' => 'Nach Updates suchen',
// Status banner
'channel_badge' => 'Kanal: :channel',
'latest_release_badge' => 'neuestes Release v:tag',
'banner_default' => 'Installierte Version. „Nach Updates suchen“ vergleicht sie ehrlich mit dem neuesten getaggten Release im Kanal. Aktualisierung läuft über den Deploy (Image ziehen + migrieren).',
'checked_at' => 'geprüft :time',
// Update-check results (notifications / status line)
'status_none_tagged' => 'Noch kein Release getaggt — installierte Version v:installed (Kanal: :channel).',
'status_update_available' => 'Update verfügbar: v:latest (Kanal: :channel).',
'status_current' => 'Aktuell — v:installed (Kanal: :channel).',
// Changelog panel
'changelog_title' => 'Änderungsprotokoll',
'changelog_subtitle' => ':count Versionen',
'unreleased' => 'Unreleased',
'in_progress' => 'in Arbeit',
'changelog_empty' => 'Kein Änderungsprotokoll gefunden.',
// Installed panel
'installed_title' => 'Installiert',
'running' => 'läuft',
'build' => 'Build',
'branch' => 'Branch',
'channel' => 'Kanal',
'latest_release' => 'Neuestes Release',
'license' => 'Lizenz',
// Update panel
'update_title' => 'Aktualisierung',
'update_hint' => 'Updates kommen über getaggte Releases im Deploy — neues Image ziehen und Migrationen anwenden:',
// Project panel
'project_title' => 'Projekt',
'open_core' => 'Open Core · :license',
// Page title
'title' => 'Version — Clusev',
];

23
lang/en/audit.php Normal file
View File

@ -0,0 +1,23 @@
<?php
// Audit log page strings (R16). Shared buttons live in common.php.
return [
// Header
'eyebrow' => 'Security',
'heading' => 'Audit log',
'event_count' => ':count events',
// Events panel
'panel_title' => 'Events',
'panel_subtitle' => 'last 50 · newest first',
'search_label' => 'Search audit log',
'search_placeholder' => 'Actor, action, target …',
// Empty state
'empty_title' => 'No events',
'empty_filtered' => 'No audit entries found for “:query”.',
'empty_none' => 'There are no audit entries yet.',
// Page title
'title' => 'Audit log — Clusev',
];

74
lang/en/auth.php Normal file
View File

@ -0,0 +1,74 @@
<?php
// Auth feature strings (login, password change, 2FA challenge + setup, auth brand panel).
// Shared actions/status live in common.php (R16). en = natural English, same terse register.
return [
// ── Login ────────────────────────────────────────────────────────────
'control_plane' => 'Control Plane',
'login_heading' => 'Sign in',
'login_subtitle' => 'Access to the Clusev panel.',
'email' => 'Email',
'password' => 'Password',
'remember_me' => 'Stay signed in',
'login_submit' => 'Sign in',
'checking' => 'Checking…',
'secured' => 'Secured',
'security_note' => 'Connection over SSH · 2FA required · every action lands in the audit log.',
// ── Password change ──────────────────────────────────────────────────
'security' => 'Security',
'password_change_heading' => 'Change password',
'password_change_subtitle' => 'Set your own password (min. 12 characters, upper/lowercase + a digit).',
'current_password' => 'Current password',
'new_password' => 'New password',
'confirm_new_password' => 'Confirm new password',
'save_and_continue' => 'Save & continue',
'saving' => 'Saving…',
// ── Two-factor (shared label) ────────────────────────────────────────
'two_factor' => 'Two-factor',
// ── 2FA challenge ────────────────────────────────────────────────────
'challenge_heading' => 'Verification',
'challenge_subtitle' => '6-digit code from your authenticator app.',
'code' => 'Code',
'back_to_login' => 'Back to sign in',
// ── 2FA setup ────────────────────────────────────────────────────────
'setup_heading' => 'Set up',
'setup_subtitle' => 'Scan the code with an authenticator app (Aegis, Google Authenticator, …) and confirm with a code.',
'qr_alt' => '2FA QR code',
'secret_manual' => 'Secret (manual)',
'confirmation_code' => 'Confirmation code',
'activate' => 'Activate',
// ── Brand panel (auth layout) ────────────────────────────────────────
'brand_headline_lead' => 'One console for your',
'brand_headline_accent' => 'entire fleet',
'brand_tagline' => 'Agentless over SSH. Metrics, services, files and a gapless audit log — securely controlled from one panel.',
'terminal_title' => 'clusev · security model',
'terminal_ssh' => 'agentless · exec + SFTP over phpseclib',
'terminal_key' => 'host keys pinned via TOFU',
'terminal_2fa' => 'enforced on every login',
'terminal_log' => 'every action in the audit log',
'terminal_core' => 'Open Core · AGPL-licensed',
'footer_copyright' => '© :year Clusev · Fleet Control',
'footer_operational' => 'operational',
// ── Page titles ──────────────────────────────────────────────────────
'title_login' => 'Sign in — Clusev',
'title_password_change' => 'Change password — Clusev',
'title_challenge' => 'Verification — Clusev',
'title_setup' => 'Set up 2FA — Clusev',
// ── Validation / error messages ──────────────────────────────────────
'too_many_attempts' => 'Too many attempts. Please try again in :seconds seconds.',
'invalid_credentials' => 'These credentials do not match.',
'session_expired' => 'Session expired. Please sign in again.',
'invalid_code' => 'Invalid code.',
'code_mismatch' => 'Code does not match — check your authenticator apps clock.',
// ── Validation attribute names ───────────────────────────────────────
'attr_current_password' => 'current password',
'attr_new_password' => 'new password',
];

43
lang/en/common.php Normal file
View File

@ -0,0 +1,43 @@
<?php
// Shared UI strings reused across the app (R16). Feature-specific strings live in
// their own group file (lang/<code>/<group>.php). Keep this list small and generic.
return [
// Actions
'save' => 'Save',
'cancel' => 'Cancel',
'close' => 'Close',
'delete' => 'Delete',
'remove' => 'Remove',
'add' => 'Add',
'edit' => 'Edit',
'enable' => 'Enable',
'disable' => 'Disable',
'lock' => 'Lock',
'unlock' => 'Unblock',
'back' => 'Back',
'retry' => 'Try again',
'confirm' => 'Confirm',
'open' => 'Open',
'more' => 'more',
'apply' => 'Apply',
// Status / state
'online' => 'Online',
'offline' => 'Offline',
'warning' => 'Warning',
'active' => 'active',
'inactive' => 'inactive',
'secure' => 'secure',
'unsupported' => 'n/a',
'unknown' => 'unknown',
'loading' => 'Loading…',
'none' => '—',
// Generic
'search' => 'Search',
'actions' => 'Actions',
'server_not_found' => 'Server not found.',
'failed' => 'Failed.',
'done' => 'Done.',
];

33
lang/en/dashboard.php Normal file
View File

@ -0,0 +1,33 @@
<?php
// Dashboard / live metrics view (R16). English translation.
return [
// Page
'page_title' => 'Clusev — Dashboard',
'heading' => 'Overview',
// Service state labels (systemd ActiveState, operator wording)
'svc_online' => 'active',
'svc_warning' => 'degraded',
'svc_offline' => 'stopped',
// Utilisation chart
'utilization' => 'Utilization',
'utilization_subtitle' => 'CPU & Memory · 15 min',
'axis_minus_15min' => '-15 min',
'axis_now' => 'now',
// systemd services panel
'services_title' => 'systemd services',
'col_state' => 'State',
'no_service_data' => 'No service data — server not connected.',
// Audit panel
'audit_title' => 'Audit log',
'audit_subtitle' => 'recent events',
'no_events' => 'No events.',
// Cores (CPU)
'core' => 'core',
'cores' => 'cores',
];

36
lang/en/files.php Normal file
View File

@ -0,0 +1,36 @@
<?php
// File manager (SFTP) page strings (R16). Shared buttons live in common.php.
return [
// Header
'eyebrow' => 'Files',
'heading' => 'Files',
'sftp_connected' => 'SFTP connected',
'sftp_disconnected' => 'not connected',
// Listing panel
'panel_subtitle' => ':dirs folders · :files files',
'upload' => 'Upload',
// Column headers
'col_name' => 'Name',
'col_perms' => 'Permissions',
'col_size' => 'Size',
'col_modified' => 'Modified',
// Row actions
'download' => 'Download',
// Upload / notifications
'uploaded' => '“:name” uploaded.',
'upload_failed' => 'Upload failed: :error',
// Delete (confirm modal)
'delete_heading' => 'Delete file',
'delete_body' => '“:name” will be permanently removed from :path.',
'delete_notify' => '“:name” deleted.',
'delete_failed' => 'Delete failed: :error',
// Page title
'title' => 'Files — Clusev',
];

169
lang/en/modals.php Normal file
View File

@ -0,0 +1,169 @@
<?php
// Modal dialogs (wire-elements/modal). English translation of lang/de/modals.php.
// Same keys in both files (R16). Shared strings come from common.*.
return [
// ── Add SSH key ───────────────────────────────────────────────────────
'add_ssh_key' => [
'title' => 'Add SSH key',
'subtitle_before' => 'Paste a public key — it is appended to',
'subtitle_after' => '.',
'public_key_label' => 'Public key',
'generate' => 'Generate new pair',
'generating' => 'generating…',
'public_key_placeholder' => 'ssh-ed25519 AAAA… comment — or generate above',
'private_key_warning' => 'Private key — shown only now, store it securely.',
'notify_added' => 'SSH key added.',
],
// ── Create server ─────────────────────────────────────────────────────
'create_server' => [
'title' => 'Add server',
'subtitle' => 'Add a new host to the fleet and store its SSH access in the encrypted vault.',
'name_label' => 'Name',
'name_placeholder' => 'e.g. Prod-Web-01',
'ip_host_label' => 'IP/Host',
'ip_host_placeholder' => '10.10.90.10 or host.example.com',
'ssh_port_label' => 'SSH port',
'user_label' => 'User',
'auth_label' => 'Authentication',
'auth_password' => 'Password',
'auth_key' => 'Private key',
'secret_key_label' => 'Private key (PEM)',
'secret_password_label' => 'Password',
'key_placeholder' => '-----BEGIN OPENSSH PRIVATE KEY-----',
'passphrase_label' => 'Passphrase (optional)',
'passphrase_placeholder' => 'empty = none',
'credential_name_label' => 'Access name (optional)',
'credential_name_placeholder' => 'e.g. Root login · Deploy user',
'validation_ip_or_host' => 'Please enter a valid IP address or hostname.',
'attr_name' => 'Name',
'attr_ip' => 'IP/Host',
'attr_ssh_port' => 'SSH port',
'attr_user' => 'User',
'attr_auth' => 'Authentication',
'attr_secret_key' => 'Private key',
'attr_secret_password' => 'Password',
'attr_credential_name' => 'Access name',
'notify_added' => 'Server added.',
],
// ── Edit credential ───────────────────────────────────────────────────
'edit_credential' => [
'title' => 'SSH access',
'subtitle_before' => 'Store the access here — e.g.',
'subtitle_after' => 'for full control (systemctl, journal).',
'name_label' => 'Name (optional)',
'name_placeholder' => 'e.g. Prod-Server X · Backup user',
'user_label' => 'User',
'auth_label' => 'Authentication',
'auth_password' => 'Password',
'auth_key' => 'Private key',
'secret_key_label' => 'Private key (PEM)',
'secret_password_label' => 'Password',
'key_placeholder' => '-----BEGIN OPENSSH PRIVATE KEY-----',
'passphrase_label' => 'Passphrase (optional)',
'passphrase_placeholder' => 'empty = none',
'error_required' => 'User and password/key are required.',
'error_name_too_long' => 'Name may be at most 120 characters long.',
'notify_saved' => 'Credentials saved.',
],
// ── fail2ban: ban IP ──────────────────────────────────────────────────
'fail2ban_ban' => [
'title' => 'Ban IP manually',
'subtitle' => 'Bans an IP address immediately in the selected jail. Loopback and the Clusev access cannot be banned.',
'jail_label' => 'Jail',
'ip_label' => 'IP address',
'ip_placeholder' => 'e.g. 203.0.113.10',
'submit' => 'Ban IP',
'error_ban_failed' => 'Ban failed.',
'notify_banned' => 'IP :ip banned.',
],
// ── fail2ban: config ──────────────────────────────────────────────────
'fail2ban_config' => [
'title' => 'Configure fail2ban',
'subtitle' => 'Defines when fail2ban bans an IP on :server and for how long. Repeated failed logins within the time window lead to a ban.',
'this_server' => 'this server',
'bantime_label' => 'Ban duration',
'bantime_placeholder' => 'e.g. 600, 10m, 1h, -1 = permanent',
'maxretry_label' => 'Max. retries',
'findtime_label' => 'Time window',
'findtime_placeholder' => 'e.g. 600, 10m, 1h',
'error_read_locked' => 'Current configuration could not be read — saving is blocked.',
'error_bantime_invalid' => 'Invalid duration (e.g. 600, 10m, 1h, -1 for permanent).',
'error_findtime_invalid' => 'Invalid duration (e.g. 600, 10m, 1h).',
'error_save_failed' => 'Save failed. :output',
'attr_bantime' => 'Ban duration',
'attr_maxretry' => 'Max. retries',
'attr_findtime' => 'Time window',
'notify_saved' => 'fail2ban configured.',
],
// ── File editor ───────────────────────────────────────────────────────
'file_editor' => [
'too_big' => 'File too large to edit (over 256 KB) — please download it.',
'binary' => 'Binary file — cannot be shown in the editor.',
'notify_saved' => '“:name” saved.',
],
// ── Firewall rule ─────────────────────────────────────────────────────
'firewall_rule' => [
'title' => 'Add firewall rule',
'subtitle_firewalld' => 'Opens a port in firewallds default zone.',
'subtitle_ufw' => 'New UFW rule. Leave source empty = from anywhere.',
'action_label' => 'Action',
'action_allow' => 'Allow (allow)',
'action_deny' => 'Drop (deny)',
'action_reject' => 'Reject (reject)',
'action_limit' => 'Limit (limit)',
'port_label' => 'Port',
'port_optional' => '(optional)',
'port_placeholder' => 'e.g. 8080',
'proto_label' => 'Protocol',
'proto_both' => 'Both',
'source_label' => 'Source (optional)',
'source_placeholder' => 'IP or CIDR, e.g. 10.0.0.0/8',
'error_add_failed' => 'Rule could not be added.',
'summary_port_opened' => 'Port :port/:proto opened',
'summary_from' => 'from :source',
'notify_added' => 'Firewall rule added.',
],
// ── Hardening action ──────────────────────────────────────────────────
'hardening_action' => [
'title_fallback' => 'Apply hardening',
'result_ok' => 'Applied successfully.',
'result_failed' => 'Failed.',
'error_unknown' => 'Unknown error.',
'audit_failed_suffix' => '(failed)',
'notify_applied' => ':action applied.',
'notify_failed' => ':action failed.',
],
// ── System update ─────────────────────────────────────────────────────
'system_update' => [
'title' => 'System updates',
'subtitle' => 'Updates the installed packages on :server via the package manager. No new version jumps are forced — only available updates are applied.',
'this_server' => 'this server',
'unsupported_fallback' => 'Not available on this system.',
'count_unknown' => 'Update count unknown',
'count_one' => '1 package update available',
'count_many' => ':count package updates available',
'hint_unknown' => 'Could not be determined — updating is still possible.',
'hint_recommended' => 'Updating recommended.',
'hint_up_to_date' => 'The system is up to date.',
'result_ok' => 'System updated.',
'result_failed' => 'Update failed.',
'submit' => 'Update now',
'notify_updated' => 'System updated.',
'notify_failed' => 'Update failed.',
],
// ── Generic confirm dialog defaults (ConfirmAction) ───────────────────
'confirm_action' => [
'default_heading' => 'Confirm action',
'default_notify' => 'Action carried out.',
],
];

174
lang/en/servers.php Normal file
View File

@ -0,0 +1,174 @@
<?php
// Servers area: fleet index, server detail, switcher (R16). English translation.
return [
// ── Page titles ───────────────────────────────────────────────────────
'index_title' => 'Servers — Clusev',
'show_title' => 'Server details — Clusev',
// ── Status labels (status pills / KPIs) ───────────────────────────────
'status_online' => 'Online',
'status_warning' => 'Warning',
'status_offline' => 'Offline',
// ── Index: header ─────────────────────────────────────────────────────
'fleet_eyebrow' => 'Fleet',
'heading' => 'Servers',
'add_server' => 'Add server',
'online_of_total' => ':online / :total online',
// ── Index: KPI grid ───────────────────────────────────────────────────
'kpi_total' => 'Total',
'kpi_online' => 'Online',
'kpi_warning' => 'Warning',
'kpi_offline' => 'Offline',
// ── Index: fleet panel ────────────────────────────────────────────────
'fleet_title' => 'Fleet',
'in_cluster' => ':count in cluster',
'search_label' => 'Search servers',
'search_placeholder' => 'Name or IP…',
'empty_title' => 'No servers found.',
'empty_hint' => 'Adjust the search term or reset the filter.',
// ── Show: back link ───────────────────────────────────────────────────
'back_to_fleet' => 'Back to fleet',
// ── Show: hero header ─────────────────────────────────────────────────
'uptime' => 'Uptime :value',
'last_seen' => 'last :value',
'system_updates' => 'System updates',
'files' => 'Files',
// ── Show: offline banner ──────────────────────────────────────────────
'offline_banner' => 'No live connection — showing the last stored values. Check access/reachability.',
// ── Show: SSH access (credential) ─────────────────────────────────────
'ssh_access_title' => 'SSH access',
'ssh_access_subtitle' => 'Stored login for live control',
'cred_locked' => 'Locked',
'cred_active' => 'Active',
'cred_auth_key' => 'SSH key',
'cred_auth_password' => 'Password',
'cred_none_title' => 'No access stored',
'cred_none_hint' => 'Without SSH access, live control is not possible.',
'cred_deposit' => 'Store access',
// ── Show: specifications ──────────────────────────────────────────────
'specs_title' => 'Specifications',
'specs_subtitle' => 'Hardware profile',
'spec_arch' => 'Architecture',
'spec_kernel' => 'Kernel',
'spec_virt' => 'Virtualization',
'spec_cores' => 'Cores',
'spec_ram' => 'RAM',
'spec_disk' => 'Disk',
'spec_system' => 'System',
'spec_package_manager' => 'Package manager',
'spec_firewall' => 'Firewall',
'gb' => ':value GB',
// ── Show: vitals ──────────────────────────────────────────────────────
'vital_cpu' => 'CPU',
'vital_ram' => 'RAM',
'vital_disk' => 'Disk',
'load' => 'Load :value',
'cores_count' => ':count cores',
'ram_used_of_total' => ':used / :total GB',
'disk_used_of_total' => ':used / :total GB',
// ── Show: security / hardening ────────────────────────────────────────
'security_title' => 'Security',
'security_subtitle' => 'Hardening checklist',
'check_secure' => 'secure',
'check_open' => 'open',
'fail2ban_configure' => 'Configure fail2ban',
// ── Show: firewall rules ──────────────────────────────────────────────
'firewall_title' => 'Firewall rules',
'firewall_unavailable' => 'Not available',
'firewall_sub_active' => ' · active',
'firewall_sub_inactive' => ' · inactive',
'firewall_sub_not_installed' => ' · not installed',
'firewall_add_rule' => 'Rule',
'firewall_not_supported' => 'Firewall management is not available on this system.',
'firewall_read_error' => 'Firewall status could not be read (connection/permissions). Please reload later.',
'firewall_tool_not_installed' => ':tool is not installed.',
'firewalld_inactive' => 'firewalld is installed but inactive.',
'firewall_default' => 'Default',
'firewall_incoming' => 'incoming: :value',
'firewall_outgoing' => 'outgoing: :value',
'firewall_zone' => 'Zone',
'firewall_readonly' => 'firewalld: view only — please manage rules directly on the server.',
'firewall_rule_from' => 'from :value',
'firewall_remove_rule' => 'Remove rule',
'firewall_no_rules' => 'No rules defined.',
// ── Show: fail2ban status ─────────────────────────────────────────────
'fail2ban_title' => 'fail2ban status',
'fail2ban_unavailable' => 'Not available',
'fail2ban_sub_active' => 'active · :count banned',
'fail2ban_sub_installed_inactive' => 'installed · inactive',
'fail2ban_sub_not_installed' => 'not installed',
'fail2ban_ban_ip' => 'Ban IP',
'fail2ban_not_supported' => 'fail2ban is not available on this system.',
'fail2ban_read_error' => 'fail2ban status could not be read. Please reload later.',
'fail2ban_state_installed_inactive' => 'fail2ban is installed but inactive.',
'fail2ban_state_not_installed' => 'fail2ban is not installed.',
'fail2ban_banned' => 'banned: :count',
'fail2ban_failed' => 'failed attempts: :count',
'fail2ban_no_banned' => 'No banned IPs.',
'whitelist_label' => 'Whitelist (ignoreip)',
'whitelist_placeholder' => 'IP or CIDR',
// ── Show: volumes ─────────────────────────────────────────────────────
'volumes_title' => 'Volumes',
'mountpoints' => ':count mount points',
'no_volumes' => 'No volumes read.',
// ── Show: network interfaces ──────────────────────────────────────────
'interfaces_title' => 'Network interfaces',
'interfaces_count' => ':count interfaces',
'col_name' => 'Name',
'col_ip' => 'IP',
'col_mac' => 'MAC',
'col_rx' => 'RX',
'col_tx' => 'TX',
// ── Show: SSH keys ────────────────────────────────────────────────────
'ssh_keys_title' => 'SSH keys',
'ssh_keys_authorized' => ':count authorized',
'ssh_keys_add' => 'Add key',
'ssh_keys_remove' => 'Remove key',
'ssh_keys_none' => 'No authorized keys.',
// ── Show: loading skeletons ───────────────────────────────────────────
'loading' => 'loading…',
// ── Switcher ──────────────────────────────────────────────────────────
'switcher_no_server' => 'No server',
// ── Notifications (Show component) ────────────────────────────────────
'notify_access_locked' => 'Access locked.',
'notify_access_unlocked' => 'Access unlocked.',
'notify_access_deleted' => 'Access deleted.',
'notify_remove_failed' => 'Removal failed: :error',
'notify_key_removed' => 'Key “:name” removed.',
'notify_rule_not_found' => 'Rule no longer exists.',
'notify_firewall_rule_removed' => 'Firewall rule removed.',
'notify_unban_failed' => 'Unban failed: :error',
'notify_ip_unbanned' => 'IP :ip unbanned.',
'notify_whitelist_error' => 'Whitelist: :error',
'notify_ip_whitelisted' => ':ip added to the whitelist.',
'notify_ip_unwhitelisted' => ':ip removed from the whitelist.',
'error_unknown' => 'unknown',
'error_failed' => 'failed',
// ── Confirm modals (Show component) ───────────────────────────────────
'confirm_remove_key_heading' => 'Remove SSH key',
'confirm_remove_key_body' => 'The key “:name” will lose access to :server.',
'confirm_delete_credential_heading' => 'Delete access',
'confirm_delete_credential_body' => 'The stored SSH access for :server will be removed permanently. Live control will no longer be possible afterwards.',
'confirm_remove_rule_heading' => 'Remove firewall rule',
'confirm_remove_rule_body' => 'The rule “:label” will be removed from the firewall of :server.',
];

57
lang/en/services.php Normal file
View File

@ -0,0 +1,57 @@
<?php
// Services (systemd) page strings (R16). Shared buttons live in common.php.
return [
// Header / overview
'eyebrow' => 'Services',
'heading' => 'Services',
'fleet_active' => ':active / :total active',
// systemd panel
'panel_title' => 'systemd services',
'panel_subtitle' => ':total units · :server',
'search_label' => 'Search services',
'search_placeholder' => 'Search service…',
// Service status labels (status triad)
'status_online' => 'active',
'status_warning' => 'degraded',
'status_offline' => 'stopped',
// Service actions
'start' => 'Start',
'stop' => 'Stop',
'restart' => 'Restart',
// Empty state
'empty_title' => 'No service found',
'empty_hint' => 'Search “:search” returned no matches.',
// Journal panel
'journal_title' => 'Journal',
'journal_subtitle' => 'journalctl -f · :server',
'journal_live' => 'live',
'journal_follow' => 'Following journal · :count lines',
// Confirm modal — operations
'confirm_start_heading' => 'Start service',
'confirm_start_phrase' => 'will be started',
'confirm_start_label' => 'Start',
'confirm_stop_heading' => 'Stop service',
'confirm_stop_phrase' => 'will be stopped',
'confirm_stop_label' => 'Stop',
'confirm_restart_heading' => 'Restart service',
'confirm_restart_phrase' => 'will be restarted',
'confirm_restart_label' => 'Restart',
'confirm_body' => ':name :phrase. Executed via systemctl on :server.',
'confirm_notify' => ':name: :label executed.',
// Action results / notifications
'action_error' => ':name: :error',
'action_done' => ':name: :op executed.',
'action_failed' => ':name: failed — :output',
'no_permission' => 'no permission',
// Page title
'title' => 'Services — Clusev',
];

49
lang/en/settings.php Normal file
View File

@ -0,0 +1,49 @@
<?php
// Settings (profile + security) page strings (R16). Shared buttons live in common.php.
return [
// Identity header
'account' => 'Account',
'role_admin' => 'Administrator',
'two_factor_on' => '2FA on',
'two_factor_off' => '2FA off',
// Section nav tabs
'tab_profile' => 'Profile',
'tab_security' => 'Security',
// Profile panel
'profile_title' => 'Profile',
'profile_subtitle' => 'Name and email',
'name' => 'Name',
'email' => 'Email',
// Password panel
'password_title' => 'Password',
'password_subtitle' => 'At least 10 characters',
'current_password' => 'Current password',
'new_password' => 'New password',
'repeat_password' => 'Repeat',
'change_password' => 'Change password',
// Two-factor panel
'twofa_title' => 'Two-factor authentication',
'twofa_subtitle' => 'TOTP (authenticator app)',
'twofa_status_on' => '2FA is active',
'twofa_status_off' => '2FA is not set up',
'twofa_hint_on' => 'Login requires a TOTP code.',
'twofa_hint_off' => 'Recommended — protects login with a second factor.',
'twofa_setup' => 'Set up',
// Notifications
'profile_saved' => 'Profile saved.',
'password_changed' => 'Password changed.',
// Disable-2FA confirmation modal
'disable_2fa_heading' => 'Disable 2FA',
'disable_2fa_body' => 'Two-factor authentication will be removed. Your account will then be protected by password only.',
'disable_2fa_notify' => '2FA disabled.',
// Page title
'title' => 'Settings — Clusev',
];

49
lang/en/shell.php Normal file
View File

@ -0,0 +1,49 @@
<?php
// App shell: sidebar nav, topbar, command palette, layout chrome (R16).
return [
// Sidebar — accessibility / brand
'nav_aria' => 'Side navigation',
'menu_close' => 'Close menu',
'menu_open' => 'Open menu',
'logout' => 'Sign out',
// Sidebar — nav group labels
'group_fleet' => 'Fleet',
'group_account' => 'Account',
// Sidebar — nav items
'nav_dashboard' => 'Dashboard',
'nav_servers' => 'Servers',
'nav_services' => 'Services',
'nav_files' => 'Files',
'nav_audit' => 'Audit log',
'nav_settings' => 'Settings',
'nav_system' => 'System',
'nav_versions' => 'Version',
// Sidebar — user / 2FA badge
'twofa_on' => '2FA on',
'twofa_off' => '2FA off',
// Topbar
'title_default' => 'Overview',
'commands_open_aria' => 'Open commands (Ctrl K)',
'commands_title' => 'Commands (Ctrl K)',
'shortcut_ctrl_k' => 'Ctrl K',
'fleet_online' => ':online / :total online',
// Command palette
'cmdk_open' => 'Open commands',
'cmdk_focus_search' => 'Focus search on page',
'cmdk_show_help' => 'Show this help',
'cmdk_placeholder' => 'Command or page…',
'cmdk_no_match' => 'No match.',
'cmdk_open_hint' => 'open',
'cmdk_shortcuts_hint' => '? Shortcuts',
'cmdk_esc' => 'Esc',
'shortcuts_heading' => 'Keyboard shortcuts',
// Toaster fallback
'toast_done' => 'Done',
];

47
lang/en/system.php Normal file
View File

@ -0,0 +1,47 @@
<?php
// System settings (Domain/TLS + release channel) page strings (R16). Shared buttons live in common.php.
return [
// Header
'eyebrow' => 'System',
'heading' => 'Domain, TLS & release channel',
'subheading' => 'Panel access address and update source',
'https_active' => 'HTTPS active',
'https_plain' => 'Plaintext HTTP',
// Domain & TLS panel
'tls_title' => 'Domain & TLS',
'tls_subtitle' => 'Panel reachability and Let\'s Encrypt certificate',
'tls_active_title' => 'TLS active — certificate issued automatically via Let\'s Encrypt',
'tls_active_body_pre' => 'Reachable at',
'tls_active_body_post' => 'issued and renewed automatically. Requirement: the domain points to this server via DNS.',
'tls_bare_title' => 'Bare-IP mode: plaintext HTTP',
'tls_bare_body' => 'Without a domain the panel — including 2FA and audit — runs over unencrypted HTTP. Enter a domain and TLS is then set up automatically, with no further steps.',
// Read-only domain facts
'domain' => 'Domain',
'domain_via_ip' => '— (access via IP)',
'access_address' => 'Access address',
'access_bare_ip' => 'HTTP (bare IP)',
'domain_note_pre' => 'The panel domain is set at installation and stays consistent with URL, WebSocket and cookie security. To change it, re-run',
'domain_note_post' => 'with the new domain — TLS then sets itself up automatically.',
// Release channel panel
'channel_title' => 'Release channel',
'channel_subtitle' => 'Source for updates',
'channel_radiogroup_label' => 'Release channel',
'channel_current' => 'Channel:',
// Channel descriptions (CHANNELS array)
'channel_stable' => 'Only tested releases approved by the maintainer (tagged versions). Recommended for production use.',
'channel_beta' => 'Pre-releases of upcoming versions for testing new features. May be unstable. Development builds are never offered to users.',
// Change-channel confirmation modal
'change_channel_heading' => 'Switch release channel',
'change_channel_body' => 'The release channel will be set to “:channel”. Updates will then be pulled from this source.',
'change_channel_confirm' => 'Switch',
'change_channel_notify' => 'Release channel set to “:channel”.',
// Page title
'title' => 'System — Clusev',
];

47
lang/en/versions.php Normal file
View File

@ -0,0 +1,47 @@
<?php
// Version & releases page strings (R16). Shared buttons live in common.php.
return [
// Header
'eyebrow' => 'Version',
'heading' => 'Version & releases',
'check_updates' => 'Check for updates',
// Status banner
'channel_badge' => 'Channel: :channel',
'latest_release_badge' => 'latest release v:tag',
'banner_default' => 'Installed version. “Check for updates” honestly compares it against the newest tagged release in the channel. Updating runs through the deploy (pull image + migrate).',
'checked_at' => 'checked :time',
// Update-check results (notifications / status line)
'status_none_tagged' => 'No release tagged yet — installed version v:installed (channel: :channel).',
'status_update_available' => 'Update available: v:latest (channel: :channel).',
'status_current' => 'Up to date — v:installed (channel: :channel).',
// Changelog panel
'changelog_title' => 'Changelog',
'changelog_subtitle' => ':count versions',
'unreleased' => 'Unreleased',
'in_progress' => 'in progress',
'changelog_empty' => 'No changelog found.',
// Installed panel
'installed_title' => 'Installed',
'running' => 'running',
'build' => 'Build',
'branch' => 'Branch',
'channel' => 'Channel',
'latest_release' => 'Latest release',
'license' => 'License',
// Update panel
'update_title' => 'Update',
'update_hint' => 'Updates arrive via tagged releases in the deploy — pull the new image and apply migrations:',
// Project panel
'project_title' => 'Project',
'open_core' => 'Open Core · :license',
// Page title
'title' => 'Version — Clusev',
];

View File

@ -0,0 +1,16 @@
{{-- DE/EN language switch (R16). Plain links full reload so the whole UI re-renders
in the chosen language; persists per-user + session via the locale route. --}}
@php $current = app()->getLocale(); @endphp
<div class="inline-flex shrink-0 items-center rounded-md border border-line bg-inset p-0.5 font-mono text-[10px] uppercase tracking-wider"
role="group" aria-label="Sprache / Language">
@foreach (['de' => 'DE', 'en' => 'EN'] as $code => $abbr)
<a href="{{ route('locale.set', $code) }}"
@class([
'rounded px-2 py-1 transition-colors',
'bg-accent/15 text-accent-text' => $current === $code,
'text-ink-4 hover:text-ink-2' => $current !== $code,
])
aria-current="{{ $current === $code ? 'true' : 'false' }}"
title="{{ $code === 'de' ? 'Deutsch' : 'English' }}">{{ $abbr }}</a>
@endforeach
</div>

View File

@ -2,7 +2,7 @@
<aside class="fixed inset-y-0 left-0 z-40 flex w-[272px] -translate-x-full flex-col border-r border-line bg-surface transition-transform duration-200 lg:translate-x-0"
:class="{ '!translate-x-0': nav }"
x-bind:inert="! nav && ! desktop"
aria-label="Seitennavigation">
aria-label="{{ __('shell.nav_aria') }}">
{{-- Brand --}}
<div class="flex h-14 shrink-0 items-center gap-2.5 border-b border-line px-4">
<span class="grid h-8 w-8 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]">
@ -11,7 +11,7 @@
<span class="font-display text-lg font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
<button type="button" @click="nav = false"
class="ml-auto grid min-h-11 min-w-11 place-items-center rounded-md text-ink-3 hover:bg-raised hover:text-ink lg:hidden"
aria-label="Menü schließen">
aria-label="{{ __('shell.menu_close') }}">
<x-icon name="x" class="h-5 w-5" />
</button>
</div>
@ -23,17 +23,17 @@
{{-- Nav --}}
<nav class="flex-1 space-y-1 overflow-y-auto p-3">
<p class="px-3 pb-1 pt-2 font-mono text-[10px] uppercase tracking-widest text-ink-4">Flotte</p>
<x-nav-item icon="dashboard" href="/" :active="request()->is('/')">Dashboard</x-nav-item>
<x-nav-item icon="server" href="/servers" :active="request()->is('servers*')">Server</x-nav-item>
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">Dienste</x-nav-item>
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">Dateien</x-nav-item>
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">Audit-Log</x-nav-item>
<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('/')">{{ __('shell.nav_dashboard') }}</x-nav-item>
<x-nav-item icon="server" href="/servers" :active="request()->is('servers*')">{{ __('shell.nav_servers') }}</x-nav-item>
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item>
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item>
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item>
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">Konto</p>
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">Einstellungen</x-nav-item>
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">System</x-nav-item>
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')">Version</x-nav-item>
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
<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>
</nav>
{{-- User --}}
@ -44,12 +44,12 @@
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-raised font-mono text-xs text-ink-2">{{ strtoupper(substr($u?->name ?? 'U', 0, 2)) }}</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-ink">{{ $u?->name ?? '—' }}</span>
<span class="block truncate font-mono text-[11px] {{ $u?->hasTwoFactorEnabled() ? 'text-online' : 'text-ink-3' }}">{{ $u?->hasTwoFactorEnabled() ? '2FA aktiv' : '2FA aus' }}</span>
<span class="block truncate font-mono text-[11px] {{ $u?->hasTwoFactorEnabled() ? 'text-online' : 'text-ink-3' }}">{{ $u?->hasTwoFactorEnabled() ? __('shell.twofa_on') : __('shell.twofa_off') }}</span>
</span>
</a>
<form method="POST" action="{{ route('logout') }}" class="shrink-0">
@csrf
<button type="submit" class="grid min-h-11 min-w-11 place-items-center rounded-md text-ink-3 hover:bg-raised hover:text-ink" aria-label="Abmelden">
<button type="submit" class="grid min-h-11 min-w-11 place-items-center rounded-md text-ink-3 hover:bg-raised hover:text-ink" aria-label="{{ __('shell.logout') }}">
<x-icon name="logout" class="h-[18px] w-[18px]" />
</button>
</form>

View File

@ -1,24 +1,26 @@
@props(['title' => 'Übersicht'])
@props(['title' => null])
@php($title ??= __('shell.title_default'))
<header class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-line bg-base/90 px-4 backdrop-blur sm:px-6 lg:px-8">
<button type="button" @click="nav = true"
class="grid min-h-11 min-w-11 shrink-0 place-items-center rounded-md text-ink-2 hover:bg-raised hover:text-ink lg:hidden"
aria-label="Menü öffnen">
aria-label="{{ __('shell.menu_open') }}">
<x-icon name="menu" class="h-5 w-5" />
</button>
<h1 class="min-w-0 truncate font-display text-sm font-semibold text-ink sm:text-base">{{ $title }}</h1>
<div class="ml-auto flex min-w-0 items-center gap-2">
<x-lang-switch />
<button type="button" @click="$dispatch('cmdk-open')"
class="hidden items-center gap-2 rounded-md border border-line bg-inset px-2.5 py-1.5 text-ink-3 transition-colors hover:border-line-strong hover:text-ink sm:flex"
aria-label="Befehle öffnen (Strg K)" title="Befehle (Strg K)">
aria-label="{{ __('shell.commands_open_aria') }}" title="{{ __('shell.commands_title') }}">
<x-icon name="command" class="h-3.5 w-3.5" />
<span class="font-mono text-[10px] uppercase tracking-wider">Strg K</span>
<span class="font-mono text-[10px] uppercase tracking-wider">{{ __('shell.shortcut_ctrl_k') }}</span>
</button>
@php
$fleetTotal = \App\Models\Server::count();
$fleetOnline = \App\Models\Server::where('status', 'online')->count();
@endphp
<x-status-pill :status="$fleetOnline > 0 ? 'online' : 'offline'">{{ $fleetOnline }} / {{ $fleetTotal }} online</x-status-pill>
<x-status-pill :status="$fleetOnline > 0 ? 'online' : 'offline'">{{ __('shell.fleet_online', ['online' => $fleetOnline, 'total' => $fleetTotal]) }}</x-status-pill>
</div>
</header>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="de">
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="de">
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@ -28,10 +28,10 @@
<div class="relative z-10 flex max-w-md flex-col gap-7">
<div class="space-y-3">
<h1 class="text-balance font-display text-4xl font-bold leading-[1.12] tracking-tight text-ink">
Eine Konsole für deine <span class="text-accent">gesamte Flotte</span>.
{{ __('auth.brand_headline_lead') }} <span class="text-accent">{{ __('auth.brand_headline_accent') }}</span>.
</h1>
<p class="max-w-sm text-sm leading-relaxed text-ink-2">
Agentenlos über SSH. Metriken, Dienste, Dateien und ein lückenloses Audit-Log sicher aus einem Panel gesteuert.
{{ __('auth.brand_tagline') }}
</p>
</div>
@ -42,27 +42,28 @@
<span class="h-2 w-2 rounded-full bg-warning/50"></span>
<span class="h-2 w-2 rounded-full bg-online/50"></span>
</span>
<span class="font-mono text-[10px] text-ink-4">clusev · sicherheitsmodell</span>
<span class="font-mono text-[10px] text-ink-4">{{ __('auth.terminal_title') }}</span>
</div>
<div class="space-y-2.5 p-4 font-mono text-xs leading-relaxed">
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">ssh</span> agentenlos · exec + SFTP über phpseclib</p>
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">key</span> Host-Keys via TOFU gepinnt</p>
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">2fa</span> erzwungen für jeden Login</p>
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">log</span> jede Aktion im Audit-Log</p>
<p class="flex items-center gap-2.5 text-ink-3"><span class="w-8 shrink-0 text-cyan">core</span> Open Core · AGPL-lizenziert</p>
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">ssh</span> {{ __('auth.terminal_ssh') }}</p>
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">key</span> {{ __('auth.terminal_key') }}</p>
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">2fa</span> {{ __('auth.terminal_2fa') }}</p>
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">log</span> {{ __('auth.terminal_log') }}</p>
<p class="flex items-center gap-2.5 text-ink-3"><span class="w-8 shrink-0 text-cyan">core</span> {{ __('auth.terminal_core') }}</p>
</div>
</div>
</div>
{{-- bottom: copyright + status --}}
<div class="relative z-10 flex items-center justify-between gap-4 font-mono text-[11px] text-ink-4">
<span>© {{ date('Y') }} Clusev · Fleet Control</span>
<span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-online"></span> betriebsbereit</span>
<span>{{ __('auth.footer_copyright', ['year' => date('Y')]) }}</span>
<span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-online"></span> {{ __('auth.footer_operational') }}</span>
</div>
</aside>
{{-- Form panel --}}
<main class="relative flex items-center justify-center px-5 py-10 sm:px-8">
<div class="absolute right-5 top-5 sm:right-8"><x-lang-switch /></div>
<div class="w-full max-w-sm">
{{-- wordmark for small screens (brand panel hidden) --}}
<div class="mb-8 flex items-center gap-2.5 lg:hidden">

View File

@ -2,17 +2,17 @@
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Sicherheit</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Audit-Log</h2>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('audit.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('audit.heading') }}</h2>
</div>
<x-status-pill status="online">{{ $events->count() }} Ereignisse</x-status-pill>
<x-status-pill status="online">{{ __('audit.event_count', ['count' => $events->count()]) }}</x-status-pill>
</div>
{{-- Ereignisliste --}}
<x-panel title="Ereignisse" subtitle="letzte 50 · neueste zuerst" :padded="false">
<x-panel :title="__('audit.panel_title')" :subtitle="__('audit.panel_subtitle')" :padded="false">
<x-slot:actions>
<label class="relative block">
<span class="sr-only">Audit-Log durchsuchen</span>
<span class="sr-only">{{ __('audit.search_label') }}</span>
<span class="pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-ink-4">
<x-icon name="search" class="h-3.5 w-3.5" />
</span>
@ -20,7 +20,7 @@
type="search"
data-page-search
wire:model.live.debounce.300ms="q"
placeholder="Akteur, Aktion, Ziel …"
placeholder="{{ __('audit.search_placeholder') }}"
class="min-h-11 w-44 rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-64"
/>
</label>
@ -59,12 +59,12 @@
<span class="grid h-10 w-10 place-items-center rounded-md bg-raised text-ink-4">
<x-icon name="audit" class="h-5 w-5" />
</span>
<p class="font-display text-sm font-semibold text-ink-2">Keine Ereignisse</p>
<p class="font-display text-sm font-semibold text-ink-2">{{ __('audit.empty_title') }}</p>
<p class="max-w-xs text-[11px] text-ink-4">
@if (trim($q) !== '')
Für {{ $q }} wurden keine Audit-Einträge gefunden.
{{ __('audit.empty_filtered', ['query' => $q]) }}
@else
Es liegen noch keine Audit-Einträge vor.
{{ __('audit.empty_none') }}
@endif
</p>
</div>

View File

@ -6,21 +6,21 @@
<div class="space-y-7">
<div class="space-y-1.5">
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Control Plane</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Anmelden</h1>
<p class="font-mono text-xs text-ink-3">Zugang zum Clusev-Panel.</p>
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.control_plane') }}</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.login_heading') }}</h1>
<p class="font-mono text-xs text-ink-3">{{ __('auth.login_subtitle') }}</p>
</div>
<form wire:submit="authenticate" class="space-y-4">
<div>
<label for="email" class="{{ $label }}">E-Mail</label>
<label for="email" class="{{ $label }}">{{ __('auth.email') }}</label>
<input wire:model="email" id="email" type="email" autocomplete="username" autofocus
class="{{ $field }}" placeholder="admin@clusev.local" />
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label for="password" class="{{ $label }}">Passwort</label>
<label for="password" class="{{ $label }}">{{ __('auth.password') }}</label>
<input wire:model="password" id="password" type="password" autocomplete="current-password"
class="{{ $field }}" placeholder="••••••••" />
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
@ -28,24 +28,24 @@
<label class="flex items-center gap-2 font-mono text-xs text-ink-2">
<input wire:model="remember" type="checkbox" class="h-4 w-4 rounded border-line bg-inset accent-accent" />
Angemeldet bleiben
{{ __('auth.remember_me') }}
</label>
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="authenticate">
<svg wire:loading wire:target="authenticate" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span wire:loading.remove wire:target="authenticate">Anmelden</span>
<span wire:loading wire:target="authenticate">Prüfe…</span>
<span wire:loading.remove wire:target="authenticate">{{ __('auth.login_submit') }}</span>
<span wire:loading wire:target="authenticate">{{ __('auth.checking') }}</span>
</x-btn>
</form>
<div class="flex items-center gap-3">
<span class="h-px flex-1 bg-line"></span>
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-ink-4">Gesichert</span>
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-ink-4">{{ __('auth.secured') }}</span>
<span class="h-px flex-1 bg-line"></span>
</div>
<p class="flex items-start gap-2 font-mono text-[11px] leading-relaxed text-ink-4">
<x-icon name="shield" class="mt-px h-3.5 w-3.5 shrink-0 text-online" />
Verbindung über SSH · 2FA erforderlich · jede Aktion landet im Audit-Log.
{{ __('auth.security_note') }}
</p>
</div>

View File

@ -6,33 +6,33 @@
<div class="space-y-7">
<div class="space-y-1.5">
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Sicherheit</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Passwort ändern</h1>
<p class="font-mono text-xs leading-relaxed text-ink-3">Lege ein eigenes Passwort fest (min. 12 Zeichen, Groß-/Kleinschreibung + Zahl).</p>
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.security') }}</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.password_change_heading') }}</h1>
<p class="font-mono text-xs leading-relaxed text-ink-3">{{ __('auth.password_change_subtitle') }}</p>
</div>
<form wire:submit="update" class="space-y-4">
<div>
<label for="current" class="{{ $label }}">Aktuelles Passwort</label>
<label for="current" class="{{ $label }}">{{ __('auth.current_password') }}</label>
<input wire:model="current" id="current" type="password" autocomplete="current-password" class="{{ $field }}" />
@error('current') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label for="password" class="{{ $label }}">Neues Passwort</label>
<label for="password" class="{{ $label }}">{{ __('auth.new_password') }}</label>
<input wire:model="password" id="password" type="password" autocomplete="new-password" class="{{ $field }}" />
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label for="password_confirmation" class="{{ $label }}">Neues Passwort bestätigen</label>
<label for="password_confirmation" class="{{ $label }}">{{ __('auth.confirm_new_password') }}</label>
<input wire:model="password_confirmation" id="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }}" />
</div>
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="update">
<svg wire:loading wire:target="update" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span wire:loading.remove wire:target="update">Speichern &amp; weiter</span>
<span wire:loading wire:target="update">Speichere…</span>
<span wire:loading.remove wire:target="update">{{ __('auth.save_and_continue') }}</span>
<span wire:loading wire:target="update">{{ __('auth.saving') }}</span>
</x-btn>
</form>
</div>

View File

@ -6,14 +6,14 @@
<div class="space-y-7">
<div class="space-y-1.5">
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Zwei-Faktor</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Bestätigung</h1>
<p class="font-mono text-xs text-ink-3">6-stelliger Code aus deiner Authenticator-App.</p>
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.two_factor') }}</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.challenge_heading') }}</h1>
<p class="font-mono text-xs text-ink-3">{{ __('auth.challenge_subtitle') }}</p>
</div>
<form wire:submit="verify" class="space-y-4">
<div>
<label for="code" class="{{ $label }}">Code</label>
<label for="code" class="{{ $label }}">{{ __('auth.code') }}</label>
<input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" autofocus
class="{{ $field }}" placeholder="000000" />
@error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
@ -21,14 +21,14 @@
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify">
<svg wire:loading wire:target="verify" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span wire:loading.remove wire:target="verify">Bestätigen</span>
<span wire:loading wire:target="verify">Prüfe…</span>
<span wire:loading.remove wire:target="verify">{{ __('common.confirm') }}</span>
<span wire:loading wire:target="verify">{{ __('auth.checking') }}</span>
</x-btn>
</form>
<div class="flex justify-center">
<a href="{{ route('login') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">
<x-icon name="chevron-left" class="h-3.5 w-3.5" /> Zurück zur Anmeldung
<x-icon name="chevron-left" class="h-3.5 w-3.5" /> {{ __('auth.back_to_login') }}
</a>
</div>
</div>

View File

@ -6,24 +6,24 @@
<div class="space-y-6">
<div class="space-y-1.5">
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Zwei-Faktor</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Einrichten</h1>
<p class="font-mono text-xs leading-relaxed text-ink-3">Scanne den Code mit einer Authenticator-App (Aegis, Google Authenticator, ) und bestätige mit einem Code.</p>
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.two_factor') }}</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.setup_heading') }}</h1>
<p class="font-mono text-xs leading-relaxed text-ink-3">{{ __('auth.setup_subtitle') }}</p>
</div>
<div class="flex flex-col items-center gap-3">
<div class="rounded-lg border border-line bg-ink p-3">
<img src="{{ $this->qrCode() }}" alt="2FA-QR-Code" class="h-44 w-44" />
<img src="{{ $this->qrCode() }}" alt="{{ __('auth.qr_alt') }}" class="h-44 w-44" />
</div>
<div class="w-full rounded-md border border-line bg-inset px-3 py-2 text-center">
<p class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Geheimnis (manuell)</p>
<p class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('auth.secret_manual') }}</p>
<p class="mt-1 break-all font-mono text-xs text-ink-2">{{ $secret }}</p>
</div>
</div>
<form wire:submit="confirm" class="space-y-4">
<div>
<label for="code" class="{{ $label }}">Bestätigungs-Code</label>
<label for="code" class="{{ $label }}">{{ __('auth.confirmation_code') }}</label>
<input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" autofocus
class="{{ $field }}" placeholder="000000" />
@error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
@ -31,8 +31,8 @@
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="confirm">
<svg wire:loading wire:target="confirm" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span wire:loading.remove wire:target="confirm">Aktivieren</span>
<span wire:loading wire:target="confirm">Prüfe…</span>
<span wire:loading.remove wire:target="confirm">{{ __('auth.activate') }}</span>
<span wire:loading wire:target="confirm">{{ __('auth.checking') }}</span>
</x-btn>
</form>
</div>

View File

@ -1,5 +1,5 @@
@php
$svcLabel = ['online' => 'aktiv', 'warning' => 'degradiert', 'offline' => 'gestoppt'];
$svcLabel = ['online' => __('dashboard.svc_online'), 'warning' => __('dashboard.svc_warning'), 'offline' => __('dashboard.svc_offline')];
// Build SVG polyline paths from the real CPU/MEM history series (cache-backed, polled).
$chart = function (array $s) {
@ -21,7 +21,7 @@
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ $active?->name ?? '—' }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Übersicht</h2>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('dashboard.heading') }}</h2>
</div>
<x-status-pill :status="$active?->status ?? 'offline'">{{ $active?->ip ?? '—' }}</x-status-pill>
</div>
@ -35,7 +35,7 @@
</div>
{{-- Big dual-series chart --}}
<x-panel title="Auslastung" subtitle="CPU & Memory · 15 Min">
<x-panel :title="__('dashboard.utilization')" :subtitle="__('dashboard.utilization_subtitle')">
<x-slot:actions>
<span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-accent"></span><span class="text-ink-2">CPU</span></span>
<span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-cyan"></span><span class="text-ink-2">MEM</span></span>
@ -74,21 +74,21 @@
</div>
<div class="mt-1.5 flex justify-between font-mono text-[9px] text-ink-4">
<span>-15 Min</span><span>-10</span><span>-5</span><span>jetzt</span>
<span>{{ __('dashboard.axis_minus_15min') }}</span><span>-10</span><span>-5</span><span>{{ __('dashboard.axis_now') }}</span>
</div>
</div>
</x-panel>
{{-- systemd table + audit --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-3">
<x-panel title="systemd-Dienste" :subtitle="$active?->name" class="lg:col-span-2" :padded="false">
<x-panel :title="__('dashboard.services_title')" :subtitle="$active?->name" class="lg:col-span-2" :padded="false">
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-line">
<th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:px-5">Unit</th>
<th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3">Status</th>
<th class="px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3">Zustand</th>
<th class="px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3">{{ __('dashboard.col_state') }}</th>
<th class="hidden px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:table-cell">Boot</th>
</tr>
</thead>
@ -114,7 +114,7 @@
<td class="hidden px-4 py-2.5 text-right font-mono text-xs sm:table-cell {{ $svc['enabled'] ? 'text-ink-2' : 'text-ink-4' }}">{{ $svc['enabled'] ? 'enabled' : 'disabled' }}</td>
</tr>
@empty
<tr><td colspan="4" class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine Dienstdaten Server nicht verbunden.</td></tr>
<tr><td colspan="4" class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('dashboard.no_service_data') }}</td></tr>
@endforelse
@endif
</tbody>
@ -122,7 +122,7 @@
</div>
</x-panel>
<x-panel title="Audit-Log" subtitle="letzte Ereignisse" :padded="false">
<x-panel :title="__('dashboard.audit_title')" :subtitle="__('dashboard.audit_subtitle')" :padded="false">
<div class="divide-y divide-line">
@forelse ($events as $e)
<div class="flex items-start gap-3 px-4 py-2.5 sm:px-5">
@ -134,7 +134,7 @@
<span class="shrink-0 font-mono text-[11px] text-ink-4">{{ $e->created_at->diffForHumans(null, true) }}</span>
</div>
@empty
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine Ereignisse.</p>
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('dashboard.no_events') }}</p>
@endforelse
</div>
</x-panel>

View File

@ -23,10 +23,10 @@
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Dateien</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Dateien</h2>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('files.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('files.heading') }}</h2>
</div>
<x-status-pill :status="$connected ? 'online' : 'offline'">{{ $connected ? 'SFTP verbunden' : 'nicht verbunden' }}</x-status-pill>
<x-status-pill :status="$connected ? 'online' : 'offline'">{{ $connected ? __('files.sftp_connected') : __('files.sftp_disconnected') }}</x-status-pill>
</div>
{{-- Breadcrumb / path --}}
@ -47,23 +47,23 @@
</x-panel>
{{-- Listing --}}
<x-panel :title="$path" :subtitle="$dirs . ' Ordner · ' . $files . ' Dateien'" :padded="false">
<x-panel :title="$path" :subtitle="__('files.panel_subtitle', ['dirs' => $dirs, 'files' => $files])" :padded="false">
<x-slot:actions>
<label class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 text-xs font-medium text-accent-text transition-colors hover:bg-accent/15">
<x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="upload" />
<svg wire:loading wire:target="upload" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
Hochladen
{{ __('files.upload') }}
<input type="file" wire:model="upload" class="hidden" />
</label>
</x-slot:actions>
{{-- Column header (desktop only) --}}
<div class="hidden border-b border-line px-4 py-2 sm:px-5 lg:grid lg:grid-cols-[minmax(0,1fr)_8rem_6rem_11rem_auto] lg:items-center lg:gap-4">
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Name</span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Rechte</span>
<span class="text-right font-mono text-[10px] uppercase tracking-wider text-ink-4">Größe</span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Geändert</span>
<span class="sr-only">Aktionen</span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_name') }}</span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_perms') }}</span>
<span class="text-right font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_size') }}</span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_modified') }}</span>
<span class="sr-only">{{ __('common.actions') }}</span>
</div>
<div class="divide-y divide-line" wire:loading.class="opacity-40" wire:target="load,open,go,up">
@ -119,10 +119,10 @@
{{-- Row actions --}}
<div class="flex shrink-0 items-center gap-1 lg:justify-end">
@unless ($isDir)
<x-btn variant="ghost" wire:click="download({{ $loop->index }})">Download</x-btn>
<x-btn variant="ghost" wire:click="edit({{ $loop->index }})">Bearbeiten</x-btn>
<x-btn variant="ghost" wire:click="download({{ $loop->index }})">{{ __('files.download') }}</x-btn>
<x-btn variant="ghost" wire:click="edit({{ $loop->index }})">{{ __('common.edit') }}</x-btn>
@endunless
<x-btn variant="ghost-danger" wire:click="confirmDelete({{ $loop->index }})">Löschen</x-btn>
<x-btn variant="ghost-danger" wire:click="confirmDelete({{ $loop->index }})">{{ __('common.delete') }}</x-btn>
</div>
</div>
@endforeach

View File

@ -4,30 +4,30 @@
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">SSH-Schlüssel hinzufügen</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">Öffentlichen Schlüssel einfügen er wird in <span class="font-mono text-ink-3">~/.ssh/authorized_keys</span> ergänzt.</p>
<h2 class="font-display text-base font-semibold text-ink">{{ __('modals.add_ssh_key.title') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('modals.add_ssh_key.subtitle_before') }} <span class="font-mono text-ink-3">~/.ssh/authorized_keys</span> {{ __('modals.add_ssh_key.subtitle_after') }}</p>
</div>
</div>
<div class="mt-4">
<div class="mb-1.5 flex items-center justify-between">
<label class="font-mono text-[11px] uppercase tracking-wider text-ink-3">Öffentlicher Schlüssel</label>
<label class="font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.add_ssh_key.public_key_label') }}</label>
<button type="button" wire:click="generate" wire:target="generate" wire:loading.attr="disabled" class="font-mono text-[11px] text-accent-text transition-colors hover:text-accent disabled:opacity-50">
<span wire:loading.remove wire:target="generate">Neues Paar generieren</span>
<span wire:loading wire:target="generate">generiere…</span>
<span wire:loading.remove wire:target="generate">{{ __('modals.add_ssh_key.generate') }}</span>
<span wire:loading wire:target="generate">{{ __('modals.add_ssh_key.generating') }}</span>
</button>
</div>
<textarea
wire:model="publicKey"
rows="3"
placeholder="ssh-ed25519 AAAA… kommentar — oder oben generieren"
placeholder="{{ __('modals.add_ssh_key.public_key_placeholder') }}"
class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"
></textarea>
@if ($generatedPrivate)
<div class="mt-3 rounded-md border border-warning/30 bg-warning/10 p-3">
<p class="flex items-center gap-1.5 font-mono text-[11px] text-warning">
<x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />Privater Schlüssel erscheint nur jetzt, sicher speichern.
<x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ __('modals.add_ssh_key.private_key_warning') }}
</p>
<textarea readonly rows="4" x-on:click="$el.select()"
class="mt-2 w-full rounded-md border border-line bg-void px-3 py-2 font-mono text-[10px] leading-relaxed text-ink-2 focus:outline-none">{{ $generatedPrivate }}</textarea>
@ -42,11 +42,11 @@
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
<x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
Hinzufügen
{{ __('common.add') }}
</x-btn>
</div>
</div>

View File

@ -17,7 +17,7 @@
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
<x-btn :variant="$danger ? 'danger' : 'primary'" wire:click="confirm" wire:target="confirm" wire:loading.attr="disabled">
<x-icon :name="$icon ?: ($danger ? 'alert' : 'activity')" class="h-3.5 w-3.5" wire:loading.remove wire:target="confirm" />
<svg wire:loading wire:target="confirm" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>

View File

@ -4,28 +4,28 @@
<x-icon name="server" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">Server hinzufügen</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">Neuen Host in die Flotte aufnehmen und den SSH-Zugang im verschlüsselten Tresor hinterlegen.</p>
<h2 class="font-display text-base font-semibold text-ink">{{ __('modals.create_server.title') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('modals.create_server.subtitle') }}</p>
</div>
</div>
<div class="mt-4 space-y-3">
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Name</label>
<input wire:model="name" type="text" placeholder="z. B. Prod-Web-01" maxlength="120"
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.name_label') }}</label>
<input wire:model="name" type="text" placeholder="{{ __('modals.create_server.name_placeholder') }}" maxlength="120"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
@error('name')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="sm:col-span-2">
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">IP/Host</label>
<input wire:model="ip" type="text" placeholder="10.10.90.10 oder host.example.com"
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.ip_host_label') }}</label>
<input wire:model="ip" type="text" placeholder="{{ __('modals.create_server.ip_host_placeholder') }}"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
@error('ip')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">SSH-Port</label>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.ssh_port_label') }}</label>
<input wire:model="sshPort" type="number" min="1" max="65535" placeholder="22"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
@error('sshPort')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
@ -33,23 +33,23 @@
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Benutzer</label>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.user_label') }}</label>
<input wire:model="username" type="text" placeholder="root"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
@error('username')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Authentifizierung</label>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.auth_label') }}</label>
<select wire:model.live="authType"
class="h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none">
<option value="password">Passwort</option>
<option value="key">Privater Schlüssel</option>
<option value="password">{{ __('modals.create_server.auth_password') }}</option>
<option value="key">{{ __('modals.create_server.auth_key') }}</option>
</select>
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? 'Privater Schlüssel (PEM)' : 'Passwort' }}</label>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? __('modals.create_server.secret_key_label') : __('modals.create_server.secret_password_label') }}</label>
@if ($authType === 'key')
<textarea wire:model="secret" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea>
@ -62,26 +62,26 @@
@if ($authType === 'key')
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Passphrase (optional)</label>
<input wire:model="passphrase" type="password" placeholder="leer = keine"
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.passphrase_label') }}</label>
<input wire:model="passphrase" type="password" placeholder="{{ __('modals.create_server.passphrase_placeholder') }}"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
</div>
@endif
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Zugangs-Name (optional)</label>
<input wire:model="credentialName" type="text" placeholder="z. B. Root-Login · Deploy-User" maxlength="120"
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.create_server.credential_name_label') }}</label>
<input wire:model="credentialName" type="text" placeholder="{{ __('modals.create_server.credential_name_placeholder') }}" maxlength="120"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
@error('credentialName')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
<x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
Hinzufügen
{{ __('common.add') }}
</x-btn>
</div>
</div>

View File

@ -4,32 +4,32 @@
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">SSH-Zugang</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">Hier den Zugang hinterlegen z. B. <span class="font-mono text-ink-3">root</span> für volle Steuerung (systemctl, Journal).</p>
<h2 class="font-display text-base font-semibold text-ink">{{ __('modals.edit_credential.title') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('modals.edit_credential.subtitle_before') }} <span class="font-mono text-ink-3">root</span> {{ __('modals.edit_credential.subtitle_after') }}</p>
</div>
</div>
<div class="mt-4 space-y-3">
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Name (optional)</label>
<input wire:model="name" type="text" placeholder="z. B. Prod-Server X · Backup-User" maxlength="120"
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.edit_credential.name_label') }}</label>
<input wire:model="name" type="text" placeholder="{{ __('modals.edit_credential.name_placeholder') }}" maxlength="120"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Benutzer</label>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.edit_credential.user_label') }}</label>
<input wire:model="username" type="text" placeholder="root"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Authentifizierung</label>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.edit_credential.auth_label') }}</label>
<select wire:model.live="authType"
class="h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none">
<option value="password">Passwort</option>
<option value="key">Privater Schlüssel</option>
<option value="password">{{ __('modals.edit_credential.auth_password') }}</option>
<option value="key">{{ __('modals.edit_credential.auth_key') }}</option>
</select>
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? 'Privater Schlüssel (PEM)' : 'Passwort' }}</label>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? __('modals.edit_credential.secret_key_label') : __('modals.edit_credential.secret_password_label') }}</label>
@if ($authType === 'key')
<textarea wire:model="secret" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea>
@ -40,8 +40,8 @@
</div>
@if ($authType === 'key')
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Passphrase (optional)</label>
<input wire:model="passphrase" type="password" placeholder="leer = keine"
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.edit_credential.passphrase_label') }}</label>
<input wire:model="passphrase" type="password" placeholder="{{ __('modals.edit_credential.passphrase_placeholder') }}"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
</div>
@endif
@ -54,11 +54,11 @@
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
Speichern
{{ __('common.save') }}
</x-btn>
</div>
</div>

View File

@ -9,10 +9,9 @@
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">IP manuell sperren</h2>
<h2 class="font-display text-base font-semibold text-ink">{{ __('modals.fail2ban_ban.title') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">
Sperrt eine IP-Adresse sofort im gewählten Jail. Loopback und der Clusev-Zugang
können nicht gesperrt werden.
{{ __('modals.fail2ban_ban.subtitle') }}
</p>
</div>
</div>
@ -26,7 +25,7 @@
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="{{ $label }}">Jail</label>
<label class="{{ $label }}">{{ __('modals.fail2ban_ban.jail_label') }}</label>
<select wire:model="jail" class="{{ $select }}">
@forelse ($jails as $j)
<option value="{{ $j }}">{{ $j }}</option>
@ -36,16 +35,16 @@
</select>
</div>
<div>
<label class="{{ $label }}">IP-Adresse</label>
<input wire:model="ip" type="text" placeholder="z. B. 203.0.113.10" class="{{ $field }}" />
<label class="{{ $label }}">{{ __('modals.fail2ban_ban.ip_label') }}</label>
<input wire:model="ip" type="text" placeholder="{{ __('modals.fail2ban_ban.ip_placeholder') }}" class="{{ $field }}" />
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
IP sperren
{{ __('modals.fail2ban_ban.submit') }}
</x-btn>
</div>
</div>

View File

@ -9,7 +9,7 @@
<span class="block truncate font-mono text-[11px] text-ink-3">{{ $active->ip }}</span>
</span>
@else
<span class="min-w-0 flex-1 text-sm text-ink-3">Kein Server</span>
<span class="min-w-0 flex-1 text-sm text-ink-3">{{ __('servers.switcher_no_server') }}</span>
@endif
<x-icon name="switcher" class="h-4 w-4 shrink-0 text-ink-3" />
</button>

View File

@ -1,42 +1,42 @@
@php
$label = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline'];
$label = ['online' => __('servers.status_online'), 'warning' => __('servers.status_warning'), 'offline' => __('servers.status_offline')];
@endphp
<div class="space-y-4">
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Flotte</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Server</h2>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('servers.fleet_eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('servers.heading') }}</h2>
</div>
<div class="flex items-center gap-2">
<x-btn variant="accent" wire:click="$dispatch('openModal', { component: 'modals.create-server' })">
<x-icon name="plus" class="h-3.5 w-3.5" />
Server hinzufügen
{{ __('servers.add_server') }}
</x-btn>
<x-status-pill status="online">{{ $online }} / {{ $total }} online</x-status-pill>
<x-status-pill status="online">{{ __('servers.online_of_total', ['online' => $online, 'total' => $total]) }}</x-status-pill>
</div>
</div>
{{-- KPI grid: 1 2 4 --}}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<x-kpi label="Gesamt" :value="$total" icon="server" />
<x-kpi label="Online" :value="$online" tone="online" icon="activity" />
<x-kpi label="Warnung" :value="$warning" tone="warning" />
<x-kpi label="Offline" :value="$offline" tone="offline" />
<x-kpi :label="__('servers.kpi_total')" :value="$total" icon="server" />
<x-kpi :label="__('servers.kpi_online')" :value="$online" tone="online" icon="activity" />
<x-kpi :label="__('servers.kpi_warning')" :value="$warning" tone="warning" />
<x-kpi :label="__('servers.kpi_offline')" :value="$offline" tone="offline" />
</div>
{{-- Fleet --}}
<x-panel title="Flotte" :subtitle="$total . ' im Cluster'" :padded="false">
<x-panel :title="__('servers.fleet_title')" :subtitle="__('servers.in_cluster', ['count' => $total])" :padded="false">
<x-slot:actions>
<label class="relative block">
<span class="sr-only">Server suchen</span>
<span class="sr-only">{{ __('servers.search_label') }}</span>
<x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
<input
type="search"
data-page-search
wire:model.live.debounce.300ms="search"
placeholder="Name oder IP…"
placeholder="{{ __('servers.search_placeholder') }}"
class="min-h-11 w-full rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56"
/>
</label>
@ -44,8 +44,8 @@
@if ($servers->isEmpty())
<div class="px-4 py-10 text-center sm:px-5">
<p class="text-sm text-ink-2">Keine Server gefunden.</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">Suchbegriff anpassen oder Filter zurücksetzen.</p>
<p class="text-sm text-ink-2">{{ __('servers.empty_title') }}</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('servers.empty_hint') }}</p>
</div>
@else
<div class="divide-y divide-line">

View File

@ -2,7 +2,7 @@
// Threshold tone for gauges/bars: >=90 offline, >=75 warning, else online.
$tone = fn (int $v): string => $v >= 90 ? 'offline' : ($v >= 75 ? 'warning' : 'online');
$barColor = ['online' => 'bg-online', 'warning' => 'bg-warning', 'offline' => 'bg-offline'];
$statusLabel = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline'][$server->status] ?? ucfirst($server->status);
$statusLabel = ['online' => __('servers.status_online'), 'warning' => __('servers.status_warning'), 'offline' => __('servers.status_offline')][$server->status] ?? ucfirst($server->status);
$specs = $server->specs ?? [];
$cores = $specs['cores'] ?? null;
@ -12,27 +12,27 @@
$diskUsed = $diskGb !== null ? (int) round((float) $diskGb * $server->disk / 100) : null;
$specRows = [
['Architektur', $specs['arch'] ?? '—'],
['Kernel', $specs['kernel'] ?? '—'],
['Virtualisierung', $specs['virt'] ?? '—'],
['Kerne', $cores ?? '—'],
['RAM', $ramGb !== null ? $ramGb.' GB' : '—'],
['Disk', $diskGb !== null ? $diskGb.' GB' : '—'],
[__('servers.spec_arch'), $specs['arch'] ?? '—'],
[__('servers.spec_kernel'), $specs['kernel'] ?? '—'],
[__('servers.spec_virt'), $specs['virt'] ?? '—'],
[__('servers.spec_cores'), $cores ?? '—'],
[__('servers.spec_ram'), $ramGb !== null ? __('servers.gb', ['value' => $ramGb]) : '—'],
[__('servers.spec_disk'), $diskGb !== null ? __('servers.gb', ['value' => $diskGb]) : '—'],
];
// Detected OS tooling (populated once the live profile loads).
if (! empty($os)) {
$pm = ($os['packageManager'] ?? 'none') !== 'none' ? $os['packageManager'] : '—';
$fw = ($os['firewallTool'] ?? 'none') !== 'none' ? $os['firewallTool'] : '—';
$specRows[] = ['System', $os['familyLabel'] ?? '—'];
$specRows[] = ['Paketverwaltung', $pm];
$specRows[] = ['Firewall', $fw];
$specRows[] = [__('servers.spec_system'), $os['familyLabel'] ?? '—'];
$specRows[] = [__('servers.spec_package_manager'), $pm];
$specRows[] = [__('servers.spec_firewall'), $fw];
}
$vitals = [
['label' => 'CPU', 'icon' => 'cpu', 'val' => (int) $server->cpu, 'sub' => ($loadAvg > 0 ? 'Last '.number_format($loadAvg, 2).' · ' : '').($cores ? $cores.' Kerne' : '—')],
['label' => 'RAM', 'icon' => 'activity', 'val' => (int) $server->mem, 'sub' => $ramUsed !== null ? $ramUsed.' / '.$ramGb.' GB' : '—'],
['label' => 'Disk', 'icon' => 'server', 'val' => (int) $server->disk, 'sub' => $diskUsed !== null ? $diskUsed.' / '.$diskGb.' GB' : '—'],
['label' => __('servers.vital_cpu'), 'icon' => 'cpu', 'val' => (int) $server->cpu, 'sub' => ($loadAvg > 0 ? __('servers.load', ['value' => number_format($loadAvg, 2)]).' · ' : '').($cores ? __('servers.cores_count', ['count' => $cores]) : '—')],
['label' => __('servers.vital_ram'), 'icon' => 'activity', 'val' => (int) $server->mem, 'sub' => $ramUsed !== null ? __('servers.ram_used_of_total', ['used' => $ramUsed, 'total' => $ramGb]) : '—'],
['label' => __('servers.vital_disk'), 'icon' => 'server', 'val' => (int) $server->disk, 'sub' => $diskUsed !== null ? __('servers.disk_used_of_total', ['used' => $diskUsed, 'total' => $diskGb]) : '—'],
];
@endphp
@ -40,7 +40,7 @@
<a href="{{ route('servers.index') }}"
class="inline-flex min-h-11 items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.2em] text-ink-3 hover:text-ink-2">
<x-icon name="switcher" class="h-3.5 w-3.5 rotate-90" />
Zurück zur Flotte
{{ __('servers.back_to_fleet') }}
</a>
{{-- Hero header --}}
@ -66,18 +66,18 @@
<span class="text-ink-4">·</span>
<span>{{ $server->os }}</span>
<span class="text-ink-4">·</span>
<span>Uptime {{ $server->uptime ?: '—' }}</span>
<span>{{ __('servers.uptime', ['value' => $server->uptime ?: '—']) }}</span>
<span class="text-ink-4">·</span>
<span>zuletzt {{ optional($server->last_seen_at)->diffForHumans() ?? '—' }}</span>
<span>{{ __('servers.last_seen', ['value' => optional($server->last_seen_at)->diffForHumans() ?? '—']) }}</span>
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.system-update', arguments: { serverId: {{ $server->id }} } })">
<x-icon name="rotate" class="h-3.5 w-3.5" /> System-Updates
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('servers.system_updates') }}
</x-btn>
<x-btn variant="secondary" wire:click="openFiles">
<x-icon name="folder" class="h-3.5 w-3.5" /> Dateien
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('servers.files') }}
</x-btn>
</div>
</div>
@ -85,7 +85,7 @@
@if ($ready && ! $connected)
<div class="flex items-center gap-2.5 rounded-lg border border-warning/25 bg-warning/10 px-4 py-3">
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
<p class="text-sm text-ink-2">Keine Live-Verbindung gezeigt werden die zuletzt gespeicherten Werte. Zugang/Erreichbarkeit prüfen.</p>
<p class="text-sm text-ink-2">{{ __('servers.offline_banner') }}</p>
</div>
@endif
@ -93,10 +93,10 @@
@php
$cred = $server->credential;
$credStatus = $cred ? ($cred->disabled ? 'offline' : 'online') : null;
$credStatusLabel = $cred ? ($cred->disabled ? 'Gesperrt' : 'Aktiv') : null;
$credAuthLabel = $cred ? ($cred->auth_type === 'key' ? 'SSH-Key' : 'Passwort') : null;
$credStatusLabel = $cred ? ($cred->disabled ? __('servers.cred_locked') : __('servers.cred_active')) : null;
$credAuthLabel = $cred ? ($cred->auth_type === 'key' ? __('servers.cred_auth_key') : __('servers.cred_auth_password')) : null;
@endphp
<x-panel title="SSH-Zugang" subtitle="Hinterlegte Anmeldung für die Live-Steuerung" :padded="false">
<x-panel :title="__('servers.ssh_access_title')" :subtitle="__('servers.ssh_access_subtitle')" :padded="false">
@if ($cred)
<div class="flex flex-wrap items-center gap-x-4 gap-y-3 px-4 py-4 sm:px-5">
<span @class([
@ -118,15 +118,15 @@
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.edit-credential', arguments: { serverId: {{ $server->id }} } })">
<x-icon name="settings" class="h-3.5 w-3.5" /> Bearbeiten
<x-icon name="settings" class="h-3.5 w-3.5" /> {{ __('common.edit') }}
</x-btn>
<x-btn variant="secondary" wire:click="toggleCredential" wire:target="toggleCredential" wire:loading.attr="disabled">
<x-icon name="power" class="h-3.5 w-3.5" wire:loading.remove wire:target="toggleCredential" />
<svg wire:loading wire:target="toggleCredential" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ $cred->disabled ? 'Entsperren' : 'Sperren' }}
{{ $cred->disabled ? __('common.unlock') : __('common.lock') }}
</x-btn>
<x-btn variant="ghost-danger" wire:click="confirmDeleteCredential">
<x-icon name="trash" class="h-3.5 w-3.5" /> Löschen
<x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('common.delete') }}
</x-btn>
</div>
</div>
@ -136,11 +136,11 @@
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<p class="truncate font-display text-sm font-semibold text-ink">Kein Zugang hinterlegt</p>
<p class="mt-1.5 font-mono text-[11px] text-ink-3">Ohne SSH-Zugang ist keine Live-Steuerung möglich.</p>
<p class="truncate font-display text-sm font-semibold text-ink">{{ __('servers.cred_none_title') }}</p>
<p class="mt-1.5 font-mono text-[11px] text-ink-3">{{ __('servers.cred_none_hint') }}</p>
</div>
<x-btn variant="accent" class="shrink-0" wire:click="$dispatch('openModal', { component: 'modals.edit-credential', arguments: { serverId: {{ $server->id }} } })">
<x-icon name="plus" class="h-3.5 w-3.5" /> Zugang hinterlegen
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.cred_deposit') }}
</x-btn>
</div>
@endif
@ -164,7 +164,7 @@
@if ($ready)
{{-- Spezifikationen + Sicherheit --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Spezifikationen" subtitle="Hardware-Profil" :padded="false">
<x-panel :title="__('servers.specs_title')" :subtitle="__('servers.specs_subtitle')" :padded="false">
<dl class="divide-y divide-line">
@foreach ($specRows as [$key, $val])
<div class="flex items-center justify-between gap-3 px-4 py-2.5 sm:px-5">
@ -175,7 +175,7 @@
</dl>
</x-panel>
<x-panel title="Sicherheit" subtitle="Hardening-Checkliste" :padded="false">
<x-panel :title="__('servers.security_title')" :subtitle="__('servers.security_subtitle')" :padded="false">
<div class="divide-y divide-line">
@foreach ($hardening as $check)
@php $supported = $check['supported'] ?? true; @endphp
@ -198,16 +198,16 @@
'shrink-0 font-mono text-[10px] uppercase tracking-wider',
'text-online' => $check['secure'],
'text-warning' => ! $check['secure'],
])>{{ $check['secure'] ? 'sicher' : 'offen' }}</span>
])>{{ $check['secure'] ? __('servers.check_secure') : __('servers.check_open') }}</span>
@else
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">n. v.</span>
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.unsupported') }}</span>
@endif
</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $supported ? $check['detail'] : $check['reason'] }}</p>
</div>
@if ($supported)
@if ($check['key'] === 'fail2ban')
<x-btn variant="ghost" size="sm" icon class="shrink-0" title="fail2ban konfigurieren"
<x-btn variant="ghost" size="sm" icon class="shrink-0" title="{{ __('servers.fail2ban_configure') }}"
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })">
<x-icon name="settings" class="h-3.5 w-3.5" />
</x-btn>
@ -215,7 +215,7 @@
{{-- Uniform style across all rows (R10): always the bordered secondary button. --}}
<x-btn variant="secondary" size="sm" class="shrink-0"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })">
{{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }}
{{ $check['featureOn'] ? __('common.disable') : __('common.enable') }}
</x-btn>
@endif
</div>
@ -239,51 +239,51 @@
$fwDefaults = $fw['defaults'] ?? [];
$actTone = ['ALLOW' => 'online', 'LIMIT' => 'warning', 'DENY' => 'offline', 'REJECT' => 'offline'];
@endphp
<x-panel title="Firewall-Regeln"
:subtitle="$fwSupported ? ($fwToolLabel . ($fwInstalled ? ($fwActive ? ' · aktiv' : ' · inaktiv') : ' · nicht installiert')) : 'Nicht verfügbar'"
<x-panel :title="__('servers.firewall_title')"
:subtitle="$fwSupported ? ($fwToolLabel . ($fwInstalled ? ($fwActive ? __('servers.firewall_sub_active') : __('servers.firewall_sub_inactive')) : __('servers.firewall_sub_not_installed'))) : __('servers.firewall_unavailable')"
:padded="false">
@if ($fwSupported && $fwManageable)
<x-slot:actions>
<x-btn variant="accent" size="sm"
wire:click="$dispatch('openModal', { component: 'modals.firewall-rule', arguments: { serverId: {{ $server->id }}, tool: '{{ $fwTool }}' } })">
<x-icon name="plus" class="h-3.5 w-3.5" /> Regel
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.firewall_add_rule') }}
</x-btn>
</x-slot:actions>
@endif
@if (! $fwSupported)
<div class="px-4 py-4 sm:px-5">
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $fw['reason'] ?? 'Firewall-Verwaltung ist auf diesem System nicht verfügbar.' }}</p>
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $fw['reason'] ?? __('servers.firewall_not_supported') }}</p>
</div>
@elseif ($fwReadError)
<div class="flex flex-wrap items-center gap-2.5 px-4 py-4 sm:px-5">
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
<p class="font-mono text-[11px] text-ink-3">Firewall-Status konnte nicht gelesen werden (Verbindung/Rechte). Bitte später erneut laden.</p>
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_read_error') }}</p>
</div>
@elseif (! $fwInstalled)
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
<p class="font-mono text-[11px] text-ink-3">{{ $fwToolLabel }} ist nicht installiert.</p>
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_tool_not_installed', ['tool' => $fwToolLabel]) }}</p>
<x-btn variant="secondary" size="sm"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'firewall', enable: true } })">
Aktivieren
{{ __('common.enable') }}
</x-btn>
</div>
@elseif ($fwTool === 'firewalld' && ! $fwActive)
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
<p class="font-mono text-[11px] text-ink-3">firewalld ist installiert, aber inaktiv.</p>
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewalld_inactive') }}</p>
<x-btn variant="secondary" size="sm"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'firewall', enable: true } })">
Aktivieren
{{ __('common.enable') }}
</x-btn>
</div>
@else
<div class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2.5 sm:px-5">
@if ($fwTool === 'ufw')
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Standard</span>
<x-badge tone="neutral">eingehend: {{ $fwDefaults['incoming'] ?? '—' }}</x-badge>
<x-badge tone="neutral">ausgehend: {{ $fwDefaults['outgoing'] ?? '—' }}</x-badge>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.firewall_default') }}</span>
<x-badge tone="neutral">{{ __('servers.firewall_incoming', ['value' => $fwDefaults['incoming'] ?? '—']) }}</x-badge>
<x-badge tone="neutral">{{ __('servers.firewall_outgoing', ['value' => $fwDefaults['outgoing'] ?? '—']) }}</x-badge>
@else
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Zone</span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.firewall_zone') }}</span>
<x-badge tone="neutral">{{ $fwDefaults['zone'] ?? '—' }}</x-badge>
@endif
</div>
@ -291,7 +291,7 @@
@if ($fwReadOnly)
<div class="flex items-start gap-2.5 border-b border-line bg-inset px-4 py-2.5 sm:px-5">
<x-icon name="lock" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" />
<p class="font-mono text-[11px] leading-relaxed text-ink-3">firewalld: nur Anzeige Regeln bitte direkt am Server verwalten.</p>
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ __('servers.firewall_readonly') }}</p>
</div>
@endif
@ -310,7 +310,7 @@
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm text-ink">{{ $rule['label'] ?? ($rule['to'] ?? $rule['raw']) }}</p>
@if ($fwTool === 'ufw' && ($rule['from'] ?? '') !== '' && ! \Illuminate\Support\Str::contains($rule['from'], 'Anywhere'))
<p class="truncate font-mono text-[11px] text-ink-3">von {{ $rule['from'] }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ __('servers.firewall_rule_from', ['value' => $rule['from']]) }}</p>
@endif
</div>
<span @class([
@ -320,7 +320,7 @@
'text-offline' => $ruleTone === 'offline',
])>{{ strtolower($rule['action'] ?? 'allow') }}</span>
@unless ($fwReadOnly)
<x-btn variant="ghost-danger" size="sm" icon class="shrink-0" title="Regel entfernen"
<x-btn variant="ghost-danger" size="sm" icon class="shrink-0" title="{{ __('servers.firewall_remove_rule') }}"
wire:click="confirmDeleteRule({{ $loop->index }})">
<x-icon name="trash" class="h-3.5 w-3.5" />
</x-btn>
@ -328,7 +328,7 @@
</div>
@empty
<div class="px-4 py-4 sm:px-5">
<p class="font-mono text-[11px] text-ink-3">Keine Regeln definiert.</p>
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_no_rules') }}</p>
</div>
@endforelse
</div>
@ -346,37 +346,37 @@
$f2bIgnore = $f2b['ignoreip'] ?? [];
$f2bBanned = array_sum(array_map(fn ($j) => $j['currentlyBanned'] ?? 0, $f2bJails));
@endphp
<x-panel title="fail2ban-Status"
:subtitle="$f2bSupported ? ($f2bInstalled ? ($f2bActive ? ('aktiv · ' . $f2bBanned . ' gesperrt') : 'installiert · inaktiv') : 'nicht installiert') : 'Nicht verfügbar'"
<x-panel :title="__('servers.fail2ban_title')"
:subtitle="$f2bSupported ? ($f2bInstalled ? ($f2bActive ? __('servers.fail2ban_sub_active', ['count' => $f2bBanned]) : __('servers.fail2ban_sub_installed_inactive')) : __('servers.fail2ban_sub_not_installed')) : __('servers.fail2ban_unavailable')"
:padded="false">
@if ($f2bSupported && $f2bInstalled && $f2bActive)
<x-slot:actions>
<x-btn variant="ghost" size="sm" icon title="fail2ban konfigurieren"
<x-btn variant="ghost" size="sm" icon title="{{ __('servers.fail2ban_configure') }}"
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })">
<x-icon name="settings" class="h-3.5 w-3.5" />
</x-btn>
<x-btn variant="accent" size="sm"
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-ban', arguments: { serverId: {{ $server->id }}, jails: {{ \Illuminate\Support\Js::from(array_map(fn ($j) => $j['name'], $f2bJails)) }} } })">
<x-icon name="lock" class="h-3.5 w-3.5" /> IP sperren
<x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_ban_ip') }}
</x-btn>
</x-slot:actions>
@endif
@if (! $f2bSupported)
<div class="px-4 py-4 sm:px-5">
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $f2b['reason'] ?? 'fail2ban ist auf diesem System nicht verfügbar.' }}</p>
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ $f2b['reason'] ?? __('servers.fail2ban_not_supported') }}</p>
</div>
@elseif ($f2bReadError)
<div class="flex flex-wrap items-center gap-2.5 px-4 py-4 sm:px-5">
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
<p class="font-mono text-[11px] text-ink-3">fail2ban-Status konnte nicht gelesen werden. Bitte später erneut laden.</p>
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.fail2ban_read_error') }}</p>
</div>
@elseif (! $f2bInstalled || ! $f2bActive)
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
<p class="font-mono text-[11px] text-ink-3">fail2ban ist {{ $f2bInstalled ? 'installiert, aber inaktiv' : 'nicht installiert' }}.</p>
<p class="font-mono text-[11px] text-ink-3">{{ $f2bInstalled ? __('servers.fail2ban_state_installed_inactive') : __('servers.fail2ban_state_not_installed') }}</p>
<x-btn variant="secondary" size="sm"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'fail2ban', enable: true } })">
Aktivieren
{{ __('common.enable') }}
</x-btn>
</div>
@else
@ -386,8 +386,8 @@
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="font-mono text-sm text-ink">{{ $jail['name'] }}</p>
<p class="font-mono text-[11px] text-ink-3">
<span @class(['text-offline' => ($jail['currentlyBanned'] ?? 0) > 0])>gebannt: {{ $jail['currentlyBanned'] ?? 0 }}</span>
· Fehlversuche: {{ $jail['currentlyFailed'] ?? 0 }}
<span @class(['text-offline' => ($jail['currentlyBanned'] ?? 0) > 0])>{{ __('servers.fail2ban_banned', ['count' => $jail['currentlyBanned'] ?? 0]) }}</span>
· {{ __('servers.fail2ban_failed', ['count' => $jail['currentlyFailed'] ?? 0]) }}
</p>
</div>
@if (count($jail['bannedIps'] ?? []))
@ -395,12 +395,12 @@
@foreach ($jail['bannedIps'] as $ip)
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-[11px] text-ink-2">{{ $ip }}</span>
<x-btn variant="ghost" size="sm" wire:click="unbanIp({{ \Illuminate\Support\Js::from($jail['name']) }}, {{ \Illuminate\Support\Js::from($ip) }})">Entsperren</x-btn>
<x-btn variant="ghost" size="sm" wire:click="unbanIp({{ \Illuminate\Support\Js::from($jail['name']) }}, {{ \Illuminate\Support\Js::from($ip) }})">{{ __('common.unlock') }}</x-btn>
</div>
@endforeach
</div>
@else
<p class="mt-1 font-mono text-[11px] text-ink-4">Keine gebannten IPs.</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('servers.fail2ban_no_banned') }}</p>
@endif
</div>
@endforeach
@ -408,13 +408,13 @@
{{-- Whitelist (ignoreip) --}}
<div class="border-t border-line px-4 py-3 sm:px-5">
<p class="mb-2 font-mono text-[10px] uppercase tracking-wider text-ink-4">Whitelist (ignoreip)</p>
<p class="mb-2 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.whitelist_label') }}</p>
<div class="flex flex-wrap gap-1.5">
@foreach ($f2bIgnore as $ip)
<span class="inline-flex items-center gap-1.5 rounded-sm border border-line bg-inset px-2 py-1 font-mono text-[11px] text-ink-2">
{{ $ip }}
@unless (in_array($ip, ['127.0.0.1/8', '::1'], true))
<button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="Entfernen">
<button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="{{ __('common.remove') }}">
<x-icon name="x" class="h-3 w-3" />
</button>
@endunless
@ -422,9 +422,9 @@
@endforeach
</div>
<div class="mt-2.5 flex items-center gap-2">
<input wire:model="newIgnoreIp" wire:keydown.enter="addIgnore" type="text" placeholder="IP oder CIDR"
<input wire:model="newIgnoreIp" wire:keydown.enter="addIgnore" type="text" placeholder="{{ __('servers.whitelist_placeholder') }}"
class="h-8 w-full max-w-xs rounded-md border border-line bg-inset px-2.5 font-mono text-[11px] text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
<x-btn variant="secondary" size="sm" wire:click="addIgnore">Hinzufügen</x-btn>
<x-btn variant="secondary" size="sm" wire:click="addIgnore">{{ __('common.add') }}</x-btn>
</div>
</div>
@endif
@ -432,7 +432,7 @@
{{-- Volumes + Netzwerk-Interfaces --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Volumes" :subtitle="count($volumes) . ' Mountpoints'" :padded="false">
<x-panel :title="__('servers.volumes_title')" :subtitle="__('servers.mountpoints', ['count' => count($volumes)])" :padded="false">
<div class="divide-y divide-line">
@forelse ($volumes as $vol)
@php $vt = $tone((int) $vol['used']); @endphp
@ -451,21 +451,21 @@
</div>
</div>
@empty
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine Volumes gelesen.</p>
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('servers.no_volumes') }}</p>
@endforelse
</div>
</x-panel>
<x-panel title="Netzwerk-Interfaces" :subtitle="count($interfaces) . ' Schnittstellen'" :padded="false">
<x-panel :title="__('servers.interfaces_title')" :subtitle="__('servers.interfaces_count', ['count' => count($interfaces)])" :padded="false">
<div class="overflow-x-auto">
<table class="w-full min-w-[34rem] text-left">
<thead>
<tr class="border-b border-line font-mono text-[10px] uppercase tracking-wider text-ink-4">
<th class="px-4 py-2 font-medium sm:px-5">Name</th>
<th class="px-4 py-2 font-medium">IP</th>
<th class="px-4 py-2 font-medium">MAC</th>
<th class="px-4 py-2 text-right font-medium">RX</th>
<th class="px-4 py-2 pr-4 text-right font-medium sm:pr-5">TX</th>
<th class="px-4 py-2 font-medium sm:px-5">{{ __('servers.col_name') }}</th>
<th class="px-4 py-2 font-medium">{{ __('servers.col_ip') }}</th>
<th class="px-4 py-2 font-medium">{{ __('servers.col_mac') }}</th>
<th class="px-4 py-2 text-right font-medium">{{ __('servers.col_rx') }}</th>
<th class="px-4 py-2 pr-4 text-right font-medium sm:pr-5">{{ __('servers.col_tx') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
@ -485,10 +485,10 @@
</div>
{{-- SSH-Schlüssel --}}
<x-panel title="SSH-Schlüssel" :subtitle="count($sshKeys) . ' autorisiert'" :padded="false">
<x-panel :title="__('servers.ssh_keys_title')" :subtitle="__('servers.ssh_keys_authorized', ['count' => count($sshKeys)])" :padded="false">
<x-slot:actions>
<x-btn variant="accent" wire:click="$dispatch('openModal', { component: 'modals.add-ssh-key', arguments: { serverId: {{ $server->id }} } })">
<x-icon name="plus" class="h-3.5 w-3.5" /> Schlüssel hinzufügen
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.ssh_keys_add') }}
</x-btn>
</x-slot:actions>
<div class="divide-y divide-line">
@ -504,20 +504,20 @@
</div>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
</div>
<x-btn variant="ghost-danger" icon wire:click="confirmKeyRemoval({{ $loop->index }})" title="Schlüssel entfernen">
<x-btn variant="ghost-danger" icon wire:click="confirmKeyRemoval({{ $loop->index }})" title="{{ __('servers.ssh_keys_remove') }}">
<x-icon name="trash" class="h-3.5 w-3.5" />
</x-btn>
</div>
@empty
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine autorisierten Schlüssel.</p>
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('servers.ssh_keys_none') }}</p>
@endforelse
</div>
</x-panel>
@else
{{-- skeletons for the SSH-loaded sections --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
@foreach (['Spezifikationen', 'Sicherheit'] as $t)
<x-panel :title="$t" subtitle="lädt…" :padded="false">
@foreach ([__('servers.specs_title'), __('servers.security_title')] as $t)
<x-panel :title="$t" :subtitle="__('servers.loading')" :padded="false">
<div class="space-y-3 p-4 sm:p-5">
<x-skeleton class="h-3 w-full" /><x-skeleton class="h-3 w-2/3" /><x-skeleton class="h-3 w-5/6" /><x-skeleton class="h-3 w-1/2" />
</div>
@ -525,15 +525,15 @@
@endforeach
</div>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
@foreach (['Volumes', 'Netzwerk-Interfaces'] as $t)
<x-panel :title="$t" subtitle="lädt…" :padded="false">
@foreach ([__('servers.volumes_title'), __('servers.interfaces_title')] as $t)
<x-panel :title="$t" :subtitle="__('servers.loading')" :padded="false">
<div class="space-y-3 p-4 sm:p-5">
<x-skeleton class="h-3 w-full" /><x-skeleton class="h-3 w-3/4" /><x-skeleton class="h-3 w-5/6" />
</div>
</x-panel>
@endforeach
</div>
<x-panel title="SSH-Schlüssel" subtitle="lädt…" :padded="false">
<x-panel :title="__('servers.ssh_keys_title')" :subtitle="__('servers.loading')" :padded="false">
<div class="space-y-3 p-4 sm:p-5"><x-skeleton class="h-3 w-2/3" /><x-skeleton class="h-3 w-1/2" /></div>
</x-panel>
@endif

View File

@ -1,5 +1,5 @@
@php
$svcLabel = ['online' => 'aktiv', 'warning' => 'degradiert', 'offline' => 'gestoppt'];
$svcLabel = ['online' => __('services.status_online'), 'warning' => __('services.status_warning'), 'offline' => __('services.status_offline')];
$list = $this->filteredServices;
$total = count($services);
$active = collect($services)->where('status', 'online')->count();
@ -14,24 +14,24 @@
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Dienste</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Dienste</h2>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('services.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('services.heading') }}</h2>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ $server }}</p>
</div>
<x-status-pill :status="$fleetTone">{{ $active }} / {{ $total }} aktiv</x-status-pill>
<x-status-pill :status="$fleetTone">{{ __('services.fleet_active', ['active' => $active, 'total' => $total]) }}</x-status-pill>
</div>
{{-- systemd services --}}
<x-panel title="systemd-Dienste" :subtitle="$total . ' Units · ' . $server" :padded="false">
<x-panel :title="__('services.panel_title')" :subtitle="__('services.panel_subtitle', ['total' => $total, 'server' => $server])" :padded="false">
<x-slot:actions>
<label class="relative block">
<span class="sr-only">Dienste durchsuchen</span>
<span class="sr-only">{{ __('services.search_label') }}</span>
<x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
<input
type="search"
data-page-search
wire:model.live.debounce.250ms="search"
placeholder="Dienst suchen…"
placeholder="{{ __('services.search_placeholder') }}"
class="h-9 w-40 rounded-md border border-line bg-inset pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56"
/>
</label>
@ -71,16 +71,16 @@
{{-- R5: wire to wire-elements/modal --}}
<div class="flex items-center gap-1.5">
<x-btn variant="secondary" wire:click="confirm('start', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">Starten</x-btn>
<x-btn variant="secondary" wire:click="confirm('stop', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Stopp</x-btn>
<x-btn variant="accent" wire:click="confirm('restart', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Neustart</x-btn>
<x-btn variant="secondary" wire:click="confirm('start', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">{{ __('services.start') }}</x-btn>
<x-btn variant="secondary" wire:click="confirm('stop', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.stop') }}</x-btn>
<x-btn variant="accent" wire:click="confirm('restart', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.restart') }}</x-btn>
</div>
</div>
</div>
@empty
<div class="px-4 py-10 text-center sm:px-5">
<p class="font-mono text-sm text-ink-3">Kein Dienst gefunden</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">Suche {{ $search }} ergab keine Treffer.</p>
<p class="font-mono text-sm text-ink-3">{{ __('services.empty_title') }}</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('services.empty_hint', ['search' => $search]) }}</p>
</div>
@endforelse
@endif
@ -88,9 +88,9 @@
</x-panel>
{{-- Journal --}}
<x-panel title="Journal" :subtitle="'journalctl -f · ' . $server" :padded="false">
<x-panel :title="__('services.journal_title')" :subtitle="__('services.journal_subtitle', ['server' => $server])" :padded="false">
<x-slot:actions>
<x-badge tone="cyan">live</x-badge>
<x-badge tone="cyan">{{ __('services.journal_live') }}</x-badge>
</x-slot:actions>
<div class="overflow-x-auto">
@ -108,7 +108,7 @@
<div class="border-t border-line px-4 py-2 sm:px-5">
<p class="flex items-center gap-1.5 font-mono text-[11px] text-ink-4">
<span class="inline-flex h-1.5 w-1.5 rounded-full bg-online"></span>
Folge Journal · {{ count($journal) }} Zeilen
{{ __('services.journal_follow', ['count' => count($journal)]) }}
</p>
</div>
</x-panel>

View File

@ -5,8 +5,8 @@
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline';
$tabs = [
['key' => 'profile', 'label' => 'Profil', 'icon' => 'settings'],
['key' => 'security', 'label' => 'Sicherheit', 'icon' => 'shield'],
['key' => 'profile', 'label' => __('settings.tab_profile'), 'icon' => 'settings'],
['key' => 'security', 'label' => __('settings.tab_security'), 'icon' => 'shield'],
];
@endphp
@ -15,18 +15,18 @@
<div class="flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-panel">
<span class="grid h-14 w-14 shrink-0 place-items-center rounded-xl border border-accent/25 bg-accent/10 font-display text-xl font-semibold text-accent">{{ $initials }}</span>
<div class="min-w-0 flex-1">
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Konto</p>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('settings.account') }}</p>
<h2 class="mt-0.5 truncate font-display text-xl font-semibold text-ink">{{ $u->name }}</h2>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $u->email }}</p>
</div>
<div class="flex items-center gap-2">
<x-badge tone="neutral">Administrator</x-badge>
<x-badge tone="neutral">{{ __('settings.role_admin') }}</x-badge>
<span @class([
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[11px]',
'border-online/30 text-online' => $twoFactorEnabled,
'border-line text-ink-3' => ! $twoFactorEnabled,
])>
<x-status-dot :status="$twoFactorEnabled ? 'online' : 'offline'" />{{ $twoFactorEnabled ? '2FA aktiv' : '2FA aus' }}
<x-status-dot :status="$twoFactorEnabled ? 'online' : 'offline'" />{{ $twoFactorEnabled ? __('settings.two_factor_on') : __('settings.two_factor_off') }}
</span>
</div>
</div>
@ -48,68 +48,68 @@
{{-- Content --}}
<div class="space-y-5">
@if ($tab === 'profile')
<x-panel title="Profil" subtitle="Name und E-Mail">
<x-panel :title="__('settings.profile_title')" :subtitle="__('settings.profile_subtitle')">
<form wire:submit="updateProfile" class="space-y-4">
<div>
<label class="{{ $label }}">Name</label>
<label class="{{ $label }}">{{ __('settings.name') }}</label>
<input wire:model="name" type="text" class="{{ $field }}" />
@error('name') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">E-Mail</label>
<label class="{{ $label }}">{{ __('settings.email') }}</label>
<input wire:model="email" type="email" class="{{ $field }} font-mono" />
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="flex justify-end">
<x-btn variant="primary" type="submit" wire:target="updateProfile" wire:loading.attr="disabled">
<svg wire:loading wire:target="updateProfile" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
Speichern
{{ __('common.save') }}
</x-btn>
</div>
</form>
</x-panel>
@else
<x-panel title="Passwort" subtitle="Mindestens 10 Zeichen">
<x-panel :title="__('settings.password_title')" :subtitle="__('settings.password_subtitle')">
<form wire:submit="updatePassword" class="space-y-4">
<div>
<label class="{{ $label }}">Aktuelles Passwort</label>
<label class="{{ $label }}">{{ __('settings.current_password') }}</label>
<input wire:model="current_password" type="password" autocomplete="current-password" class="{{ $field }} font-mono" />
@error('current_password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="{{ $label }}">Neues Passwort</label>
<label class="{{ $label }}">{{ __('settings.new_password') }}</label>
<input wire:model="password" type="password" autocomplete="new-password" class="{{ $field }} font-mono" />
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">Wiederholen</label>
<label class="{{ $label }}">{{ __('settings.repeat_password') }}</label>
<input wire:model="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }} font-mono" />
</div>
</div>
<div class="flex justify-end">
<x-btn variant="primary" type="submit" wire:target="updatePassword" wire:loading.attr="disabled">
<svg wire:loading wire:target="updatePassword" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
Passwort ändern
{{ __('settings.change_password') }}
</x-btn>
</div>
</form>
</x-panel>
<x-panel title="Zwei-Faktor-Authentifizierung" subtitle="TOTP (Authenticator-App)">
<x-panel :title="__('settings.twofa_title')" :subtitle="__('settings.twofa_subtitle')">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<x-status-dot :status="$twoFactorEnabled ? 'online' : 'offline'" />
<div>
<p class="text-sm text-ink">{{ $twoFactorEnabled ? '2FA ist aktiv' : '2FA ist nicht eingerichtet' }}</p>
<p class="font-mono text-[11px] text-ink-3">{{ $twoFactorEnabled ? 'Der Login erfordert einen TOTP-Code.' : 'Empfohlen — schützt den Login mit einem zweiten Faktor.' }}</p>
<p class="text-sm text-ink">{{ $twoFactorEnabled ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}</p>
<p class="font-mono text-[11px] text-ink-3">{{ $twoFactorEnabled ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}</p>
</div>
</div>
@if ($twoFactorEnabled)
<x-btn variant="ghost-danger" wire:click="confirmDisableTwoFactor">Deaktivieren</x-btn>
<x-btn variant="ghost-danger" wire:click="confirmDisableTwoFactor">{{ __('common.disable') }}</x-btn>
@else
<x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate>
<x-icon name="shield" class="h-3.5 w-3.5" /> Einrichten
<x-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }}
</x-btn>
@endif
</div>

View File

@ -5,86 +5,70 @@
<x-icon name="settings" class="h-6 w-6" />
</span>
<div class="min-w-0 flex-1">
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">System</p>
<h2 class="mt-0.5 truncate font-display text-xl font-semibold text-ink">Domain, TLS & Release-Kanal</h2>
<p class="truncate font-mono text-[11px] text-ink-3">Zugriffsadresse des Panels und Update-Quelle</p>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('system.eyebrow') }}</p>
<h2 class="mt-0.5 truncate font-display text-xl font-semibold text-ink">{{ __('system.heading') }}</h2>
<p class="truncate font-mono text-[11px] text-ink-3">{{ __('system.subheading') }}</p>
</div>
<span @class([
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[11px]',
'border-online/30 text-online' => $hasTls,
'border-warning/30 text-warning' => ! $hasTls,
])>
<x-status-dot :status="$hasTls ? 'online' : 'warning'" />{{ $hasTls ? 'HTTPS aktiv' : 'Klartext-HTTP' }}
<x-status-dot :status="$hasTls ? 'online' : 'warning'" />{{ $hasTls ? __('system.https_active') : __('system.https_plain') }}
</span>
</div>
{{-- Domain & TLS --}}
<x-panel title="Domain & TLS" subtitle="Erreichbarkeit des Panels und Let's-Encrypt-Zertifikat">
<x-panel :title="__('system.tls_title')" :subtitle="__('system.tls_subtitle')">
{{-- Current TLS state --}}
@if ($hasTls)
<div class="mb-4 flex items-start gap-3 rounded-md border border-online/25 bg-online/5 p-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-online" />
<div class="min-w-0">
<p class="text-sm text-ink">TLS aktiv Zertifikat automatisch über Let's Encrypt</p>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">Erreichbar unter
<p class="text-sm text-ink">{{ __('system.tls_active_title') }}</p>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ __('system.tls_active_body_pre') }}
<a href="{{ $panelUrl }}" class="text-accent-text hover:underline">{{ $panelUrl }}</a> ·
wird automatisch ausgestellt und erneuert. Voraussetzung: die Domain zeigt per DNS auf diesen Server.</p>
{{ __('system.tls_active_body_post') }}</p>
</div>
</div>
@else
<div class="mb-4 flex items-start gap-3 rounded-md border border-warning/25 bg-warning/5 p-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div class="min-w-0">
<p class="text-sm text-ink">Bare-IP-Modus: Klartext-HTTP</p>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">Ohne Domain läuft das Panel inkl. 2FA und Audit über unverschlüsseltes HTTP. Domain eintragen TLS wird dann automatisch eingerichtet, ohne weitere Schritte.</p>
<p class="text-sm text-ink">{{ __('system.tls_bare_title') }}</p>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ __('system.tls_bare_body') }}</p>
</div>
</div>
@endif
{{-- Architecture rationale: why TLS is terminated by Caddy (collapsible) --}}
<div class="mb-4 rounded-md border border-line bg-inset p-3" x-data="{ open: false }">
<button type="button" @click="open = ! open" class="flex w-full items-center gap-2 text-left">
<x-icon name="shield" class="h-3.5 w-3.5 shrink-0 text-ink-3" />
<span class="text-sm text-ink-2">Warum läuft TLS über Caddy?</span>
<span class="ml-auto shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4" x-text="open ? 'schließen' : 'mehr'"></span>
</button>
<div x-show="open" x-cloak x-transition class="mt-2.5 space-y-1.5 font-mono text-[11px] leading-relaxed text-ink-3">
<p>Clusev nutzt <span class="text-ink-2">genau einen</span> nach außen offenen Dienst: Caddy. Es terminiert TLS, holt und erneuert das Let's-Encrypt-Zertifikat automatisch und leitet die Reverb-WebSockets (wss) über dieselbe Adresse ohne Skript, Cronjob oder Reload-Hook.</p>
<p>App und Reverb bleiben intern erreichbar (kein offener Port nach außen). Caddy ist ein eigener Container mit eigener Version, daher kann ein App-Update die TLS-Terminierung nie unterbrechen.</p>
<p>Ein zusätzliches certbot + nginx wären <span class="text-ink-2">mehr</span> bewegliche Teile (eigene 443-Konfiguration, WebSocket-Upgrade, Renewal-Timer, Reload-Hook) bei nur einer Domain kein Gewinn.</p>
</div>
</div>
{{-- Read-only: the panel domain is an install-time decision (kept consistent
with URL/WebSocket/cookie security); TLS provisions itself automatically. --}}
<dl class="divide-y divide-line overflow-hidden rounded-md border border-line">
<div class="flex items-center justify-between gap-3 px-4 py-2.5">
<dt class="text-sm text-ink-2">Domain</dt>
<dd class="truncate font-mono text-sm text-ink">{{ $domain !== '' ? $domain : '— (Zugriff per IP)' }}</dd>
<dt class="text-sm text-ink-2">{{ __('system.domain') }}</dt>
<dd class="truncate font-mono text-sm text-ink">{{ $domain !== '' ? $domain : __('system.domain_via_ip') }}</dd>
</div>
<div class="flex items-center justify-between gap-3 px-4 py-2.5">
<dt class="text-sm text-ink-2">Zugriffsadresse</dt>
<dt class="text-sm text-ink-2">{{ __('system.access_address') }}</dt>
<dd class="truncate font-mono text-sm text-ink">
@if ($panelUrl)
<a href="{{ $panelUrl }}" class="text-accent-text hover:underline">{{ $panelUrl }}</a>
@else
HTTP (Bare-IP)
{{ __('system.access_bare_ip') }}
@endif
</dd>
</div>
</dl>
<p class="mt-2.5 font-mono text-[11px] leading-relaxed text-ink-4">
Die Panel-Domain wird bei der Installation gesetzt und bleibt mit URL, WebSocket und
Cookie-Sicherheit konsistent. Zum Ändern <span class="text-ink-3">install.sh</span> mit der
neuen Domain erneut ausführen TLS richtet sich danach automatisch ein.
{{ __('system.domain_note_pre') }} <span class="text-ink-3">install.sh</span> {{ __('system.domain_note_post') }}
</p>
</x-panel>
{{-- Release-Kanal --}}
<x-panel title="Release-Kanal" subtitle="Quelle für Updates">
<x-panel :title="__('system.channel_title')" :subtitle="__('system.channel_subtitle')">
<div class="space-y-4">
{{-- Segmented control --}}
<div role="radiogroup" aria-label="Release-Kanal" class="inline-flex w-full max-w-md rounded-md border border-line bg-inset p-1">
<div role="radiogroup" aria-label="{{ __('system.channel_radiogroup_label') }}" class="inline-flex w-full max-w-md rounded-md border border-line bg-inset p-1">
@foreach ($channels as $key => $desc)
<button type="button"
role="radio"
@ -104,7 +88,7 @@
<div class="flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
<x-icon name="git-branch" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
<div class="min-w-0">
<p class="text-sm text-ink">Kanal: <span class="font-mono text-accent-text">{{ $channel }}</span></p>
<p class="text-sm text-ink">{{ __('system.channel_current') }} <span class="font-mono text-accent-text">{{ $channel }}</span></p>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ $channels[$channel] ?? '' }}</p>
</div>
</div>

View File

@ -19,13 +19,13 @@
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Version</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Version &amp; Releases</h2>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('versions.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('versions.heading') }}</h2>
</div>
<x-btn variant="secondary" wire:click="checkUpdates" wire:target="checkUpdates" wire:loading.attr="disabled">
<svg wire:loading wire:target="checkUpdates" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="checkUpdates" />
Nach Updates suchen
{{ __('versions.check_updates') }}
</x-btn>
</div>
@ -45,27 +45,27 @@
<div class="min-w-0 flex-1">
<p class="flex flex-wrap items-center gap-2 font-display text-base font-semibold text-ink">
Clusev <span class="font-mono text-accent-text">v{{ $version }}</span>
<x-badge tone="accent">Kanal: {{ $channel }}</x-badge>
<x-badge tone="accent">{{ __('versions.channel_badge', ['channel' => $channel]) }}</x-badge>
@if ($latestTag)
<x-badge tone="neutral">neuestes Release v{{ $latestTag }}</x-badge>
<x-badge tone="neutral">{{ __('versions.latest_release_badge', ['tag' => $latestTag]) }}</x-badge>
@endif
</p>
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">
@if ($updateStatus)
{{ $updateStatus }}
@else
Installierte Version. „Nach Updates suchen" vergleicht sie ehrlich mit dem neuesten getaggten Release im Kanal. Aktualisierung läuft über den Deploy (Image ziehen + migrieren).
{{ __('versions.banner_default') }}
@endif
</p>
</div>
@if ($lastChecked)
<span class="shrink-0 font-mono text-[11px] text-ink-4">geprüft {{ $lastChecked }}</span>
<span class="shrink-0 font-mono text-[11px] text-ink-4">{{ __('versions.checked_at', ['time' => $lastChecked]) }}</span>
@endif
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
{{-- Release history BY VERSION --}}
<x-panel title="Änderungsprotokoll" :subtitle="count($releases) . ' Versionen'" :padded="false">
<x-panel :title="__('versions.changelog_title')" :subtitle="__('versions.changelog_subtitle', ['count' => count($releases)])" :padded="false">
<div class="px-4 py-4 sm:px-5">
@forelse ($releases as $rel)
<div class="flex gap-4 pb-6 last:pb-0">
@ -77,8 +77,8 @@
{{-- Version + date header --}}
<div class="flex flex-wrap items-center gap-2">
@if ($rel['unreleased'])
<span class="font-display text-sm font-semibold text-ink">Unreleased</span>
<x-badge tone="cyan">in Arbeit</x-badge>
<span class="font-display text-sm font-semibold text-ink">{{ __('versions.unreleased') }}</span>
<x-badge tone="cyan">{{ __('versions.in_progress') }}</x-badge>
@else
<span class="font-mono text-sm font-semibold text-accent-text">v{{ $rel['version'] }}</span>
@if ($rel['date'])
@ -108,39 +108,39 @@
</div>
</div>
@empty
<p class="font-mono text-[11px] text-ink-3">Kein Änderungsprotokoll gefunden.</p>
<p class="font-mono text-[11px] text-ink-3">{{ __('versions.changelog_empty') }}</p>
@endforelse
</div>
</x-panel>
{{-- Aside --}}
<div class="space-y-5">
<x-panel title="Installiert" :padded="false">
<x-panel :title="__('versions.installed_title')" :padded="false">
<div class="space-y-3 px-4 py-4 sm:px-5">
<div class="flex items-baseline gap-2">
<span class="font-display text-2xl font-bold text-ink">v{{ $version }}</span>
<span class="inline-flex items-center gap-1.5 font-mono text-[11px] text-online"><x-status-dot status="online" />läuft</span>
<span class="inline-flex items-center gap-1.5 font-mono text-[11px] text-online"><x-status-dot status="online" />{{ __('versions.running') }}</span>
</div>
<dl class="space-y-2 font-mono text-[11px]">
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Build</dt><dd class="text-ink-2">{{ $build['sha'] ?? '—' }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Branch</dt><dd class="truncate text-ink-2">{{ $build['branch'] }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Kanal</dt><dd class="text-ink-2">{{ $channel }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Neuestes Release</dt><dd class="text-ink-2">{{ $latestTag ? 'v'.$latestTag : '—' }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Lizenz</dt><dd class="text-ink-2">{{ $license }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.build') }}</dt><dd class="text-ink-2">{{ $build['sha'] ?? '—' }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.branch') }}</dt><dd class="truncate text-ink-2">{{ $build['branch'] }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.channel') }}</dt><dd class="text-ink-2">{{ $channel }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.latest_release') }}</dt><dd class="text-ink-2">{{ $latestTag ? 'v'.$latestTag : '—' }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.license') }}</dt><dd class="text-ink-2">{{ $license }}</dd></div>
</dl>
</div>
</x-panel>
<x-panel title="Aktualisierung" :padded="false">
<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">Updates kommen über getaggte Releases im Deploy neues Image ziehen und Migrationen anwenden:</p>
<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>
</div>
</x-panel>
<x-panel title="Projekt" :padded="false">
<x-panel :title="__('versions.project_title')" :padded="false">
<div class="space-y-3 px-4 py-4 sm:px-5">
<a href="{{ $repository }}" target="_blank" rel="noopener noreferrer" class="flex items-center gap-3 rounded-md border border-line bg-inset px-3 py-2.5 transition-colors hover:border-accent/30">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-raised text-ink-3"><x-icon name="git-branch" class="h-4 w-4" /></span>
@ -150,7 +150,7 @@ artisan migrate --force</pre>
</span>
<x-icon name="chevron-left" class="h-4 w-4 shrink-0 rotate-180 text-ink-4" />
</a>
<p class="flex items-center gap-2 font-mono text-[11px] text-ink-3"><x-icon name="shield" class="h-3.5 w-3.5 shrink-0 text-cyan" /> Open Core · {{ $license }}</p>
<p class="flex items-center gap-2 font-mono text-[11px] text-ink-3"><x-icon name="shield" class="h-3.5 w-3.5 shrink-0 text-cyan" /> {{ __('versions.open_core', ['license' => $license]) }}</p>
</div>
</x-panel>
</div>

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\EnsureSecurityOnboarded;
use App\Http\Middleware\SetLocale;
use App\Livewire\Audit;
use App\Livewire\Auth;
use App\Livewire\Dashboard;
@ -10,9 +11,23 @@ use App\Livewire\Services;
use App\Livewire\Settings;
use App\Livewire\System;
use App\Livewire\Versions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth as AuthFacade;
use Illuminate\Support\Facades\Route;
// Language switch (public — also usable from the login page). Persists to the signed-in
// user and the session; only known locales (R16) are accepted, then we return back.
Route::get('/locale/{locale}', function (Request $request, string $locale) {
if (in_array($locale, SetLocale::SUPPORTED, true)) {
$request->session()->put('locale', $locale);
if ($user = $request->user()) {
$user->forceFill(['locale' => $locale])->save();
}
}
return redirect()->back();
})->name('locale.set');
Route::middleware('guest')->group(function () {
Route::get('/login', Auth\Login::class)->name('login');
Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge');

View File

@ -258,22 +258,24 @@ npm run build # ❌ run node inside the vite/app container
---
## R9 — German UI copy, no emoji
## R9 — UI copy register: terse, operational, no emoji
**Why.** Console/status-page register: terse, operational, factual. Status is communicated
by **color/dots/pills**, never emoji. Technical identifiers stay native.
by **color/dots/pills**, never emoji. Technical identifiers stay native. German is the
default language, but every visible string is **localized** via lang files — see **R16**
(no hard-coded copy in views/components/services).
✅ **Correct**
```blade
<x-status-pill status="online">Online</x-status-pill>
<x-status-pill status="online">{{ __('common.online') }}</x-status-pill>
<span class="font-mono">nginx.service</span>
<button>Dienst neu starten</button>
<button>{{ __('services.restart') }}</button>
```
❌ **Forbidden**
```blade
<span>🟢 Online ✅</span> {{-- ❌ emoji as status --}}
<button>Restart service</button> {{-- ❌ English UI copy --}}
<span>🟢 Online ✅</span> {{-- ❌ emoji as status --}}
<button>Dienst neu starten</button> {{-- ❌ hard-coded copy (must be __('…'), R16) --}}
```
Keep native technical tokens as-is: `nginx.service`, `systemd`, `chmod 600`, `SSH`, `2FA`.
@ -452,6 +454,51 @@ Marking a task done while Codex still reports an error or a security issue.
---
## R16 — Every UI string is localized (German + English)
**Why.** Clusev ships in **two languages, user-selectable** (DE default, EN). There must be
**no hard-coded user-facing copy** anywhere — every visible string (views, Livewire components,
service messages, notifications, validation, modal copy) goes through Laravel localization so a
new string is never English-only or German-only.
**Structure (mandatory).** Translations live in `lang/<code>/<group>.php` returning a nested
array, one **group per feature area** mirroring the view tree, plus a shared `common` group:
```
lang/
de/ common.php auth.php dashboard.php servers.php services.php files.php
audit.php settings.php system.php versions.php modals.php shell.php
en/ (same files, same keys)
```
- Access with `__('group.key')` / `@lang`; interpolate with `:placeholders`
(`__('servers.banned', ['ip' => $ip])`).
- **Keys are identical across `de` and `en`** — every key MUST exist in BOTH. Reuse `common.*`
for shared actions/status (`save`, `cancel`, `online`, …) instead of duplicating.
- Supported locales are declared once in `App\Http\Middleware\SetLocale::SUPPORTED`; the active
locale comes from the user's saved preference → session → `config('app.locale')`.
✅ **Correct**
```blade
<x-btn>{{ __('common.save') }}</x-btn>
<h2>{{ __('servers.detail_title') }}</h2>
```
```php
$this->dispatch('notify', message: __('servers.credential_saved'));
```
❌ **Forbidden**
```blade
<x-btn>Speichern</x-btn> {{-- ❌ hard-coded — add a key to lang/{de,en} --}}
```
```php
$this->error = 'Server nicht gefunden.'; // ❌ use __('common.server_not_found')
```
Adding a string in only one language, or omitting a key from `en`/`de`, is a rule violation.
---
## Appendix — Secret hygiene
The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in