i18n: complete DE/EN localization (modals, shell, backend) + v0.3.0
- modals: wire 5 hard-coded views + 10 components to lang/{de,en}/modals.php
- shell: command-palette (incl. app.js cmdk(nav,actions) refactor), server-item,
toaster fallback
- backend: new lang/{de,en}/backend.php; localize Hardening/Firewall/Fail2ban/
Maintenance services, OsProfile, plus FleetService + CredentialVault errors
- fix: localized page <title> on Dashboard/Servers index+show (->title(), the
Livewire-3 way; the stray title() methods were never auto-called)
- fix: topbar inline @php() was swallowed by Blade's raw-block precompiler up to a
later @endphp -> 500 on every authed page; converted to block @php ... @endphp
- versions: localize changelog section labels via a canonical heading map
- translation-quality pass: Unlock, Add credentials, clearer auth/notify copy
Bump 0.2.1 -> 0.3.0; CHANGELOG.
Verified: Pint clean; npm build OK; R12 all routes HTTP 200 + 0 console errors in
BOTH locales (titles/nav/labels switch language); 589 used keys resolve in de+en
with full group parity; Codex review clean; adversarial DE/EN audit (0 high).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
v0.3.0
parent
5691051ad8
commit
ec1516184e
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -13,6 +13,27 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
|
|||
|
||||
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
|
||||
|
||||
## [0.3.0] - 2026-06-13
|
||||
|
||||
Zweisprachige Oberfläche: die gesamte UI gibt es jetzt auf Deutsch und Englisch, frei umschaltbar pro Konto.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Sprache wählbar (Deutsch / Englisch).** Ein DE/EN-Umschalter (oben rechts, sowie auf der
|
||||
Login-Seite) stellt die Sprache um; die Wahl wird pro Konto gespeichert und gilt sofort für
|
||||
die gesamte Oberfläche. Ohne Wahl greift die Standardsprache (Deutsch).
|
||||
- Übersetzungen liegen strukturiert unter `lang/{de,en}/<gruppe>.php` — eine Gruppe je Feature
|
||||
plus geteiltes `common`, Schlüssel in beiden Sprachen identisch.
|
||||
|
||||
### Geändert
|
||||
- **Alle sichtbaren Texte sind lokalisiert.** Views, Komponenten und Backend-Services geben
|
||||
Text ausschließlich über `__('gruppe.schlüssel')` aus, nie mehr fest verdrahtet — neue,
|
||||
verbindliche Regel **R16**. Native Technik-Begriffe (`nginx.service`, `SSH`, `2FA`, Ports)
|
||||
bleiben unverändert.
|
||||
|
||||
### Entfernt
|
||||
- Erläuternde „Warum …?"-Texte auf der System-Seite — die Seite zeigt nur noch Funktion und
|
||||
Status.
|
||||
|
||||
## [0.2.1] - 2026-06-13
|
||||
|
||||
Verfeinerungen an 0.2.0: vollautomatisches TLS ohne sichtbare Konfiguration und einheitliche Buttons.
|
||||
|
|
|
|||
|
|
@ -131,6 +131,6 @@ class Dashboard extends Component
|
|||
'loadSub' => $cores.' '.($cores === 1 ? __('dashboard.core') : __('dashboard.cores')),
|
||||
'services' => $this->svcRows,
|
||||
'events' => AuditEvent::with('server')->latest()->limit(6)->get(),
|
||||
]);
|
||||
])->title($this->title());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class AddSshKey extends ModalComponent
|
|||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -73,7 +73,7 @@ class AddSshKey extends ModalComponent
|
|||
]);
|
||||
|
||||
$this->dispatch('keyChanged');
|
||||
$this->dispatch('notify', message: 'SSH-Schlüssel hinzugefügt.');
|
||||
$this->dispatch('notify', message: __('modals.add_ssh_key.notify_added'));
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ use LivewireUI\Modal\ModalComponent;
|
|||
*/
|
||||
class ConfirmAction extends ModalComponent
|
||||
{
|
||||
public string $heading = 'Aktion bestätigen';
|
||||
public string $heading = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public string $confirmLabel = 'Bestätigen';
|
||||
public string $confirmLabel = '';
|
||||
|
||||
public bool $danger = false;
|
||||
|
||||
|
|
@ -40,14 +40,29 @@ class ConfirmAction extends ModalComponent
|
|||
public array $params = [];
|
||||
|
||||
/** Toast shown on confirm. Pass '' to defer the notification to the handler
|
||||
* (e.g. when the real outcome is only known after a remote command runs). */
|
||||
public string $notify = 'Aktion ausgeführt.';
|
||||
* (e.g. when the real outcome is only known after a remote command runs);
|
||||
* leave null to fall back to the generic default toast. */
|
||||
public ?string $notify = null;
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'md';
|
||||
}
|
||||
|
||||
/** Apply localized fallbacks for any copy the opener did not pass. */
|
||||
public function boot(): void
|
||||
{
|
||||
if ($this->heading === '') {
|
||||
$this->heading = __('modals.confirm_action.default_heading');
|
||||
}
|
||||
if ($this->confirmLabel === '') {
|
||||
$this->confirmLabel = __('common.confirm');
|
||||
}
|
||||
if ($this->notify === null) {
|
||||
$this->notify = __('modals.confirm_action.default_notify');
|
||||
}
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
if ($this->auditAction !== '') {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class CreateServer extends ModalComponent
|
|||
$numericDotted = preg_match('/^[0-9.]+$/', $value) === 1;
|
||||
$isHost = ! $numericDotted && preg_match('/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/', $value) === 1;
|
||||
if (! $isIp && ! $isHost) {
|
||||
$fail('Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.');
|
||||
$fail(__('modals.create_server.validation_ip_or_host'));
|
||||
}
|
||||
}],
|
||||
'sshPort' => ['required', 'integer', 'min:1', 'max:65535'],
|
||||
|
|
@ -72,13 +72,13 @@ class CreateServer extends ModalComponent
|
|||
protected function validationAttributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Name',
|
||||
'ip' => 'IP/Host',
|
||||
'sshPort' => 'SSH-Port',
|
||||
'username' => 'Benutzer',
|
||||
'authType' => 'Authentifizierung',
|
||||
'secret' => $this->authType === 'key' ? 'Privater Schlüssel' : 'Passwort',
|
||||
'credentialName' => 'Zugangs-Name',
|
||||
'name' => __('modals.create_server.attr_name'),
|
||||
'ip' => __('modals.create_server.attr_ip'),
|
||||
'sshPort' => __('modals.create_server.attr_ssh_port'),
|
||||
'username' => __('modals.create_server.attr_user'),
|
||||
'authType' => __('modals.create_server.attr_auth'),
|
||||
'secret' => $this->authType === 'key' ? __('modals.create_server.attr_secret_key') : __('modals.create_server.attr_secret_password'),
|
||||
'credentialName' => __('modals.create_server.attr_credential_name'),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ class CreateServer extends ModalComponent
|
|||
});
|
||||
|
||||
$this->dispatch('serverCreated');
|
||||
$this->dispatch('notify', message: 'Server hinzugefügt.');
|
||||
$this->dispatch('notify', message: __('modals.create_server.notify_added'));
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,17 +50,17 @@ class EditCredential extends ModalComponent
|
|||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
if (trim($this->username) === '' || trim($this->secret) === '') {
|
||||
$this->error = 'Benutzer und Passwort/Schlüssel sind erforderlich.';
|
||||
$this->error = __('modals.edit_credential.error_required');
|
||||
|
||||
return;
|
||||
}
|
||||
if (mb_strlen(trim($this->name)) > 120) {
|
||||
$this->error = 'Name darf höchstens 120 Zeichen lang sein.';
|
||||
$this->error = __('modals.edit_credential.error_name_too_long');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -84,7 +84,7 @@ class EditCredential extends ModalComponent
|
|||
]);
|
||||
|
||||
$this->dispatch('credentialChanged');
|
||||
$this->dispatch('notify', message: 'Zugangsdaten gespeichert.');
|
||||
$this->dispatch('notify', message: __('modals.edit_credential.notify_saved'));
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class Fail2banBan extends ModalComponent
|
|||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ class Fail2banBan extends ModalComponent
|
|||
}
|
||||
|
||||
if (! $res['ok']) {
|
||||
$this->error = $res['output'] !== '' ? $res['output'] : 'Sperren fehlgeschlagen.';
|
||||
$this->error = $res['output'] !== '' ? $res['output'] : __('modals.fail2ban_ban.error_ban_failed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ class Fail2banBan extends ModalComponent
|
|||
]);
|
||||
|
||||
$this->dispatch('fail2banChanged');
|
||||
$this->dispatch('notify', message: 'IP '.trim($this->ip).' gesperrt.');
|
||||
$this->dispatch('notify', message: __('modals.fail2ban_ban.notify_banned', ['ip' => trim($this->ip)]));
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class Fail2banConfig extends ModalComponent
|
|||
|
||||
$server = Server::find($serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ class Fail2banConfig extends ModalComponent
|
|||
// Never overwrite the remote policy with defaults the operator never saw —
|
||||
// saving is only allowed once the current values were read successfully.
|
||||
if (! $this->loaded) {
|
||||
$this->error = 'Aktuelle Konfiguration konnte nicht gelesen werden — Speichern ist gesperrt.';
|
||||
$this->error = __('modals.fail2ban_config.error_read_locked');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -83,17 +83,17 @@ class Fail2banConfig extends ModalComponent
|
|||
'maxretry' => ['required', 'integer', 'min:1', 'max:100'],
|
||||
'findtime' => $duration,
|
||||
], [
|
||||
'bantime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h, -1 für dauerhaft).',
|
||||
'findtime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h).',
|
||||
'bantime.regex' => __('modals.fail2ban_config.error_bantime_invalid'),
|
||||
'findtime.regex' => __('modals.fail2ban_config.error_findtime_invalid'),
|
||||
], [
|
||||
'bantime' => 'Sperrdauer',
|
||||
'maxretry' => 'Max. Fehlversuche',
|
||||
'findtime' => 'Zeitfenster',
|
||||
'bantime' => __('modals.fail2ban_config.attr_bantime'),
|
||||
'maxretry' => __('modals.fail2ban_config.attr_maxretry'),
|
||||
'findtime' => __('modals.fail2ban_config.attr_findtime'),
|
||||
]);
|
||||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ class Fail2banConfig extends ModalComponent
|
|||
}
|
||||
|
||||
if (! $result['ok']) {
|
||||
$this->error = 'Speichern fehlgeschlagen. '.$result['output'];
|
||||
$this->error = __('modals.fail2ban_config.error_save_failed', ['output' => $result['output']]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -129,7 +129,7 @@ class Fail2banConfig extends ModalComponent
|
|||
]);
|
||||
|
||||
$this->dispatch('hardeningApplied');
|
||||
$this->dispatch('notify', message: 'fail2ban konfiguriert.');
|
||||
$this->dispatch('notify', message: __('modals.fail2ban_config.notify_saved'));
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class FileEditor extends ModalComponent
|
|||
{
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
$this->loaded = true;
|
||||
|
||||
return;
|
||||
|
|
@ -71,7 +71,7 @@ class FileEditor extends ModalComponent
|
|||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -94,7 +94,7 @@ class FileEditor extends ModalComponent
|
|||
]);
|
||||
|
||||
$this->dispatch('fileSaved');
|
||||
$this->dispatch('notify', message: '„'.$this->name.'" gespeichert.');
|
||||
$this->dispatch('notify', message: __('modals.file_editor.notify_saved', ['name' => $this->name]));
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class FirewallRule extends ModalComponent
|
|||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -64,7 +64,7 @@ class FirewallRule extends ModalComponent
|
|||
}
|
||||
|
||||
if (! $res['ok']) {
|
||||
$this->error = $res['output'] !== '' ? $res['output'] : 'Regel konnte nicht hinzugefügt werden.';
|
||||
$this->error = $res['output'] !== '' ? $res['output'] : __('modals.firewall_rule.error_add_failed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -79,7 +79,7 @@ class FirewallRule extends ModalComponent
|
|||
]);
|
||||
|
||||
$this->dispatch('firewallChanged');
|
||||
$this->dispatch('notify', message: 'Firewall-Regel hinzugefügt.');
|
||||
$this->dispatch('notify', message: __('modals.firewall_rule.notify_added'));
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +87,10 @@ class FirewallRule extends ModalComponent
|
|||
private function summary(?int $port): string
|
||||
{
|
||||
if ($this->tool === 'firewalld') {
|
||||
return 'Port '.$port.'/'.($this->proto === 'udp' ? 'udp' : 'tcp').' geöffnet';
|
||||
return __('modals.firewall_rule.summary_port_opened', [
|
||||
'port' => (string) $port,
|
||||
'proto' => $this->proto === 'udp' ? 'udp' : 'tcp',
|
||||
]);
|
||||
}
|
||||
|
||||
$parts = [$this->action];
|
||||
|
|
@ -95,7 +98,7 @@ class FirewallRule extends ModalComponent
|
|||
$parts[] = $port.($this->proto !== 'any' ? '/'.$this->proto : '');
|
||||
}
|
||||
if (trim($this->from) !== '') {
|
||||
$parts[] = 'von '.trim($this->from);
|
||||
$parts[] = __('modals.firewall_rule.summary_from', ['source' => trim($this->from)]);
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class HardeningAction extends ModalComponent
|
|||
|
||||
$server = Server::find($serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ class HardeningAction extends ModalComponent
|
|||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -92,22 +92,22 @@ class HardeningAction extends ModalComponent
|
|||
$this->done = true;
|
||||
$this->ok = $result['ok'];
|
||||
// Clean result only — no raw command output on success; a short reason on failure.
|
||||
$this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: 'Unbekannter Fehler.', 200);
|
||||
$this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: __('modals.hardening_action.error_unknown'), 200);
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'harden.'.$this->action.($this->enable ? '.on' : '.off'),
|
||||
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' (fehlgeschlagen)'),
|
||||
'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' '.__('modals.hardening_action.audit_failed_suffix')),
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
if ($this->ok) {
|
||||
$this->dispatch('hardeningApplied');
|
||||
$this->dispatch('notify', message: $this->heading.' angewendet.');
|
||||
$this->dispatch('notify', message: __('modals.hardening_action.notify_applied', ['action' => $this->heading]));
|
||||
} else {
|
||||
$this->dispatch('notify', message: $this->heading.' fehlgeschlagen.');
|
||||
$this->dispatch('notify', message: __('modals.hardening_action.notify_failed', ['action' => $this->heading]));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class SystemUpdate extends ModalComponent
|
|||
|
||||
$server = Server::find($serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ class SystemUpdate extends ModalComponent
|
|||
|
||||
$server = Server::find($this->serverId);
|
||||
if (! $server) {
|
||||
$this->error = 'Server nicht gefunden.';
|
||||
$this->error = __('common.server_not_found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -86,7 +86,7 @@ class SystemUpdate extends ModalComponent
|
|||
$this->done = true;
|
||||
$this->ok = false;
|
||||
$this->output = $e->getMessage();
|
||||
$this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.');
|
||||
$this->dispatch('notify', message: __('modals.system_update.notify_failed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -102,15 +102,15 @@ class SystemUpdate extends ModalComponent
|
|||
'server_id' => $server->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'system.package_upgrade',
|
||||
'target' => $server->name.($this->ok ? '' : ' (fehlgeschlagen)'),
|
||||
'target' => $server->name.($this->ok ? '' : ' '.__('modals.system_update.audit_failed_suffix')),
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
if ($this->ok) {
|
||||
$this->pending = 0;
|
||||
$this->dispatch('notify', message: 'System aktualisiert.');
|
||||
$this->dispatch('notify', message: __('modals.system_update.notify_updated'));
|
||||
} else {
|
||||
$this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.');
|
||||
$this->dispatch('notify', message: __('modals.system_update.notify_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,6 @@ class Index extends Component
|
|||
'online' => (int) ($counts['online'] ?? 0),
|
||||
'warning' => (int) ($counts['warning'] ?? 0),
|
||||
'offline' => (int) ($counts['offline'] ?? 0),
|
||||
]);
|
||||
])->title($this->title());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -488,6 +488,6 @@ class Show extends Component
|
|||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.servers.show');
|
||||
return view('livewire.servers.show')->title($this->title());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ class Fail2banService
|
|||
|
||||
$res = $this->fleet->runPrivileged($server, $cmd);
|
||||
if (! str_contains($res['output'], 'CLUSEV_READ_OK')) {
|
||||
throw new RuntimeException('fail2ban-Konfiguration konnte nicht gelesen werden.');
|
||||
throw new RuntimeException(__('backend.fail2ban_config_read_failed'));
|
||||
}
|
||||
|
||||
return $this->parseDefaults($res['output']);
|
||||
|
|
@ -184,7 +184,7 @@ class Fail2banService
|
|||
public function unban(Server $server, string $jail, string $ip): array
|
||||
{
|
||||
if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return ['ok' => false, 'output' => 'Ungültige Eingabe.'];
|
||||
return ['ok' => false, 'output' => __('backend.invalid_input')];
|
||||
}
|
||||
if ($reason = $this->support($server)) {
|
||||
return ['ok' => false, 'output' => $reason];
|
||||
|
|
@ -200,7 +200,7 @@ class Fail2banService
|
|||
public function ban(Server $server, string $jail, string $ip): array
|
||||
{
|
||||
if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return ['ok' => false, 'output' => 'Ungültige Eingabe.'];
|
||||
return ['ok' => false, 'output' => __('backend.invalid_input')];
|
||||
}
|
||||
if ($reason = $this->support($server)) {
|
||||
return ['ok' => false, 'output' => $reason];
|
||||
|
|
@ -213,7 +213,7 @@ class Fail2banService
|
|||
$loopback = $this->sameIp($ip, '::1')
|
||||
|| (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false && str_starts_with($ip, '127.'));
|
||||
if ($loopback || ($cp !== null && $this->sameIp($ip, $cp))) {
|
||||
return ['ok' => false, 'output' => 'Diese IP gehört zum Clusev-Zugang und kann nicht gesperrt werden (Aussperrgefahr).'];
|
||||
return ['ok' => false, 'output' => __('backend.ip_belongs_to_clusev')];
|
||||
}
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, 'fail2ban-client set '.$jail.' banip '.$ip);
|
||||
|
|
@ -226,7 +226,7 @@ class Fail2banService
|
|||
{
|
||||
$ip = trim($ip);
|
||||
if (! $this->validIpOrCidr($ip)) {
|
||||
return ['ok' => false, 'output' => 'Ungültige IP-Adresse oder CIDR.'];
|
||||
return ['ok' => false, 'output' => __('backend.invalid_ip_or_cidr')];
|
||||
}
|
||||
if ($reason = $this->support($server)) {
|
||||
return ['ok' => false, 'output' => $reason];
|
||||
|
|
@ -234,7 +234,7 @@ class Fail2banService
|
|||
|
||||
$cfg = $this->readConfig($server);
|
||||
if (in_array($ip, $cfg['ignoreip'], true)) {
|
||||
return ['ok' => true, 'noop' => true, 'output' => 'Bereits auf der Whitelist.'];
|
||||
return ['ok' => true, 'noop' => true, 'output' => __('backend.already_whitelisted')];
|
||||
}
|
||||
$list = $cfg['ignoreip'];
|
||||
$list[] = $ip;
|
||||
|
|
@ -250,12 +250,12 @@ class Fail2banService
|
|||
return ['ok' => false, 'output' => $reason];
|
||||
}
|
||||
if (in_array($ip, self::LOOPBACK, true)) {
|
||||
return ['ok' => false, 'output' => 'Loopback bleibt immer auf der Whitelist.'];
|
||||
return ['ok' => false, 'output' => __('backend.loopback_stays_whitelisted')];
|
||||
}
|
||||
|
||||
$cfg = $this->readConfig($server);
|
||||
if (! in_array($ip, $cfg['ignoreip'], true)) {
|
||||
return ['ok' => true, 'noop' => true, 'output' => 'Nicht auf der Whitelist.'];
|
||||
return ['ok' => true, 'noop' => true, 'output' => __('backend.not_on_whitelist')];
|
||||
}
|
||||
$list = array_values(array_filter($cfg['ignoreip'], fn (string $e): bool => $e !== $ip));
|
||||
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ class FirewallService
|
|||
|
||||
// Rule mutation is ufw-only this release; firewalld is read-only.
|
||||
if ($os->firewallTool === 'firewalld') {
|
||||
return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.'];
|
||||
return ['ok' => false, 'output' => __('backend.firewalld_read_only')];
|
||||
}
|
||||
|
||||
$action = in_array($action, ['allow', 'deny', 'reject', 'limit'], true) ? $action : 'allow';
|
||||
|
|
@ -123,13 +123,13 @@ class FirewallService
|
|||
$from = ($from !== null && trim($from) !== '') ? trim($from) : null;
|
||||
|
||||
if ($port !== null && ($port < 1 || $port > 65535)) {
|
||||
return ['ok' => false, 'output' => 'Ungültiger Port (1–65535).'];
|
||||
return ['ok' => false, 'output' => __('backend.invalid_port')];
|
||||
}
|
||||
if ($from !== null && ! $this->validIpOrCidr($from)) {
|
||||
return ['ok' => false, 'output' => 'Ungültige Quelle — IP-Adresse oder CIDR erwartet.'];
|
||||
return ['ok' => false, 'output' => __('backend.invalid_source')];
|
||||
}
|
||||
if ($port === null && $from === null) {
|
||||
return ['ok' => false, 'output' => 'Port oder Quelle ist erforderlich.'];
|
||||
return ['ok' => false, 'output' => __('backend.port_or_source_required')];
|
||||
}
|
||||
|
||||
// LOCKOUT GUARD: a deny/reject that can cover the SSH connection would sever
|
||||
|
|
@ -140,15 +140,14 @@ class FirewallService
|
|||
$sshPort = $this->sshPort($server);
|
||||
if (in_array($action, ['deny', 'reject'], true) && in_array($proto, ['tcp', 'any'], true)
|
||||
&& ($port === null || $port === $sshPort)) {
|
||||
return ['ok' => false, 'output' => ($port === null
|
||||
? 'Eine '.$action.'-Regel ohne Port würde auch den SSH-Zugang blockieren und ist nicht erlaubt. '
|
||||
: 'Eine '.$action.'-Regel auf den SSH-Port ('.$sshPort.') ist nicht erlaubt (Aussperrgefahr). ')
|
||||
.'Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.'];
|
||||
return ['ok' => false, 'output' => $port === null
|
||||
? __('backend.deny_without_port_blocks_ssh', ['action' => $action])
|
||||
: __('backend.deny_on_ssh_port_blocks_ssh', ['action' => $action, 'port' => $sshPort])];
|
||||
}
|
||||
|
||||
$tool = FirewallTool::for($os);
|
||||
if ($tool->isFirewalld() && $port === null) {
|
||||
return ['ok' => false, 'output' => 'firewalld: Bitte einen Port angeben.'];
|
||||
return ['ok' => false, 'output' => __('backend.firewalld_port_required')];
|
||||
}
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, $tool->addRuleScript($action, $proto, $port, $from), 60);
|
||||
|
|
@ -172,7 +171,7 @@ class FirewallService
|
|||
|
||||
// Rule mutation is ufw-only this release; firewalld is read-only.
|
||||
if ($os->firewallTool === 'firewalld') {
|
||||
return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.'];
|
||||
return ['ok' => false, 'output' => __('backend.firewalld_read_only')];
|
||||
}
|
||||
|
||||
$tool = FirewallTool::for($os);
|
||||
|
|
@ -185,13 +184,13 @@ class FirewallService
|
|||
// also race-free (no rule number to shift).
|
||||
$status = $this->status($server);
|
||||
if (! empty($status['readError'])) {
|
||||
return ['ok' => false, 'output' => 'Firewall-Status konnte nicht gelesen werden — Regel nicht entfernt.'];
|
||||
return ['ok' => false, 'output' => __('backend.firewall_status_read_failed')];
|
||||
}
|
||||
$known = array_column($status['rules'], 'spec');
|
||||
if (! in_array($spec, $known, true)) {
|
||||
// The rule vanished between confirm and apply — report not_found so the
|
||||
// caller does NOT audit a deletion that never happened.
|
||||
return ['ok' => true, 'notFound' => true, 'output' => 'Regel existiert nicht mehr.'];
|
||||
return ['ok' => true, 'notFound' => true, 'output' => __('backend.rule_gone')];
|
||||
}
|
||||
// LOCKOUT GUARD: refuse to remove an allow/limit rule that keeps the SSH port open.
|
||||
if ($this->ufwSpecOpensSsh($spec, $sshPort)) {
|
||||
|
|
@ -245,8 +244,7 @@ class FirewallService
|
|||
/** German refusal shown when a delete would sever Clusev's own SSH access. */
|
||||
private function sshGuardMessage(int $port): string
|
||||
{
|
||||
return "Diese Regel hält den SSH-Zugang (Port {$port}) offen und kann nicht entfernt werden — "
|
||||
.'sonst sperrt sich Clusev selbst aus. Deaktiviere bei Bedarf die gesamte Firewall.';
|
||||
return __('backend.ssh_rule_guard', ['port' => $port]);
|
||||
}
|
||||
|
||||
// NOTE: editing default policies (e.g. "deny incoming") was intentionally NOT
|
||||
|
|
|
|||
|
|
@ -395,7 +395,7 @@ class FleetService
|
|||
try {
|
||||
$content = $sftp->get('.ssh/authorized_keys');
|
||||
if (trim($content) === '') {
|
||||
throw new RuntimeException('authorized_keys leer oder nicht lesbar.');
|
||||
throw new RuntimeException(__('backend.authorized_keys_unreadable'));
|
||||
}
|
||||
|
||||
$lines = array_values(array_filter(
|
||||
|
|
@ -442,7 +442,7 @@ class FleetService
|
|||
|
||||
try {
|
||||
if (! $sftp->delete($path)) {
|
||||
throw new RuntimeException('Loeschen fehlgeschlagen (Rechte?).');
|
||||
throw new RuntimeException(__('backend.delete_failed_perms'));
|
||||
}
|
||||
} finally {
|
||||
$sftp->disconnect();
|
||||
|
|
@ -456,7 +456,7 @@ class FleetService
|
|||
|
||||
try {
|
||||
if ($sftp->size($path) > 50_000_000) {
|
||||
throw new RuntimeException('Datei zu groß für den Download (>50 MB).');
|
||||
throw new RuntimeException(__('backend.file_too_large_download'));
|
||||
}
|
||||
|
||||
return (string) $sftp->get($path);
|
||||
|
|
@ -491,7 +491,7 @@ class FleetService
|
|||
|
||||
try {
|
||||
if (! $sftp->put($path, $content)) {
|
||||
throw new RuntimeException('Speichern fehlgeschlagen (Rechte?).');
|
||||
throw new RuntimeException(__('backend.save_failed_perms'));
|
||||
}
|
||||
} finally {
|
||||
$sftp->disconnect();
|
||||
|
|
@ -504,7 +504,7 @@ class FleetService
|
|||
|
||||
try {
|
||||
if (! $sftp->putFromFile($remotePath, $localPath)) {
|
||||
throw new RuntimeException('Upload fehlgeschlagen (Rechte?).');
|
||||
throw new RuntimeException(__('backend.upload_failed_perms'));
|
||||
}
|
||||
} finally {
|
||||
$sftp->disconnect();
|
||||
|
|
@ -748,7 +748,7 @@ class FleetService
|
|||
}
|
||||
|
||||
if ($out === []) {
|
||||
$out[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => 'Systemjournal benötigt erhöhte Rechte (Gruppe adm/systemd-journal) oder sudo.'];
|
||||
$out[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => __('backend.journal_needs_privileges')];
|
||||
}
|
||||
|
||||
return $out;
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class HardeningService
|
|||
|
||||
$res = $this->fleet->runPrivileged($server, $cmd);
|
||||
if (! $res['ok']) {
|
||||
throw new RuntimeException('Härtungs-Status konnte nicht gelesen werden.');
|
||||
throw new RuntimeException(__('backend.hardening_status_read_failed'));
|
||||
}
|
||||
|
||||
return $this->parseState($res['output'], $os);
|
||||
|
|
@ -99,7 +99,7 @@ class HardeningService
|
|||
$upg = $pkg['autoupdate'] ?? ['installed' => false, 'active' => false];
|
||||
|
||||
$detail = fn (string $name, array $st): string => $name.' · '
|
||||
.(! $st['installed'] ? 'nicht installiert' : ($st['active'] ? 'aktiv' : 'inaktiv'));
|
||||
.(! $st['installed'] ? __('backend.item_not_installed') : ($st['active'] ? __('common.active') : __('common.inactive')));
|
||||
// A package feature is only effectively ON when installed AND active —
|
||||
// stale config (e.g. apt periodic = 1 after removal) must not read as secure.
|
||||
$on = fn (array $st): bool => $st['installed'] && $st['active'];
|
||||
|
|
@ -115,11 +115,11 @@ class HardeningService
|
|||
|
||||
// `featureOn` drives the button (Aktivieren/Deaktivieren); `secure` drives the OK/Offen pill.
|
||||
return [
|
||||
$row('ssh_root', 'SSH-Root-Login', 'PermitRootLogin '.$root, $root !== 'no', $root === 'no', $os->supports('ssh')),
|
||||
$row('ssh_password', 'SSH-Passwort-Login', 'PasswordAuthentication '.$pwauth, $pwauth !== 'no', $pwauth === 'no', $os->supports('ssh')),
|
||||
$row('ssh_root', __('backend.label_ssh_root'), 'PermitRootLogin '.$root, $root !== 'no', $root === 'no', $os->supports('ssh')),
|
||||
$row('ssh_password', __('backend.label_ssh_password'), 'PasswordAuthentication '.$pwauth, $pwauth !== 'no', $pwauth === 'no', $os->supports('ssh')),
|
||||
$row('fail2ban', 'fail2ban', $detail('fail2ban.service', $f2b), $on($f2b), $on($f2b), $os->supports('fail2ban')),
|
||||
$row('firewall', $os->firewallLabel(), $detail($fwName, $fw), $on($fw), $on($fw), $os->supports('firewall')),
|
||||
$row('unattended', 'Automatische Updates', $detail($auName, $upg), $on($upg), $on($upg), $os->supports('auto_updates')),
|
||||
$row('unattended', __('backend.label_auto_updates'), $detail($auName, $upg), $on($upg), $on($upg), $os->supports('auto_updates')),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -127,12 +127,12 @@ class HardeningService
|
|||
public function title(string $action, bool $enable): string
|
||||
{
|
||||
return match ($action) {
|
||||
'ssh_root' => $enable ? 'SSH-Root-Login erlauben' : 'SSH-Root-Login deaktivieren',
|
||||
'ssh_password' => $enable ? 'SSH-Passwort-Login erlauben' : 'SSH-Passwort-Login deaktivieren',
|
||||
'fail2ban' => $enable ? 'fail2ban aktivieren' : 'fail2ban deaktivieren',
|
||||
'firewall' => $enable ? 'Firewall aktivieren' : 'Firewall deaktivieren',
|
||||
'unattended' => $enable ? 'Automatische Updates aktivieren' : 'Automatische Updates deaktivieren',
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
'ssh_root' => $enable ? __('backend.title_ssh_root_enable') : __('backend.title_ssh_root_disable'),
|
||||
'ssh_password' => $enable ? __('backend.title_ssh_password_enable') : __('backend.title_ssh_password_disable'),
|
||||
'fail2ban' => $enable ? __('backend.title_fail2ban_enable') : __('backend.title_fail2ban_disable'),
|
||||
'firewall' => $enable ? __('backend.title_firewall_enable') : __('backend.title_firewall_disable'),
|
||||
'unattended' => $enable ? __('backend.title_unattended_enable') : __('backend.title_unattended_disable'),
|
||||
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -140,21 +140,21 @@ class HardeningService
|
|||
{
|
||||
return match ($action) {
|
||||
'ssh_root' => $enable
|
||||
? 'Erlaubt direkten Root-Login über SSH wieder (PermitRootLogin yes) und lädt den SSH-Dienst neu.'
|
||||
: 'Unterbindet direkten Root-Login über SSH (PermitRootLogin no) und lädt den SSH-Dienst neu.',
|
||||
? __('backend.desc_ssh_root_enable')
|
||||
: __('backend.desc_ssh_root_disable'),
|
||||
'ssh_password' => $enable
|
||||
? 'Erlaubt die Anmeldung per Passwort wieder (PasswordAuthentication yes).'
|
||||
: 'Anmeldung nur noch per SSH-Key (PasswordAuthentication no). Nur möglich, wenn ein Key hinterlegt ist.',
|
||||
? __('backend.desc_ssh_password_enable')
|
||||
: __('backend.desc_ssh_password_disable'),
|
||||
'fail2ban' => $enable
|
||||
? 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.'
|
||||
: 'Stoppt fail2ban und deaktiviert den Autostart.',
|
||||
? __('backend.desc_fail2ban_enable')
|
||||
: __('backend.desc_fail2ban_disable'),
|
||||
'firewall' => $enable
|
||||
? 'Installiert die Firewall (falls nötig), gibt vorher SSH + 80/443 frei und aktiviert sie.'
|
||||
: 'Deaktiviert die Firewall des Servers.',
|
||||
? __('backend.desc_firewall_enable')
|
||||
: __('backend.desc_firewall_disable'),
|
||||
'unattended' => $enable
|
||||
? 'Installiert (falls nötig) und aktiviert die automatischen Sicherheitsupdates des Systems.'
|
||||
: 'Schaltet die automatischen Updates ab.',
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
? __('backend.desc_unattended_enable')
|
||||
: __('backend.desc_unattended_disable'),
|
||||
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +169,7 @@ class HardeningService
|
|||
'fail2ban' => 'fail2ban',
|
||||
'firewall' => 'firewall',
|
||||
'unattended' => 'auto_updates',
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),
|
||||
};
|
||||
if ($reason = $os->supports($feature)) {
|
||||
return ['ok' => false, 'output' => $reason];
|
||||
|
|
@ -184,10 +184,10 @@ class HardeningService
|
|||
// Lock-out guard: never disable password auth without a usable key path.
|
||||
if ($action === 'ssh_password' && ! $enable) {
|
||||
if ($server->credential?->auth_type === 'password') {
|
||||
return ['ok' => false, 'output' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.'];
|
||||
return ['ok' => false, 'output' => __('backend.ssh_password_self_lockout')];
|
||||
}
|
||||
if (! $this->hasAuthorizedKey($server)) {
|
||||
return ['ok' => false, 'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).'];
|
||||
return ['ok' => false, 'output' => __('backend.ssh_password_no_key')];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +210,7 @@ class HardeningService
|
|||
? $pm->ensureInstalledScript('fail2ban').' && systemctl enable --now fail2ban'
|
||||
: 'systemctl disable --now fail2ban',
|
||||
'unattended' => $this->autoUpdateScript($os, $pm, $enable),
|
||||
default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"),
|
||||
default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,12 +50,12 @@ final class OsProfile
|
|||
public function familyLabel(): string
|
||||
{
|
||||
return match ($this->family) {
|
||||
'debian' => 'Debian/Ubuntu',
|
||||
'rhel' => 'RHEL/Fedora',
|
||||
'suse' => 'openSUSE/SLES',
|
||||
'arch' => 'Arch Linux',
|
||||
'alpine' => 'Alpine Linux',
|
||||
default => 'Unbekanntes System',
|
||||
'debian' => __('backend.family_debian'),
|
||||
'rhel' => __('backend.family_rhel'),
|
||||
'suse' => __('backend.family_suse'),
|
||||
'arch' => __('backend.family_arch'),
|
||||
'alpine' => __('backend.family_alpine'),
|
||||
default => __('backend.family_unknown'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -63,9 +63,9 @@ final class OsProfile
|
|||
public function firewallLabel(): string
|
||||
{
|
||||
return match ($this->firewallTool) {
|
||||
'ufw' => 'Firewall (UFW)',
|
||||
'firewalld' => 'Firewall (firewalld)',
|
||||
default => 'Firewall',
|
||||
'ufw' => __('backend.firewall_label_ufw'),
|
||||
'firewalld' => __('backend.firewall_label_firewalld'),
|
||||
default => __('backend.firewall_label_generic'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -79,27 +79,27 @@ final class OsProfile
|
|||
return match ($feature) {
|
||||
'ssh' => $this->isSystemd()
|
||||
? null
|
||||
: 'SSH-Härtung benötigt systemd, das auf '.$this->familyLabel().' nicht erkannt wurde.',
|
||||
: __('backend.support_ssh', ['family' => $this->familyLabel()]),
|
||||
'services' => $this->isSystemd()
|
||||
? null
|
||||
: 'Dienst-Steuerung benötigt systemd, das hier nicht erkannt wurde.',
|
||||
: __('backend.support_services'),
|
||||
'updates' => $this->managesPackages()
|
||||
? null
|
||||
: 'Paket-Updates über Clusev werden unter '.$this->familyLabel().' noch nicht unterstützt.',
|
||||
: __('backend.support_updates', ['family' => $this->familyLabel()]),
|
||||
'fail2ban' => ($this->managesPackages() && $this->isSystemd())
|
||||
? null
|
||||
: 'fail2ban wird unter '.$this->familyLabel().' über Clusev noch nicht unterstützt.',
|
||||
: __('backend.support_fail2ban', ['family' => $this->familyLabel()]),
|
||||
'firewall' => ($this->firewallTool !== 'none' && $this->isSystemd()
|
||||
&& ($this->firewallTool === 'firewalld' || $this->managesPackages() || $this->has('ufw')))
|
||||
? null
|
||||
: 'Eine unterstützte Firewall (UFW oder firewalld) wurde unter '.$this->familyLabel().' nicht gefunden.',
|
||||
: __('backend.support_firewall', ['family' => $this->familyLabel()]),
|
||||
// apt -> unattended-upgrades; dnf -> dnf-automatic. A yum-ONLY host (e.g.
|
||||
// CentOS 7) maps to the dnf manager for upgrades but has no dnf-automatic
|
||||
// (it uses yum-cron), so require a real `dnf` binary for this feature.
|
||||
'auto_updates' => (($this->packageManager === 'apt'
|
||||
|| ($this->packageManager === 'dnf' && $this->has('dnf'))) && $this->isSystemd())
|
||||
? null
|
||||
: 'Automatische Updates werden unter '.$this->familyLabel().' über Clusev noch nicht unterstützt.',
|
||||
: __('backend.support_auto_updates', ['family' => $this->familyLabel()]),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@ class CredentialVault
|
|||
$cred = $server->credential;
|
||||
|
||||
if (! $cred) {
|
||||
throw new RuntimeException("Kein SSH-Credential für Server {$server->name} hinterlegt.");
|
||||
throw new RuntimeException(__('backend.no_ssh_credential', ['server' => $server->name]));
|
||||
}
|
||||
|
||||
if ($cred->disabled_at !== null) {
|
||||
throw new RuntimeException("SSH-Zugang für {$server->name} ist gesperrt.");
|
||||
throw new RuntimeException(__('backend.ssh_access_locked', ['server' => $server->name]));
|
||||
}
|
||||
|
||||
$secret = $cred->auth_type === 'key'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
return [
|
||||
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash
|
||||
// is resolved from .git at runtime (see App\Livewire\Versions\Index).
|
||||
'version' => '0.2.1',
|
||||
'version' => '0.3.0',
|
||||
|
||||
// Default user channel. Only 'stable' and 'beta' are ever offered to users.
|
||||
'channel' => 'stable',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
// Human-readable strings returned by backend services (HardeningService,
|
||||
// FirewallService, Fail2banService, MaintenanceService, OsProfile) and shown in
|
||||
// the UI: confirm-modal copy, guard/lockout refusals, read errors, and the
|
||||
// supports()/reason sentences. Technical tokens (sshd, ufw, firewalld, ports,
|
||||
// paths) stay verbatim. Reuse common.* for save/cancel/active/inactive/etc.
|
||||
return [
|
||||
// ── HardeningService: state labels + detail ──────────────────────────
|
||||
'item_not_installed' => 'nicht installiert',
|
||||
'label_ssh_root' => 'SSH-Root-Login',
|
||||
'label_ssh_password' => 'SSH-Passwort-Login',
|
||||
'label_auto_updates' => 'Automatische Updates',
|
||||
|
||||
// ── HardeningService: modal titles (per direction) ───────────────────
|
||||
'title_ssh_root_enable' => 'SSH-Root-Login erlauben',
|
||||
'title_ssh_root_disable' => 'SSH-Root-Login deaktivieren',
|
||||
'title_ssh_password_enable' => 'SSH-Passwort-Login erlauben',
|
||||
'title_ssh_password_disable' => 'SSH-Passwort-Login deaktivieren',
|
||||
'title_fail2ban_enable' => 'fail2ban aktivieren',
|
||||
'title_fail2ban_disable' => 'fail2ban deaktivieren',
|
||||
'title_firewall_enable' => 'Firewall aktivieren',
|
||||
'title_firewall_disable' => 'Firewall deaktivieren',
|
||||
'title_unattended_enable' => 'Automatische Updates aktivieren',
|
||||
'title_unattended_disable' => 'Automatische Updates deaktivieren',
|
||||
|
||||
// ── HardeningService: modal descriptions (per direction) ─────────────
|
||||
'desc_ssh_root_enable' => 'Erlaubt direkten Root-Login über SSH wieder (PermitRootLogin yes) und lädt den SSH-Dienst neu.',
|
||||
'desc_ssh_root_disable' => 'Unterbindet direkten Root-Login über SSH (PermitRootLogin no) und lädt den SSH-Dienst neu.',
|
||||
'desc_ssh_password_enable' => 'Erlaubt die Anmeldung per Passwort wieder (PasswordAuthentication yes).',
|
||||
'desc_ssh_password_disable' => 'Anmeldung nur noch per SSH-Key (PasswordAuthentication no). Nur möglich, wenn ein Key hinterlegt ist.',
|
||||
'desc_fail2ban_enable' => 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.',
|
||||
'desc_fail2ban_disable' => 'Stoppt fail2ban und deaktiviert den Autostart.',
|
||||
'desc_firewall_enable' => 'Installiert die Firewall (falls nötig), gibt vorher SSH + 80/443 frei und aktiviert sie.',
|
||||
'desc_firewall_disable' => 'Deaktiviert die Firewall des Servers.',
|
||||
'desc_unattended_enable' => 'Installiert (falls nötig) und aktiviert die automatischen Sicherheitsupdates des Systems.',
|
||||
'desc_unattended_disable' => 'Schaltet die automatischen Updates ab.',
|
||||
|
||||
// ── HardeningService: errors + guards ────────────────────────────────
|
||||
'hardening_status_read_failed' => 'Härtungs-Status konnte nicht gelesen werden.',
|
||||
'ssh_password_self_lockout' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.',
|
||||
'ssh_password_no_key' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).',
|
||||
|
||||
// ── FirewallService: guards / reasons / errors ───────────────────────
|
||||
'firewalld_read_only' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.',
|
||||
'invalid_port' => 'Ungültiger Port (1–65535).',
|
||||
'invalid_source' => 'Ungültige Quelle — IP-Adresse oder CIDR erwartet.',
|
||||
'port_or_source_required' => 'Port oder Quelle ist erforderlich.',
|
||||
'deny_without_port_blocks_ssh' => 'Eine :action-Regel ohne Port würde auch den SSH-Zugang blockieren und ist nicht erlaubt. Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.',
|
||||
'deny_on_ssh_port_blocks_ssh' => 'Eine :action-Regel auf den SSH-Port (:port) ist nicht erlaubt (Aussperrgefahr). Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.',
|
||||
'firewalld_port_required' => 'firewalld: Bitte einen Port angeben.',
|
||||
'firewall_status_read_failed' => 'Firewall-Status konnte nicht gelesen werden — Regel nicht entfernt.',
|
||||
'rule_gone' => 'Regel existiert nicht mehr.',
|
||||
'ssh_rule_guard' => 'Diese Regel hält den SSH-Zugang (Port :port) offen und kann nicht entfernt werden — sonst sperrt sich Clusev selbst aus. Deaktiviere bei Bedarf die gesamte Firewall.',
|
||||
|
||||
// ── Fail2banService: guards / reasons / errors ───────────────────────
|
||||
'ip_belongs_to_clusev' => 'Diese IP gehört zum Clusev-Zugang und kann nicht gesperrt werden (Aussperrgefahr).',
|
||||
'already_whitelisted' => 'Bereits auf der Whitelist.',
|
||||
'not_on_whitelist' => 'Nicht auf der Whitelist.',
|
||||
'loopback_stays_whitelisted' => 'Loopback bleibt immer auf der Whitelist.',
|
||||
'invalid_input' => 'Ungültige Eingabe.',
|
||||
'invalid_ip_or_cidr' => 'Ungültige IP-Adresse oder CIDR.',
|
||||
'fail2ban_config_read_failed' => 'fail2ban-Konfiguration konnte nicht gelesen werden.',
|
||||
|
||||
// ── OsProfile: family labels ─────────────────────────────────────────
|
||||
'family_debian' => 'Debian/Ubuntu',
|
||||
'family_rhel' => 'RHEL/Fedora',
|
||||
'family_suse' => 'openSUSE/SLES',
|
||||
'family_arch' => 'Arch Linux',
|
||||
'family_alpine' => 'Alpine Linux',
|
||||
'family_unknown' => 'Unbekanntes System',
|
||||
|
||||
// ── OsProfile: firewall labels ───────────────────────────────────────
|
||||
'firewall_label_ufw' => 'Firewall (UFW)',
|
||||
'firewall_label_firewalld' => 'Firewall (firewalld)',
|
||||
'firewall_label_generic' => 'Firewall',
|
||||
|
||||
// ── OsProfile: supports() reasons (:family = familyLabel()) ──────────
|
||||
'support_ssh' => 'SSH-Härtung benötigt systemd, das auf :family nicht erkannt wurde.',
|
||||
'support_services' => 'Dienst-Steuerung benötigt systemd, das hier nicht erkannt wurde.',
|
||||
'support_updates' => 'Paket-Updates über Clusev werden unter :family noch nicht unterstützt.',
|
||||
'support_fail2ban' => 'fail2ban wird unter :family über Clusev noch nicht unterstützt.',
|
||||
'support_firewall' => 'Eine unterstützte Firewall (UFW oder firewalld) wurde unter :family nicht gefunden.',
|
||||
'support_auto_updates' => 'Automatische Updates werden unter :family über Clusev noch nicht unterstützt.',
|
||||
|
||||
// ── FleetService: SFTP / file-manager / journal errors ───────────────
|
||||
'authorized_keys_unreadable' => 'authorized_keys leer oder nicht lesbar.',
|
||||
'delete_failed_perms' => 'Löschen fehlgeschlagen (Rechte?).',
|
||||
'file_too_large_download' => 'Datei zu groß für den Download (>50 MB).',
|
||||
'save_failed_perms' => 'Speichern fehlgeschlagen (Rechte?).',
|
||||
'upload_failed_perms' => 'Upload fehlgeschlagen (Rechte?).',
|
||||
'journal_needs_privileges' => 'Systemjournal benötigt erhöhte Rechte (Gruppe adm/systemd-journal) oder sudo.',
|
||||
|
||||
// ── CredentialVault: SSH credential guards (:server = name) ──────────
|
||||
'no_ssh_credential' => 'Kein SSH-Credential für Server :server hinterlegt.',
|
||||
'ssh_access_locked' => 'SSH-Zugang für :server ist gesperrt.',
|
||||
|
||||
// ── HardeningService: invalid action invariant (:action) ─────────────
|
||||
'unknown_hardening' => 'Unbekannte Härtung: :action',
|
||||
];
|
||||
|
|
@ -158,6 +158,7 @@ return [
|
|||
'result_ok' => 'System aktualisiert.',
|
||||
'result_failed' => 'Aktualisierung fehlgeschlagen.',
|
||||
'submit' => 'Jetzt aktualisieren',
|
||||
'audit_failed_suffix' => '(fehlgeschlagen)',
|
||||
'notify_updated' => 'System aktualisiert.',
|
||||
'notify_failed' => 'Aktualisierung fehlgeschlagen.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ return [
|
|||
'in_progress' => 'in Arbeit',
|
||||
'changelog_empty' => 'Kein Änderungsprotokoll gefunden.',
|
||||
|
||||
// Changelog section labels (Keep a Changelog subsections)
|
||||
'section_added' => 'Hinzugefügt',
|
||||
'section_changed' => 'Geändert',
|
||||
'section_fixed' => 'Behoben',
|
||||
'section_security' => 'Sicherheit',
|
||||
'section_removed' => 'Entfernt',
|
||||
'section_deprecated' => 'Veraltet',
|
||||
|
||||
// Installed panel
|
||||
'installed_title' => 'Installiert',
|
||||
'running' => 'läuft',
|
||||
|
|
|
|||
|
|
@ -63,10 +63,10 @@ return [
|
|||
|
||||
// ── Validation / error messages ──────────────────────────────────────
|
||||
'too_many_attempts' => 'Too many attempts. Please try again in :seconds seconds.',
|
||||
'invalid_credentials' => 'These credentials do not match.',
|
||||
'invalid_credentials' => 'These credentials are incorrect.',
|
||||
'session_expired' => 'Session expired. Please sign in again.',
|
||||
'invalid_code' => 'Invalid code.',
|
||||
'code_mismatch' => 'Code does not match — check your authenticator app’s clock.',
|
||||
'code_mismatch' => 'Code does not match — check your authenticator app’s time.',
|
||||
|
||||
// ── Validation attribute names ───────────────────────────────────────
|
||||
'attr_current_password' => 'current password',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
// Human-readable strings returned by backend services (HardeningService,
|
||||
// FirewallService, Fail2banService, MaintenanceService, OsProfile) and shown in
|
||||
// the UI: confirm-modal copy, guard/lockout refusals, read errors, and the
|
||||
// supports()/reason sentences. Technical tokens (sshd, ufw, firewalld, ports,
|
||||
// paths) stay verbatim. Reuse common.* for save/cancel/active/inactive/etc.
|
||||
return [
|
||||
// ── HardeningService: state labels + detail ──────────────────────────
|
||||
'item_not_installed' => 'not installed',
|
||||
'label_ssh_root' => 'SSH root login',
|
||||
'label_ssh_password' => 'SSH password login',
|
||||
'label_auto_updates' => 'Automatic updates',
|
||||
|
||||
// ── HardeningService: modal titles (per direction) ───────────────────
|
||||
'title_ssh_root_enable' => 'Allow SSH root login',
|
||||
'title_ssh_root_disable' => 'Disable SSH root login',
|
||||
'title_ssh_password_enable' => 'Allow SSH password login',
|
||||
'title_ssh_password_disable' => 'Disable SSH password login',
|
||||
'title_fail2ban_enable' => 'Enable fail2ban',
|
||||
'title_fail2ban_disable' => 'Disable fail2ban',
|
||||
'title_firewall_enable' => 'Enable firewall',
|
||||
'title_firewall_disable' => 'Disable firewall',
|
||||
'title_unattended_enable' => 'Enable automatic updates',
|
||||
'title_unattended_disable' => 'Disable automatic updates',
|
||||
|
||||
// ── HardeningService: modal descriptions (per direction) ─────────────
|
||||
'desc_ssh_root_enable' => 'Re-allows direct root login over SSH (PermitRootLogin yes) and reloads the SSH service.',
|
||||
'desc_ssh_root_disable' => 'Blocks direct root login over SSH (PermitRootLogin no) and reloads the SSH service.',
|
||||
'desc_ssh_password_enable' => 'Re-allows password login (PasswordAuthentication yes).',
|
||||
'desc_ssh_password_disable' => 'Login via SSH key only (PasswordAuthentication no). Only possible if a key is in place.',
|
||||
'desc_fail2ban_enable' => 'Installs fail2ban (if needed) and starts the service. Repeated failed logins get banned.',
|
||||
'desc_fail2ban_disable' => 'Stops fail2ban and disables autostart.',
|
||||
'desc_firewall_enable' => 'Installs the firewall (if needed), opens SSH + 80/443 first, then enables it.',
|
||||
'desc_firewall_disable' => 'Disables the server firewall.',
|
||||
'desc_unattended_enable' => 'Installs (if needed) and enables the system\'s automatic security updates.',
|
||||
'desc_unattended_disable' => 'Turns off automatic updates.',
|
||||
|
||||
// ── HardeningService: errors + guards ────────────────────────────────
|
||||
'hardening_status_read_failed' => 'Could not read hardening status.',
|
||||
'ssh_password_self_lockout' => 'Clusev connects itself via password — password login cannot be disabled, otherwise Clusev loses access. Set up SSH key access first.',
|
||||
'ssh_password_no_key' => 'No SSH key in place — password login cannot be disabled (lockout risk).',
|
||||
|
||||
// ── FirewallService: guards / reasons / errors ───────────────────────
|
||||
'firewalld_read_only' => 'Rule management for firewalld is read-only in this version — please configure it on the server.',
|
||||
'invalid_port' => 'Invalid port (1–65535).',
|
||||
'invalid_source' => 'Invalid source — IP address or CIDR expected.',
|
||||
'port_or_source_required' => 'Port or source is required.',
|
||||
'deny_without_port_blocks_ssh' => 'A :action rule without a port would also block SSH access and is not allowed. Use fail2ban to block individual IPs from SSH access.',
|
||||
'deny_on_ssh_port_blocks_ssh' => 'A :action rule on the SSH port (:port) is not allowed (lockout risk). Use fail2ban to block individual IPs from SSH access.',
|
||||
'firewalld_port_required' => 'firewalld: Please specify a port.',
|
||||
'firewall_status_read_failed' => 'Could not read firewall status — rule not removed.',
|
||||
'rule_gone' => 'Rule no longer exists.',
|
||||
'ssh_rule_guard' => 'This rule keeps SSH access (port :port) open and cannot be removed — otherwise Clusev locks itself out. Disable the whole firewall if needed.',
|
||||
|
||||
// ── Fail2banService: guards / reasons / errors ───────────────────────
|
||||
'ip_belongs_to_clusev' => 'This IP belongs to Clusev\'s access and cannot be banned (lockout risk).',
|
||||
'already_whitelisted' => 'Already on the whitelist.',
|
||||
'not_on_whitelist' => 'Not on the whitelist.',
|
||||
'loopback_stays_whitelisted' => 'Loopback always stays on the whitelist.',
|
||||
'invalid_input' => 'Invalid input.',
|
||||
'invalid_ip_or_cidr' => 'Invalid IP address or CIDR.',
|
||||
'fail2ban_config_read_failed' => 'Could not read fail2ban configuration.',
|
||||
|
||||
// ── OsProfile: family labels ─────────────────────────────────────────
|
||||
'family_debian' => 'Debian/Ubuntu',
|
||||
'family_rhel' => 'RHEL/Fedora',
|
||||
'family_suse' => 'openSUSE/SLES',
|
||||
'family_arch' => 'Arch Linux',
|
||||
'family_alpine' => 'Alpine Linux',
|
||||
'family_unknown' => 'Unknown system',
|
||||
|
||||
// ── OsProfile: firewall labels ───────────────────────────────────────
|
||||
'firewall_label_ufw' => 'Firewall (UFW)',
|
||||
'firewall_label_firewalld' => 'Firewall (firewalld)',
|
||||
'firewall_label_generic' => 'Firewall',
|
||||
|
||||
// ── OsProfile: supports() reasons (:family = familyLabel()) ──────────
|
||||
'support_ssh' => 'SSH hardening requires systemd, which was not detected on :family.',
|
||||
'support_services' => 'Service control requires systemd, which was not detected here.',
|
||||
'support_updates' => 'Package updates via Clusev are not yet supported on :family.',
|
||||
'support_fail2ban' => 'fail2ban is not yet supported on :family via Clusev.',
|
||||
'support_firewall' => 'No supported firewall (UFW or firewalld) was found on :family.',
|
||||
'support_auto_updates' => 'Automatic updates are not yet supported on :family via Clusev.',
|
||||
|
||||
// ── FleetService: SFTP / file-manager / journal errors ───────────────
|
||||
'authorized_keys_unreadable' => 'authorized_keys is empty or unreadable.',
|
||||
'delete_failed_perms' => 'Delete failed (permissions?).',
|
||||
'file_too_large_download' => 'File too large to download (>50 MB).',
|
||||
'save_failed_perms' => 'Save failed (permissions?).',
|
||||
'upload_failed_perms' => 'Upload failed (permissions?).',
|
||||
'journal_needs_privileges' => 'The system journal requires elevated privileges (group adm/systemd-journal) or sudo.',
|
||||
|
||||
// ── CredentialVault: SSH credential guards (:server = name) ──────────
|
||||
'no_ssh_credential' => 'No SSH credential stored for server :server.',
|
||||
'ssh_access_locked' => 'SSH access for :server is locked.',
|
||||
|
||||
// ── HardeningService: invalid action invariant (:action) ─────────────
|
||||
'unknown_hardening' => 'Unknown hardening action: :action',
|
||||
];
|
||||
|
|
@ -14,7 +14,7 @@ return [
|
|||
'enable' => 'Enable',
|
||||
'disable' => 'Disable',
|
||||
'lock' => 'Lock',
|
||||
'unlock' => 'Unblock',
|
||||
'unlock' => 'Unlock',
|
||||
'back' => 'Back',
|
||||
'retry' => 'Try again',
|
||||
'confirm' => 'Confirm',
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ return [
|
|||
'result_ok' => 'System updated.',
|
||||
'result_failed' => 'Update failed.',
|
||||
'submit' => 'Update now',
|
||||
'audit_failed_suffix' => '(failed)',
|
||||
'notify_updated' => 'System updated.',
|
||||
'notify_failed' => 'Update failed.',
|
||||
],
|
||||
|
|
@ -164,6 +165,6 @@ return [
|
|||
// ── Generic confirm dialog defaults (ConfirmAction) ───────────────────
|
||||
'confirm_action' => [
|
||||
'default_heading' => 'Confirm action',
|
||||
'default_notify' => 'Action carried out.',
|
||||
'default_notify' => 'Action completed.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ return [
|
|||
'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',
|
||||
'cred_deposit' => 'Add credentials',
|
||||
|
||||
// ── Show: specifications ──────────────────────────────────────────────
|
||||
'specs_title' => 'Specifications',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ return [
|
|||
'in_progress' => 'in progress',
|
||||
'changelog_empty' => 'No changelog found.',
|
||||
|
||||
// Changelog section labels (Keep a Changelog subsections)
|
||||
'section_added' => 'Added',
|
||||
'section_changed' => 'Changed',
|
||||
'section_fixed' => 'Fixed',
|
||||
'section_security' => 'Security',
|
||||
'section_removed' => 'Removed',
|
||||
'section_deprecated' => 'Deprecated',
|
||||
|
||||
// Installed panel
|
||||
'installed_title' => 'Installed',
|
||||
'running' => 'running',
|
||||
|
|
|
|||
|
|
@ -68,31 +68,21 @@ document.addEventListener('alpine:init', () => {
|
|||
}));
|
||||
|
||||
// ── Command palette + keyboard shortcuts ────────────────────────────
|
||||
// German nav targets (mirrors the sidebar) with a leader-key hint. Leader
|
||||
// mnemonics: i=dIenste, l=Log, e=Einstellungen, y=sYstem (d/s already taken).
|
||||
const CMDK_NAV = [
|
||||
{ label: 'Dashboard', href: '/', hint: 'g d' },
|
||||
{ label: 'Server', href: '/servers', hint: 'g s' },
|
||||
{ label: 'Dienste', href: '/services', hint: 'g i' },
|
||||
{ label: 'Dateien', href: '/files', hint: 'g f' },
|
||||
{ label: 'Audit-Log', href: '/audit', hint: 'g l' },
|
||||
{ label: 'Einstellungen', href: '/settings', hint: 'g e' },
|
||||
{ label: 'System', href: '/system', hint: 'g y' },
|
||||
{ label: 'Version', href: '/versions', hint: 'g v' },
|
||||
];
|
||||
const CMDK_ACTIONS = [
|
||||
{ label: 'Server hinzufügen', action: 'create-server', hint: '' },
|
||||
];
|
||||
// Nav + action item labels are passed in from the Blade markup (already
|
||||
// translated via __()), so the palette stays in sync with the active locale.
|
||||
// CMDK_GO maps leader-key mnemonics to routes (i=dIenste, l=Log,
|
||||
// e=Einstellungen, y=sYstem — d/s already taken) and stays in JS.
|
||||
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' };
|
||||
|
||||
window.Alpine.data('cmdk', () => ({
|
||||
window.Alpine.data('cmdk', (nav = [], actions = []) => ({
|
||||
nav,
|
||||
actions,
|
||||
open: false,
|
||||
help: false,
|
||||
query: '',
|
||||
active: 0,
|
||||
leader: false,
|
||||
leaderTimer: null,
|
||||
nav: CMDK_NAV,
|
||||
|
||||
init() {
|
||||
// Reset overlays after an SPA navigation. The component is recreated on each
|
||||
|
|
@ -112,7 +102,7 @@ document.addEventListener('alpine:init', () => {
|
|||
|
||||
get items() {
|
||||
const q = this.query.trim().toLowerCase();
|
||||
const all = [...CMDK_NAV, ...CMDK_ACTIONS];
|
||||
const all = [...this.nav, ...this.actions];
|
||||
return q === '' ? all : all.filter((i) => i.label.toLowerCase().includes(q));
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -5,21 +5,36 @@
|
|||
--}}
|
||||
@php
|
||||
$chords = [
|
||||
['Strg / ⌘ K', 'Befehle öffnen'],
|
||||
['/', 'Suche auf der Seite fokussieren'],
|
||||
['?', 'Diese Hilfe anzeigen'],
|
||||
['g d', 'Dashboard'],
|
||||
['g s', 'Server'],
|
||||
['g i', 'Dienste'],
|
||||
['g f', 'Dateien'],
|
||||
['g l', 'Audit-Log'],
|
||||
['g e', 'Einstellungen'],
|
||||
['g y', 'System'],
|
||||
['g v', 'Version'],
|
||||
['Strg / ⌘ K', __('shell.cmdk_open')],
|
||||
['/', __('shell.cmdk_focus_search')],
|
||||
['?', __('shell.cmdk_show_help')],
|
||||
['g d', __('shell.nav_dashboard')],
|
||||
['g s', __('shell.nav_servers')],
|
||||
['g i', __('shell.nav_services')],
|
||||
['g f', __('shell.nav_files')],
|
||||
['g l', __('shell.nav_audit')],
|
||||
['g e', __('shell.nav_settings')],
|
||||
['g y', __('shell.nav_system')],
|
||||
['g v', __('shell.nav_versions')],
|
||||
];
|
||||
|
||||
$nav = [
|
||||
['label' => __('shell.nav_dashboard'), 'href' => '/', 'hint' => 'g d'],
|
||||
['label' => __('shell.nav_servers'), 'href' => '/servers', 'hint' => 'g s'],
|
||||
['label' => __('shell.nav_services'), 'href' => '/services', 'hint' => 'g i'],
|
||||
['label' => __('shell.nav_files'), 'href' => '/files', 'hint' => 'g f'],
|
||||
['label' => __('shell.nav_audit'), 'href' => '/audit', 'hint' => 'g l'],
|
||||
['label' => __('shell.nav_settings'), 'href' => '/settings', 'hint' => 'g e'],
|
||||
['label' => __('shell.nav_system'), 'href' => '/system', 'hint' => 'g y'],
|
||||
['label' => __('shell.nav_versions'), 'href' => '/versions', 'hint' => 'g v'],
|
||||
];
|
||||
|
||||
$actions = [
|
||||
['label' => __('servers.add_server'), 'action' => 'create-server', 'hint' => ''],
|
||||
];
|
||||
$kbd = 'rounded border border-line bg-inset px-1.5 py-0.5 font-mono text-[10px] text-ink-2';
|
||||
@endphp
|
||||
<div x-data="cmdk"
|
||||
<div x-data="cmdk(@js($nav), @js($actions))"
|
||||
@keydown.window="onKey($event)"
|
||||
@keydown.escape.window="close()"
|
||||
@cmdk-open.window="toggle(true)">
|
||||
|
|
@ -33,13 +48,13 @@
|
|||
<div class="flex items-center gap-2.5 border-b border-line px-4">
|
||||
<x-icon name="search" class="h-4 w-4 shrink-0 text-ink-3" />
|
||||
<input x-ref="input" x-model="query" type="text" autocomplete="off" spellcheck="false"
|
||||
placeholder="Befehl oder Seite…"
|
||||
placeholder="{{ __('shell.cmdk_placeholder') }}"
|
||||
@input="active = 0"
|
||||
@keydown.down.prevent="move(1)"
|
||||
@keydown.up.prevent="move(-1)"
|
||||
@keydown.enter.prevent="run()"
|
||||
class="h-12 w-full bg-transparent font-mono text-sm text-ink placeholder:text-ink-4 focus:outline-none" />
|
||||
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">Esc</span>
|
||||
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('shell.cmdk_esc') }}</span>
|
||||
</div>
|
||||
|
||||
<ul class="max-h-80 overflow-y-auto py-1.5">
|
||||
|
|
@ -55,12 +70,12 @@
|
|||
</button>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="items.length === 0" class="px-4 py-3 font-mono text-[11px] text-ink-4">Kein Treffer.</li>
|
||||
<li x-show="items.length === 0" class="px-4 py-3 font-mono text-[11px] text-ink-4">{{ __('shell.cmdk_no_match') }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-line px-4 py-2 font-mono text-[10px] text-ink-4">
|
||||
<span class="flex items-center gap-1.5"><x-icon name="corner-down-left" class="h-3 w-3" /> öffnen</span>
|
||||
<button type="button" @click="open = false; help = true" class="hover:text-ink-2">? Tastenkürzel</button>
|
||||
<span class="flex items-center gap-1.5"><x-icon name="corner-down-left" class="h-3 w-3" /> {{ __('shell.cmdk_open_hint') }}</span>
|
||||
<button type="button" @click="open = false; help = true" class="hover:text-ink-2">{{ __('shell.cmdk_shortcuts_hint') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -71,8 +86,8 @@
|
|||
<div x-show="help" x-transition class="relative w-full max-w-md overflow-hidden rounded-lg border border-line bg-surface shadow-pop">
|
||||
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
|
||||
<x-icon name="command" class="h-4 w-4 shrink-0 text-accent-text" />
|
||||
<h2 class="font-display text-sm font-semibold text-ink">Tastenkürzel</h2>
|
||||
<button type="button" @click="help = false" class="ml-auto text-ink-4 hover:text-ink" aria-label="Schließen">
|
||||
<h2 class="font-display text-sm font-semibold text-ink">{{ __('shell.shortcuts_heading') }}</h2>
|
||||
<button type="button" @click="help = false" class="ml-auto text-ink-4 hover:text-ink" aria-label="{{ __('common.close') }}">
|
||||
<x-icon name="x" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
@props(['name', 'ip', 'status' => 'online', 'cpu' => 0, 'mem' => 0, 'os' => null])
|
||||
@php
|
||||
$label = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline'][$status] ?? ucfirst($status);
|
||||
$label = ['online' => __('common.online'), 'warning' => __('common.warning'), 'offline' => __('common.offline')][$status] ?? ucfirst($status);
|
||||
@endphp
|
||||
<a href="#" class="flex items-center gap-3 rounded-md border border-line bg-inset px-3 py-2.5 transition-colors hover:border-line-strong">
|
||||
<x-status-dot :status="$status" :ping="$status === 'online'" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
@props(['title' => null])
|
||||
@php($title ??= __('shell.title_default'))
|
||||
@php
|
||||
// Block form (not inline @php(...)): an inline @php here would be swallowed by
|
||||
// Blade's raw-block precompiler up to the @endphp further down this file.
|
||||
$title ??= __('shell.title_default');
|
||||
@endphp
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
<div
|
||||
x-data="{ toasts: [] }"
|
||||
x-on:notify.window="
|
||||
const msg = $event.detail?.message ?? $event.detail?.[0]?.message ?? 'Erledigt';
|
||||
const msg = $event.detail?.message ?? $event.detail?.[0]?.message ?? @js(__('shell.toast_done'));
|
||||
const id = (window.crypto?.randomUUID?.() ?? String(Date.now() + Math.random()));
|
||||
toasts.push({ id, msg });
|
||||
setTimeout(() => { toasts = toasts.filter(t => t.id !== id) }, 4000);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,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">fail2ban konfigurieren</h2>
|
||||
<h2 class="font-display text-base font-semibold text-ink">{{ __('modals.fail2ban_config.title') }}</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">
|
||||
Legt fest, wann fail2ban auf {{ $serverName ?: 'diesem Server' }} eine IP sperrt und wie lange.
|
||||
Wiederholte fehlgeschlagene Anmeldungen innerhalb des Zeitfensters führen zur Sperre.
|
||||
{{ __('modals.fail2ban_config.subtitle', ['server' => $serverName ?: __('modals.fail2ban_config.this_server')]) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -22,22 +21,22 @@
|
|||
@if ($loaded)
|
||||
<div class="mt-5 space-y-4">
|
||||
<div>
|
||||
<label for="f2b-bantime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Sperrdauer</label>
|
||||
<input id="f2b-bantime" type="text" maxlength="40" wire:model="bantime" placeholder="z. B. 600, 10m, 1h, -1 = dauerhaft"
|
||||
<label for="f2b-bantime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.fail2ban_config.bantime_label') }}</label>
|
||||
<input id="f2b-bantime" type="text" maxlength="40" wire:model="bantime" placeholder="{{ __('modals.fail2ban_config.bantime_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('bantime')<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 for="f2b-maxretry" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Max. Fehlversuche</label>
|
||||
<label for="f2b-maxretry" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.fail2ban_config.maxretry_label') }}</label>
|
||||
<input id="f2b-maxretry" type="number" min="1" max="100" wire:model="maxretry"
|
||||
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('maxretry')<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 for="f2b-findtime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Zeitfenster</label>
|
||||
<input id="f2b-findtime" type="text" maxlength="40" wire:model="findtime" placeholder="z. B. 600, 10m, 1h"
|
||||
<label for="f2b-findtime" class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('modals.fail2ban_config.findtime_label') }}</label>
|
||||
<input id="f2b-findtime" type="text" maxlength="40" wire:model="findtime" placeholder="{{ __('modals.fail2ban_config.findtime_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('findtime')<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>
|
||||
|
|
@ -45,12 +44,12 @@
|
|||
@endif
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ $loaded ? 'Abbrechen' : 'Schließen' }}</x-btn>
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ $loaded ? __('common.cancel') : __('common.close') }}</x-btn>
|
||||
@if ($loaded)
|
||||
<x-btn variant="accent" 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>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@
|
|||
@elseif ($error)
|
||||
<p class="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" />{{ $error }}</p>
|
||||
@elseif ($tooBig)
|
||||
<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" />Datei zu groß zum Bearbeiten (über 256 KB) — bitte herunterladen.</p>
|
||||
<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" />{{ __('modals.file_editor.too_big') }}</p>
|
||||
@elseif ($binary)
|
||||
<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" />Binärdatei — kann nicht im Editor angezeigt werden.</p>
|
||||
<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" />{{ __('modals.file_editor.binary') }}</p>
|
||||
@else
|
||||
<textarea wire:model="content" rows="14" spellcheck="false"
|
||||
class="w-full resize-y rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2 focus:border-accent/40 focus:outline-none"></textarea>
|
||||
|
|
@ -25,11 +25,11 @@
|
|||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center justify-end gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
|
||||
@if ($loaded && ! $error && ! $tooBig && ! $binary)
|
||||
<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>
|
||||
Speichern
|
||||
{{ __('common.save') }}
|
||||
</x-btn>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@
|
|||
<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">Firewall-Regel hinzufügen</h2>
|
||||
<h2 class="font-display text-base font-semibold text-ink">{{ __('modals.firewall_rule.title') }}</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">
|
||||
@if ($tool === 'firewalld')
|
||||
Öffnet einen Port in der Standard-Zone von firewalld.
|
||||
{{ __('modals.firewall_rule.subtitle_firewalld') }}
|
||||
@else
|
||||
Neue UFW-Regel. Quelle leer lassen = von überall.
|
||||
{{ __('modals.firewall_rule.subtitle_ufw') }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -30,28 +30,28 @@
|
|||
<div class="mt-4 space-y-3">
|
||||
@if ($tool !== 'firewalld')
|
||||
<div>
|
||||
<label class="{{ $label }}">Aktion</label>
|
||||
<label class="{{ $label }}">{{ __('modals.firewall_rule.action_label') }}</label>
|
||||
<select wire:model="action" class="{{ $select }}">
|
||||
<option value="allow">Erlauben (allow)</option>
|
||||
<option value="deny">Verwerfen (deny)</option>
|
||||
<option value="reject">Ablehnen (reject)</option>
|
||||
<option value="limit">Begrenzen (limit)</option>
|
||||
<option value="allow">{{ __('modals.firewall_rule.action_allow') }}</option>
|
||||
<option value="deny">{{ __('modals.firewall_rule.action_deny') }}</option>
|
||||
<option value="reject">{{ __('modals.firewall_rule.action_reject') }}</option>
|
||||
<option value="limit">{{ __('modals.firewall_rule.action_limit') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="{{ $label }}">Port {{ $tool === 'firewalld' ? '' : '(optional)' }}</label>
|
||||
<input wire:model="port" type="number" min="1" max="65535" placeholder="z. B. 8080" class="{{ $field }}" />
|
||||
<label class="{{ $label }}">{{ __('modals.firewall_rule.port_label') }} {{ $tool === 'firewalld' ? '' : __('modals.firewall_rule.port_optional') }}</label>
|
||||
<input wire:model="port" type="number" min="1" max="65535" placeholder="{{ __('modals.firewall_rule.port_placeholder') }}" class="{{ $field }}" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="{{ $label }}">Protokoll</label>
|
||||
<label class="{{ $label }}">{{ __('modals.firewall_rule.proto_label') }}</label>
|
||||
<select wire:model="proto" class="{{ $select }}">
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
@if ($tool !== 'firewalld')
|
||||
<option value="any">Beide</option>
|
||||
<option value="any">{{ __('modals.firewall_rule.proto_both') }}</option>
|
||||
@endif
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -59,18 +59,18 @@
|
|||
|
||||
@if ($tool !== 'firewalld')
|
||||
<div>
|
||||
<label class="{{ $label }}">Quelle (optional)</label>
|
||||
<input wire:model="from" type="text" placeholder="IP oder CIDR, z. B. 10.0.0.0/8" class="{{ $field }}" />
|
||||
<label class="{{ $label }}">{{ __('modals.firewall_rule.source_label') }}</label>
|
||||
<input wire:model="from" type="text" placeholder="{{ __('modals.firewall_rule.source_placeholder') }}" class="{{ $field }}" />
|
||||
</div>
|
||||
@endif
|
||||
</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,7 +4,7 @@
|
|||
<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">{{ $heading ?: 'Härtung anwenden' }}</h2>
|
||||
<h2 class="font-display text-base font-semibold text-ink">{{ $heading ?: __('modals.hardening_action.title_fallback') }}</h2>
|
||||
@if ($description)
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ $description }}</p>
|
||||
@endif
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
])>
|
||||
<x-icon :name="$ok ? 'activity' : 'alert'" @class(['mt-0.5 h-4 w-4 shrink-0', 'text-online' => $ok, 'text-offline' => ! $ok]) />
|
||||
<div class="min-w-0">
|
||||
<p @class(['text-sm', 'text-online' => $ok, 'text-offline' => ! $ok])>{{ $ok ? 'Erfolgreich angewendet.' : 'Fehlgeschlagen.' }}</p>
|
||||
<p @class(['text-sm', 'text-online' => $ok, 'text-offline' => ! $ok])>{{ $ok ? __('modals.hardening_action.result_ok') : __('modals.hardening_action.result_failed') }}</p>
|
||||
@if (! $ok && $output !== '')
|
||||
<p class="mt-1 break-words font-mono text-[11px] text-ink-3">{{ $output }}</p>
|
||||
@endif
|
||||
|
|
@ -34,14 +34,14 @@
|
|||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
@if ($done)
|
||||
<x-btn variant="primary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
|
||||
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
|
||||
@else
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
|
||||
@unless ($error)
|
||||
<x-btn variant="accent" wire:click="apply" wire:target="apply" wire:loading.attr="disabled">
|
||||
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="apply" />
|
||||
<svg wire:loading wire:target="apply" 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>
|
||||
Anwenden
|
||||
{{ __('common.apply') }}
|
||||
</x-btn>
|
||||
@endunless
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@
|
|||
<x-icon name="rotate" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-base font-semibold text-ink">System-Updates</h2>
|
||||
<h2 class="font-display text-base font-semibold text-ink">{{ __('modals.system_update.title') }}</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">
|
||||
Aktualisiert die installierten Pakete auf {{ $serverName ?: 'diesem Server' }} über die
|
||||
Paketverwaltung. Es werden keine neuen Versionssprünge erzwungen — nur verfügbare Updates eingespielt.
|
||||
{{ __('modals.system_update.subtitle', ['server' => $serverName ?: __('modals.system_update.this_server')]) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -20,7 +19,7 @@
|
|||
@elseif (! $supported)
|
||||
<div class="mt-4 flex items-center gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-3.5 py-3">
|
||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||||
<p class="text-sm text-ink-2">{{ $unsupportedReason ?: 'Auf diesem System nicht verfügbar.' }}</p>
|
||||
<p class="text-sm text-ink-2">{{ $unsupportedReason ?: __('modals.system_update.unsupported_fallback') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-4 flex items-center gap-3.5 rounded-md border border-line bg-inset px-3.5 py-3">
|
||||
|
|
@ -35,18 +34,18 @@
|
|||
<div class="min-w-0">
|
||||
<p class="font-display text-sm font-semibold text-ink">
|
||||
@if ($pending === null)
|
||||
Update-Anzahl unbekannt
|
||||
{{ __('modals.system_update.count_unknown') }}
|
||||
@elseif ($pending === 1)
|
||||
1 Paket-Update verfügbar
|
||||
{{ __('modals.system_update.count_one') }}
|
||||
@else
|
||||
{{ $pending }} Paket-Updates verfügbar
|
||||
{{ __('modals.system_update.count_many', ['count' => $pending]) }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-3">
|
||||
@if ($pending === null)
|
||||
Konnte nicht ermittelt werden — Aktualisierung trotzdem möglich.
|
||||
{{ __('modals.system_update.hint_unknown') }}
|
||||
@else
|
||||
{{ $pending > 0 ? 'Aktualisierung empfohlen.' : 'Das System ist auf dem aktuellen Stand.' }}
|
||||
{{ $pending > 0 ? __('modals.system_update.hint_recommended') : __('modals.system_update.hint_up_to_date') }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -64,7 +63,7 @@
|
|||
'text-offline' => ! $ok,
|
||||
])>
|
||||
<x-icon :name="$ok ? 'activity' : 'alert'" class="h-3.5 w-3.5 shrink-0" />
|
||||
{{ $ok ? 'System aktualisiert.' : 'Aktualisierung fehlgeschlagen.' }}
|
||||
{{ $ok ? __('modals.system_update.result_ok') : __('modals.system_update.result_failed') }}
|
||||
</p>
|
||||
@if (! $ok && $output !== '')
|
||||
<pre class="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-ink-3">{{ $output }}</pre>
|
||||
|
|
@ -75,14 +74,14 @@
|
|||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
@if ($done)
|
||||
<x-btn variant="primary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
|
||||
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
|
||||
@else
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
|
||||
@if (! $error && $supported)
|
||||
<x-btn variant="accent" wire:click="upgrade" wire:target="upgrade" wire:loading.attr="disabled">
|
||||
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="upgrade" />
|
||||
<svg wire:loading wire:target="upgrade" 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>
|
||||
Jetzt aktualisieren
|
||||
{{ __('modals.system_update.submit') }}
|
||||
</x-btn>
|
||||
@endif
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -484,7 +484,7 @@
|
|||
</x-panel>
|
||||
</div>
|
||||
|
||||
{{-- SSH-Schlüssel --}}
|
||||
{{-- SSH keys --}}
|
||||
<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 }} } })">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
@php
|
||||
// Map a Keep-a-Changelog subsection to a badge tone.
|
||||
$tone = fn (string $g): string => [
|
||||
'Hinzugefügt' => 'accent',
|
||||
'Geändert' => 'cyan',
|
||||
'Behoben' => 'neutral',
|
||||
'Sicherheit' => 'accent',
|
||||
'Entfernt' => 'neutral',
|
||||
'Veraltet' => 'neutral',
|
||||
][$g] ?? 'neutral';
|
||||
// CHANGELOG.md uses German Keep-a-Changelog headings as the canonical section keys;
|
||||
// map each to a localized display label + a badge tone.
|
||||
$sectionKey = [
|
||||
'Hinzugefügt' => 'added',
|
||||
'Geändert' => 'changed',
|
||||
'Behoben' => 'fixed',
|
||||
'Sicherheit' => 'security',
|
||||
'Entfernt' => 'removed',
|
||||
'Veraltet' => 'deprecated',
|
||||
];
|
||||
$sectionTone = [
|
||||
'added' => 'accent', 'changed' => 'cyan', 'fixed' => 'neutral',
|
||||
'security' => 'accent', 'removed' => 'neutral', 'deprecated' => 'neutral',
|
||||
];
|
||||
$sectionLabel = fn (string $g): string => isset($sectionKey[$g]) ? __('versions.section_'.$sectionKey[$g]) : $g;
|
||||
$tone = fn (string $g): string => $sectionTone[$sectionKey[$g] ?? ''] ?? 'neutral';
|
||||
|
||||
$repoHost = parse_url($repository, PHP_URL_HOST);
|
||||
$repoPath = trim((string) parse_url($repository, PHP_URL_PATH), '/');
|
||||
|
|
@ -92,7 +99,7 @@
|
|||
@foreach ($rel['groups'] as $group => $items)
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-center gap-2">
|
||||
<x-badge :tone="$tone($group)" class="shrink-0">{{ $group }}</x-badge>
|
||||
<x-badge :tone="$tone($group)" class="shrink-0">{{ $sectionLabel($group) }}</x-badge>
|
||||
</div>
|
||||
<ul class="space-y-1.5 pl-0.5">
|
||||
@foreach ($items as $text)
|
||||
|
|
|
|||
Loading…
Reference in New Issue