feat(alerts): threshold alerting + notifications (feature 2/8)

Turns the existing metrics into an ops tool: rules that fire when a server crosses a
threshold and resolve when it recovers, with e-mail + webhook notifications. The event
sink later features (cert-expiry, uptime-down, patch-available, posture-drop) plug into.

- alert_rules (metric cpu|mem|disk|load|offline, comparator gt|lt, threshold, scope
  all|group|server) + alert_incidents (firing|resolved, FK cascade).
- AlertEvaluator — per-server state machine: opens ONE firing incident on a fresh breach
  (notifies), resolves on recovery (notifies), dedups a sustained breach (one breach = one
  alert). Numeric metrics skip a truly-offline server (stale reading); the `offline` rule
  fires only on offline, not a reachable "warning".
- AlertNotifier — best-effort e-mail (queued Mailable to configured recipients, falls back
  to admin e-mails) + generic webhook JSON POST (Slack/Discord/Mattermost/custom; only http(s)
  URLs). Every channel is wrapped so a broken SMTP/unreachable hook is logged, never thrown.
- PollMetrics evaluates after each poll (online, fresh status) and on a failed poll (offline),
  each guarded so an alerting error can never kill the poll loop.
- Alerts\Index page, manage-panel (admin): route can:-mw + mount() + per-method gate() on every
  mutation. Rule delete via signed ConfirmToken + R5 modal (no double audit). Scope group/server
  resolves a client UUID to an id server-side (never trusts a client id). Channels saved to
  Settings. Sidebar "Alarme" nav + firing-incident badge (cached 60s).
- lang/{de,en}/alerts.php + audit.php alert.* + shell.nav_alerts (de/en parity). No emoji.

Codex review raised three MEDIUMs, all fixed here:
- Webhook SSRF: strict http(s) scheme + reject hosts resolving to loopback/private/link-local/
  reserved ranges (blocks the 169.254.169.254 cloud-metadata classic), validated at save AND send,
  redirects disabled — so an admin-configured hook can't be pointed at an internal service.
- Incident dedup is now a DB invariant: a unique `firing_key` ("{rule}:{server}" while firing,
  null on resolve) means two concurrent poll ticks can't both open a duplicate incident (the loser
  hits a unique violation, caught → no-op).
- `load` is float-safe: threshold + incident value are decimal, compared as floats, so load 1.5 vs
  threshold 1 fires (it used to truncate to 1 > 1 = false).

31 new tests: evaluator (fire/resolve/dedup, offline vs warning, scope all/group/server, disabled,
fractional-load, firing_key unique, key-freed-on-resolve), notifier (email/fallback/webhook/
non-http-ignored/failure-swallowed/SSRF-reject/public-accept), component (RBAC route gating, rule
CRUD, uuid→id scope resolve, offline→threshold 0, toggle, delete via token, channels, unsafe-webhook
rejected, incidents render). 669 tests green, Pint, lang parity, Codex-reviewed (fixes applied).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 20:07:54 +02:00
parent 40d8dfc96d
commit 0472b3531a
23 changed files with 1503 additions and 2 deletions

View File

@ -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<string, int|float|string> $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());
}
}
}

View File

@ -0,0 +1,189 @@
<?php
namespace App\Livewire\Alerts;
use App\Models\AlertIncident;
use App\Models\AlertRule;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Models\Setting;
use App\Services\AlertNotifier;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Alert rules + notification channels + active incidents. Managing rules/channels =
* manage-panel (admin): route mw + mount() + a per-method gate() (since /livewire/update
* skips route mw). Viewing incidents is open (read-only). Rule deletion goes through the
* signed single-use ConfirmToken + the R5 confirm modal.
*/
#[Layout('layouts.app')]
class Index extends Component
{
// New-rule form
public string $name = '';
public string $metric = 'cpu';
public string $comparator = 'gt';
public float $threshold = 80; // float: `load` thresholds are fractional (e.g. 1.5)
public string $scopeType = 'all';
public ?string $scopeUuid = null; // group OR server uuid, resolved on create
// Channels
public string $emailTo = '';
public string $webhooks = '';
public function mount(): void
{
abort_unless(Auth::user()?->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());
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Mail;
use App\Models\AlertIncident;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/** Queued alert e-mail — one per fire/resolve transition. Plain text (no external assets). */
class AlertNotification extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public AlertIncident $incident,
public bool $resolved,
) {}
public function envelope(): Envelope
{
$server = $this->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,
],
);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AlertIncident extends Model
{
protected $guarded = [];
protected $casts = [
'value' => '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';
}
}

77
app/Models/AlertRule.php Normal file
View File

@ -0,0 +1,77 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* A threshold rule over a server metric. When a targeted server crosses it, the AlertEvaluator
* opens a firing AlertIncident and notifies; when it recovers, the incident resolves.
*/
class AlertRule extends Model
{
protected $guarded = [];
protected $casts = [
'enabled' => '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<string, int|string> $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);
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Services;
use App\Models\AlertIncident;
use App\Models\AlertRule;
use App\Models\Server;
use Illuminate\Database\QueryException;
/**
* The alert state machine. Given a server's fresh reading, opens a firing incident on a new breach
* (and notifies) and resolves an open incident on recovery (and notifies). Idempotent: a sustained
* breach with an already-open incident does nothing, so one breach = one alert, not a flood.
*/
class AlertEvaluator
{
public function __construct(private AlertNotifier $notifier) {}
/**
* @param array<string, int|string> $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.
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace App\Services;
use App\Enums\Role;
use App\Mail\AlertNotification;
use App\Models\AlertIncident;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Fans one alert transition (fire / resolve) out to the configured channels. Every channel is
* BEST-EFFORT: a broken SMTP config or an unreachable webhook is logged, never thrown, so a
* notification failure can never break the poll loop that triggered it.
*/
class AlertNotifier
{
public function notify(AlertIncident $incident, bool $resolved): void
{
$this->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<string> */
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<string> */
private function webhookUrls(): array
{
return array_values(array_filter(
$this->splitLines((string) Setting::get('alert_webhooks', '')),
fn (string $u) => self::isSafeWebhookUrl($u),
));
}
/** @return array<string, mixed> */
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<string> */
private function splitLines(string $raw): array
{
return array_values(array_filter(array_map('trim', preg_split('/[\r\n,]+/', $raw) ?: [])));
}
}

View File

@ -67,6 +67,7 @@ class ConfirmToken
'releasePublic',
'releaseYank',
'groupConfirmed',
'alertRuleDeleted',
];
/** How long an issued confirm stays valid (seconds) — generous for a human click. */

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('alert_rules', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('alert_incidents', function (Blueprint $table) {
$table->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');
}
};

82
lang/de/alerts.php Normal file
View File

@ -0,0 +1,82 @@
<?php
return [
'eyebrow' => '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',
];

View File

@ -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',

View File

@ -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',

82
lang/en/alerts.php Normal file
View File

@ -0,0 +1,82 @@
<?php
return [
'eyebrow' => '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',
];

View File

@ -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',

View File

@ -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',

View File

@ -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). --}}
<aside class="fixed inset-y-0 left-0 z-40 flex w-[272px] -translate-x-full flex-col border-r border-line bg-surface transition-transform duration-200 lg:translate-x-0"
@ -51,6 +57,7 @@
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')" data-tour="settings">{{ __('shell.nav_settings') }}</x-nav-item>
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
@can('manage-panel')
<x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item>
<x-nav-item icon="shield-alert" href="/threats" :active="request()->is('threats*')" :badge="$threatCount > 0 ? $threatCount : null" :badge-title="$threatCount > 0 ? __('shell.threats_badge', ['count' => $threatCount]) : null">{{ __('shell.nav_threats') }}</x-nav-item>
@endcan
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')" :badge="$updateAvailable ? '1' : null" :badge-title="$updateAvailable ? __('shell.update_available') : null">{{ __('shell.nav_versions') }}</x-nav-item>

View File

@ -0,0 +1,176 @@
@php
$field = 'h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none';
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$scopeName = function ($rule) use ($groupNames, $serverNames) {
return match ($rule->scope_type) {
'group' => $groupNames[$rule->scope_id] ?? __('alerts.scope_group'),
'server' => $serverNames[$rule->scope_id] ?? __('alerts.scope_server'),
default => __('alerts.scope_all'),
};
};
@endphp
<div class="space-y-5">
{{-- Header --}}
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('alerts.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('alerts.title') }}</h2>
<p class="mt-1 text-sm text-ink-3">{{ __('alerts.subtitle') }}</p>
</div>
{{-- Active incidents --}}
<x-panel :title="__('alerts.incidents_title')" :subtitle="__('alerts.incidents_subtitle')" :padded="false">
@if ($incidents->isEmpty())
<div class="flex flex-col items-center gap-2 py-8 text-center">
<x-status-dot status="online" class="h-2.5 w-2.5" />
<p class="font-display text-sm font-semibold text-ink">{{ __('alerts.no_incidents_title') }}</p>
<p class="text-xs text-ink-3">{{ __('alerts.no_incidents') }}</p>
</div>
@else
<div class="divide-y divide-line">
@foreach ($incidents as $incident)
<div wire:key="inc-{{ $incident->id }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
<x-status-dot status="offline" :ping="true" class="h-2.5 w-2.5" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-ink">{{ $incident->rule?->name ?? '—' }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $incident->server?->name ?? '—' }} · {{ __('alerts.incident_value', ['value' => $incident->value]) }}</p>
</div>
<span class="shrink-0 font-mono text-[11px] text-ink-4">{{ __('alerts.incident_since', ['time' => $incident->started_at?->diffForHumans() ?? '—']) }}</span>
</div>
@endforeach
</div>
@endif
</x-panel>
{{-- New rule --}}
<x-panel :title="__('alerts.new_rule')">
<form wire:submit="createRule" class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="{{ $label }}">{{ __('alerts.name_label') }}</label>
<input wire:model="name" type="text" maxlength="80" placeholder="{{ __('alerts.name_placeholder') }}" class="{{ $field }} placeholder:text-ink-4" />
@error('name') <p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('alerts.metric_label') }}</label>
<select wire:model.live="metric" class="{{ $field }}">
@foreach ($metrics as $m)
<option value="{{ $m }}">{{ __('alerts.metric_'.$m) }}</option>
@endforeach
</select>
</div>
</div>
@if ($metric !== 'offline')
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="{{ $label }}">{{ __('alerts.comparator_label') }}</label>
<select wire:model="comparator" class="{{ $field }}">
@foreach ($comparators as $c)
<option value="{{ $c }}">{{ __('alerts.comparator_'.$c) }}</option>
@endforeach
</select>
</div>
<div>
<label class="{{ $label }}">{{ __('alerts.threshold_label') }}</label>
<input wire:model="threshold" type="number" min="0" max="100" class="{{ $field }} font-mono" />
@error('threshold') <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>
@endif
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="{{ $label }}">{{ __('alerts.scope_label') }}</label>
<select wire:model.live="scopeType" class="{{ $field }}">
@foreach ($scopes as $s)
<option value="{{ $s }}">{{ __('alerts.scope_'.$s) }}</option>
@endforeach
</select>
</div>
@if ($scopeType === 'group')
<div>
<label class="{{ $label }}">{{ __('alerts.scope_target_label') }}</label>
<select wire:model="scopeUuid" class="{{ $field }}">
<option value=""></option>
@foreach ($groups as $grp)
<option value="{{ $grp->uuid }}">{{ $grp->name }}</option>
@endforeach
</select>
</div>
@elseif ($scopeType === 'server')
<div>
<label class="{{ $label }}">{{ __('alerts.scope_target_label') }}</label>
<select wire:model="scopeUuid" class="{{ $field }}">
<option value=""></option>
@foreach ($servers as $srv)
<option value="{{ $srv->uuid }}">{{ $srv->name }}</option>
@endforeach
</select>
</div>
@endif
</div>
<div class="flex justify-end">
<x-btn variant="primary" size="lg" type="submit">{{ __('alerts.create') }}</x-btn>
</div>
</form>
</x-panel>
{{-- Rules --}}
<x-panel :title="__('alerts.rules_title')" :padded="false">
@if ($rules->isEmpty())
<div class="px-4 py-8 text-center sm:px-5">
<p class="font-display text-sm font-semibold text-ink">{{ __('alerts.no_rules_title') }}</p>
<p class="mt-1 text-xs text-ink-3">{{ __('alerts.no_rules') }}</p>
</div>
@else
<div class="divide-y divide-line">
@foreach ($rules as $rule)
<div wire:key="rule-{{ $rule->uuid }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-ink">{{ $rule->name }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">
@if ($rule->metric === 'offline')
{{ __('alerts.rule_summary_offline', ['scope' => $scopeName($rule)]) }}
@else
{{ __('alerts.rule_summary', ['scope' => $scopeName($rule), 'metric' => __('alerts.metric_'.$rule->metric), 'comparator' => __('alerts.comparator_'.$rule->comparator), 'threshold' => $rule->threshold]) }}
@endif
</p>
</div>
<button type="button" wire:click="toggleRule('{{ $rule->uuid }}')"
@class([
'inline-flex min-h-9 shrink-0 items-center gap-1.5 rounded-md border px-2.5 text-xs transition-colors',
'border-online/20 bg-online/10 text-online' => $rule->enabled,
'border-line bg-inset text-ink-3 hover:bg-raised' => ! $rule->enabled,
])>
<x-status-dot :status="$rule->enabled ? 'online' : 'offline'" class="h-2 w-2" />
{{ $rule->enabled ? __('alerts.enabled') : __('alerts.disabled') }}
</button>
<x-modal-trigger variant="danger-soft" action="confirmDeleteRule('{{ $rule->uuid }}')">{{ __('common.delete') }}</x-modal-trigger>
</div>
@endforeach
</div>
@endif
</x-panel>
{{-- Channels --}}
<x-panel :title="__('alerts.channels_title')" :subtitle="__('alerts.channels_subtitle')">
<form wire:submit="saveChannels" class="space-y-4">
<div>
<label class="{{ $label }}">{{ __('alerts.email_label') }}</label>
<textarea wire:model="emailTo" rows="2" class="{{ $field }} h-auto py-2.5 font-mono" placeholder="ops@example.com"></textarea>
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('alerts.email_hint') }}</p>
</div>
<div>
<label class="{{ $label }}">{{ __('alerts.webhooks_label') }}</label>
<textarea wire:model="webhooks" rows="2" class="{{ $field }} h-auto py-2.5 font-mono" placeholder="https://hooks.slack.com/…"></textarea>
@error('webhooks') <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
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('alerts.webhooks_hint') }}</p>
</div>
<div class="flex justify-end">
<x-btn variant="primary" size="lg" type="submit">{{ __('alerts.save') }}</x-btn>
</div>
</form>
</x-panel>
</div>

View File

@ -0,0 +1,9 @@
{{ $resolved ? __('alerts.mail_resolved_line') : __('alerts.mail_firing_line') }}
{{ __('alerts.mail_server') }}: {{ $server }}
{{ __('alerts.mail_rule') }}: {{ $rule }}
{{ __('alerts.mail_metric') }}: {{ $metric }}
{{ __('alerts.mail_value') }}: {{ $value }}
{{ __('alerts.mail_threshold') }}: {{ $threshold }}
Clusev

View File

@ -5,6 +5,7 @@ use App\Http\Middleware\BlockBannedIp;
use App\Http\Middleware\EnsureSecurityOnboarded;
use App\Http\Middleware\SetLocale;
use App\Http\Middleware\VerifyTerminalSidecarSecret;
use App\Livewire\Alerts;
use App\Livewire\Audit;
use App\Livewire\Auth;
use App\Livewire\Dashboard;
@ -227,6 +228,7 @@ Route::middleware('auth')->group(function () {
// Honeypot intelligence dashboard — admin-only (defense-in-depth: the route middleware here
// AND Threats\Index::mount() abort_unless both gate on manage-panel).
Route::get('/threats', Threats\Index::class)->name('threats')->middleware('can:manage-panel');
Route::get('/alerts', Alerts\Index::class)->middleware('can:manage-panel')->name('alerts');
Route::get('/settings', Settings\Index::class)->name('settings');
Route::get('/versions', Versions\Index::class)->name('versions');

View File

@ -0,0 +1,195 @@
<?php
namespace Tests\Feature;
use App\Models\AlertIncident;
use App\Models\AlertRule;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Services\AlertEvaluator;
use App\Services\AlertNotifier;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class AlertEvaluatorTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
/** Evaluator with a notifier spy so no real mail/http is sent; returns [$evaluator, $notifier]. */
private function make(): array
{
$notifier = Mockery::mock(AlertNotifier::class);
return [new AlertEvaluator($notifier), $notifier];
}
private function server(string $ip = '10.0.0.1'): Server
{
return Server::create(['name' => 'box', 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']);
}
public function test_a_numeric_breach_opens_a_firing_incident_and_notifies(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), false);
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
$server = $this->server();
$eval->evaluate($server, ['cpu' => 95, 'mem' => 10, 'disk' => 10, 'load' => 1], 'online');
$this->assertDatabaseHas('alert_incidents', ['server_id' => $server->id, 'state' => 'firing', 'value' => 95]);
}
public function test_a_sustained_breach_does_not_open_a_second_incident(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify')->once(); // fired once, deduped after
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
$server = $this->server();
$eval->evaluate($server, ['cpu' => 95], 'online');
$eval->evaluate($server, ['cpu' => 96], 'online'); // still breached → dedup
$this->assertSame(1, AlertIncident::where('state', 'firing')->count());
}
public function test_recovery_resolves_the_incident_and_notifies(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), false);
$notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), true);
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
$server = $this->server();
$eval->evaluate($server, ['cpu' => 95], 'online'); // fire
$eval->evaluate($server, ['cpu' => 20], 'online'); // recover
$this->assertSame(0, AlertIncident::where('state', 'firing')->count());
$this->assertDatabaseHas('alert_incidents', ['state' => 'resolved']);
}
public function test_lt_comparator_fires_below_the_threshold(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify')->once();
AlertRule::create(['name' => 'Disk free low', 'metric' => 'disk', 'comparator' => 'lt', 'threshold' => 10, 'scope_type' => 'all', 'enabled' => true]);
$server = $this->server();
$eval->evaluate($server, ['disk' => 5], 'online');
$this->assertDatabaseHas('alert_incidents', ['state' => 'firing', 'value' => 5]);
}
public function test_offline_rule_fires_when_not_online_and_skips_numeric(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify')->once()->with(Mockery::type(AlertIncident::class), false);
AlertRule::create(['name' => 'Down', 'metric' => 'offline', 'scope_type' => 'all', 'enabled' => true]);
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 1, 'scope_type' => 'all', 'enabled' => true]);
$server = $this->server();
// Offline poll: stale metrics, status offline → only the offline rule fires, cpu is skipped.
$eval->evaluate($server, [], 'offline');
$this->assertSame(1, AlertIncident::where('state', 'firing')->count());
$this->assertDatabaseHas('alert_incidents', ['alert_rule_id' => AlertRule::where('metric', 'offline')->first()->id]);
}
public function test_offline_incident_resolves_when_the_server_comes_back(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify')->twice();
AlertRule::create(['name' => 'Down', 'metric' => 'offline', 'scope_type' => 'all', 'enabled' => true]);
$server = $this->server();
$eval->evaluate($server, [], 'offline'); // fire
$eval->evaluate($server, ['cpu' => 5], 'online'); // back online → resolve
$this->assertSame(0, AlertIncident::where('state', 'firing')->count());
}
public function test_a_fractional_load_breach_fires_and_is_not_truncated(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify')->once();
// Regression: load 1.5 with threshold 1 used to truncate to `1 > 1` = false and never fire.
AlertRule::create(['name' => 'Load', 'metric' => 'load', 'comparator' => 'gt', 'threshold' => 1, 'scope_type' => 'all', 'enabled' => true]);
$eval->evaluate($this->server(), ['cpu' => 5, 'load' => 1.5], 'online');
$this->assertDatabaseHas('alert_incidents', ['state' => 'firing', 'value' => 1.5]);
}
public function test_firing_key_is_a_unique_db_invariant(): void
{
$rule = AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
$server = $this->server();
$key = $rule->id.':'.$server->id;
AlertIncident::create(['alert_rule_id' => $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 90, 'firing_key' => $key, 'started_at' => now()]);
// A concurrent tick opening the same firing incident hits the unique index → no duplicate.
$this->expectException(QueryException::class);
AlertIncident::create(['alert_rule_id' => $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 91, 'firing_key' => $key, 'started_at' => now()]);
}
public function test_a_resolved_incident_frees_the_firing_key_for_a_re_breach(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify')->times(3); // fire, resolve, fire again
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
$server = $this->server();
$eval->evaluate($server, ['cpu' => 95], 'online'); // fire
$eval->evaluate($server, ['cpu' => 10], 'online'); // resolve → firing_key nulled
$eval->evaluate($server, ['cpu' => 95], 'online'); // re-breach can claim the key again
$this->assertSame(1, AlertIncident::where('state', 'firing')->count());
$this->assertSame(2, AlertIncident::count()); // one resolved + one new firing
}
public function test_a_disabled_rule_never_fires(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify')->never();
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 1, 'scope_type' => 'all', 'enabled' => false]);
$eval->evaluate($this->server(), ['cpu' => 99], 'online');
$this->assertDatabaseCount('alert_incidents', 0);
}
public function test_scope_server_only_targets_that_server(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify')->never();
$target = $this->server('10.0.0.1');
$other = $this->server('10.0.0.2');
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 50, 'scope_type' => 'server', 'scope_id' => $target->id, 'enabled' => true]);
$eval->evaluate($other, ['cpu' => 99], 'online'); // not the targeted server → nothing
$this->assertDatabaseCount('alert_incidents', 0);
}
public function test_scope_group_targets_only_members(): void
{
[$eval, $notifier] = $this->make();
$notifier->shouldReceive('notify')->once();
$group = ServerGroup::create(['name' => 'prod', 'color' => 'online']);
$member = $this->server('10.0.0.1');
$group->servers()->attach($member->id);
AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 50, 'scope_type' => 'group', 'scope_id' => $group->id, 'enabled' => true]);
$eval->evaluate($member, ['cpu' => 99], 'online');
$this->assertDatabaseHas('alert_incidents', ['server_id' => $member->id, 'state' => 'firing']);
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace Tests\Feature;
use App\Mail\AlertNotification;
use App\Models\AlertIncident;
use App\Models\AlertRule;
use App\Models\Server;
use App\Models\Setting;
use App\Models\User;
use App\Services\AlertNotifier;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class AlertNotifierTest extends TestCase
{
use RefreshDatabase;
private function incident(): AlertIncident
{
$rule = AlertRule::create(['name' => 'CPU', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
return AlertIncident::create(['alert_rule_id' => $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 95, 'started_at' => now()]);
}
public function test_email_is_queued_to_the_configured_recipients(): void
{
Mail::fake();
Setting::put('alert_email_to', "ops@example.com\nsecond@example.com");
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
Mail::assertQueued(AlertNotification::class, fn (AlertNotification $m) => $m->hasTo('ops@example.com'));
}
public function test_falls_back_to_admin_emails_when_none_configured(): void
{
Mail::fake();
$admin = User::factory()->create(['email' => 'admin@example.com', 'must_change_password' => false]); // admin by default
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
Mail::assertQueued(AlertNotification::class, fn (AlertNotification $m) => $m->hasTo('admin@example.com'));
}
public function test_a_webhook_is_posted_with_the_payload(): void
{
Mail::fake();
Http::fake();
Setting::put('alert_webhooks', 'https://8.8.8.8/incoming'); // public IP literal (no DNS, passes the SSRF check)
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
Http::assertSent(fn ($request) => $request->url() === 'https://8.8.8.8/incoming'
&& $request['state'] === 'firing'
&& $request['server'] === 'box'
&& (int) $request['value'] === 95); // value is a float column (95.0)
}
public function test_a_non_http_webhook_value_is_ignored(): void
{
Mail::fake();
Http::fake();
Setting::put('alert_webhooks', "javascript:alert(1)\nnot a url");
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
Http::assertNothingSent();
}
public function test_unsafe_webhook_urls_are_rejected(): void
{
$unsafe = [
'http://127.0.0.1/hook', // loopback
'https://10.0.0.5/hook', // private
'http://192.168.1.10/x', // private
'http://169.254.169.254/latest/meta-data', // link-local (cloud-metadata SSRF classic)
'http://localhost/x', // resolves to loopback
'ftp://example.com/x', // bad scheme
'httpx://8.8.8.8/x', // bad scheme
'https:///x', // no host
];
foreach ($unsafe as $url) {
$this->assertFalse(AlertNotifier::isSafeWebhookUrl($url), "should reject {$url}");
}
}
public function test_public_https_webhook_is_accepted(): void
{
$this->assertTrue(AlertNotifier::isSafeWebhookUrl('https://8.8.8.8/hook')); // public IP, no DNS needed
}
public function test_a_failing_channel_is_swallowed(): void
{
Mail::fake();
Http::fake(fn () => throw new \RuntimeException('connection refused'));
Setting::put('alert_webhooks', 'https://8.8.8.8/incoming');
// Must NOT throw — a broken channel can never break the poll loop that called notify().
app(AlertNotifier::class)->notify($this->incident(), resolved: false);
$this->assertTrue(true);
}
}

View File

@ -0,0 +1,182 @@
<?php
namespace Tests\Feature;
use App\Livewire\Alerts\Index;
use App\Models\AlertIncident;
use App\Models\AlertRule;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Models\Setting;
use App\Models\User;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class AlertsComponentTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
return User::factory()->create(['must_change_password' => false]);
}
private function operator(): User
{
return User::factory()->operator()->create(['must_change_password' => false]);
}
private function viewer(): User
{
return User::factory()->viewer()->create(['must_change_password' => false]);
}
// ── route gating (manage-panel = admin) ─────────────────────────────────────
public function test_viewer_cannot_open_alerts(): void
{
$this->actingAs($this->viewer())->get('/alerts')->assertForbidden();
}
public function test_operator_cannot_open_alerts(): void
{
$this->actingAs($this->operator())->get('/alerts')->assertForbidden();
}
public function test_admin_can_open_alerts(): void
{
$this->actingAs($this->admin())->get('/alerts')->assertOk();
}
// ── rule create ─────────────────────────────────────────────────────────────
public function test_admin_creates_a_fleet_wide_numeric_rule(): void
{
$this->actingAs($this->admin());
Livewire::test(Index::class)
->set('name', 'CPU high')
->set('metric', 'cpu')
->set('comparator', 'gt')
->set('threshold', 90)
->set('scopeType', 'all')
->call('createRule')
->assertHasNoErrors();
$this->assertDatabaseHas('alert_rules', ['name' => 'CPU high', 'metric' => 'cpu', 'threshold' => 90, 'scope_type' => 'all', 'enabled' => true]);
$this->assertDatabaseHas('audit_events', ['action' => 'alert.rule_create', 'target' => 'CPU high']);
}
public function test_group_scope_resolves_the_uuid_to_an_id(): void
{
$this->actingAs($this->admin());
$group = ServerGroup::create(['name' => 'prod', 'color' => 'online']);
Livewire::test(Index::class)
->set('name', 'prod cpu')
->set('metric', 'cpu')
->set('scopeType', 'group')
->set('scopeUuid', $group->uuid)
->call('createRule')
->assertHasNoErrors();
$this->assertDatabaseHas('alert_rules', ['name' => 'prod cpu', 'scope_type' => 'group', 'scope_id' => $group->id]);
}
public function test_offline_metric_stores_threshold_zero(): void
{
$this->actingAs($this->admin());
Livewire::test(Index::class)
->set('name', 'down')
->set('metric', 'offline')
->set('threshold', 80)
->set('scopeType', 'all')
->call('createRule')
->assertHasNoErrors();
$this->assertDatabaseHas('alert_rules', ['name' => 'down', 'metric' => 'offline', 'threshold' => 0]);
}
public function test_create_rejects_an_unknown_metric(): void
{
$this->actingAs($this->admin());
Livewire::test(Index::class)
->set('name', 'x')
->set('metric', 'temperature')
->call('createRule')
->assertHasErrors('metric');
$this->assertDatabaseCount('alert_rules', 0);
}
// ── toggle / delete ─────────────────────────────────────────────────────────
public function test_toggle_flips_enabled(): void
{
$this->actingAs($this->admin());
$rule = AlertRule::create(['name' => 'r', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
Livewire::test(Index::class)->call('toggleRule', $rule->uuid);
$this->assertFalse($rule->fresh()->enabled);
}
public function test_admin_deletes_a_rule_via_a_confirmed_token(): void
{
$this->actingAs($this->admin());
$rule = AlertRule::create(['name' => 'gone', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
$token = ConfirmToken::issue('alertRuleDeleted', ['uuid' => $rule->uuid], 'alert.rule_delete', $rule->name, null);
ConfirmToken::confirm($token);
Livewire::test(Index::class)->call('deleteRule', $token)->assertOk();
$this->assertDatabaseMissing('alert_rules', ['id' => $rule->id]);
}
// ── channels ────────────────────────────────────────────────────────────────
public function test_admin_saves_notification_channels(): void
{
$this->actingAs($this->admin());
Livewire::test(Index::class)
->set('emailTo', "ops@example.com\n")
->set('webhooks', 'https://8.8.8.8/x') // public IP literal — passes the SSRF host check
->call('saveChannels');
$this->assertSame('ops@example.com', Setting::get('alert_email_to'));
$this->assertSame('https://8.8.8.8/x', Setting::get('alert_webhooks'));
$this->assertDatabaseHas('audit_events', ['action' => 'alert.channels_update']);
}
public function test_save_channels_rejects_an_unsafe_webhook_url(): void
{
$this->actingAs($this->admin());
Livewire::test(Index::class)
->set('webhooks', 'http://169.254.169.254/latest/meta-data') // SSRF to cloud metadata
->call('saveChannels')
->assertHasErrors('webhooks');
$this->assertNull(Setting::get('alert_webhooks')); // not stored
}
// ── incidents render ────────────────────────────────────────────────────────
public function test_active_incidents_are_shown(): void
{
$this->actingAs($this->admin());
$rule = AlertRule::create(['name' => 'CPU high', 'metric' => 'cpu', 'comparator' => 'gt', 'threshold' => 80, 'scope_type' => 'all', 'enabled' => true]);
$server = Server::create(['name' => 'web1', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
AlertIncident::create(['alert_rule_id' => $rule->id, 'server_id' => $server->id, 'state' => 'firing', 'value' => 95, 'started_at' => now()]);
Livewire::test(Index::class)
->assertSee('CPU high')
->assertSee('web1');
}
}