clusev/app/Livewire/Alerts/Index.php

190 lines
6.6 KiB
PHP

<?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());
}
}