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
parent
40d8dfc96d
commit
0472b3531a
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ?: [])));
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,7 @@ class ConfirmToken
|
|||
'releasePublic',
|
||||
'releaseYank',
|
||||
'groupConfirmed',
|
||||
'alertRuleDeleted',
|
||||
];
|
||||
|
||||
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue