diff --git a/app/Console/Commands/PollMetrics.php b/app/Console/Commands/PollMetrics.php index d5c82b0..a050a39 100644 --- a/app/Console/Commands/PollMetrics.php +++ b/app/Console/Commands/PollMetrics.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; use App\Events\MetricsTicked; use App\Models\Server; +use App\Services\AlertEvaluator; use App\Services\FleetService; use App\Support\Ssh\CredentialVault; use App\Support\Ssh\SshClient; @@ -28,7 +29,7 @@ class PollMetrics extends Command protected $description = 'Poll real metrics over SSH for credentialed servers, persist + broadcast'; - public function handle(FleetService $fleet, CredentialVault $vault): int + public function handle(FleetService $fleet, CredentialVault $vault, AlertEvaluator $alerts): int { $interval = max(2, (int) $this->option('interval')); @@ -59,8 +60,9 @@ class PollMetrics extends Command $clients[$server->id] ??= (new SshClient($vault, timeout: 12))->connect($server); $m = $fleet->readMetrics($clients[$server->id]); - $fleet->applyMetrics($server, $m); + $fleet->applyMetrics($server, $m); // forceFills the fresh status onto $server broadcast(new MetricsTicked($m['cpu'], $m['mem'], $server->name)); + $this->evaluateAlerts($alerts, $server, $m, $server->status); $this->line("ok {$server->name} cpu={$m['cpu']} mem={$m['mem']} disk={$m['disk']} load={$m['load']}"); } catch (Throwable $e) { @@ -70,6 +72,7 @@ class PollMetrics extends Command unset($clients[$server->id]); } $server->forceFill(['status' => 'offline'])->save(); + $this->evaluateAlerts($alerts, $server, [], 'offline'); // an offline rule fires here $this->warn("offline {$server->name}: ".$e->getMessage()); } } @@ -87,4 +90,19 @@ class PollMetrics extends Command return self::SUCCESS; } + + /** + * Run the alert rules for a freshly-polled server. Best-effort: an alerting/notification error + * must NEVER kill the poll loop, so any throwable is caught and logged as a warning line. + * + * @param array $metrics + */ + private function evaluateAlerts(AlertEvaluator $alerts, Server $server, array $metrics, string $status): void + { + try { + $alerts->evaluate($server, $metrics, $status); + } catch (Throwable $e) { + $this->warn("alert eval failed {$server->name}: ".$e->getMessage()); + } + } } diff --git a/app/Livewire/Alerts/Index.php b/app/Livewire/Alerts/Index.php new file mode 100644 index 0000000..6eb7bf0 --- /dev/null +++ b/app/Livewire/Alerts/Index.php @@ -0,0 +1,189 @@ +can('manage-panel'), 403); + $this->emailTo = (string) Setting::get('alert_email_to', ''); + $this->webhooks = (string) Setting::get('alert_webhooks', ''); + } + + public function title(): string + { + return __('alerts.title'); + } + + private function gate(): void + { + abort_unless(Auth::user()?->can('manage-panel'), 403); + } + + private function audit(string $action, string $target): void + { + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => $action, + 'target' => $target, + 'ip' => request()->ip(), + ]); + } + + public function createRule(): void + { + $this->gate(); + + $data = $this->validate([ + 'name' => ['required', 'string', 'max:80'], + 'metric' => ['required', Rule::in(AlertRule::METRICS)], + 'comparator' => ['required', Rule::in(AlertRule::COMPARATORS)], + 'threshold' => ['required', 'numeric', 'min:0', 'max:10000'], // numeric: `load` can be fractional + 'scopeType' => ['required', Rule::in(AlertRule::SCOPES)], + ]); + + // Resolve the scope target (a group/server uuid) to its id — never trust a client-sent id. + $scopeId = null; + if ($data['scopeType'] === 'group') { + $scopeId = ServerGroup::where('uuid', $this->scopeUuid)->firstOrFail()->id; + } elseif ($data['scopeType'] === 'server') { + $scopeId = Server::where('uuid', $this->scopeUuid)->firstOrFail()->id; + } + + $rule = AlertRule::create([ + 'name' => $data['name'], + 'metric' => $data['metric'], + 'comparator' => $data['comparator'], + 'threshold' => $data['metric'] === 'offline' ? 0 : $data['threshold'], + 'scope_type' => $data['scopeType'], + 'scope_id' => $scopeId, + 'enabled' => true, + ]); + + $this->audit('alert.rule_create', $rule->name); + $this->reset('name', 'scopeUuid'); + $this->dispatch('notify', message: __('alerts.rule_created', ['name' => $rule->name])); + } + + public function toggleRule(string $uuid): void + { + $this->gate(); + $rule = AlertRule::where('uuid', $uuid)->firstOrFail(); + $rule->update(['enabled' => ! $rule->enabled]); + $this->audit('alert.rule_update', $rule->name); + } + + public function confirmDeleteRule(string $uuid): void + { + $this->gate(); + $rule = AlertRule::where('uuid', $uuid)->firstOrFail(); + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('alerts.delete_heading'), + 'body' => __('alerts.delete_body', ['name' => $rule->name]), + 'confirmLabel' => __('common.delete'), + 'danger' => true, + 'icon' => 'trash', + 'notify' => __('alerts.rule_deleted', ['name' => $rule->name]), + 'token' => ConfirmToken::issue('alertRuleDeleted', ['uuid' => $rule->uuid], 'alert.rule_delete', $rule->name, null), + ], + ); + } + + #[On('alertRuleDeleted')] + public function deleteRule(string $confirmToken): void + { + $this->gate(); + + try { + $payload = ConfirmToken::consume($confirmToken, 'alertRuleDeleted'); + } catch (InvalidConfirmToken) { + return; + } + + // NOT audited here — the confirm modal wrote alert.rule_delete from the sealed token. + AlertRule::where('uuid', $payload['params']['uuid'])->first()?->delete(); // incidents cascade + } + + public function saveChannels(): void + { + $this->gate(); + + // SSRF guard: refuse to store a webhook whose scheme isn't http(s) or whose host resolves to + // an internal/loopback/private address. Checked here (immediate feedback) and again at send. + foreach (preg_split('/[\r\n]+/', trim($this->webhooks)) ?: [] as $line) { + $line = trim($line); + if ($line !== '' && ! AlertNotifier::isSafeWebhookUrl($line)) { + $this->addError('webhooks', __('alerts.webhook_invalid', ['url' => $line])); + + return; + } + } + + Setting::put('alert_email_to', trim($this->emailTo)); + Setting::put('alert_webhooks', trim($this->webhooks)); + $this->audit('alert.channels_update', __('alerts.channels')); + $this->dispatch('notify', message: __('alerts.channels_saved')); + } + + public function render(): View + { + return view('livewire.alerts.index', [ + 'rules' => AlertRule::orderBy('name')->get(), + 'incidents' => AlertIncident::with(['rule', 'server'])->where('state', 'firing')->latest('started_at')->limit(50)->get(), + 'groups' => ServerGroup::orderBy('name')->get(['uuid', 'name']), + 'servers' => Server::orderBy('name')->get(['uuid', 'name']), + // id → name maps so a rule's scope target renders without a per-rule query (no N+1). + 'groupNames' => ServerGroup::pluck('name', 'id'), + 'serverNames' => Server::pluck('name', 'id'), + 'metrics' => AlertRule::METRICS, + 'comparators' => AlertRule::COMPARATORS, + 'scopes' => AlertRule::SCOPES, + ])->title($this->title()); + } +} diff --git a/app/Mail/AlertNotification.php b/app/Mail/AlertNotification.php new file mode 100644 index 0000000..f49cbc2 --- /dev/null +++ b/app/Mail/AlertNotification.php @@ -0,0 +1,47 @@ +incident->server?->name ?? '—'; + $rule = $this->incident->rule?->name ?? '—'; + $prefix = $this->resolved ? __('alerts.mail_subject_resolved') : __('alerts.mail_subject_firing'); + + return new Envelope(subject: "[{$prefix}] {$rule} — {$server}"); + } + + public function content(): Content + { + return new Content( + text: 'mail.alert', + with: [ + 'incident' => $this->incident, + 'resolved' => $this->resolved, + 'server' => $this->incident->server?->name ?? '—', + 'rule' => $this->incident->rule?->name ?? '—', + 'metric' => $this->incident->rule?->metric ?? '—', + 'value' => $this->incident->value, + 'threshold' => $this->incident->rule?->threshold ?? 0, + ], + ); + } +} diff --git a/app/Models/AlertIncident.php b/app/Models/AlertIncident.php new file mode 100644 index 0000000..8e0f58e --- /dev/null +++ b/app/Models/AlertIncident.php @@ -0,0 +1,32 @@ + 'float', + 'started_at' => 'datetime', + 'resolved_at' => 'datetime', + ]; + + public function rule(): BelongsTo + { + return $this->belongsTo(AlertRule::class, 'alert_rule_id'); + } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function isFiring(): bool + { + return $this->state === 'firing'; + } +} diff --git a/app/Models/AlertRule.php b/app/Models/AlertRule.php new file mode 100644 index 0000000..a9a4455 --- /dev/null +++ b/app/Models/AlertRule.php @@ -0,0 +1,77 @@ + 'boolean', + 'threshold' => 'float', // % metrics are whole, but `load` is fractional (see the migration) + ]; + + /** Metrics a rule can watch. `offline` fires when a server can't be polled (status offline). */ + public const METRICS = ['cpu', 'mem', 'disk', 'load', 'offline']; + + public const COMPARATORS = ['gt', 'lt']; + + public const SCOPES = ['all', 'group', 'server']; + + protected static function booted(): void + { + static::creating(fn (self $r) => $r->uuid ??= (string) Str::uuid()); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function incidents(): HasMany + { + return $this->hasMany(AlertIncident::class); + } + + /** Whether this rule applies to the given server (all / a group's members / one server). */ + public function targets(Server $server): bool + { + return match ($this->scope_type) { + 'server' => (int) $this->scope_id === $server->id, + 'group' => $server->groups()->where('server_groups.id', $this->scope_id)->exists(), + default => true, // 'all' + }; + } + + /** + * Does the reading breach this rule? For numeric metrics, compare against the threshold with the + * rule's comparator; for `offline`, the breach is simply that the server is not online. + * + * @param array $metrics cpu/mem/disk/load ints; status when offline + */ + public function breached(array $metrics, string $status): bool + { + if ($this->metric === 'offline') { + return $status === 'offline'; // only a truly-down server, not a reachable "warning" + } + + $value = (float) ($metrics[$this->metric] ?? 0); + $threshold = (float) $this->threshold; + + return $this->comparator === 'lt' ? $value < $threshold : $value > $threshold; + } + + /** The reading to record on the incident (the breached value; 0 for offline). Float for `load`. */ + public function reading(array $metrics): float + { + return $this->metric === 'offline' ? 0.0 : (float) ($metrics[$this->metric] ?? 0); + } +} diff --git a/app/Services/AlertEvaluator.php b/app/Services/AlertEvaluator.php new file mode 100644 index 0000000..b2a96c4 --- /dev/null +++ b/app/Services/AlertEvaluator.php @@ -0,0 +1,77 @@ + $metrics cpu/mem/disk/load ints (empty when offline) + * @param string $status the server's fresh status (online|warning|offline) + */ + public function evaluate(Server $server, array $metrics, string $status): void + { + $rules = AlertRule::where('enabled', true)->get(); + + foreach ($rules as $rule) { + if (! $rule->targets($server)) { + continue; + } + + // Numeric metrics need a fresh reading; skip them only when the server is truly OFFLINE + // (stale numbers). A reachable "warning" server still has fresh metrics, so it evaluates. + if ($rule->metric !== 'offline' && $status === 'offline') { + continue; + } + + $this->transition($rule, $server, $rule->breached($metrics, $status), $rule->reading($metrics)); + } + } + + private function transition(AlertRule $rule, Server $server, bool $breached, float $reading): void + { + $open = AlertIncident::where('alert_rule_id', $rule->id) + ->where('server_id', $server->id) + ->where('state', 'firing') + ->first(); + + if ($breached && ! $open) { + try { + $incident = AlertIncident::create([ + 'alert_rule_id' => $rule->id, + 'server_id' => $server->id, + 'state' => 'firing', + 'value' => $reading, + // Unique while firing → a concurrent tick that also tries to open this incident + // hits a unique violation instead of creating a duplicate (see the migration). + 'firing_key' => $rule->id.':'.$server->id, + 'started_at' => now(), + ]); + } catch (QueryException) { + return; // another tick opened it first — dedup, no duplicate incident/notification + } + $this->notifier->notify($incident, resolved: false); + + return; + } + + if (! $breached && $open) { + // Clear firing_key on resolve so a later re-breach can claim the unique key again. + $open->update(['state' => 'resolved', 'firing_key' => null, 'resolved_at' => now()]); + $this->notifier->notify($open, resolved: true); + } + + // else: still firing with an open incident, or fine with none → dedup, no-op. + } +} diff --git a/app/Services/AlertNotifier.php b/app/Services/AlertNotifier.php new file mode 100644 index 0000000..d1cc052 --- /dev/null +++ b/app/Services/AlertNotifier.php @@ -0,0 +1,140 @@ +email($incident, $resolved); + $this->webhooks($incident, $resolved); + } + + private function email(AlertIncident $incident, bool $resolved): void + { + try { + $recipients = $this->recipients(); + if ($recipients === []) { + return; + } + Mail::to($recipients)->queue(new AlertNotification($incident, $resolved)); + } catch (Throwable $e) { + Log::warning('alert email failed: '.$e->getMessage()); + } + } + + private function webhooks(AlertIncident $incident, bool $resolved): void + { + $payload = $this->payload($incident, $resolved); + + foreach ($this->webhookUrls() as $url) { + try { + // withoutRedirecting: a 30x could otherwise bounce the POST to an internal target that + // the SSRF host-check below never saw. timeout keeps a slow hook from stalling the loop. + Http::timeout(5)->withoutRedirecting()->asJson()->post($url, $payload); + } catch (Throwable $e) { + Log::warning('alert webhook failed: '.$e->getMessage()); + } + } + } + + /** + * SSRF guard for an operator-configured webhook URL: exactly http(s), and the host must NOT + * resolve to a loopback/private/link-local/reserved address (so a hook can't be pointed at an + * internal service). Admin-only config, but defence in depth — checked here AND at save time. + */ + public static function isSafeWebhookUrl(string $url): bool + { + $parts = parse_url(trim($url)); + if ($parts === false || ! isset($parts['scheme'], $parts['host'])) { + return false; + } + if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { + return false; + } + + $host = $parts['host']; + // IP literal → itself; hostname → its A records (IPv4). Unresolvable / IPv6-only → refuse. + $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []); + if ($ips === []) { + return false; + } + + foreach ($ips as $ip) { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return false; // loopback/private/link-local/reserved → blocked + } + } + + return true; + } + + /** @return list */ + private function recipients(): array + { + $configured = $this->splitLines((string) Setting::get('alert_email_to', '')); + $emails = array_values(array_filter($configured, fn (string $e) => filter_var($e, FILTER_VALIDATE_EMAIL) !== false)); + + if ($emails !== []) { + return $emails; + } + + // Fall back to every admin's e-mail so a fresh install still alerts someone. + return User::query()->where('role', Role::Admin->value) + ->whereNotNull('email') + ->pluck('email')->all(); + } + + /** @return list */ + private function webhookUrls(): array + { + return array_values(array_filter( + $this->splitLines((string) Setting::get('alert_webhooks', '')), + fn (string $u) => self::isSafeWebhookUrl($u), + )); + } + + /** @return array */ + private function payload(AlertIncident $incident, bool $resolved): array + { + $server = $incident->server?->name ?? '—'; + $rule = $incident->rule?->name ?? '—'; + $state = $resolved ? 'resolved' : 'firing'; + $text = $resolved + ? __('alerts.mail_subject_resolved')." — {$rule} ({$server})" + : __('alerts.mail_subject_firing')." — {$rule} ({$server})"; + + // A single `text` field satisfies Slack/Discord/Mattermost/Telegram-relay incoming webhooks; + // the structured fields let a custom consumer route on server/metric/state. + return [ + 'text' => $text, + 'state' => $state, + 'server' => $server, + 'rule' => $rule, + 'metric' => $incident->rule?->metric, + 'value' => $incident->value, + 'threshold' => $incident->rule?->threshold, + ]; + } + + /** @return list */ + private function splitLines(string $raw): array + { + return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: []))); + } +} diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index c585a54..13e4889 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -67,6 +67,7 @@ class ConfirmToken 'releasePublic', 'releaseYank', 'groupConfirmed', + 'alertRuleDeleted', ]; /** How long an issued confirm stays valid (seconds) — generous for a human click. */ diff --git a/database/migrations/2026_07_05_100002_create_alert_rules_table.php b/database/migrations/2026_07_05_100002_create_alert_rules_table.php new file mode 100644 index 0000000..0d0e2ba --- /dev/null +++ b/database/migrations/2026_07_05_100002_create_alert_rules_table.php @@ -0,0 +1,33 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('name'); + $table->string('metric'); // cpu|mem|disk|load|offline + $table->string('comparator')->default('gt'); // gt|lt (ignored for offline) + // decimal, not int: a % metric is 0-100 but `load` is a float (e.g. 1.5) — an int + // threshold would truncate it so `load 1.5 > 1` never fires. + $table->decimal('threshold', 8, 2)->default(0); + $table->string('scope_type')->default('all'); // all|group|server + $table->unsignedBigInteger('scope_id')->nullable(); + $table->boolean('enabled')->default(true); + $table->timestamps(); + + $table->index(['enabled', 'metric']); + }); + } + + public function down(): void + { + Schema::dropIfExists('alert_rules'); + } +}; diff --git a/database/migrations/2026_07_05_100003_create_alert_incidents_table.php b/database/migrations/2026_07_05_100003_create_alert_incidents_table.php new file mode 100644 index 0000000..e5b4df9 --- /dev/null +++ b/database/migrations/2026_07_05_100003_create_alert_incidents_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('alert_rule_id')->constrained()->cascadeOnDelete(); + $table->foreignId('server_id')->constrained()->cascadeOnDelete(); + $table->string('state')->default('firing'); // firing|resolved + $table->decimal('value', 8, 2)->default(0); // float-capable (see threshold, for `load`) + // Set to "{rule_id}:{server_id}" ONLY while firing, NULL once resolved. The unique index + // makes "at most one FIRING incident per rule+server" a DB invariant, so two concurrent + // poll ticks cannot both open a duplicate incident (the loser hits a unique violation). + $table->string('firing_key')->nullable()->unique(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + + // Hot path: "is there an open incident for this rule+server?" + $table->index(['alert_rule_id', 'server_id', 'state']); + }); + } + + public function down(): void + { + Schema::dropIfExists('alert_incidents'); + } +}; diff --git a/lang/de/alerts.php b/lang/de/alerts.php new file mode 100644 index 0000000..2989022 --- /dev/null +++ b/lang/de/alerts.php @@ -0,0 +1,82 @@ + 'Betrieb', + 'title' => 'Alarme', + 'subtitle' => 'Schwellwert-Regeln + Benachrichtigungen für die Flotte', + + // New rule + 'new_rule' => 'Neue Regel', + 'name_label' => 'Name', + 'name_placeholder' => 'CPU hoch, Server offline …', + 'metric_label' => 'Metrik', + 'comparator_label' => 'Bedingung', + 'threshold_label' => 'Schwelle', + 'scope_label' => 'Geltungsbereich', + 'scope_target_label' => 'Ziel', + 'create' => 'Anlegen', + + // Metrics + 'metric_cpu' => 'CPU', + 'metric_mem' => 'Arbeitsspeicher', + 'metric_disk' => 'Festplatte', + 'metric_load' => 'Load', + 'metric_offline' => 'Server offline', + + // Comparators + 'comparator_gt' => 'größer als', + 'comparator_lt' => 'kleiner als', + + // Scopes + 'scope_all' => 'Ganze Flotte', + 'scope_group' => 'Gruppe', + 'scope_server' => 'Einzelner Server', + + // Rules list + 'rules_title' => 'Regeln', + 'no_rules_title' => 'Keine Regeln', + 'no_rules' => 'Noch keine Alarmregeln. Lege oben die erste an.', + 'enabled' => 'Aktiv', + 'disabled' => 'Inaktiv', + 'rule_summary_offline' => ':scope · Server offline', + 'rule_summary' => ':scope · :metric :comparator :threshold', + + // Incidents + 'incidents_title' => 'Aktive Alarme', + 'incidents_subtitle' => 'derzeit ausgelöst', + 'no_incidents_title' => 'Alles ruhig', + 'no_incidents' => 'Derzeit keine aktiven Alarme.', + 'incident_value' => 'Wert: :value', + 'incident_since' => 'seit :time', + + // Channels + 'channels' => 'Kanäle', + 'channels_title' => 'Benachrichtigungs-Kanäle', + 'channels_subtitle' => 'wohin Alarme gehen', + 'email_label' => 'E-Mail-Empfänger', + 'email_hint' => 'Eine Adresse pro Zeile. Leer = alle Admins.', + 'webhooks_label' => 'Webhooks', + 'webhooks_hint' => 'Eine URL pro Zeile (Slack/Discord/Mattermost/eigene).', + 'save' => 'Speichern', + + // Toasts + 'rule_created' => 'Regel „:name“ angelegt.', + 'rule_deleted' => 'Regel „:name“ gelöscht.', + 'channels_saved' => 'Kanäle gespeichert.', + 'webhook_invalid' => 'Ungültige oder unsichere Webhook-URL: :url (nur http/https zu öffentlichen Hosts).', + + // Delete confirm + 'delete_heading' => 'Regel löschen', + 'delete_body' => 'Die Regel „:name“ und ihre Alarm-Historie werden gelöscht.', + + // Mail + 'mail_subject_firing' => 'ALARM', + 'mail_subject_resolved' => 'BEHOBEN', + 'mail_firing_line' => 'Ein Alarm wurde ausgelöst.', + 'mail_resolved_line' => 'Ein Alarm wurde behoben.', + 'mail_server' => 'Server', + 'mail_rule' => 'Regel', + 'mail_metric' => 'Metrik', + 'mail_value' => 'Wert', + 'mail_threshold' => 'Schwelle', +]; diff --git a/lang/de/audit.php b/lang/de/audit.php index 4d86fd5..d7ee025 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -33,6 +33,10 @@ return [ // Human-readable labels for the raw action codes shown in the event list. 'actions' => [ + 'alert.rule_create' => 'Alarmregel angelegt', + 'alert.rule_update' => 'Alarmregel geändert', + 'alert.rule_delete' => 'Alarmregel gelöscht', + 'alert.channels_update' => 'Alarm-Kanäle geändert', 'audit.retention_set' => 'Aufbewahrung geändert', 'auth.login_failed' => 'Fehlgeschlagene Anmeldung', 'auth.2fa_failed' => 'Fehlgeschlagene 2FA-Prüfung', diff --git a/lang/de/shell.php b/lang/de/shell.php index 3f8d6f7..dc71e54 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -22,6 +22,7 @@ return [ 'nav_terminal' => 'Terminal', 'nav_settings' => 'Einstellungen', 'nav_system' => 'System', + 'nav_alerts' => 'Alarme', 'nav_threats' => 'Bedrohungen', 'nav_versions' => 'Version', 'nav_help' => 'Hilfe', diff --git a/lang/en/alerts.php b/lang/en/alerts.php new file mode 100644 index 0000000..9ad8f27 --- /dev/null +++ b/lang/en/alerts.php @@ -0,0 +1,82 @@ + 'Operations', + 'title' => 'Alerts', + 'subtitle' => 'Threshold rules + notifications for the fleet', + + // New rule + 'new_rule' => 'New rule', + 'name_label' => 'Name', + 'name_placeholder' => 'High CPU, server offline …', + 'metric_label' => 'Metric', + 'comparator_label' => 'Condition', + 'threshold_label' => 'Threshold', + 'scope_label' => 'Scope', + 'scope_target_label' => 'Target', + 'create' => 'Create', + + // Metrics + 'metric_cpu' => 'CPU', + 'metric_mem' => 'Memory', + 'metric_disk' => 'Disk', + 'metric_load' => 'Load', + 'metric_offline' => 'Server offline', + + // Comparators + 'comparator_gt' => 'greater than', + 'comparator_lt' => 'less than', + + // Scopes + 'scope_all' => 'Whole fleet', + 'scope_group' => 'Group', + 'scope_server' => 'Single server', + + // Rules list + 'rules_title' => 'Rules', + 'no_rules_title' => 'No rules', + 'no_rules' => 'No alert rules yet. Create the first one above.', + 'enabled' => 'Enabled', + 'disabled' => 'Disabled', + 'rule_summary_offline' => ':scope · server offline', + 'rule_summary' => ':scope · :metric :comparator :threshold', + + // Incidents + 'incidents_title' => 'Active alerts', + 'incidents_subtitle' => 'currently firing', + 'no_incidents_title' => 'All quiet', + 'no_incidents' => 'No active alerts right now.', + 'incident_value' => 'Value: :value', + 'incident_since' => 'since :time', + + // Channels + 'channels' => 'Channels', + 'channels_title' => 'Notification channels', + 'channels_subtitle' => 'where alerts go', + 'email_label' => 'E-mail recipients', + 'email_hint' => 'One address per line. Empty = all admins.', + 'webhooks_label' => 'Webhooks', + 'webhooks_hint' => 'One URL per line (Slack/Discord/Mattermost/custom).', + 'save' => 'Save', + + // Toasts + 'rule_created' => 'Rule “:name” created.', + 'rule_deleted' => 'Rule “:name” deleted.', + 'channels_saved' => 'Channels saved.', + 'webhook_invalid' => 'Invalid or unsafe webhook URL: :url (only http/https to public hosts).', + + // Delete confirm + 'delete_heading' => 'Delete rule', + 'delete_body' => 'The rule “:name” and its alert history will be deleted.', + + // Mail + 'mail_subject_firing' => 'ALERT', + 'mail_subject_resolved' => 'RESOLVED', + 'mail_firing_line' => 'An alert has fired.', + 'mail_resolved_line' => 'An alert has resolved.', + 'mail_server' => 'Server', + 'mail_rule' => 'Rule', + 'mail_metric' => 'Metric', + 'mail_value' => 'Value', + 'mail_threshold' => 'Threshold', +]; diff --git a/lang/en/audit.php b/lang/en/audit.php index 65ae915..3b51256 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -33,6 +33,10 @@ return [ // Human-readable labels for the raw action codes shown in the event list. 'actions' => [ + 'alert.rule_create' => 'Alert rule created', + 'alert.rule_update' => 'Alert rule changed', + 'alert.rule_delete' => 'Alert rule deleted', + 'alert.channels_update' => 'Alert channels changed', 'audit.retention_set' => 'Retention changed', 'auth.login_failed' => 'Failed sign-in', 'auth.2fa_failed' => 'Failed 2FA check', diff --git a/lang/en/shell.php b/lang/en/shell.php index 056edf5..6c3a040 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -22,6 +22,7 @@ return [ 'nav_terminal' => 'Terminal', 'nav_settings' => 'Settings', 'nav_system' => 'System', + 'nav_alerts' => 'Alerts', 'nav_threats' => 'Threats', 'nav_versions' => 'Version', 'nav_help' => 'Help', diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index b96328f..1928653 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -10,6 +10,12 @@ ->where('created_at', '>', now()->subDay()) ->count(), ); + // Active (firing) alert incidents → the Alarme nav badge. Cached like the threats count. + $alertCount = \Illuminate\Support\Facades\Cache::remember( + 'alerts:firing_count', + 60, + fn () => \App\Models\AlertIncident::where('state', 'firing')->count(), + ); @endphp {{-- Fixed on desktop, off-canvas drawer on mobile/tablet (toggled by `nav` in the layout). --}}