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
parent
c34ced0c0e
commit
5691051ad8
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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 app’s clock.',
|
||||
|
||||
// ── Validation attribute names ───────────────────────────────────────
|
||||
'attr_current_password' => 'current password',
|
||||
'attr_new_password' => 'new password',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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 firewalld’s 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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
59
rules.md
59
rules.md
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue