feat: alerts v1 (navigation + guards)
This commit is contained in:
parent
c57f680f39
commit
8c2798a10e
106
app/Console/Commands/TenantpilotDispatchAlerts.php
Normal file
106
app/Console/Commands/TenantpilotDispatchAlerts.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Alerts\DeliverAlertsJob;
|
||||
use App\Jobs\Alerts\EvaluateAlertsJob;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\OperationRunService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class TenantpilotDispatchAlerts extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:alerts:dispatch {--workspace=* : Limit dispatch to one or more workspace IDs}';
|
||||
|
||||
protected $description = 'Queue workspace-scoped alert evaluation and delivery jobs idempotently.';
|
||||
|
||||
public function handle(OperationRunService $operationRuns): int
|
||||
{
|
||||
if (! (bool) config('tenantpilot.alerts.enabled', true)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$workspaceFilter = array_values(array_filter(array_map(
|
||||
static fn (mixed $value): int => (int) $value,
|
||||
(array) $this->option('workspace'),
|
||||
)));
|
||||
|
||||
$workspaces = $this->resolveWorkspaces($workspaceFilter);
|
||||
|
||||
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
|
||||
|
||||
$queuedEvaluate = 0;
|
||||
$queuedDeliver = 0;
|
||||
$skippedEvaluate = 0;
|
||||
$skippedDeliver = 0;
|
||||
|
||||
foreach ($workspaces as $workspace) {
|
||||
$evaluateRun = $operationRuns->ensureWorkspaceRunWithIdentity(
|
||||
workspace: $workspace,
|
||||
type: 'alerts.evaluate',
|
||||
identityInputs: ['slot_key' => $slotKey],
|
||||
context: [
|
||||
'trigger' => 'scheduled_dispatch',
|
||||
'slot_key' => $slotKey,
|
||||
],
|
||||
initiator: null,
|
||||
);
|
||||
|
||||
if ($evaluateRun->wasRecentlyCreated) {
|
||||
EvaluateAlertsJob::dispatch((int) $workspace->getKey(), (int) $evaluateRun->getKey());
|
||||
$queuedEvaluate++;
|
||||
} else {
|
||||
$skippedEvaluate++;
|
||||
}
|
||||
|
||||
$deliverRun = $operationRuns->ensureWorkspaceRunWithIdentity(
|
||||
workspace: $workspace,
|
||||
type: 'alerts.deliver',
|
||||
identityInputs: ['slot_key' => $slotKey],
|
||||
context: [
|
||||
'trigger' => 'scheduled_dispatch',
|
||||
'slot_key' => $slotKey,
|
||||
],
|
||||
initiator: null,
|
||||
);
|
||||
|
||||
if ($deliverRun->wasRecentlyCreated) {
|
||||
DeliverAlertsJob::dispatch((int) $workspace->getKey(), (int) $deliverRun->getKey());
|
||||
$queuedDeliver++;
|
||||
} else {
|
||||
$skippedDeliver++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Alert dispatch scanned %d workspace(s): evaluate queued=%d skipped=%d, deliver queued=%d skipped=%d.',
|
||||
$workspaces->count(),
|
||||
$queuedEvaluate,
|
||||
$skippedEvaluate,
|
||||
$queuedDeliver,
|
||||
$skippedDeliver,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $workspaceIds
|
||||
* @return Collection<int, Workspace>
|
||||
*/
|
||||
private function resolveWorkspaces(array $workspaceIds): Collection
|
||||
{
|
||||
return Workspace::query()
|
||||
->when(
|
||||
$workspaceIds !== [],
|
||||
fn ($query) => $query->whereIn('id', $workspaceIds),
|
||||
fn ($query) => $query->whereHas('tenants'),
|
||||
)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
27
app/Filament/Clusters/Monitoring/AlertsCluster.php
Normal file
27
app/Filament/Clusters/Monitoring/AlertsCluster.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Clusters\Monitoring;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Clusters\Cluster;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Enums\SubNavigationPosition;
|
||||
use UnitEnum;
|
||||
|
||||
class AlertsCluster extends Cluster
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return Filament::getCurrentPanel()?->getId() === 'admin';
|
||||
}
|
||||
}
|
||||
@ -4,30 +4,76 @@
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||
use App\Filament\Widgets\Alerts\AlertsKpiHeader;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class Alerts extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
protected static ?string $cluster = AlertsCluster::class;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Alerts';
|
||||
protected static ?string $navigationLabel = 'Overview';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||
|
||||
protected static ?string $slug = 'alerts';
|
||||
protected static ?string $slug = 'overview';
|
||||
|
||||
protected static ?string $title = 'Alerts';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.alerts';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
AlertsKpiHeader::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
|
||||
278
app/Filament/Resources/AlertDeliveryResource.php
Normal file
278
app/Filament/Resources/AlertDeliveryResource.php
Normal file
@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||
use App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
class AlertDeliveryResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = AlertDelivery::class;
|
||||
|
||||
protected static ?string $slug = 'alert-deliveries';
|
||||
|
||||
protected static ?string $cluster = AlertsCluster::class;
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'id';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Alert deliveries';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('viewAny', AlertDelivery::class);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertDelivery) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('view', $record);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Read-only history list intentionally has no list-header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk actions are exposed for read-only deliveries.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Deliveries are generated by jobs and intentionally have no empty-state CTA.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational with no mutating header actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$user = auth()->user();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['tenant', 'rule', 'destination'])
|
||||
->when(
|
||||
! $user instanceof User,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when(
|
||||
! is_int($workspaceId),
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when(
|
||||
is_int($workspaceId),
|
||||
fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId),
|
||||
)
|
||||
->when(
|
||||
$user instanceof User,
|
||||
fn (Builder $query): Builder => $query->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id')),
|
||||
)
|
||||
->when(
|
||||
Filament::getTenant() instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) Filament::getTenant()->getKey()),
|
||||
)
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Delivery')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state))
|
||||
->color(fn (?string $state): string => self::statusColor((string) $state)),
|
||||
TextEntry::make('event_type')
|
||||
->label('Event')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)),
|
||||
TextEntry::make('severity')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('tenant.name')
|
||||
->label('Tenant'),
|
||||
TextEntry::make('rule.name')
|
||||
->label('Rule')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('destination.name')
|
||||
->label('Destination')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('attempt_count')
|
||||
->label('Attempts'),
|
||||
TextEntry::make('fingerprint_hash')
|
||||
->label('Fingerprint')
|
||||
->copyable(),
|
||||
TextEntry::make('send_after')
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('sent_at')
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('last_error_code')
|
||||
->label('Last error code')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('last_error_message')
|
||||
->label('Last error message')
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('created_at')
|
||||
->dateTime(),
|
||||
TextEntry::make('updated_at')
|
||||
->dateTime(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Payload')
|
||||
->schema([
|
||||
ViewEntry::make('payload')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (AlertDelivery $record): array => is_array($record->payload) ? $record->payload : [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->recordUrl(fn (AlertDelivery $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
: null)
|
||||
->columns([
|
||||
TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->since(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
TextColumn::make('event_type')
|
||||
->label('Event')
|
||||
->badge(),
|
||||
TextColumn::make('severity')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||
->placeholder('—'),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state))
|
||||
->color(fn (?string $state): string => self::statusColor((string) $state)),
|
||||
TextColumn::make('rule.name')
|
||||
->label('Rule')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('destination.name')
|
||||
->label('Destination')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('attempt_count')
|
||||
->label('Attempts'),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options([
|
||||
AlertDelivery::STATUS_QUEUED => 'Queued',
|
||||
AlertDelivery::STATUS_DEFERRED => 'Deferred',
|
||||
AlertDelivery::STATUS_SENT => 'Sent',
|
||||
AlertDelivery::STATUS_FAILED => 'Failed',
|
||||
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
|
||||
AlertDelivery::STATUS_CANCELED => 'Canceled',
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make()->label('View'),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListAlertDeliveries::route('/'),
|
||||
'view' => Pages\ViewAlertDelivery::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function statusLabel(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
AlertDelivery::STATUS_QUEUED => 'Queued',
|
||||
AlertDelivery::STATUS_DEFERRED => 'Deferred',
|
||||
AlertDelivery::STATUS_SENT => 'Sent',
|
||||
AlertDelivery::STATUS_FAILED => 'Failed',
|
||||
AlertDelivery::STATUS_SUPPRESSED => 'Suppressed',
|
||||
AlertDelivery::STATUS_CANCELED => 'Canceled',
|
||||
default => ucfirst($status),
|
||||
};
|
||||
}
|
||||
|
||||
private static function statusColor(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
AlertDelivery::STATUS_QUEUED => 'gray',
|
||||
AlertDelivery::STATUS_DEFERRED => 'warning',
|
||||
AlertDelivery::STATUS_SENT => 'success',
|
||||
AlertDelivery::STATUS_FAILED => 'danger',
|
||||
AlertDelivery::STATUS_SUPPRESSED => 'info',
|
||||
AlertDelivery::STATUS_CANCELED => 'gray',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAlertDeliveries extends ListRecords
|
||||
{
|
||||
protected static string $resource = AlertDeliveryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_alerts',
|
||||
returnActionName: 'operate_hub_return_alerts',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewAlertDelivery extends ViewRecord
|
||||
{
|
||||
protected static string $resource = AlertDeliveryResource::class;
|
||||
}
|
||||
380
app/Filament/Resources/AlertDestinationResource.php
Normal file
380
app/Filament/Resources/AlertDestinationResource.php
Normal file
@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||
use App\Filament\Resources\AlertDestinationResource\Pages;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use UnitEnum;
|
||||
|
||||
class AlertDestinationResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = AlertDestination::class;
|
||||
|
||||
protected static ?string $slug = 'alert-destinations';
|
||||
|
||||
protected static ?string $cluster = AlertsCluster::class;
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Alert targets';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('viewAny', AlertDestination::class);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('create', AlertDestination::class);
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('update', $record);
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertDestination) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('delete', $record);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert destinations in v1.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when(
|
||||
$workspaceId !== null,
|
||||
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||
)
|
||||
->when(
|
||||
$workspaceId === null,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Select::make('type')
|
||||
->required()
|
||||
->options(self::typeOptions())
|
||||
->native(false)
|
||||
->live(),
|
||||
Toggle::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
TextInput::make('teams_webhook_url')
|
||||
->label('Teams webhook URL')
|
||||
->placeholder('https://...')
|
||||
->url()
|
||||
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_TEAMS_WEBHOOK),
|
||||
TagsInput::make('email_recipients')
|
||||
->label('Email recipients')
|
||||
->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_EMAIL)
|
||||
->placeholder('ops@example.com')
|
||||
->nestedRecursiveRules(['email']),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
|
||||
? static::getUrl('edit', ['record' => $record])
|
||||
: null)
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable(),
|
||||
TextColumn::make('type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => self::typeLabel((string) $state)),
|
||||
TextColumn::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->badge()
|
||||
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
|
||||
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||
TextColumn::make('updated_at')
|
||||
->since(),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->label('Edit')
|
||||
->visible(fn (AlertDestination $record): bool => static::canEdit($record)),
|
||||
ActionGroup::make([
|
||||
Action::make('toggle_enabled')
|
||||
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||
->icon(fn (AlertDestination $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
|
||||
->action(function (AlertDestination $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $user->can('update', $record)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$enabled = ! (bool) $record->is_enabled;
|
||||
$record->forceFill([
|
||||
'is_enabled' => $enabled,
|
||||
])->save();
|
||||
|
||||
$actionId = $enabled
|
||||
? AuditActionId::AlertDestinationEnabled
|
||||
: AuditActionId::AlertDestinationDisabled;
|
||||
|
||||
self::audit($record, $actionId, [
|
||||
'alert_destination_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'type' => (string) $record->type,
|
||||
'is_enabled' => $enabled,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title($enabled ? 'Destination enabled' : 'Destination disabled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('delete')
|
||||
->label('Delete')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function (AlertDestination $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $user->can('delete', $record)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
self::audit($record, AuditActionId::AlertDestinationDeleted, [
|
||||
'alert_destination_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'type' => (string) $record->type,
|
||||
]);
|
||||
|
||||
$record->delete();
|
||||
|
||||
Notification::make()
|
||||
->title('Destination deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])->label('More'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([])->label('More'),
|
||||
])
|
||||
->emptyStateActions([
|
||||
\Filament\Actions\CreateAction::make()
|
||||
->label('Create target')
|
||||
->disabled(fn (): bool => ! static::canCreate()),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListAlertDestinations::route('/'),
|
||||
'create' => Pages\CreateAlertDestination::route('/create'),
|
||||
'edit' => Pages\EditAlertDestination::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function normalizePayload(array $data, ?AlertDestination $record = null): array
|
||||
{
|
||||
$type = trim((string) ($data['type'] ?? $record?->type ?? ''));
|
||||
$existingConfig = is_array($record?->config ?? null) ? $record->config : [];
|
||||
|
||||
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
|
||||
$webhookUrl = trim((string) ($data['teams_webhook_url'] ?? ''));
|
||||
|
||||
if ($webhookUrl === '' && $record instanceof AlertDestination) {
|
||||
$webhookUrl = trim((string) Arr::get($existingConfig, 'webhook_url', ''));
|
||||
}
|
||||
|
||||
$data['config'] = [
|
||||
'webhook_url' => $webhookUrl,
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === AlertDestination::TYPE_EMAIL) {
|
||||
$recipients = Arr::wrap($data['email_recipients'] ?? []);
|
||||
$recipients = array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients)));
|
||||
|
||||
if ($recipients === [] && $record instanceof AlertDestination) {
|
||||
$existingRecipients = Arr::get($existingConfig, 'recipients', []);
|
||||
$recipients = is_array($existingRecipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $existingRecipients))) : [];
|
||||
}
|
||||
|
||||
$data['config'] = [
|
||||
'recipients' => array_values(array_unique($recipients)),
|
||||
];
|
||||
}
|
||||
|
||||
unset($data['teams_webhook_url'], $data['email_recipients']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function typeOptions(): array
|
||||
{
|
||||
return [
|
||||
AlertDestination::TYPE_TEAMS_WEBHOOK => 'Microsoft Teams webhook',
|
||||
AlertDestination::TYPE_EMAIL => 'Email',
|
||||
];
|
||||
}
|
||||
|
||||
public static function typeLabel(string $type): string
|
||||
{
|
||||
return self::typeOptions()[$type] ?? ucfirst($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function assertValidConfigPayload(array $data): void
|
||||
{
|
||||
$type = (string) ($data['type'] ?? '');
|
||||
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
|
||||
|
||||
if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
|
||||
$webhook = trim((string) Arr::get($config, 'webhook_url', ''));
|
||||
|
||||
if ($webhook === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'teams_webhook_url' => ['The Teams webhook URL is required.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === AlertDestination::TYPE_EMAIL) {
|
||||
$recipients = Arr::get($config, 'recipients', []);
|
||||
$recipients = is_array($recipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients))) : [];
|
||||
|
||||
if ($recipients === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'email_recipients' => ['At least one recipient is required for email destinations.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public static function audit(AlertDestination $record, AuditActionId $actionId, array $metadata): void
|
||||
{
|
||||
$workspace = $record->workspace;
|
||||
|
||||
if ($workspace === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: $actionId->value,
|
||||
context: [
|
||||
'metadata' => $metadata,
|
||||
],
|
||||
actor: auth()->user() instanceof User ? auth()->user() : null,
|
||||
resourceType: 'alert_destination',
|
||||
resourceId: (string) $record->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateAlertDestination extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AlertDestinationResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$data['workspace_id'] = (int) $workspaceId;
|
||||
$data = AlertDestinationResource::normalizePayload($data);
|
||||
AlertDestinationResource::assertValidConfigPayload($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
AlertDestinationResource::audit($record, AuditActionId::AlertDestinationCreated, [
|
||||
'alert_destination_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'type' => (string) $record->type,
|
||||
'is_enabled' => (bool) $record->is_enabled,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Destination created')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditAlertDestination extends EditRecord
|
||||
{
|
||||
protected static string $resource = AlertDestinationResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$record = $this->record;
|
||||
$data = AlertDestinationResource::normalizePayload(
|
||||
data: $data,
|
||||
record: $record instanceof AlertDestination ? $record : null,
|
||||
);
|
||||
AlertDestinationResource::assertValidConfigPayload($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
AlertDestinationResource::audit($record, AuditActionId::AlertDestinationUpdated, [
|
||||
'alert_destination_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'type' => (string) $record->type,
|
||||
'is_enabled' => (bool) $record->is_enabled,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Destination updated')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertDestinationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAlertDestinations extends ListRecords
|
||||
{
|
||||
protected static string $resource = AlertDestinationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
...app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_alerts',
|
||||
returnActionName: 'operate_hub_return_alerts',
|
||||
),
|
||||
CreateAction::make()
|
||||
->label('Create target')
|
||||
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label('Create target')
|
||||
->disabled(fn (): bool => ! AlertDestinationResource::canCreate()),
|
||||
];
|
||||
}
|
||||
}
|
||||
462
app/Filament/Resources/AlertRuleResource.php
Normal file
462
app/Filament/Resources/AlertRuleResource.php
Normal file
@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||
use App\Filament\Resources\AlertRuleResource\Pages;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use UnitEnum;
|
||||
|
||||
class AlertRuleResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = AlertRule::class;
|
||||
|
||||
protected static ?string $slug = 'alert-rules';
|
||||
|
||||
protected static ?string $cluster = AlertsCluster::class;
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Alert rules';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('viewAny', AlertRule::class);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('create', AlertRule::class);
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertRule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('update', $record);
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $record instanceof AlertRule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('delete', $record);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert rules in v1.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with('destinations')
|
||||
->when(
|
||||
$workspaceId !== null,
|
||||
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
||||
)
|
||||
->when(
|
||||
$workspaceId === null,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Toggle::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
Select::make('event_type')
|
||||
->required()
|
||||
->options(self::eventTypeOptions())
|
||||
->native(false),
|
||||
Select::make('minimum_severity')
|
||||
->required()
|
||||
->options(self::severityOptions())
|
||||
->native(false),
|
||||
Select::make('tenant_scope_mode')
|
||||
->required()
|
||||
->options([
|
||||
AlertRule::TENANT_SCOPE_ALL => 'All tenants',
|
||||
AlertRule::TENANT_SCOPE_ALLOWLIST => 'Allowlist',
|
||||
])
|
||||
->default(AlertRule::TENANT_SCOPE_ALL)
|
||||
->native(false)
|
||||
->live(),
|
||||
Select::make('tenant_allowlist')
|
||||
->label('Tenant allowlist')
|
||||
->multiple()
|
||||
->options(self::tenantOptions())
|
||||
->visible(fn (Get $get): bool => $get('tenant_scope_mode') === AlertRule::TENANT_SCOPE_ALLOWLIST)
|
||||
->native(false),
|
||||
TextInput::make('cooldown_seconds')
|
||||
->label('Cooldown (seconds)')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->nullable(),
|
||||
Toggle::make('quiet_hours_enabled')
|
||||
->label('Enable quiet hours')
|
||||
->default(false)
|
||||
->live(),
|
||||
TextInput::make('quiet_hours_start')
|
||||
->label('Quiet hours start')
|
||||
->type('time')
|
||||
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
||||
TextInput::make('quiet_hours_end')
|
||||
->label('Quiet hours end')
|
||||
->type('time')
|
||||
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
||||
Select::make('quiet_hours_timezone')
|
||||
->label('Quiet hours timezone')
|
||||
->options(self::timezoneOptions())
|
||||
->searchable()
|
||||
->native(false)
|
||||
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
||||
Select::make('destination_ids')
|
||||
->label('Destinations')
|
||||
->multiple()
|
||||
->required()
|
||||
->options(self::destinationOptions())
|
||||
->native(false),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->recordUrl(fn (AlertRule $record): ?string => static::canEdit($record)
|
||||
? static::getUrl('edit', ['record' => $record])
|
||||
: null)
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable(),
|
||||
TextColumn::make('event_type')
|
||||
->label('Event')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => self::eventTypeLabel((string) $state)),
|
||||
TextColumn::make('minimum_severity')
|
||||
->label('Min severity')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => self::severityOptions()[(string) $state] ?? ucfirst((string) $state)),
|
||||
TextColumn::make('destinations_count')
|
||||
->label('Destinations')
|
||||
->counts('destinations'),
|
||||
TextColumn::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->badge()
|
||||
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
|
||||
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->label('Edit')
|
||||
->visible(fn (AlertRule $record): bool => static::canEdit($record)),
|
||||
ActionGroup::make([
|
||||
Action::make('toggle_enabled')
|
||||
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||
->icon(fn (AlertRule $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
|
||||
->requiresConfirmation()
|
||||
->action(function (AlertRule $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $user->can('update', $record)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$enabled = ! (bool) $record->is_enabled;
|
||||
$record->forceFill([
|
||||
'is_enabled' => $enabled,
|
||||
])->save();
|
||||
|
||||
$actionId = $enabled
|
||||
? AuditActionId::AlertRuleEnabled
|
||||
: AuditActionId::AlertRuleDisabled;
|
||||
|
||||
self::audit($record, $actionId, [
|
||||
'alert_rule_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'event_type' => (string) $record->event_type,
|
||||
'is_enabled' => $enabled,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title($enabled ? 'Rule enabled' : 'Rule disabled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('delete')
|
||||
->label('Delete')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function (AlertRule $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $user->can('delete', $record)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
self::audit($record, AuditActionId::AlertRuleDeleted, [
|
||||
'alert_rule_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'event_type' => (string) $record->event_type,
|
||||
]);
|
||||
|
||||
$record->delete();
|
||||
|
||||
Notification::make()
|
||||
->title('Rule deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])->label('More'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([])->label('More'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListAlertRules::route('/'),
|
||||
'create' => Pages\CreateAlertRule::route('/create'),
|
||||
'edit' => Pages\EditAlertRule::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function normalizePayload(array $data): array
|
||||
{
|
||||
$tenantAllowlist = Arr::wrap($data['tenant_allowlist'] ?? []);
|
||||
$tenantAllowlist = array_values(array_unique(array_filter(array_map(static fn (mixed $value): int => (int) $value, $tenantAllowlist))));
|
||||
|
||||
if (($data['tenant_scope_mode'] ?? AlertRule::TENANT_SCOPE_ALL) !== AlertRule::TENANT_SCOPE_ALLOWLIST) {
|
||||
$tenantAllowlist = [];
|
||||
}
|
||||
|
||||
$quietHoursEnabled = (bool) ($data['quiet_hours_enabled'] ?? false);
|
||||
|
||||
$data['is_enabled'] = (bool) ($data['is_enabled'] ?? true);
|
||||
$data['tenant_allowlist'] = $tenantAllowlist;
|
||||
$data['cooldown_seconds'] = is_numeric($data['cooldown_seconds'] ?? null) ? (int) $data['cooldown_seconds'] : null;
|
||||
$data['quiet_hours_enabled'] = $quietHoursEnabled;
|
||||
|
||||
if (! $quietHoursEnabled) {
|
||||
$data['quiet_hours_start'] = null;
|
||||
$data['quiet_hours_end'] = null;
|
||||
$data['quiet_hours_timezone'] = null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $destinationIds
|
||||
*/
|
||||
public static function syncDestinations(AlertRule $record, array $destinationIds): void
|
||||
{
|
||||
$allowedDestinationIds = AlertDestination::query()
|
||||
->where('workspace_id', (int) $record->workspace_id)
|
||||
->whereIn('id', $destinationIds)
|
||||
->pluck('id')
|
||||
->map(static fn (mixed $value): int => (int) $value)
|
||||
->all();
|
||||
|
||||
$record->destinations()->syncWithPivotValues(
|
||||
array_values(array_unique($allowedDestinationIds)),
|
||||
['workspace_id' => (int) $record->workspace_id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function eventTypeOptions(): array
|
||||
{
|
||||
return [
|
||||
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
|
||||
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
|
||||
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function severityOptions(): array
|
||||
{
|
||||
return [
|
||||
'low' => 'Low',
|
||||
'medium' => 'Medium',
|
||||
'high' => 'High',
|
||||
'critical' => 'Critical',
|
||||
];
|
||||
}
|
||||
|
||||
public static function eventTypeLabel(string $eventType): string
|
||||
{
|
||||
return self::eventTypeOptions()[$eventType] ?? ucfirst(str_replace('_', ' ', $eventType));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function destinationOptions(): array
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AlertDestination::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function tenantOptions(): array
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Tenant::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function timezoneOptions(): array
|
||||
{
|
||||
$identifiers = \DateTimeZone::listIdentifiers();
|
||||
sort($identifiers);
|
||||
|
||||
return array_combine($identifiers, $identifiers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public static function audit(AlertRule $record, AuditActionId $actionId, array $metadata): void
|
||||
{
|
||||
$workspace = $record->workspace;
|
||||
|
||||
if ($workspace === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = auth()->user();
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: $actionId->value,
|
||||
context: [
|
||||
'metadata' => $metadata,
|
||||
],
|
||||
actor: $actor instanceof User ? $actor : null,
|
||||
resourceType: 'alert_rule',
|
||||
resourceId: (string) $record->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertRuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Models\AlertRule;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CreateAlertRule extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AlertRuleResource::class;
|
||||
|
||||
/**
|
||||
* @var array<int, int>
|
||||
*/
|
||||
private array $destinationIds = [];
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
$data['workspace_id'] = (int) $workspaceId;
|
||||
|
||||
$this->destinationIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): int => (int) $value,
|
||||
Arr::wrap($data['destination_ids'] ?? []),
|
||||
))));
|
||||
|
||||
unset($data['destination_ids']);
|
||||
|
||||
return AlertRuleResource::normalizePayload($data);
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertRule) {
|
||||
return;
|
||||
}
|
||||
|
||||
AlertRuleResource::syncDestinations($record, $this->destinationIds);
|
||||
|
||||
AlertRuleResource::audit($record, AuditActionId::AlertRuleCreated, [
|
||||
'alert_rule_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'event_type' => (string) $record->event_type,
|
||||
'minimum_severity' => (string) $record->minimum_severity,
|
||||
'is_enabled' => (bool) $record->is_enabled,
|
||||
'destination_ids' => $this->destinationIds,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Rule created')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertRuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Models\AlertRule;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EditAlertRule extends EditRecord
|
||||
{
|
||||
protected static string $resource = AlertRuleResource::class;
|
||||
|
||||
/**
|
||||
* @var array<int, int>
|
||||
*/
|
||||
private array $destinationIds = [];
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if ($record instanceof AlertRule) {
|
||||
$data['destination_ids'] = $record->destinations()
|
||||
->pluck('alert_destinations.id')
|
||||
->map(static fn (mixed $value): int => (int) $value)
|
||||
->all();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$this->destinationIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): int => (int) $value,
|
||||
Arr::wrap($data['destination_ids'] ?? []),
|
||||
))));
|
||||
|
||||
unset($data['destination_ids']);
|
||||
|
||||
return AlertRuleResource::normalizePayload($data);
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$record = $this->record;
|
||||
|
||||
if (! $record instanceof AlertRule) {
|
||||
return;
|
||||
}
|
||||
|
||||
AlertRuleResource::syncDestinations($record, $this->destinationIds);
|
||||
|
||||
AlertRuleResource::audit($record, AuditActionId::AlertRuleUpdated, [
|
||||
'alert_rule_id' => (int) $record->getKey(),
|
||||
'name' => (string) $record->name,
|
||||
'event_type' => (string) $record->event_type,
|
||||
'minimum_severity' => (string) $record->minimum_severity,
|
||||
'is_enabled' => (bool) $record->is_enabled,
|
||||
'destination_ids' => $this->destinationIds,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Rule updated')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AlertRuleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAlertRules extends ListRecords
|
||||
{
|
||||
protected static string $resource = AlertRuleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
...app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_alerts',
|
||||
returnActionName: 'operate_hub_return_alerts',
|
||||
),
|
||||
CreateAction::make()
|
||||
->label('Create rule')
|
||||
->disabled(fn (): bool => ! AlertRuleResource::canCreate()),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableEmptyStateActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label('Create rule')
|
||||
->disabled(fn (): bool => ! AlertRuleResource::canCreate()),
|
||||
];
|
||||
}
|
||||
}
|
||||
107
app/Filament/Widgets/Alerts/AlertsKpiHeader.php
Normal file
107
app/Filament/Widgets/Alerts/AlertsKpiHeader.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Alerts;
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class AlertsKpiHeader extends StatsOverviewWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stats = [];
|
||||
|
||||
if (AlertRuleResource::canViewAny()) {
|
||||
$totalRules = (int) AlertRule::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->count();
|
||||
|
||||
$enabledRules = (int) AlertRule::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('is_enabled', true)
|
||||
->count();
|
||||
|
||||
$stats[] = Stat::make('Enabled rules', $enabledRules)
|
||||
->description('Total '.$totalRules);
|
||||
}
|
||||
|
||||
if (AlertDestinationResource::canViewAny()) {
|
||||
$totalDestinations = (int) AlertDestination::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->count();
|
||||
|
||||
$enabledDestinations = (int) AlertDestination::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('is_enabled', true)
|
||||
->count();
|
||||
|
||||
$stats[] = Stat::make('Enabled targets', $enabledDestinations)
|
||||
->description('Total '.$totalDestinations);
|
||||
}
|
||||
|
||||
if (AlertDeliveryResource::canViewAny()) {
|
||||
$deliveriesQuery = $this->deliveriesQueryForViewer($user, $workspaceId);
|
||||
|
||||
$deliveries24Hours = (int) (clone $deliveriesQuery)
|
||||
->where('created_at', '>=', now()->subDay())
|
||||
->count();
|
||||
|
||||
$failed7Days = (int) (clone $deliveriesQuery)
|
||||
->where('created_at', '>=', now()->subDays(7))
|
||||
->where('status', AlertDelivery::STATUS_FAILED)
|
||||
->count();
|
||||
|
||||
$stats[] = Stat::make('Deliveries (24h)', $deliveries24Hours);
|
||||
$stats[] = Stat::make('Failed (7d)', $failed7Days);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private function deliveriesQueryForViewer(User $user, int $workspaceId): Builder
|
||||
{
|
||||
$query = AlertDelivery::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id'));
|
||||
|
||||
$activeTenant = Filament::getTenant();
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$query->where('tenant_id', (int) $activeTenant->getKey());
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
0
app/Jobs/Alerts/.gitkeep
Normal file
0
app/Jobs/Alerts/.gitkeep
Normal file
216
app/Jobs/Alerts/DeliverAlertsJob.php
Normal file
216
app/Jobs/Alerts/DeliverAlertsJob.php
Normal file
@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Alerts;
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Alerts\AlertSender;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
|
||||
class DeliverAlertsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $workspaceId,
|
||||
public ?int $operationRunId = null,
|
||||
) {}
|
||||
|
||||
public function handle(AlertSender $alertSender, OperationRunService $operationRuns): void
|
||||
{
|
||||
$workspace = Workspace::query()->whereKey($this->workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRun = $this->resolveOperationRun($workspace, $operationRuns);
|
||||
|
||||
if (! $operationRun instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($operationRun->status === OperationRunStatus::Completed->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
);
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
$batchSize = max(1, (int) config('tenantpilot.alerts.deliver_batch_size', 200));
|
||||
$maxAttempts = max(1, (int) config('tenantpilot.alerts.delivery_max_attempts', 3));
|
||||
|
||||
$deliveries = AlertDelivery::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereIn('status', [
|
||||
AlertDelivery::STATUS_QUEUED,
|
||||
AlertDelivery::STATUS_DEFERRED,
|
||||
])
|
||||
->where(function ($query) use ($now): void {
|
||||
$query->whereNull('send_after')
|
||||
->orWhere('send_after', '<=', $now);
|
||||
})
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
$processed = 0;
|
||||
$succeeded = 0;
|
||||
$terminalFailures = 0;
|
||||
$requeued = 0;
|
||||
|
||||
foreach ($deliveries as $delivery) {
|
||||
if ($delivery->isTerminal()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
|
||||
try {
|
||||
$alertSender->send($delivery);
|
||||
|
||||
$delivery->forceFill([
|
||||
'attempt_count' => (int) $delivery->attempt_count + 1,
|
||||
'status' => AlertDelivery::STATUS_SENT,
|
||||
'send_after' => null,
|
||||
'sent_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'last_error_message' => null,
|
||||
])->save();
|
||||
|
||||
$succeeded++;
|
||||
} catch (Throwable $exception) {
|
||||
$attemptCount = (int) $delivery->attempt_count + 1;
|
||||
$errorCode = $this->sanitizeErrorCode($exception);
|
||||
$errorMessage = $this->sanitizeErrorMessage($exception);
|
||||
|
||||
if ($attemptCount >= $maxAttempts) {
|
||||
$delivery->forceFill([
|
||||
'attempt_count' => $attemptCount,
|
||||
'status' => AlertDelivery::STATUS_FAILED,
|
||||
'send_after' => null,
|
||||
'last_error_code' => $errorCode,
|
||||
'last_error_message' => $errorMessage,
|
||||
])->save();
|
||||
|
||||
$terminalFailures++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$delivery->forceFill([
|
||||
'attempt_count' => $attemptCount,
|
||||
'status' => AlertDelivery::STATUS_QUEUED,
|
||||
'send_after' => $now->addSeconds($this->backoffSeconds($attemptCount)),
|
||||
'last_error_code' => $errorCode,
|
||||
'last_error_message' => $errorMessage,
|
||||
])->save();
|
||||
|
||||
$requeued++;
|
||||
}
|
||||
}
|
||||
|
||||
$outcome = OperationRunOutcome::Succeeded->value;
|
||||
|
||||
if ($terminalFailures > 0 && $succeeded === 0 && $requeued === 0) {
|
||||
$outcome = OperationRunOutcome::Failed->value;
|
||||
} elseif ($terminalFailures > 0 || $requeued > 0) {
|
||||
$outcome = OperationRunOutcome::PartiallySucceeded->value;
|
||||
}
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: [
|
||||
'total' => count($deliveries),
|
||||
'processed' => $processed,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $terminalFailures,
|
||||
'skipped' => $requeued,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun
|
||||
{
|
||||
if (is_int($this->operationRunId) && $this->operationRunId > 0) {
|
||||
$operationRun = OperationRun::query()
|
||||
->whereKey($this->operationRunId)
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('type', 'alerts.deliver')
|
||||
->first();
|
||||
|
||||
if ($operationRun instanceof OperationRun) {
|
||||
return $operationRun;
|
||||
}
|
||||
}
|
||||
|
||||
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
|
||||
|
||||
return $operationRuns->ensureWorkspaceRunWithIdentity(
|
||||
workspace: $workspace,
|
||||
type: 'alerts.deliver',
|
||||
identityInputs: [
|
||||
'slot_key' => $slotKey,
|
||||
],
|
||||
context: [
|
||||
'trigger' => 'job',
|
||||
'slot_key' => $slotKey,
|
||||
],
|
||||
initiator: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function backoffSeconds(int $attemptCount): int
|
||||
{
|
||||
$baseSeconds = max(1, (int) config('tenantpilot.alerts.delivery_retry_base_seconds', 60));
|
||||
$maxSeconds = max($baseSeconds, (int) config('tenantpilot.alerts.delivery_retry_max_seconds', 900));
|
||||
|
||||
$exponent = max(0, $attemptCount - 1);
|
||||
$delaySeconds = $baseSeconds * (2 ** $exponent);
|
||||
|
||||
return (int) min($maxSeconds, $delaySeconds);
|
||||
}
|
||||
|
||||
private function sanitizeErrorCode(Throwable $exception): string
|
||||
{
|
||||
$shortName = class_basename($exception);
|
||||
$shortName = trim((string) $shortName);
|
||||
|
||||
if ($shortName === '') {
|
||||
return 'delivery_exception';
|
||||
}
|
||||
|
||||
return strtolower(preg_replace('/[^a-z0-9]+/i', '_', $shortName) ?? 'delivery_exception');
|
||||
}
|
||||
|
||||
private function sanitizeErrorMessage(Throwable $exception): string
|
||||
{
|
||||
$message = trim($exception->getMessage());
|
||||
|
||||
if ($message === '') {
|
||||
return 'Alert delivery failed.';
|
||||
}
|
||||
|
||||
$message = preg_replace('/https?:\/\/\S+/i', '[redacted-url]', $message) ?? $message;
|
||||
|
||||
return mb_substr($message, 0, 500);
|
||||
}
|
||||
}
|
||||
256
app/Jobs/Alerts/EvaluateAlertsJob.php
Normal file
256
app/Jobs/Alerts/EvaluateAlertsJob.php
Normal file
@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Alerts;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
|
||||
class EvaluateAlertsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $workspaceId,
|
||||
public ?int $operationRunId = null,
|
||||
) {}
|
||||
|
||||
public function handle(AlertDispatchService $dispatchService, OperationRunService $operationRuns): void
|
||||
{
|
||||
$workspace = Workspace::query()->whereKey($this->workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRun = $this->resolveOperationRun($workspace, $operationRuns);
|
||||
|
||||
if (! $operationRun instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($operationRun->status === OperationRunStatus::Completed->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Running->value,
|
||||
outcome: OperationRunOutcome::Pending->value,
|
||||
);
|
||||
|
||||
$windowStart = $this->resolveWindowStart($operationRun);
|
||||
|
||||
try {
|
||||
$events = [
|
||||
...$this->highDriftEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart),
|
||||
];
|
||||
|
||||
$createdDeliveries = 0;
|
||||
|
||||
foreach ($events as $event) {
|
||||
$createdDeliveries += $dispatchService->dispatchEvent($workspace, $event);
|
||||
}
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'total' => count($events),
|
||||
'processed' => count($events),
|
||||
'created' => $createdDeliveries,
|
||||
],
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'alerts.evaluate.failed',
|
||||
'message' => $this->sanitizeErrorMessage($exception),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun
|
||||
{
|
||||
if (is_int($this->operationRunId) && $this->operationRunId > 0) {
|
||||
$operationRun = OperationRun::query()
|
||||
->whereKey($this->operationRunId)
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('type', 'alerts.evaluate')
|
||||
->first();
|
||||
|
||||
if ($operationRun instanceof OperationRun) {
|
||||
return $operationRun;
|
||||
}
|
||||
}
|
||||
|
||||
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
|
||||
|
||||
return $operationRuns->ensureWorkspaceRunWithIdentity(
|
||||
workspace: $workspace,
|
||||
type: 'alerts.evaluate',
|
||||
identityInputs: [
|
||||
'slot_key' => $slotKey,
|
||||
],
|
||||
context: [
|
||||
'trigger' => 'job',
|
||||
'slot_key' => $slotKey,
|
||||
],
|
||||
initiator: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveWindowStart(OperationRun $operationRun): CarbonImmutable
|
||||
{
|
||||
$previous = OperationRun::query()
|
||||
->where('workspace_id', (int) $operationRun->workspace_id)
|
||||
->whereNull('tenant_id')
|
||||
->where('type', 'alerts.evaluate')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereNotNull('completed_at')
|
||||
->where('id', '<', (int) $operationRun->getKey())
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($previous instanceof OperationRun && $previous->completed_at !== null) {
|
||||
return CarbonImmutable::instance($previous->completed_at);
|
||||
}
|
||||
|
||||
$lookbackMinutes = max(1, (int) config('tenantpilot.alerts.evaluate_initial_lookback_minutes', 15));
|
||||
|
||||
return CarbonImmutable::now('UTC')->subMinutes($lookbackMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function highDriftEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
||||
{
|
||||
$findings = Finding::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->whereIn('severity', [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL])
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->where('created_at', '>', $windowStart)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$events = [];
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$events[] = [
|
||||
'event_type' => 'high_drift',
|
||||
'tenant_id' => (int) $finding->tenant_id,
|
||||
'severity' => (string) $finding->severity,
|
||||
'fingerprint_key' => 'finding:'.(int) $finding->getKey(),
|
||||
'title' => 'High drift finding detected',
|
||||
'body' => sprintf(
|
||||
'Finding %d was created with severity %s.',
|
||||
(int) $finding->getKey(),
|
||||
(string) $finding->severity,
|
||||
),
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function compareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
||||
{
|
||||
$failedRuns = OperationRun::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereNotNull('tenant_id')
|
||||
->where('type', 'drift_generate_findings')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->where('created_at', '>', $windowStart)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$events = [];
|
||||
|
||||
foreach ($failedRuns as $failedRun) {
|
||||
$tenantId = (int) ($failedRun->tenant_id ?? 0);
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$events[] = [
|
||||
'event_type' => 'compare_failed',
|
||||
'tenant_id' => $tenantId,
|
||||
'severity' => 'high',
|
||||
'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(),
|
||||
'title' => 'Drift compare failed',
|
||||
'body' => $this->firstFailureMessage($failedRun),
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $failedRun->getKey(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
private function firstFailureMessage(OperationRun $run): string
|
||||
{
|
||||
$failures = is_array($run->failure_summary) ? $run->failure_summary : [];
|
||||
|
||||
foreach ($failures as $failure) {
|
||||
if (! is_array($failure)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = trim((string) ($failure['message'] ?? ''));
|
||||
|
||||
if ($message !== '') {
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
|
||||
return 'A drift compare operation run failed.';
|
||||
}
|
||||
|
||||
private function sanitizeErrorMessage(Throwable $exception): string
|
||||
{
|
||||
$message = trim($exception->getMessage());
|
||||
|
||||
if ($message === '') {
|
||||
return 'Unexpected alert evaluation error.';
|
||||
}
|
||||
|
||||
$message = preg_replace('/https?:\/\/\S+/i', '[redacted-url]', $message) ?? $message;
|
||||
|
||||
return mb_substr($message, 0, 500);
|
||||
}
|
||||
}
|
||||
77
app/Models/AlertDelivery.php
Normal file
77
app/Models/AlertDelivery.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Prunable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AlertDelivery extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
use Prunable;
|
||||
|
||||
public const string STATUS_QUEUED = 'queued';
|
||||
|
||||
public const string STATUS_DEFERRED = 'deferred';
|
||||
|
||||
public const string STATUS_SENT = 'sent';
|
||||
|
||||
public const string STATUS_FAILED = 'failed';
|
||||
|
||||
public const string STATUS_SUPPRESSED = 'suppressed';
|
||||
|
||||
public const string STATUS_CANCELED = 'canceled';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'send_after' => 'datetime',
|
||||
'sent_at' => 'datetime',
|
||||
'payload' => 'array',
|
||||
];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function rule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AlertRule::class, 'alert_rule_id');
|
||||
}
|
||||
|
||||
public function destination(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AlertDestination::class, 'alert_destination_id');
|
||||
}
|
||||
|
||||
public function prunable(): Builder
|
||||
{
|
||||
$retentionDays = (int) config('tenantpilot.alerts.delivery_retention_days', 90);
|
||||
$retentionDays = max(1, $retentionDays);
|
||||
|
||||
return static::query()->where('created_at', '<', now()->subDays($retentionDays));
|
||||
}
|
||||
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return in_array($this->status, [
|
||||
self::STATUS_SENT,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_SUPPRESSED,
|
||||
self::STATUS_CANCELED,
|
||||
], true);
|
||||
}
|
||||
}
|
||||
49
app/Models/AlertDestination.php
Normal file
49
app/Models/AlertDestination.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AlertDestination extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const string TYPE_TEAMS_WEBHOOK = 'teams_webhook';
|
||||
|
||||
public const string TYPE_EMAIL = 'email';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $hidden = [
|
||||
'config',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_enabled' => 'boolean',
|
||||
'config' => 'encrypted:array',
|
||||
];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function rules(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(AlertRule::class, 'alert_rule_destinations')
|
||||
->using(AlertRuleDestination::class)
|
||||
->withPivot(['id', 'workspace_id'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function deliveries(): HasMany
|
||||
{
|
||||
return $this->hasMany(AlertDelivery::class);
|
||||
}
|
||||
}
|
||||
65
app/Models/AlertRule.php
Normal file
65
app/Models/AlertRule.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AlertRule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const string EVENT_HIGH_DRIFT = 'high_drift';
|
||||
|
||||
public const string EVENT_COMPARE_FAILED = 'compare_failed';
|
||||
|
||||
public const string EVENT_SLA_DUE = 'sla_due';
|
||||
|
||||
public const string TENANT_SCOPE_ALL = 'all';
|
||||
|
||||
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'is_enabled' => 'boolean',
|
||||
'tenant_allowlist' => 'array',
|
||||
'cooldown_seconds' => 'integer',
|
||||
'quiet_hours_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function destinations(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(AlertDestination::class, 'alert_rule_destinations')
|
||||
->using(AlertRuleDestination::class)
|
||||
->withPivot(['id', 'workspace_id'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function deliveries(): HasMany
|
||||
{
|
||||
return $this->hasMany(AlertDelivery::class);
|
||||
}
|
||||
|
||||
public function appliesToTenant(int $tenantId): bool
|
||||
{
|
||||
if ($this->tenant_scope_mode !== self::TENANT_SCOPE_ALLOWLIST) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$allowlist = is_array($this->tenant_allowlist) ? $this->tenant_allowlist : [];
|
||||
$allowlist = array_values(array_unique(array_map(static fn (mixed $value): int => (int) $value, $allowlist)));
|
||||
|
||||
return in_array($tenantId, $allowlist, true);
|
||||
}
|
||||
}
|
||||
30
app/Models/AlertRuleDestination.php
Normal file
30
app/Models/AlertRuleDestination.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class AlertRuleDestination extends Pivot
|
||||
{
|
||||
protected $table = 'alert_rule_destinations';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function alertRule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AlertRule::class);
|
||||
}
|
||||
|
||||
public function alertDestination(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AlertDestination::class);
|
||||
}
|
||||
}
|
||||
52
app/Notifications/Alerts/EmailAlertNotification.php
Normal file
52
app/Notifications/Alerts/EmailAlertNotification.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications\Alerts;
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class EmailAlertNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly AlertDelivery $delivery,
|
||||
private readonly array $payload,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$title = trim((string) ($this->payload['title'] ?? 'Alert'));
|
||||
$body = trim((string) ($this->payload['body'] ?? 'A matching alert event was detected.'));
|
||||
|
||||
if ($title === '') {
|
||||
$title = 'Alert';
|
||||
}
|
||||
|
||||
if ($body === '') {
|
||||
$body = 'A matching alert event was detected.';
|
||||
}
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($title)
|
||||
->line($body)
|
||||
->line('Delivery ID: '.(int) $this->delivery->getKey())
|
||||
->line('Event type: '.(string) $this->delivery->event_type)
|
||||
->line('Status: '.(string) $this->delivery->status);
|
||||
}
|
||||
}
|
||||
91
app/Policies/AlertDeliveryPolicy.php
Normal file
91
app/Policies/AlertDeliveryPolicy.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class AlertDeliveryPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool|Response
|
||||
{
|
||||
$workspace = $this->currentWorkspace($user);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, AlertDelivery $alertDelivery): bool|Response
|
||||
{
|
||||
$workspace = $this->currentWorkspace($user);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $alertDelivery->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$tenant = $alertDelivery->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||
}
|
||||
|
||||
private function currentWorkspace(User $user): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
private function authorizeForWorkspace(User $user, Workspace $workspace, string $capability): bool|Response
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $resolver->can($user, $workspace, $capability)
|
||||
? Response::allow()
|
||||
: Response::deny();
|
||||
}
|
||||
}
|
||||
106
app/Policies/AlertDestinationPolicy.php
Normal file
106
app/Policies/AlertDestinationPolicy.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class AlertDestinationPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool|Response
|
||||
{
|
||||
$workspace = $this->currentWorkspace($user);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, AlertDestination $alertDestination): bool|Response
|
||||
{
|
||||
return $this->authorizeForRecordWorkspace($user, $alertDestination, Capabilities::ALERTS_VIEW);
|
||||
}
|
||||
|
||||
public function create(User $user): bool|Response
|
||||
{
|
||||
$workspace = $this->currentWorkspace($user);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_MANAGE);
|
||||
}
|
||||
|
||||
public function update(User $user, AlertDestination $alertDestination): bool|Response
|
||||
{
|
||||
return $this->authorizeForRecordWorkspace($user, $alertDestination, Capabilities::ALERTS_MANAGE);
|
||||
}
|
||||
|
||||
public function delete(User $user, AlertDestination $alertDestination): bool|Response
|
||||
{
|
||||
return $this->authorizeForRecordWorkspace($user, $alertDestination, Capabilities::ALERTS_MANAGE);
|
||||
}
|
||||
|
||||
private function currentWorkspace(User $user): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
private function authorizeForRecordWorkspace(User $user, AlertDestination $alertDestination, string $capability): bool|Response
|
||||
{
|
||||
$workspace = $this->currentWorkspace($user);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $alertDestination->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeForWorkspace($user, $workspace, $capability);
|
||||
}
|
||||
|
||||
private function authorizeForWorkspace(User $user, Workspace $workspace, string $capability): bool|Response
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $resolver->can($user, $workspace, $capability)
|
||||
? Response::allow()
|
||||
: Response::deny();
|
||||
}
|
||||
}
|
||||
106
app/Policies/AlertRulePolicy.php
Normal file
106
app/Policies/AlertRulePolicy.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class AlertRulePolicy
|
||||
{
|
||||
public function viewAny(User $user): bool|Response
|
||||
{
|
||||
$workspace = $this->currentWorkspace($user);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, AlertRule $alertRule): bool|Response
|
||||
{
|
||||
return $this->authorizeForRecordWorkspace($user, $alertRule, Capabilities::ALERTS_VIEW);
|
||||
}
|
||||
|
||||
public function create(User $user): bool|Response
|
||||
{
|
||||
$workspace = $this->currentWorkspace($user);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_MANAGE);
|
||||
}
|
||||
|
||||
public function update(User $user, AlertRule $alertRule): bool|Response
|
||||
{
|
||||
return $this->authorizeForRecordWorkspace($user, $alertRule, Capabilities::ALERTS_MANAGE);
|
||||
}
|
||||
|
||||
public function delete(User $user, AlertRule $alertRule): bool|Response
|
||||
{
|
||||
return $this->authorizeForRecordWorkspace($user, $alertRule, Capabilities::ALERTS_MANAGE);
|
||||
}
|
||||
|
||||
private function currentWorkspace(User $user): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
private function authorizeForRecordWorkspace(User $user, AlertRule $alertRule, string $capability): bool|Response
|
||||
{
|
||||
$workspace = $this->currentWorkspace($user);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $alertRule->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->authorizeForWorkspace($user, $workspace, $capability);
|
||||
}
|
||||
|
||||
private function authorizeForWorkspace(User $user, Workspace $workspace, string $capability): bool|Response
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $resolver->can($user, $workspace, $capability)
|
||||
? Response::allow()
|
||||
: Response::deny();
|
||||
}
|
||||
}
|
||||
@ -2,12 +2,18 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Policies\AlertDeliveryPolicy;
|
||||
use App\Policies\AlertDestinationPolicy;
|
||||
use App\Policies\AlertRulePolicy;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Policies\WorkspaceSettingPolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
@ -22,6 +28,9 @@ class AuthServiceProvider extends ServiceProvider
|
||||
protected $policies = [
|
||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
||||
WorkspaceSetting::class => WorkspaceSettingPolicy::class,
|
||||
AlertDestination::class => AlertDestinationPolicy::class,
|
||||
AlertDelivery::class => AlertDeliveryPolicy::class,
|
||||
AlertRule::class => AlertRulePolicy::class,
|
||||
];
|
||||
|
||||
public function boot(): void
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Filament\Pages\TenantRequiredPermissions;
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
@ -119,11 +122,6 @@ public function panel(Panel $panel): Panel
|
||||
->icon('heroicon-o-queue-list')
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
NavigationItem::make('Alerts')
|
||||
->url(fn (): string => route('admin.monitoring.alerts'))
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group('Monitoring')
|
||||
->sort(20),
|
||||
NavigationItem::make('Audit Log')
|
||||
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
@ -149,6 +147,9 @@ public function panel(Panel $panel): Panel
|
||||
PolicyResource::class,
|
||||
ProviderConnectionResource::class,
|
||||
InventoryItemResource::class,
|
||||
AlertDestinationResource::class,
|
||||
AlertRuleResource::class,
|
||||
AlertDeliveryResource::class,
|
||||
WorkspaceResource::class,
|
||||
])
|
||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
|
||||
|
||||
@ -47,7 +47,7 @@ public function panel(Panel $panel): Panel
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
NavigationItem::make('Alerts')
|
||||
->url(fn (): string => route('admin.monitoring.alerts'))
|
||||
->url(fn (): string => url('/admin/alerts'))
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group('Monitoring')
|
||||
->sort(20),
|
||||
|
||||
0
app/Services/Alerts/.gitkeep
Normal file
0
app/Services/Alerts/.gitkeep
Normal file
192
app/Services/Alerts/AlertDispatchService.php
Normal file
192
app/Services/Alerts/AlertDispatchService.php
Normal file
@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Alerts;
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class AlertDispatchService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AlertFingerprintService $fingerprintService,
|
||||
private readonly AlertQuietHoursService $quietHoursService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
public function dispatchEvent(Workspace $workspace, array $event): int
|
||||
{
|
||||
$workspaceId = (int) $workspace->getKey();
|
||||
$tenantId = (int) ($event['tenant_id'] ?? 0);
|
||||
$eventType = trim((string) ($event['event_type'] ?? ''));
|
||||
|
||||
if ($workspaceId <= 0 || $tenantId <= 0 || $eventType === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->whereKey($tenantId)
|
||||
->where('workspace_id', $workspaceId)
|
||||
->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
$eventSeverity = $this->normalizeSeverity((string) ($event['severity'] ?? ''));
|
||||
|
||||
$rules = AlertRule::query()
|
||||
->with(['destinations' => fn ($query) => $query->where('is_enabled', true)])
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('is_enabled', true)
|
||||
->where('event_type', $eventType)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$createdDeliveries = 0;
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
if (! $rule->appliesToTenant($tenantId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->meetsMinimumSeverity($eventSeverity, (string) $rule->minimum_severity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rule->destinations as $destination) {
|
||||
$fingerprintHash = $this->fingerprintService->hash($rule, $destination, $tenantId, $event);
|
||||
|
||||
$isSuppressed = $this->shouldSuppress(
|
||||
workspaceId: $workspaceId,
|
||||
ruleId: (int) $rule->getKey(),
|
||||
destinationId: (int) $destination->getKey(),
|
||||
fingerprintHash: $fingerprintHash,
|
||||
cooldownSeconds: (int) ($rule->cooldown_seconds ?? 0),
|
||||
now: $now,
|
||||
);
|
||||
|
||||
$sendAfter = null;
|
||||
$status = AlertDelivery::STATUS_QUEUED;
|
||||
|
||||
if ($isSuppressed) {
|
||||
$status = AlertDelivery::STATUS_SUPPRESSED;
|
||||
} else {
|
||||
$deferUntil = $this->quietHoursService->deferUntil($rule, $workspace, $now);
|
||||
|
||||
if ($deferUntil instanceof CarbonImmutable) {
|
||||
$status = AlertDelivery::STATUS_DEFERRED;
|
||||
$sendAfter = $deferUntil;
|
||||
}
|
||||
}
|
||||
|
||||
AlertDelivery::query()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => $tenantId,
|
||||
'alert_rule_id' => (int) $rule->getKey(),
|
||||
'alert_destination_id' => (int) $destination->getKey(),
|
||||
'event_type' => $eventType,
|
||||
'severity' => $eventSeverity,
|
||||
'status' => $status,
|
||||
'fingerprint_hash' => $fingerprintHash,
|
||||
'send_after' => $sendAfter,
|
||||
'attempt_count' => 0,
|
||||
'payload' => $this->buildPayload($event),
|
||||
]);
|
||||
|
||||
$createdDeliveries++;
|
||||
}
|
||||
}
|
||||
|
||||
return $createdDeliveries;
|
||||
}
|
||||
|
||||
private function normalizeSeverity(string $severity): string
|
||||
{
|
||||
$severity = strtolower(trim($severity));
|
||||
|
||||
return in_array($severity, ['low', 'medium', 'high', 'critical'], true)
|
||||
? $severity
|
||||
: 'high';
|
||||
}
|
||||
|
||||
private function meetsMinimumSeverity(string $eventSeverity, string $minimumSeverity): bool
|
||||
{
|
||||
$rank = [
|
||||
'low' => 1,
|
||||
'medium' => 2,
|
||||
'high' => 3,
|
||||
'critical' => 4,
|
||||
];
|
||||
|
||||
$eventRank = $rank[$eventSeverity] ?? 0;
|
||||
$minimumRank = $rank[strtolower(trim($minimumSeverity))] ?? 0;
|
||||
|
||||
return $eventRank >= $minimumRank;
|
||||
}
|
||||
|
||||
private function shouldSuppress(
|
||||
int $workspaceId,
|
||||
int $ruleId,
|
||||
int $destinationId,
|
||||
string $fingerprintHash,
|
||||
int $cooldownSeconds,
|
||||
CarbonImmutable $now,
|
||||
): bool {
|
||||
if ($cooldownSeconds <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cutoff = $now->subSeconds($cooldownSeconds);
|
||||
|
||||
return AlertDelivery::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('alert_rule_id', $ruleId)
|
||||
->where('alert_destination_id', $destinationId)
|
||||
->where('fingerprint_hash', $fingerprintHash)
|
||||
->whereNotIn('status', [
|
||||
AlertDelivery::STATUS_SUPPRESSED,
|
||||
AlertDelivery::STATUS_CANCELED,
|
||||
])
|
||||
->where('created_at', '>=', $cutoff)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPayload(array $event): array
|
||||
{
|
||||
$title = trim((string) ($event['title'] ?? 'Alert'));
|
||||
$body = trim((string) ($event['body'] ?? 'A matching alert event was detected.'));
|
||||
|
||||
if ($title === '') {
|
||||
$title = 'Alert';
|
||||
}
|
||||
|
||||
if ($body === '') {
|
||||
$body = 'A matching alert event was detected.';
|
||||
}
|
||||
|
||||
$metadata = Arr::get($event, 'metadata', []);
|
||||
|
||||
if (! is_array($metadata)) {
|
||||
$metadata = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Services/Alerts/AlertFingerprintService.php
Normal file
52
app/Services/Alerts/AlertFingerprintService.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Alerts;
|
||||
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
|
||||
class AlertFingerprintService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
public function hash(AlertRule $rule, AlertDestination $destination, int $tenantId, array $event): string
|
||||
{
|
||||
$fingerprintKey = trim((string) ($event['fingerprint_key'] ?? ''));
|
||||
|
||||
if ($fingerprintKey === '') {
|
||||
$fingerprintKey = trim((string) ($event['idempotency_key'] ?? ''));
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'workspace_id' => (int) $rule->workspace_id,
|
||||
'rule_id' => (int) $rule->getKey(),
|
||||
'destination_id' => (int) $destination->getKey(),
|
||||
'tenant_id' => $tenantId,
|
||||
'event_type' => trim((string) ($event['event_type'] ?? '')),
|
||||
'severity' => strtolower(trim((string) ($event['severity'] ?? ''))),
|
||||
'fingerprint_key' => $fingerprintKey,
|
||||
];
|
||||
|
||||
return hash('sha256', json_encode($this->normalizeArray($payload), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeArray(array $payload): array
|
||||
{
|
||||
ksort($payload);
|
||||
|
||||
foreach ($payload as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$payload[$key] = $this->normalizeArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
132
app/Services/Alerts/AlertQuietHoursService.php
Normal file
132
app/Services/Alerts/AlertQuietHoursService.php
Normal file
@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Alerts;
|
||||
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Workspace;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class AlertQuietHoursService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorkspaceTimezoneResolver $workspaceTimezoneResolver,
|
||||
) {}
|
||||
|
||||
public function deferUntil(AlertRule $rule, Workspace $workspace, ?CarbonImmutable $now = null): ?CarbonImmutable
|
||||
{
|
||||
if (! (bool) $rule->quiet_hours_enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$start = $this->parseTime((string) ($rule->quiet_hours_start ?? ''));
|
||||
$end = $this->parseTime((string) ($rule->quiet_hours_end ?? ''));
|
||||
|
||||
if ($start === null || $end === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timezone = $this->resolveTimezone($rule, $workspace);
|
||||
$localNow = ($now ?? CarbonImmutable::now($timezone))->setTimezone($timezone);
|
||||
|
||||
if (! $this->isWithinQuietHours($localNow, $start, $end)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nextAllowedLocal = $this->nextAllowedAt($localNow, $start, $end);
|
||||
|
||||
return $nextAllowedLocal->setTimezone('UTC');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{hour:int,minute:int} $start
|
||||
* @param array{hour:int,minute:int} $end
|
||||
*/
|
||||
private function isWithinQuietHours(CarbonImmutable $localNow, array $start, array $end): bool
|
||||
{
|
||||
$nowMinutes = ((int) $localNow->format('H') * 60) + (int) $localNow->format('i');
|
||||
$startMinutes = ($start['hour'] * 60) + $start['minute'];
|
||||
$endMinutes = ($end['hour'] * 60) + $end['minute'];
|
||||
|
||||
if ($startMinutes === $endMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($startMinutes < $endMinutes) {
|
||||
return $nowMinutes >= $startMinutes && $nowMinutes < $endMinutes;
|
||||
}
|
||||
|
||||
return $nowMinutes >= $startMinutes || $nowMinutes < $endMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{hour:int,minute:int} $start
|
||||
* @param array{hour:int,minute:int} $end
|
||||
*/
|
||||
private function nextAllowedAt(CarbonImmutable $localNow, array $start, array $end): CarbonImmutable
|
||||
{
|
||||
$nowMinutes = ((int) $localNow->format('H') * 60) + (int) $localNow->format('i');
|
||||
$startMinutes = ($start['hour'] * 60) + $start['minute'];
|
||||
$endMinutes = ($end['hour'] * 60) + $end['minute'];
|
||||
|
||||
if ($startMinutes === $endMinutes) {
|
||||
return $this->atLocalTime($localNow->addDay(), $end);
|
||||
}
|
||||
|
||||
if ($startMinutes < $endMinutes) {
|
||||
if ($nowMinutes >= $startMinutes && $nowMinutes < $endMinutes) {
|
||||
return $this->atLocalTime($localNow, $end);
|
||||
}
|
||||
|
||||
return $localNow;
|
||||
}
|
||||
|
||||
if ($nowMinutes >= $startMinutes) {
|
||||
return $this->atLocalTime($localNow->addDay(), $end);
|
||||
}
|
||||
|
||||
if ($nowMinutes < $endMinutes) {
|
||||
return $this->atLocalTime($localNow, $end);
|
||||
}
|
||||
|
||||
return $localNow;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{hour:int,minute:int}|null
|
||||
*/
|
||||
private function parseTime(string $value): ?array
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if (! preg_match('/^(?<hour>[01]\\d|2[0-3]):(?<minute>[0-5]\\d)$/', $value, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'hour' => (int) $matches['hour'],
|
||||
'minute' => (int) $matches['minute'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{hour:int,minute:int} $time
|
||||
*/
|
||||
private function atLocalTime(CarbonImmutable $baseDateTime, array $time): CarbonImmutable
|
||||
{
|
||||
return $baseDateTime
|
||||
->setTime($time['hour'], $time['minute'], 0, 0);
|
||||
}
|
||||
|
||||
private function resolveTimezone(AlertRule $rule, Workspace $workspace): string
|
||||
{
|
||||
$ruleTimezone = trim((string) ($rule->quiet_hours_timezone ?? ''));
|
||||
|
||||
if ($ruleTimezone !== '' && in_array($ruleTimezone, \DateTimeZone::listIdentifiers(), true)) {
|
||||
return $ruleTimezone;
|
||||
}
|
||||
|
||||
return $this->workspaceTimezoneResolver->resolve($workspace);
|
||||
}
|
||||
}
|
||||
96
app/Services/Alerts/AlertSender.php
Normal file
96
app/Services/Alerts/AlertSender.php
Normal file
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Alerts;
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Notifications\Alerts\EmailAlertNotification;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class AlertSender
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TeamsWebhookSender $teamsWebhookSender,
|
||||
) {}
|
||||
|
||||
public function send(AlertDelivery $delivery): void
|
||||
{
|
||||
$destination = $delivery->destination;
|
||||
|
||||
if (! $destination instanceof AlertDestination) {
|
||||
throw new RuntimeException('Alert destination is missing.');
|
||||
}
|
||||
|
||||
if (! (bool) $destination->is_enabled) {
|
||||
throw new RuntimeException('Alert destination is disabled.');
|
||||
}
|
||||
|
||||
$payload = is_array($delivery->payload) ? $delivery->payload : [];
|
||||
|
||||
try {
|
||||
if ($destination->type === AlertDestination::TYPE_TEAMS_WEBHOOK) {
|
||||
$this->deliverTeams($destination, $payload);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($destination->type === AlertDestination::TYPE_EMAIL) {
|
||||
$this->deliverEmail($delivery, $destination, $payload);
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
throw new RuntimeException($this->channelFailureMessage((string) $destination->type), previous: $exception);
|
||||
}
|
||||
|
||||
throw new RuntimeException('Alert destination type is not supported.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function deliverTeams(AlertDestination $destination, array $payload): void
|
||||
{
|
||||
$config = is_array($destination->config) ? $destination->config : [];
|
||||
$webhookUrl = trim((string) Arr::get($config, 'webhook_url', ''));
|
||||
|
||||
if ($webhookUrl === '') {
|
||||
throw new RuntimeException('Teams webhook destination is not configured.');
|
||||
}
|
||||
|
||||
$this->teamsWebhookSender->send($webhookUrl, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function deliverEmail(AlertDelivery $delivery, AlertDestination $destination, array $payload): void
|
||||
{
|
||||
$config = is_array($destination->config) ? $destination->config : [];
|
||||
$recipients = Arr::get($config, 'recipients', []);
|
||||
$recipients = is_array($recipients)
|
||||
? array_values(array_unique(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients))))
|
||||
: [];
|
||||
|
||||
if ($recipients === []) {
|
||||
throw new RuntimeException('Email destination has no recipients.');
|
||||
}
|
||||
|
||||
Notification::route('mail', $recipients)
|
||||
->notify(new EmailAlertNotification($delivery, $payload));
|
||||
}
|
||||
|
||||
private function channelFailureMessage(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
AlertDestination::TYPE_TEAMS_WEBHOOK => 'Teams delivery failed.',
|
||||
AlertDestination::TYPE_EMAIL => 'Email delivery failed.',
|
||||
default => 'Alert delivery failed.',
|
||||
};
|
||||
}
|
||||
}
|
||||
58
app/Services/Alerts/TeamsWebhookSender.php
Normal file
58
app/Services/Alerts/TeamsWebhookSender.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Alerts;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class TeamsWebhookSender
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function send(string $webhookUrl, array $payload): void
|
||||
{
|
||||
$webhookUrl = trim($webhookUrl);
|
||||
|
||||
if ($webhookUrl === '') {
|
||||
throw new RuntimeException('Teams webhook URL is not configured.');
|
||||
}
|
||||
|
||||
$response = Http::timeout((int) config('tenantpilot.alerts.http_timeout_seconds', 10))
|
||||
->asJson()
|
||||
->post($webhookUrl, [
|
||||
'text' => $this->toTeamsTextPayload($payload),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'Teams delivery failed with HTTP status %d.',
|
||||
(int) $response->status(),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function toTeamsTextPayload(array $payload): string
|
||||
{
|
||||
$title = trim((string) Arr::get($payload, 'title', 'Alert'));
|
||||
$body = trim((string) Arr::get($payload, 'body', 'A matching alert event was detected.'));
|
||||
|
||||
if ($title === '') {
|
||||
$title = 'Alert';
|
||||
}
|
||||
|
||||
if ($body === '') {
|
||||
return $title;
|
||||
}
|
||||
|
||||
return $title."\n\n".$body;
|
||||
}
|
||||
}
|
||||
68
app/Services/Alerts/WorkspaceTimezoneResolver.php
Normal file
68
app/Services/Alerts/WorkspaceTimezoneResolver.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Alerts;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
|
||||
class WorkspaceTimezoneResolver
|
||||
{
|
||||
public function resolve(Workspace $workspace): string
|
||||
{
|
||||
$candidates = [
|
||||
$this->normalizeTimezone($workspace->getAttribute('timezone')),
|
||||
$this->settingTimezone($workspace, 'alerts', 'timezone'),
|
||||
$this->settingTimezone($workspace, 'workspace', 'timezone'),
|
||||
$this->settingTimezone($workspace, 'general', 'timezone'),
|
||||
$this->normalizeTimezone(config('app.timezone')),
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($candidate !== null) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return 'UTC';
|
||||
}
|
||||
|
||||
private function settingTimezone(Workspace $workspace, string $domain, string $key): ?string
|
||||
{
|
||||
$setting = WorkspaceSetting::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('domain', $domain)
|
||||
->where('key', $key)
|
||||
->first(['value']);
|
||||
|
||||
if (! $setting instanceof WorkspaceSetting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->normalizeTimezone($setting->getAttribute('value'));
|
||||
}
|
||||
|
||||
private function normalizeTimezone(mixed $value): ?string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = $value['timezone'] ?? null;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! in_array($value, \DateTimeZone::listIdentifiers(), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,8 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE,
|
||||
Capabilities::WORKSPACE_SETTINGS_VIEW,
|
||||
Capabilities::WORKSPACE_SETTINGS_MANAGE,
|
||||
Capabilities::ALERTS_VIEW,
|
||||
Capabilities::ALERTS_MANAGE,
|
||||
],
|
||||
|
||||
WorkspaceRole::Manager->value => [
|
||||
@ -50,6 +52,8 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
|
||||
Capabilities::WORKSPACE_SETTINGS_VIEW,
|
||||
Capabilities::WORKSPACE_SETTINGS_MANAGE,
|
||||
Capabilities::ALERTS_VIEW,
|
||||
Capabilities::ALERTS_MANAGE,
|
||||
],
|
||||
|
||||
WorkspaceRole::Operator->value => [
|
||||
@ -61,11 +65,13 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
|
||||
Capabilities::WORKSPACE_SETTINGS_VIEW,
|
||||
Capabilities::ALERTS_VIEW,
|
||||
],
|
||||
|
||||
WorkspaceRole::Readonly->value => [
|
||||
Capabilities::WORKSPACE_VIEW,
|
||||
Capabilities::WORKSPACE_SETTINGS_VIEW,
|
||||
Capabilities::ALERTS_VIEW,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@ -31,6 +31,18 @@ enum AuditActionId: string
|
||||
case VerificationCompleted = 'verification.completed';
|
||||
case VerificationCheckAcknowledged = 'verification.check_acknowledged';
|
||||
|
||||
case AlertDestinationCreated = 'alert_destination.created';
|
||||
case AlertDestinationUpdated = 'alert_destination.updated';
|
||||
case AlertDestinationDeleted = 'alert_destination.deleted';
|
||||
case AlertDestinationEnabled = 'alert_destination.enabled';
|
||||
case AlertDestinationDisabled = 'alert_destination.disabled';
|
||||
|
||||
case AlertRuleCreated = 'alert_rule.created';
|
||||
case AlertRuleUpdated = 'alert_rule.updated';
|
||||
case AlertRuleDeleted = 'alert_rule.deleted';
|
||||
case AlertRuleEnabled = 'alert_rule.enabled';
|
||||
case AlertRuleDisabled = 'alert_rule.disabled';
|
||||
|
||||
case WorkspaceSettingUpdated = 'workspace_setting.updated';
|
||||
case WorkspaceSettingReset = 'workspace_setting.reset';
|
||||
}
|
||||
|
||||
@ -51,6 +51,11 @@ class Capabilities
|
||||
|
||||
public const WORKSPACE_SETTINGS_MANAGE = 'workspace_settings.manage';
|
||||
|
||||
// Workspace alerts
|
||||
public const ALERTS_VIEW = 'workspace_alerts.view';
|
||||
|
||||
public const ALERTS_MANAGE = 'workspace_alerts.manage';
|
||||
|
||||
// Tenants
|
||||
public const TENANT_VIEW = 'tenant.view';
|
||||
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Support\Middleware;
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -200,12 +203,36 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Alert targets')
|
||||
->url(fn (): string => AlertDestinationResource::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group('Monitoring')
|
||||
->sort(20)
|
||||
->visible(fn (): bool => AlertDestinationResource::canViewAny()),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Alert rules')
|
||||
->url(fn (): string => AlertRuleResource::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-funnel')
|
||||
->group('Monitoring')
|
||||
->sort(21)
|
||||
->visible(fn (): bool => AlertRuleResource::canViewAny()),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Alert deliveries')
|
||||
->url(fn (): string => AlertDeliveryResource::getUrl(panel: 'admin'))
|
||||
->icon('heroicon-o-clock')
|
||||
->group('Monitoring')
|
||||
->sort(22)
|
||||
->visible(fn (): bool => AlertDeliveryResource::canViewAny()),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Alerts')
|
||||
->url(fn (): string => '/admin/alerts')
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group('Monitoring')
|
||||
->sort(20),
|
||||
->sort(23),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Audit Log')
|
||||
|
||||
@ -43,6 +43,8 @@ public static function labels(): array
|
||||
'policy_version.prune' => 'Prune policy versions',
|
||||
'policy_version.restore' => 'Restore policy versions',
|
||||
'policy_version.force_delete' => 'Delete policy versions',
|
||||
'alerts.evaluate' => 'Alerts evaluation',
|
||||
'alerts.deliver' => 'Alerts delivery',
|
||||
];
|
||||
}
|
||||
|
||||
@ -69,6 +71,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
||||
'drift_generate_findings' => 240,
|
||||
'assignments.fetch', 'assignments.restore' => 60,
|
||||
'ops.reconcile_adapter_runs' => 120,
|
||||
'alerts.evaluate', 'alerts.deliver' => 120,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ public function requiredCapabilityForType(string $operationType): ?string
|
||||
'backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||
'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
||||
'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
|
||||
|
||||
// Viewing verification reports should be possible for readonly members.
|
||||
// Starting verification is separately guarded by the verification service.
|
||||
|
||||
@ -338,6 +338,17 @@
|
||||
],
|
||||
],
|
||||
|
||||
'alerts' => [
|
||||
'enabled' => (bool) env('TENANTPILOT_ALERTS_ENABLED', true),
|
||||
'evaluate_initial_lookback_minutes' => (int) env('TENANTPILOT_ALERTS_EVALUATE_INITIAL_LOOKBACK_MINUTES', 15),
|
||||
'delivery_retention_days' => (int) env('TENANTPILOT_ALERTS_DELIVERY_RETENTION_DAYS', 90),
|
||||
'delivery_max_attempts' => (int) env('TENANTPILOT_ALERTS_DELIVERY_MAX_ATTEMPTS', 3),
|
||||
'delivery_retry_base_seconds' => (int) env('TENANTPILOT_ALERTS_DELIVERY_RETRY_BASE_SECONDS', 60),
|
||||
'delivery_retry_max_seconds' => (int) env('TENANTPILOT_ALERTS_DELIVERY_RETRY_MAX_SECONDS', 900),
|
||||
'deliver_batch_size' => (int) env('TENANTPILOT_ALERTS_DELIVER_BATCH_SIZE', 200),
|
||||
'http_timeout_seconds' => (int) env('TENANTPILOT_ALERTS_HTTP_TIMEOUT_SECONDS', 10),
|
||||
],
|
||||
|
||||
'display' => [
|
||||
'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false),
|
||||
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),
|
||||
|
||||
78
database/factories/AlertDeliveryFactory.php
Normal file
78
database/factories/AlertDeliveryFactory.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<AlertDelivery>
|
||||
*/
|
||||
class AlertDeliveryFactory extends Factory
|
||||
{
|
||||
protected $model = AlertDelivery::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'workspace_id' => function (array $attributes): int {
|
||||
$tenantId = $attributes['tenant_id'] ?? null;
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return (int) Workspace::factory()->create()->getKey();
|
||||
}
|
||||
|
||||
if ($tenant->workspace_id === null) {
|
||||
$workspaceId = (int) Workspace::factory()->create()->getKey();
|
||||
$tenant->forceFill(['workspace_id' => $workspaceId])->save();
|
||||
|
||||
return $workspaceId;
|
||||
}
|
||||
|
||||
return (int) $tenant->workspace_id;
|
||||
},
|
||||
'alert_rule_id' => function (array $attributes): int {
|
||||
$workspaceId = is_numeric($attributes['workspace_id'] ?? null)
|
||||
? (int) $attributes['workspace_id']
|
||||
: (int) Workspace::factory()->create()->getKey();
|
||||
|
||||
return (int) AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
])->getKey();
|
||||
},
|
||||
'alert_destination_id' => function (array $attributes): int {
|
||||
$workspaceId = is_numeric($attributes['workspace_id'] ?? null)
|
||||
? (int) $attributes['workspace_id']
|
||||
: (int) Workspace::factory()->create()->getKey();
|
||||
|
||||
return (int) AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
])->getKey();
|
||||
},
|
||||
'event_type' => AlertRule::EVENT_HIGH_DRIFT,
|
||||
'severity' => 'high',
|
||||
'status' => AlertDelivery::STATUS_QUEUED,
|
||||
'fingerprint_hash' => hash('sha256', fake()->uuid()),
|
||||
'send_after' => null,
|
||||
'attempt_count' => 0,
|
||||
'last_error_code' => null,
|
||||
'last_error_message' => null,
|
||||
'sent_at' => null,
|
||||
'payload' => [
|
||||
'title' => 'Alert',
|
||||
'body' => 'Delivery payload',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
40
database/factories/AlertDestinationFactory.php
Normal file
40
database/factories/AlertDestinationFactory.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<AlertDestination>
|
||||
*/
|
||||
class AlertDestinationFactory extends Factory
|
||||
{
|
||||
protected $model = AlertDestination::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'name' => 'Destination '.fake()->unique()->word(),
|
||||
'type' => AlertDestination::TYPE_TEAMS_WEBHOOK,
|
||||
'is_enabled' => true,
|
||||
'config' => [
|
||||
'webhook_url' => 'https://example.invalid/'.fake()->uuid(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function email(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'type' => AlertDestination::TYPE_EMAIL,
|
||||
'config' => [
|
||||
'recipients' => [
|
||||
fake()->safeEmail(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
database/factories/AlertRuleFactory.php
Normal file
33
database/factories/AlertRuleFactory.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<AlertRule>
|
||||
*/
|
||||
class AlertRuleFactory extends Factory
|
||||
{
|
||||
protected $model = AlertRule::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'name' => 'Rule '.fake()->unique()->word(),
|
||||
'is_enabled' => true,
|
||||
'event_type' => AlertRule::EVENT_HIGH_DRIFT,
|
||||
'minimum_severity' => 'high',
|
||||
'tenant_scope_mode' => AlertRule::TENANT_SCOPE_ALL,
|
||||
'tenant_allowlist' => [],
|
||||
'cooldown_seconds' => 900,
|
||||
'quiet_hours_enabled' => false,
|
||||
'quiet_hours_start' => null,
|
||||
'quiet_hours_end' => null,
|
||||
'quiet_hours_timezone' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
<?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_destinations', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('type');
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->text('config');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'type']);
|
||||
$table->index(['workspace_id', 'is_enabled']);
|
||||
$table->unique(['workspace_id', 'name']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('alert_destinations');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
<?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): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->string('event_type');
|
||||
$table->string('minimum_severity');
|
||||
$table->string('tenant_scope_mode')->default('all');
|
||||
$table->json('tenant_allowlist')->nullable();
|
||||
$table->unsignedInteger('cooldown_seconds')->nullable();
|
||||
$table->boolean('quiet_hours_enabled')->default(false);
|
||||
$table->string('quiet_hours_start')->nullable();
|
||||
$table->string('quiet_hours_end')->nullable();
|
||||
$table->string('quiet_hours_timezone')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'event_type']);
|
||||
$table->index(['workspace_id', 'is_enabled']);
|
||||
$table->index(['workspace_id', 'tenant_scope_mode']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('alert_rules');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
<?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_rule_destinations', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('alert_rule_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('alert_destination_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['alert_rule_id', 'alert_destination_id']);
|
||||
$table->index(['workspace_id', 'alert_rule_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('alert_rule_destinations');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
<?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_deliveries', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('alert_rule_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('alert_destination_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('event_type');
|
||||
$table->string('severity')->nullable();
|
||||
$table->string('status');
|
||||
$table->string('fingerprint_hash');
|
||||
$table->timestamp('send_after')->nullable();
|
||||
$table->unsignedInteger('attempt_count')->default(0);
|
||||
$table->string('last_error_code')->nullable();
|
||||
$table->text('last_error_message')->nullable();
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->json('payload')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'created_at']);
|
||||
$table->index(['workspace_id', 'status', 'send_after']);
|
||||
$table->index(['workspace_id', 'tenant_id', 'created_at']);
|
||||
$table->index(['workspace_id', 'alert_rule_id', 'fingerprint_hash']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('alert_deliveries');
|
||||
}
|
||||
};
|
||||
@ -1,7 +1,33 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Alerts is reserved for future work.
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Configure alert targets and rules, then review delivery history.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@if (\App\Filament\Resources\AlertDestinationResource::canViewAny())
|
||||
<x-filament::button tag="a" :href="\App\Filament\Resources\AlertDestinationResource::getUrl(panel: 'admin')">
|
||||
Alert targets
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
@if (\App\Filament\Resources\AlertRuleResource::canViewAny())
|
||||
<x-filament::button tag="a" :href="\App\Filament\Resources\AlertRuleResource::getUrl(panel: 'admin')">
|
||||
Alert rules
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
@if (\App\Filament\Resources\AlertDeliveryResource::canViewAny())
|
||||
<x-filament::button tag="a" :href="\App\Filament\Resources\AlertDeliveryResource::getUrl(panel: 'admin')">
|
||||
Alert deliveries
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
<x-filament::button tag="a" color="gray" :href="route('admin.monitoring.audit-log')">
|
||||
Audit Log
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -12,6 +12,10 @@
|
||||
|
||||
Schedule::command('tenantpilot:schedules:dispatch')->everyMinute();
|
||||
Schedule::command('tenantpilot:directory-groups:dispatch')->everyMinute();
|
||||
Schedule::command('tenantpilot:alerts:dispatch')
|
||||
->everyMinute()
|
||||
->name('tenantpilot:alerts:dispatch')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::job(new PruneOldOperationRunsJob)
|
||||
->daily()
|
||||
|
||||
@ -183,19 +183,6 @@
|
||||
})->name('admin.provider-connections.legacy-edit');
|
||||
});
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
|
||||
->name('admin.monitoring.alerts');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
|
||||
34
specs/099-alerts-v1-teams-email/checklists/requirements.md
Normal file
34
specs/099-alerts-v1-teams-email/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Alerts v1 (Teams + Email)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-16
|
||||
**Feature**: [specs/099-alerts-v1-teams-email/spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- The spec includes an explicit UI Action Matrix section because admin UI surfaces are in scope and this repo’s constitution requires it. No code-level implementation choices are included.
|
||||
291
specs/099-alerts-v1-teams-email/contracts/openapi.yaml
Normal file
291
specs/099-alerts-v1-teams-email/contracts/openapi.yaml
Normal file
@ -0,0 +1,291 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Alerts v1 (conceptual contract)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Documentation-only contract for Alerts v1.
|
||||
|
||||
v1 is implemented via Filament (Livewire) UI surfaces, not as a public REST API.
|
||||
This OpenAPI file captures the intended domain operations and payload shapes to
|
||||
keep requirements explicit and support future API extraction.
|
||||
|
||||
servers:
|
||||
- url: https://example.invalid
|
||||
|
||||
paths:
|
||||
/workspaces/{workspaceId}/alerts/destinations:
|
||||
get:
|
||||
summary: List alert destinations
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertDestination'
|
||||
post:
|
||||
summary: Create alert destination
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertDestinationCreate'
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertDestination'
|
||||
|
||||
/workspaces/{workspaceId}/alerts/destinations/{destinationId}:
|
||||
get:
|
||||
summary: Get alert destination
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/DestinationId'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertDestination'
|
||||
patch:
|
||||
summary: Update alert destination
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/DestinationId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertDestinationUpdate'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertDestination'
|
||||
delete:
|
||||
summary: Delete alert destination
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/DestinationId'
|
||||
responses:
|
||||
'204':
|
||||
description: No Content
|
||||
|
||||
/workspaces/{workspaceId}/alerts/rules:
|
||||
get:
|
||||
summary: List alert rules
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertRule'
|
||||
post:
|
||||
summary: Create alert rule
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertRuleCreate'
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertRule'
|
||||
|
||||
/workspaces/{workspaceId}/alerts/rules/{ruleId}:
|
||||
get:
|
||||
summary: Get alert rule
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/RuleId'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertRule'
|
||||
patch:
|
||||
summary: Update alert rule
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/RuleId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertRuleUpdate'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertRule'
|
||||
delete:
|
||||
summary: Delete alert rule
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/RuleId'
|
||||
responses:
|
||||
'204':
|
||||
description: No Content
|
||||
|
||||
/workspaces/{workspaceId}/alerts/deliveries:
|
||||
get:
|
||||
summary: List alert deliveries
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertDelivery'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
WorkspaceId:
|
||||
name: workspaceId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
DestinationId:
|
||||
name: destinationId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
RuleId:
|
||||
name: ruleId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
|
||||
schemas:
|
||||
AlertDestination:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
workspace_id: { type: integer }
|
||||
name: { type: string }
|
||||
type: { type: string, enum: [teams_webhook, email] }
|
||||
is_enabled: { type: boolean }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
required: [id, workspace_id, name, type, is_enabled]
|
||||
description: |
|
||||
Destination configuration details are intentionally not included here
|
||||
(secrets must not be exposed).
|
||||
|
||||
AlertDestinationCreate:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
type: { type: string, enum: [teams_webhook, email] }
|
||||
teams_webhook_url: { type: string, format: uri }
|
||||
email_recipients:
|
||||
type: array
|
||||
items: { type: string, format: email }
|
||||
required: [name, type]
|
||||
|
||||
AlertDestinationUpdate:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AlertDestinationCreate'
|
||||
|
||||
AlertRule:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
workspace_id: { type: integer }
|
||||
name: { type: string }
|
||||
is_enabled: { type: boolean }
|
||||
event_type: { type: string, enum: [high_drift, compare_failed, sla_due] }
|
||||
minimum_severity: { type: string, enum: [low, medium, high, critical] }
|
||||
tenant_scope_mode: { type: string, enum: [all, allowlist] }
|
||||
tenant_allowlist:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
cooldown_seconds: { type: integer, nullable: true }
|
||||
quiet_hours_enabled: { type: boolean }
|
||||
quiet_hours_start: { type: string, nullable: true, example: '22:00' }
|
||||
quiet_hours_end: { type: string, nullable: true, example: '06:00' }
|
||||
quiet_hours_timezone: { type: string, nullable: true, example: 'UTC' }
|
||||
destination_ids:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
required: [id, workspace_id, name, is_enabled, event_type, minimum_severity, tenant_scope_mode]
|
||||
|
||||
AlertRuleCreate:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
is_enabled: { type: boolean }
|
||||
event_type: { type: string, enum: [high_drift, compare_failed, sla_due] }
|
||||
minimum_severity: { type: string, enum: [low, medium, high, critical] }
|
||||
tenant_scope_mode: { type: string, enum: [all, allowlist] }
|
||||
tenant_allowlist:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
cooldown_seconds: { type: integer, nullable: true }
|
||||
quiet_hours_enabled: { type: boolean }
|
||||
quiet_hours_start: { type: string, nullable: true }
|
||||
quiet_hours_end: { type: string, nullable: true }
|
||||
quiet_hours_timezone: { type: string, nullable: true }
|
||||
destination_ids:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
required: [name, event_type, minimum_severity, tenant_scope_mode, destination_ids]
|
||||
|
||||
AlertRuleUpdate:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AlertRuleCreate'
|
||||
|
||||
AlertDelivery:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
workspace_id: { type: integer }
|
||||
tenant_id: { type: integer }
|
||||
alert_rule_id: { type: integer }
|
||||
alert_destination_id: { type: integer }
|
||||
fingerprint_hash: { type: string }
|
||||
status: { type: string, enum: [queued, deferred, sent, failed, suppressed, canceled] }
|
||||
send_after: { type: string, format: date-time, nullable: true }
|
||||
attempt_count: { type: integer }
|
||||
last_error_code: { type: string, nullable: true }
|
||||
last_error_message: { type: string, nullable: true }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
required: [id, workspace_id, tenant_id, alert_rule_id, alert_destination_id, fingerprint_hash, status]
|
||||
116
specs/099-alerts-v1-teams-email/data-model.md
Normal file
116
specs/099-alerts-v1-teams-email/data-model.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Data Model — 099 Alerts v1 (Teams + Email)
|
||||
|
||||
Scope: **workspace-owned** configuration and **tenant-owned** delivery history. Deliveries are always tenant-scoped (`tenant_id` NOT NULL) and must only be listed/viewed for tenants the actor is entitled to (non-entitled tenants are treated as not found / 404 semantics).
|
||||
|
||||
## Entities
|
||||
|
||||
## AlertDestination (workspace-owned)
|
||||
|
||||
Purpose: reusable delivery target.
|
||||
|
||||
Fields:
|
||||
- `id`
|
||||
- `workspace_id` (FK, required)
|
||||
- `name` (string, required)
|
||||
- `type` (enum: `teams_webhook` | `email`, required)
|
||||
- `is_enabled` (bool, default true)
|
||||
- `config` (encrypted:array, required)
|
||||
- for `teams_webhook`: `{ "webhook_url": "https://..." }`
|
||||
- for `email`: `{ "recipients": ["a@example.com", "b@example.com"] }`
|
||||
- timestamps
|
||||
|
||||
Validation rules:
|
||||
- `name`: required, max length
|
||||
- `type`: required, in allowed values
|
||||
- `config.webhook_url`: required if type is teams; must be URL
|
||||
- `config.recipients`: required if type is email; array of valid email addresses; must be non-empty
|
||||
|
||||
Security:
|
||||
- `config` must never be logged or included in audit metadata.
|
||||
|
||||
## AlertRule (workspace-owned)
|
||||
|
||||
Purpose: routing + noise controls.
|
||||
|
||||
Fields:
|
||||
- `id`
|
||||
- `workspace_id` (FK, required)
|
||||
- `name` (string, required)
|
||||
- `is_enabled` (bool, default true)
|
||||
- `event_type` (enum: `high_drift` | `compare_failed` | `sla_due`, required)
|
||||
- `minimum_severity` (enum: `low` | `medium` | `high` | `critical`, required)
|
||||
- `tenant_scope_mode` (enum: `all` | `allowlist`, required)
|
||||
- `tenant_allowlist` (array, default empty)
|
||||
- `cooldown_seconds` (int, nullable)
|
||||
- `quiet_hours_enabled` (bool, default false)
|
||||
- `quiet_hours_start` (string, e.g. `22:00`, nullable)
|
||||
- `quiet_hours_end` (string, e.g. `06:00`, nullable)
|
||||
- `quiet_hours_timezone` (IANA TZ string, nullable)
|
||||
- timestamps
|
||||
|
||||
Validation rules:
|
||||
- `name`: required
|
||||
- `event_type`: required
|
||||
- `minimum_severity`: required
|
||||
- `tenant_allowlist`: required if `tenant_scope_mode=allowlist`
|
||||
- quiet hours:
|
||||
- if enabled: start/end required, valid HH:MM, timezone optional
|
||||
|
||||
Notes:
|
||||
- Quiet-hours timezone resolution:
|
||||
- rule timezone if set
|
||||
- else workspace timezone
|
||||
- else `config('app.timezone')`
|
||||
|
||||
## AlertRuleDestination (workspace-owned pivot)
|
||||
|
||||
Fields:
|
||||
- `id`
|
||||
- `workspace_id` (FK, required)
|
||||
- `alert_rule_id` (FK)
|
||||
- `alert_destination_id` (FK)
|
||||
- timestamps
|
||||
|
||||
Constraints:
|
||||
- Unique `(alert_rule_id, alert_destination_id)`
|
||||
|
||||
## AlertDelivery (tenant-owned history)
|
||||
|
||||
Purpose: immutable record of queued/sent/failed/deferred/suppressed deliveries.
|
||||
|
||||
Fields:
|
||||
- `id`
|
||||
- `workspace_id` (FK, required)
|
||||
- `tenant_id` (FK, required)
|
||||
- `alert_rule_id` (FK, required)
|
||||
- `alert_destination_id` (FK, required)
|
||||
- `fingerprint_hash` (string, required)
|
||||
- `status` (enum: `queued` | `deferred` | `sent` | `failed` | `suppressed` | `canceled`)
|
||||
- `send_after` (timestamp, nullable)
|
||||
- `attempt_count` (int, default 0)
|
||||
- `last_error_code` (string, nullable)
|
||||
- `last_error_message` (string, nullable; sanitized)
|
||||
- `sent_at` (timestamp, nullable)
|
||||
- timestamps
|
||||
|
||||
Indexes:
|
||||
- `(workspace_id, created_at)` for history listing
|
||||
- `(workspace_id, status, send_after)` for dispatching due deferred deliveries
|
||||
- `(workspace_id, alert_rule_id, fingerprint_hash)` for dedupe/cooldown checks
|
||||
|
||||
Retention:
|
||||
- Default prune: 90 days.
|
||||
|
||||
## Relationships
|
||||
|
||||
- `AlertRule` hasMany `AlertRuleDestination` and belongsToMany `AlertDestination`.
|
||||
- `AlertDestination` belongsToMany `AlertRule`.
|
||||
- `AlertDelivery` belongsTo `AlertRule`, belongsTo `AlertDestination`, and belongsTo `Tenant`.
|
||||
|
||||
## State transitions
|
||||
|
||||
`AlertDelivery.status` transitions:
|
||||
- `queued` → `sent` | `failed` | `suppressed` | `canceled`
|
||||
- `deferred` → `queued` (when window opens) → `sent` | `failed` …
|
||||
|
||||
Terminal states: `sent`, `failed`, `suppressed`, `canceled`.
|
||||
217
specs/099-alerts-v1-teams-email/plan.md
Normal file
217
specs/099-alerts-v1-teams-email/plan.md
Normal file
@ -0,0 +1,217 @@
|
||||
# Implementation Plan: 099 — Alerts v1 (Teams + Email)
|
||||
|
||||
**Branch**: `099-alerts-v1-teams-email` | **Date**: 2026-02-16 | **Spec**: `/specs/099-alerts-v1-teams-email/spec.md`
|
||||
**Input**: Feature specification from `/specs/099-alerts-v1-teams-email/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Implement workspace-scoped alerting with:
|
||||
|
||||
- **Destinations (Targets)**: Microsoft Teams incoming webhook and Email recipients.
|
||||
- **Rules**: route by event type, minimum severity, and tenant scope.
|
||||
- **Noise controls**: deterministic fingerprint dedupe, per-rule cooldown suppression, and quiet-hours deferral.
|
||||
- **Delivery history**: read-only, includes `suppressed` entries.
|
||||
|
||||
Delivery is queue-driven with bounded exponential backoff retries. All alert pages remain DB-only at render time and never expose destination secrets.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5 (Livewire v4.0+), Laravel Queue (database default)
|
||||
**Storage**: PostgreSQL (Sail)
|
||||
**Testing**: Pest v4 via `vendor/bin/sail artisan test --compact`
|
||||
**Target Platform**: Laravel web app (Filament Admin)
|
||||
**Project Type**: Web application
|
||||
**Performance Goals**: Eligible alerts delivered within ~2 minutes outside quiet hours (SC-002)
|
||||
**Constraints**:
|
||||
- DB-only rendering for Targets/Rules/Deliveries pages (FR-015)
|
||||
- No destination secrets in logs/audit payloads (FR-011)
|
||||
- Retries use exponential backoff + bounded max attempts (FR-017)
|
||||
**Scale/Scope**: Workspace-owned configuration + tenant-owned delivery history (90-day retention) (FR-016)
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- **Livewire/Filament**: Filament v5 implies Livewire v4.0+ (compliant).
|
||||
- **Provider registration**: No new panel provider required; existing registration remains in `bootstrap/providers.php`.
|
||||
- **RBAC semantics**: Enforce non-member → 404 (deny-as-not-found) and member missing capability → 403.
|
||||
- **Capability registry**: Add `ALERTS_VIEW` and `ALERTS_MANAGE` to canonical registry; role maps reference only registry constants.
|
||||
- **Destructive actions**: Deletes and other destructive-like actions use `->requiresConfirmation()` and execute via `->action(...)`.
|
||||
- **Run observability**: Scheduled/queued scanning + deliveries create/reuse `OperationRun` for Monitoring → Operations visibility.
|
||||
- **Safe logging**: Audit logging uses `WorkspaceAuditLogger` (sanitizes context) and never records webhook URLs / recipient lists.
|
||||
- **Global search**: No new global search surfaces are required for v1; if enabled later, resources must have Edit/View pages and remain workspace-safe.
|
||||
|
||||
Result: **PASS**, assuming the above constraints are implemented and covered by tests.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/099-alerts-v1-teams-email/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── tasks.md # created later by /speckit.tasks
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ └── Resources/
|
||||
├── Jobs/
|
||||
├── Models/
|
||||
├── Policies/
|
||||
├── Services/
|
||||
│ ├── Audit/
|
||||
│ ├── Auth/
|
||||
│ └── Settings/
|
||||
└── Support/
|
||||
├── Auth/
|
||||
└── Rbac/
|
||||
|
||||
database/
|
||||
└── migrations/
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
└── Unit/
|
||||
```
|
||||
|
||||
**Structure Decision**: Use standard Laravel + Filament discovery conventions. Add Eloquent models + migrations for workspace-owned alert configuration + tenant-owned alert deliveries, queue jobs for evaluation + delivery, and Filament Resources/Pages under the existing Admin panel.
|
||||
|
||||
## Phase 0 — Outline & Research (output: research.md)
|
||||
|
||||
Unknowns / decisions to lock:
|
||||
|
||||
- Teams delivery should use Laravel HTTP client (`Http::post()`) with timeouts and safe error capture.
|
||||
- Email delivery should use Laravel mail/notifications and be queued.
|
||||
- Quiet-hours timezone fallback: rule timezone if set; else workspace timezone; if no workspace timezone exists yet, fallback to `config('app.timezone')`.
|
||||
- Secrets storage: use encrypted casts (`encrypted` / `encrypted:array`) for webhook URLs and recipient lists.
|
||||
- Retries/backoff: use job `tries` and `backoff()` for exponential backoff with a max attempt cap.
|
||||
|
||||
## Phase 1 — Design & Contracts (outputs: data-model.md, contracts/*, quickstart.md)
|
||||
|
||||
### Data model
|
||||
|
||||
Workspace-owned entities:
|
||||
|
||||
- `AlertDestination`:
|
||||
- `workspace_id`
|
||||
- `name`
|
||||
- `type` (`teams_webhook` | `email`)
|
||||
- `is_enabled`
|
||||
- `config` (encrypted array; contains webhook URL or recipient list)
|
||||
|
||||
- `AlertRule`:
|
||||
- `workspace_id`
|
||||
- `name`
|
||||
- `is_enabled`
|
||||
- `event_type` (high_drift | compare_failed | sla_due)
|
||||
- `minimum_severity`
|
||||
- `tenant_scope_mode` (all | allowlist)
|
||||
- `tenant_allowlist` (array of tenant IDs)
|
||||
- `cooldown_seconds`
|
||||
- `quiet_hours_enabled`, `quiet_hours_start`, `quiet_hours_end`, `quiet_hours_timezone`
|
||||
|
||||
- `AlertRuleDestination` (pivot): `workspace_id`, `alert_rule_id`, `alert_destination_id`
|
||||
|
||||
- `AlertDelivery` (history):
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `alert_rule_id`, `alert_destination_id`
|
||||
- `fingerprint_hash`
|
||||
- `status` (queued | deferred | sent | failed | suppressed | canceled)
|
||||
- `send_after` (for quiet-hours deferral)
|
||||
- `attempt_count`, `last_error_code`, `last_error_message` (sanitized)
|
||||
- timestamps
|
||||
|
||||
Retention: prune deliveries older than 90 days (default).
|
||||
|
||||
### Contracts
|
||||
|
||||
Create explicit schema/contracts for:
|
||||
|
||||
- Alert rule/destination create/edit payloads (validation expectations)
|
||||
- Delivery record shape (what UI displays)
|
||||
- Domain event shapes used for fingerprinting (no secrets)
|
||||
|
||||
### Filament surfaces
|
||||
|
||||
- **Targets**: CRUD destinations. Confirm on delete. Never display secrets once saved.
|
||||
- **Rules**: CRUD rules, enable/disable. Confirm destructive actions.
|
||||
- **Deliveries**: read-only viewer.
|
||||
|
||||
RBAC enforcement:
|
||||
|
||||
- Page access: `ALERTS_VIEW`.
|
||||
- Mutations: `ALERTS_MANAGE`.
|
||||
- Non-member: deny-as-not-found (404) consistently.
|
||||
- Non-member: deny-as-not-found (404) consistently.
|
||||
- Deliveries are tenant-owned and MUST only be listed/viewable for tenants the actor is entitled to; non-entitled tenants are filtered and treated as not found (404 semantics).
|
||||
- If a tenant-context is active in the current session, the Deliveries view SHOULD default-filter to that tenant.
|
||||
|
||||
### Background processing (jobs + OperationRuns)
|
||||
|
||||
- `alerts.evaluate` run: scans for new triggering events and creates `AlertDelivery` rows (including `suppressed`).
|
||||
- `alerts.deliver` run: sends due deliveries (respecting `send_after`).
|
||||
|
||||
Trigger sources (repo-grounded):
|
||||
|
||||
- **High Drift**: derived from persisted drift findings (`Finding` records) with severity High/Critical where the finding is in `status=new` (unacknowledged). “Newly active/visible” means the finding first appears (a new `Finding` row is created), not that the same existing finding is re-alerted on every evaluation cycle.
|
||||
- **Compare Failed**: derived from failed drift-generation operations (`OperationRun` where `type = drift_generate_findings` and `outcome = failed`).
|
||||
- **SLA Due**: v1 implements this trigger as a safe no-op unless/until the underlying data model provides a due-date signal.
|
||||
|
||||
Scheduling convention:
|
||||
|
||||
- A scheduled console command (`tenantpilot:alerts:dispatch`) runs every minute (registered in `routes/console.php`) and dispatches the evaluate + deliver work idempotently.
|
||||
|
||||
Idempotency:
|
||||
|
||||
- Deterministic fingerprint; unique constraints where appropriate.
|
||||
- Delivery send job transitions statuses atomically; if already terminal (`sent`/`failed`/`canceled`), it no-ops.
|
||||
|
||||
### Audit logging
|
||||
|
||||
All destination/rule mutations log via `WorkspaceAuditLogger` with redacted metadata:
|
||||
|
||||
- Record IDs, names, types, enabled flags, rule criteria.
|
||||
- Never include webhook URLs or recipient lists.
|
||||
|
||||
## Phase 2 — Task Planning (outline; tasks.md comes next)
|
||||
|
||||
1) Capabilities & policies
|
||||
- Add `ALERTS_VIEW` / `ALERTS_MANAGE` to `App\Support\Auth\Capabilities`.
|
||||
- Update `WorkspaceRoleCapabilityMap`.
|
||||
- Add Policies for new models and enforce 404/403 semantics.
|
||||
|
||||
2) Migrations + models
|
||||
- Create migrations + Eloquent models for destinations/rules/pivot/deliveries.
|
||||
- Add encrypted casts and safe `$hidden` where appropriate.
|
||||
|
||||
3) Services
|
||||
- Fingerprint builder
|
||||
- Quiet hours evaluator
|
||||
- Dispatcher to create deliveries and enqueue send jobs
|
||||
|
||||
4) Jobs
|
||||
- Evaluate triggers job
|
||||
- Send delivery job with exponential backoff + max attempts
|
||||
|
||||
5) Filament UI
|
||||
- Implement Targets/Rules/Deliveries pages with action surfaces and confirmation.
|
||||
|
||||
6) Tests (Pest)
|
||||
- RBAC: 404 for non-members; 403 for members missing capability.
|
||||
- Cooldown/dedupe: persists `suppressed` delivery history.
|
||||
- Retry policy: transitions to `failed` after bounded attempts.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are required for this feature.
|
||||
55
specs/099-alerts-v1-teams-email/quickstart.md
Normal file
55
specs/099-alerts-v1-teams-email/quickstart.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Quickstart — 099 Alerts v1 (Teams + Email)
|
||||
|
||||
This quickstart is for developers working on the Alerts v1 implementation.
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Start Sail: `vendor/bin/sail up -d`
|
||||
- Install deps (if needed): `vendor/bin/sail composer install`
|
||||
|
||||
## Database
|
||||
|
||||
- Run migrations: `vendor/bin/sail artisan migrate`
|
||||
|
||||
## Queue
|
||||
|
||||
Alerts delivery is queued. Ensure a worker is running:
|
||||
|
||||
- `vendor/bin/sail artisan queue:work`
|
||||
|
||||
Default queue connection is `database` (see `config/queue.php`).
|
||||
|
||||
## Email configuration
|
||||
|
||||
Configure mail in `.env` (examples):
|
||||
|
||||
- `MAIL_MAILER=smtp`
|
||||
- `MAIL_HOST=...`
|
||||
- `MAIL_PORT=...`
|
||||
- `MAIL_USERNAME=...`
|
||||
- `MAIL_PASSWORD=...`
|
||||
- `MAIL_ENCRYPTION=tls`
|
||||
- `MAIL_FROM_ADDRESS=...`
|
||||
- `MAIL_FROM_NAME=TenantPilot`
|
||||
|
||||
## Teams webhook configuration
|
||||
|
||||
Create a Teams incoming webhook URL and store it via the Alerts → Targets UI.
|
||||
|
||||
Note: Webhook URLs are treated as secrets and must not appear in logs or audits.
|
||||
|
||||
## Running tests
|
||||
|
||||
Run focused tests as they’re added:
|
||||
|
||||
- `vendor/bin/sail artisan test --compact --filter=Alerts`
|
||||
|
||||
Or by file:
|
||||
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/...`
|
||||
|
||||
## Formatting
|
||||
|
||||
Before finalizing changes:
|
||||
|
||||
- `vendor/bin/sail bin pint --dirty`
|
||||
47
specs/099-alerts-v1-teams-email/research.md
Normal file
47
specs/099-alerts-v1-teams-email/research.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Research — 099 Alerts v1 (Teams + Email)
|
||||
|
||||
This document resolves the Phase 0 technical unknowns from the implementation plan.
|
||||
|
||||
## Decision: Delivery mechanism (Teams)
|
||||
|
||||
- **Decision**: Use Laravel HTTP client (`Illuminate\Support\Facades\Http`) to POST JSON to a Teams incoming webhook URL.
|
||||
- **Rationale**: The repo already uses the Laravel HTTP client for remote calls (e.g., Microsoft Graph). It provides timeouts and structured error handling, and it keeps delivery logic self-contained.
|
||||
- **Alternatives considered**:
|
||||
- Guzzle direct client: unnecessary since Laravel HTTP client is already available.
|
||||
- Third-party Teams SDK: adds dependency surface; avoid without explicit need.
|
||||
|
||||
## Decision: Delivery mechanism (Email)
|
||||
|
||||
- **Decision**: Use Laravel mail/notification delivery and queue it.
|
||||
- **Rationale**: Integrates with Laravel queue retries and provides a standard path for SMTP/mail providers.
|
||||
- **Alternatives considered**:
|
||||
- Direct SMTP calls: not aligned with framework patterns.
|
||||
|
||||
## Decision: Retry + backoff policy
|
||||
|
||||
- **Decision**: Implement bounded retries using queued jobs with exponential backoff.
|
||||
- **Rationale**: Matches spec FR-017 and constitution guidance for transient failure handling.
|
||||
- **Alternatives considered**:
|
||||
- Retry loops inline: violates “start surfaces enqueue-only” and increases request latency.
|
||||
|
||||
## Decision: Secrets storage and redaction
|
||||
|
||||
- **Decision**: Store destination configuration (webhook URL, recipient list) using Laravel encrypted casts (`encrypted` / `encrypted:array`), and ensure audit/log context is sanitized.
|
||||
- **Rationale**: The repo already uses encrypted casting for sensitive payloads (example: `ProviderCredential::$casts['payload' => 'encrypted:array']`). `WorkspaceAuditLogger` sanitizes metadata.
|
||||
- **Alternatives considered**:
|
||||
- Plaintext storage + hiding in UI: insufficient; secrets can leak to logs/DB dumps.
|
||||
|
||||
## Decision: Quiet-hours timezone fallback
|
||||
|
||||
- **Decision**:
|
||||
1) Use the rule’s `quiet_hours_timezone` when set.
|
||||
2) Else use a workspace-level timezone setting.
|
||||
3) If no workspace timezone exists yet, fallback to `config('app.timezone')`.
|
||||
- **Rationale**: Implements spec FR-009 while remaining robust if workspace timezone is not yet modeled as a first-class setting.
|
||||
- **Alternatives considered**:
|
||||
- Always `UTC`: contradicts the clarified requirement (workspace fallback).
|
||||
|
||||
## Notes / follow-ups
|
||||
|
||||
- No existing mail delivery usage was found in `app/` at planning time; v1 will introduce the first alert-specific email delivery path.
|
||||
- No existing Teams webhook sender was found; v1 will implement a minimal sender using Laravel HTTP client.
|
||||
206
specs/099-alerts-v1-teams-email/spec.md
Normal file
206
specs/099-alerts-v1-teams-email/spec.md
Normal file
@ -0,0 +1,206 @@
|
||||
# Feature Specification: Alerts v1 (Teams + Email)
|
||||
|
||||
**Feature Branch**: `099-alerts-v1-teams-email`
|
||||
**Created**: 2026-02-16
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Alerts v1 (Microsoft Teams + Email)"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**:
|
||||
- Admin UI → Workspace → Monitoring → Alerts → Alert targets
|
||||
- Admin UI → Workspace → Monitoring → Alerts → Alert rules
|
||||
- Admin UI → Workspace → Monitoring → Alerts → Alert deliveries (read-only)
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned alert configuration (rules + destinations)
|
||||
- Tenant-owned alert delivery history (deliveries are always tenant-scoped)
|
||||
- Deliveries are surfaced via workspace-context canonical UI routes, but MUST only reveal deliveries for tenants the actor is entitled to
|
||||
- **Authorization Planes**:
|
||||
- Admin panel `/admin` in **workspace-context** (workspace selected via session-based workspace context)
|
||||
- This feature does **not** introduce tenant-context UI routes (no `/admin/t/{tenant}/...` pages for Alerts v1)
|
||||
- **RBAC**:
|
||||
- Workspace membership is required for any access (non-members are denied as not found / 404)
|
||||
- Viewing alert configuration/history requires `ALERTS_VIEW`
|
||||
- Creating/updating/enabling/disabling/deleting rules or destinations requires `ALERTS_MANAGE`
|
||||
- Members without `ALERTS_VIEW` receive 403 for view-only access attempts
|
||||
- Members without `ALERTS_MANAGE` receive 403 for mutation attempts; UI surfaces are disabled for them
|
||||
- Viewing deliveries additionally requires tenant entitlement for each delivery’s tenant (non-entitled tenants are filtered and treated as not found / 404 semantics)
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-16
|
||||
|
||||
- Q: Should viewing the Alerts pages (Targets / Rules / Deliveries) require `ALERTS_VIEW`, or is workspace membership alone enough to view? → A: Viewing requires `ALERTS_VIEW` (members without it get 403).
|
||||
- Q: When an event is suppressed due to cooldown/dedupe, should the system still create an entry in the delivery history (status = `suppressed`)? → A: Yes, create delivery history entries with `status=suppressed` (no send attempted).
|
||||
- Q: Should `operation.compare_failed` fire only for the single canonical run type (baseline compare), or should v1 allow a per-rule run-type allowlist? → A: Fixed: only baseline compare failures (single canonical run type).
|
||||
- Q: For quiet hours evaluation, what timezone should be used when a rule does not specify a timezone? → A: Fallback to workspace timezone.
|
||||
- Q: When a Teams/email delivery attempt fails, which retry policy should v1 use? → A: Retry with exponential backoff up to a max attempts limit; then mark `failed`.
|
||||
|
||||
### Assumptions & Dependencies
|
||||
|
||||
- Drift findings and operational run outcomes already exist in the system and can be evaluated for alert triggers.
|
||||
- Events are attributable to a workspace and (where applicable) a tenant so rules can apply tenant scoping.
|
||||
- SLA-due alerts only apply if the underlying finding data includes a due date; otherwise this trigger is a no-op.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### User Story 1 - Configure alert destinations (Priority: P1)
|
||||
|
||||
As a workspace operator, I can define alert destinations (Microsoft Teams and/or email) that can later be reused by multiple alert rules.
|
||||
|
||||
**Why this priority**: Without destinations, alerts cannot be delivered; this is the smallest useful slice.
|
||||
|
||||
**Independent Test**: Create a destination, then confirm it is listed, viewable, and can be enabled/disabled.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I have `ALERTS_MANAGE`, **When** I create a Microsoft Teams destination with a name and webhook URL, **Then** the destination is saved and appears in the destinations list as enabled.
|
||||
2. **Given** I have `ALERTS_MANAGE`, **When** I create an Email destination with one or more recipient addresses, **Then** the destination is saved and appears in the destinations list.
|
||||
3. **Given** a destination exists, **When** I disable it, **Then** it is not used for future alert deliveries.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Configure alert routing rules (Priority: P2)
|
||||
|
||||
As a workspace manager, I can configure routing rules so that only relevant events (by type, severity, and tenant scope) generate alerts, and each rule can notify multiple destinations.
|
||||
|
||||
**Why this priority**: Rules provide control over noise, scope, and who gets notified.
|
||||
|
||||
**Independent Test**: Create a rule with at least one destination, then trigger one matching event and confirm exactly one delivery is queued per destination.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I have `ALERTS_MANAGE`, **When** I create a rule that matches a specific event type and minimum severity, **Then** the rule is saved and appears as enabled.
|
||||
2. **Given** I configure a rule with tenant scope = allowlist, **When** an event from a non-allowlisted tenant occurs, **Then** no delivery is created for that rule.
|
||||
3. **Given** a rule has multiple destinations assigned, **When** a matching event occurs, **Then** deliveries are created for each enabled destination.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Deliver alerts safely (dedupe, cooldown, quiet hours) and review history (Priority: P3)
|
||||
|
||||
As an operator, I receive timely notifications for important events without spam, and I can review what was sent (or failed) in a delivery history view.
|
||||
|
||||
**Why this priority**: Alert quality and traceability are essential for governance and incident response.
|
||||
|
||||
**Independent Test**: Trigger the same event twice within cooldown and confirm only one notification is sent; enable quiet hours and confirm delivery is deferred.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a rule has a cooldown configured, **When** the same event repeats within the cooldown window, **Then** later deliveries are suppressed for that rule.
|
||||
2. **Given** quiet hours are enabled for a rule and the current time is within quiet hours (evaluated in the rule’s configured timezone or workspace timezone fallback), **When** a matching event occurs, **Then** a delivery is scheduled for the next allowed window rather than sent immediately.
|
||||
3. **Given** I have `ALERTS_VIEW`, **When** I open the deliveries viewer, **Then** I can see delivery status and timestamps without exposing destination secrets.
|
||||
4. **Given** an event is suppressed by cooldown, **When** I open the deliveries viewer, **Then** I can see a `suppressed` delivery entry that references the rule and destination (without exposing destination secrets).
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Quiet hours windows that cross midnight must still defer correctly to the next allowed time.
|
||||
- Multiple background workers triggering the same event concurrently must not cause duplicate sends.
|
||||
- A destination that is misconfigured (invalid webhook URL or invalid email address list) must fail safely and record a sanitized failure reason (no secrets).
|
||||
- The UI must not make outbound network requests while rendering pages (no external calls during page load).
|
||||
- SLA-due alerts are a no-op if the underlying data does not provide a due date yet (no errors; no false alerts).
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
|
||||
- ensure any cross-plane access is deny-as-not-found (404),
|
||||
- explicitly define 404 vs 403 semantics:
|
||||
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
|
||||
- member but missing capability → 403
|
||||
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (Channels)**: The system MUST support alert delivery via Microsoft Teams (workspace-configured webhook destination) and via email (one or more recipient addresses per destination).
|
||||
- **FR-002 (Workspace scoping)**: The system MUST scope alert rules and destinations to a workspace; rules/destinations MUST NOT be shared across workspaces.
|
||||
- **FR-003 (Routing rules)**: The system MUST allow rules to filter alert generation by:
|
||||
- event type
|
||||
- minimum severity
|
||||
- tenant scope (all tenants or allowlist)
|
||||
- **FR-004 (Multiple destinations)**: The system MUST allow a rule to notify multiple destinations.
|
||||
- **FR-005 (Event triggers v1)**: The system MUST support these trigger types:
|
||||
- High Drift: when a new drift finding first appears (i.e., a new drift finding is created) with severity High or Critical, and it is in an unacknowledged/new state
|
||||
- Compare Failed: when a baseline-compare operation run fails (fixed in v1; not configurable per rule)
|
||||
- SLA Due: when a finding passes its due date and remains unresolved (if due date data is available)
|
||||
- **FR-006 (Idempotency / dedupe)**: The system MUST prevent duplicate notifications for repeated occurrences of the same event for a given rule, using a deterministic event fingerprint that contains no secrets.
|
||||
- **FR-007 (Cooldown)**: The system MUST support a per-rule cooldown window during which repeated fingerprints are suppressed.
|
||||
- **FR-007a (Suppression visibility)**: When a notification is suppressed by cooldown/dedupe, the system MUST persist an entry in delivery history with `status=suppressed`.
|
||||
- **FR-008 (Quiet hours)**: The system MUST support optional quiet hours per rule; events during quiet hours MUST be deferred to the next allowed time window.
|
||||
- **FR-009 (Quiet hours timezone)**: Quiet hours MUST be evaluated in the rule’s configured timezone; if not set, the system MUST fallback to the workspace timezone.
|
||||
- **FR-010 (Delivery history)**: The system MUST retain a delivery history view showing, at minimum: status (queued/deferred/sent/failed/suppressed/canceled), timestamps, event type, severity, tenant association, and the rule + destination used.
|
||||
- **FR-011 (Safe logging)**: The system MUST NOT persist destination secrets (webhook URLs, email recipient lists) in logs, error messages, or audit payloads.
|
||||
- **FR-012 (Auditability)**: The system MUST write auditable events for creation, updates, enable/disable, and deletion of rules and destinations.
|
||||
- **FR-013 (Operations observability)**: Background work that scans for due alerts and performs alert delivery MUST be observable as operations runs with outcome and timestamps, so operators can diagnose failures.
|
||||
- **FR-014 (RBAC semantics)**: Authorization MUST follow these semantics:
|
||||
- non-member / not entitled to workspace scope → 404
|
||||
- member missing `ALERTS_VIEW` → 403 for viewing alert pages
|
||||
- member missing `ALERTS_MANAGE` → 403 for create/update/delete/enable/disable
|
||||
- **FR-015 (DB-only rendering)**: Alert management and delivery history pages MUST render without any outbound network requests.
|
||||
- **FR-016 (Retention)**: Delivery history MUST be retained for 90 days by default.
|
||||
- **FR-017 (Delivery retries)**: On delivery failure (Teams/email), the system MUST retry with exponential backoff up to a bounded maximum attempt limit; once the limit is reached, the delivery MUST be marked `failed`.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||
|
||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Alert Targets | Workspace → Monitoring → Alerts → Alert targets | Create target | Clickable row to Edit | Edit, More (Enable/Disable, Delete) | More (group rendered; no bulk mutations in v1) | Create target | None | Save, Cancel | Yes | Delete requires confirmation; secrets never shown/logged |
|
||||
| Alert Rules | Workspace → Monitoring → Alerts → Alert rules | Create rule | Clickable row to Edit | Edit, More (Enable/Disable, Delete) | More (group rendered; no bulk mutations in v1) | Create rule | None | Save, Cancel | Yes | Enable/Disable and Delete are audited; both require confirmation |
|
||||
| Alert Deliveries (read-only) | Workspace → Monitoring → Alerts → Alert deliveries | None | Clickable row to View | View | None | None | None | N/A | No | Read-only viewer; tenant entitlement filtering enforced |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Alert Destination**: A workspace-defined place to send notifications (Teams or email), which can be enabled/disabled.
|
||||
- **Alert Rule**: A workspace-defined routing rule that decides which events should generate alerts and which destinations they should notify.
|
||||
- **Alert Event**: A notable system occurrence (e.g., high drift, compare failure, SLA due) that may generate alerts.
|
||||
- **Event Fingerprint**: A stable, deterministic identifier used to deduplicate repeated events per rule.
|
||||
- **Alert Delivery**: A record of a planned or attempted notification send, including scheduling (quiet hours), status, and timestamps.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001 (Setup time)**: A workspace manager can create a destination and a rule and enable it in under 5 minutes.
|
||||
- **SC-002 (Delivery timeliness)**: Outside quiet hours, at least 95% of eligible alerts are delivered within 2 minutes of the triggering event.
|
||||
- **SC-003 (Noise control)**: Within a configured cooldown window, the same fingerprint does not generate more than one notification per rule.
|
||||
- **SC-004 (Security hygiene)**: No destination secrets appear in application logs or audit payloads during normal operation or error cases.
|
||||
- **SC-005 (Audit traceability)**: 100% of rule/destination create/update/enable/disable/delete actions are traceable via an audit record.
|
||||
192
specs/099-alerts-v1-teams-email/tasks.md
Normal file
192
specs/099-alerts-v1-teams-email/tasks.md
Normal file
@ -0,0 +1,192 @@
|
||||
---
|
||||
|
||||
description: "Task list for 099 Alerts v1 (Teams + Email)"
|
||||
|
||||
---
|
||||
|
||||
# Tasks: 099 — Alerts v1 (Teams + Email)
|
||||
|
||||
**Input**: Design documents from `/specs/099-alerts-v1-teams-email/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
- Spec: `/specs/099-alerts-v1-teams-email/spec.md`
|
||||
- Plan: `/specs/099-alerts-v1-teams-email/plan.md`
|
||||
- Research: `/specs/099-alerts-v1-teams-email/research.md`
|
||||
- Data model: `/specs/099-alerts-v1-teams-email/data-model.md`
|
||||
- Contracts: `/specs/099-alerts-v1-teams-email/contracts/openapi.yaml`
|
||||
|
||||
**Tests**: REQUIRED (Pest) — this feature changes runtime behavior.
|
||||
**Operations**: Jobs that perform queued delivery MUST create/update canonical `OperationRun` records and show “View run” via the Monitoring hub.
|
||||
**RBAC**: Enforce 404 vs 403 semantics and use capability registry constants (no raw strings).
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Basic structure required by the plan
|
||||
|
||||
- [X] T001 Create alerts namespace directories via keep-files `app/Services/Alerts/.gitkeep` and `app/Jobs/Alerts/.gitkeep`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: MUST complete before any user story work begins
|
||||
|
||||
- [X] T002 Add Alerts capability constants to `app/Support/Auth/Capabilities.php` (`ALERTS_VIEW`, `ALERTS_MANAGE`)
|
||||
- [X] T003 Update role → capability mapping in `app/Services/Auth/WorkspaceRoleCapabilityMap.php` for Alerts view/manage
|
||||
- [X] T004 Add audit action IDs to `app/Support/Audit/AuditActionId.php` for Alerts (destination/rule create/update/delete + enable/disable)
|
||||
- [X] T005 [P] Add operation type labels in `app/Support/OperationCatalog.php` (`alerts.evaluate`, `alerts.deliver`)
|
||||
- [X] T006 [P] Implement workspace timezone resolver `app/Services/Alerts/WorkspaceTimezoneResolver.php` (fallback to `config('app.timezone')`)
|
||||
- [X] T007 Update the Alerts “UI Action Matrix” in `specs/099-alerts-v1-teams-email/spec.md` for Targets/Rules/Deliveries surfaces (ensure it matches actual Filament actions)
|
||||
|
||||
**Checkpoint**: Capabilities + audit IDs + operation type labels exist.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Configure alert destinations (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Workspace members with manage permission can create Teams/email destinations.
|
||||
|
||||
**Independent Test**: Create a Teams destination and an Email destination; ensure secrets are never revealed after save; list/edit/disable.
|
||||
|
||||
### Tests for User Story 1 ⚠️
|
||||
|
||||
- [X] T008 [P] [US1] Add RBAC access tests (404 vs 403) in `tests/Feature/Filament/Alerts/AlertDestinationAccessTest.php`
|
||||
- [X] T009 [P] [US1] Add destination CRUD happy-path test in `tests/Feature/Filament/Alerts/AlertDestinationCrudTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T010 [P] [US1] Create migration `database/migrations/*_create_alert_destinations_table.php` (workspace_id, type, is_enabled, encrypted config, timestamps)
|
||||
- [X] T011 [P] [US1] Create model `app/Models/AlertDestination.php` (workspace relationship; encrypted config cast; hide config)
|
||||
- [X] T012 [P] [US1] Create policy `app/Policies/AlertDestinationPolicy.php` (404 non-member; 403 missing capability)
|
||||
- [X] T013 [US1] Register policy mapping in `app/Providers/AuthServiceProvider.php`
|
||||
- [X] T014 [US1] Implement Filament resource `app/Filament/Resources/AlertDestinationResource.php` (Targets; action-surface contract; max 2 visible row actions)
|
||||
- [X] T015 [US1] Add Alerts navigation entry via Filament cluster (`app/Filament/Clusters/Monitoring/AlertsCluster.php`) and register Targets resource under it
|
||||
- [X] T016 [US1] Add audited mutations in `app/Filament/Resources/AlertDestinationResource.php` using `app/Services/Audit/WorkspaceAuditLogger.php` + `app/Support/Audit/AuditActionId.php` (no secrets)
|
||||
- [X] T017 [US1] Ensure destructive actions confirm in `app/Filament/Resources/AlertDestinationResource.php` (`->action(...)` + `->requiresConfirmation()`)
|
||||
|
||||
**Checkpoint**: Destinations are functional and testable independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Configure alert routing rules (Priority: P2)
|
||||
|
||||
**Goal**: Workspace members with manage permission can define routing rules that send to destination(s).
|
||||
|
||||
**Independent Test**: Create a rule, attach destinations, set tenant allowlist, and verify it is enforced during evaluation.
|
||||
|
||||
### Tests for User Story 2 ⚠️
|
||||
|
||||
- [X] T018 [P] [US2] Add RBAC access tests (404 vs 403) in `tests/Feature/Filament/Alerts/AlertRuleAccessTest.php`
|
||||
- [X] T019 [P] [US2] Add rule CRUD test (destinations attach) in `tests/Feature/Filament/Alerts/AlertRuleCrudTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T020 [P] [US2] Create migrations `database/migrations/*_create_alert_rules_table.php` and `database/migrations/*_create_alert_rule_destinations_table.php`
|
||||
- [X] T021 [P] [US2] Create models `app/Models/AlertRule.php` and `app/Models/AlertRuleDestination.php` (casts for allowlist + quiet-hours fields)
|
||||
- [X] T022 [P] [US2] Create policy `app/Policies/AlertRulePolicy.php` (404 non-member; 403 missing capability)
|
||||
- [X] T023 [US2] Register policy mapping in `app/Providers/AuthServiceProvider.php`
|
||||
- [X] T024 [US2] Implement Filament resource `app/Filament/Resources/AlertRuleResource.php` (Rules; action-surface contract; destination picker)
|
||||
- [X] T025 [US2] Implement enable/disable actions with audit logging in `app/Filament/Resources/AlertRuleResource.php` (use `->action(...)` and confirmations)
|
||||
- [X] T026 [US2] Register Rules resource under Alerts cluster navigation (no manual `NavigationItem` duplicates)
|
||||
|
||||
**Checkpoint**: Rules are functional and testable independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Deliver alerts safely + review delivery history (Priority: P3)
|
||||
|
||||
**Goal**: Queue-driven delivery with fingerprint dedupe, cooldown suppression, quiet-hours deferral, and a delivery history viewer.
|
||||
|
||||
**Independent Test**: Trigger same event twice within cooldown → only one send + one `suppressed` record; quiet-hours → `deferred`; failures retry with exponential backoff then `failed`.
|
||||
|
||||
### Tests for User Story 3 ⚠️
|
||||
|
||||
- [X] T027 [P] [US3] Add delivery viewer access test in `tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php`
|
||||
- [X] T028 [P] [US3] Add fingerprint/cooldown suppression test in `tests/Unit/Alerts/AlertSuppressionTest.php`
|
||||
- [X] T029 [P] [US3] Add quiet-hours deferral test in `tests/Unit/Alerts/AlertQuietHoursTest.php`
|
||||
- [X] T030 [P] [US3] Add retry/backoff terminal failure test in `tests/Unit/Alerts/AlertRetryPolicyTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T031 [P] [US3] Create deliveries migration `database/migrations/*_create_alert_deliveries_table.php` (workspace_id, tenant_id NOT NULL, status, fingerprint, send_after, attempt_count, last_error_code/message, indexes)
|
||||
- [X] T032 [P] [US3] Create model `app/Models/AlertDelivery.php` (statuses incl. `suppressed`; prunable retention = 90 days default)
|
||||
- [X] T033 [P] [US3] Create policy `app/Policies/AlertDeliveryPolicy.php` (view requires `ALERTS_VIEW`; enforce tenant entitlement; 404/403 semantics)
|
||||
- [X] T034 [US3] Register policy mapping in `app/Providers/AuthServiceProvider.php`
|
||||
- [X] T035 [P] [US3] Implement fingerprint + quiet-hours helpers `app/Services/Alerts/AlertFingerprintService.php` and `app/Services/Alerts/AlertQuietHoursService.php`
|
||||
- [X] T036 [US3] Implement dispatcher `app/Services/Alerts/AlertDispatchService.php` (creates delivery rows; writes `suppressed` rows) with repo-grounded trigger sources:
|
||||
- High Drift: from `Finding` (drift) severity high/critical where `status=new` (unacknowledged); “newly active/visible” means first appearance (new finding created)
|
||||
- Compare Failed: from failed `OperationRun` where `type=drift_generate_findings`
|
||||
- SLA Due: safe no-op until a due-date signal exists in persistence
|
||||
- [X] T037 [P] [US3] Implement Teams sender `app/Services/Alerts/TeamsWebhookSender.php` (Laravel HTTP client; no secret logging)
|
||||
- [X] T038 [P] [US3] Implement Email notification `app/Notifications/Alerts/EmailAlertNotification.php` (no secrets)
|
||||
- [X] T039 [P] [US3] Implement evaluate job `app/Jobs/Alerts/EvaluateAlertsJob.php` (creates deliveries; records `OperationRun` type `alerts.evaluate`)
|
||||
- [X] T040 [P] [US3] Create dispatch command `app/Console/Commands/TenantpilotDispatchAlerts.php` (`tenantpilot:alerts:dispatch`) that queues evaluation + delivery work idempotently
|
||||
- [X] T041 [P] [US3] Wire scheduler in `routes/console.php` to run `tenantpilot:alerts:dispatch` every minute with `->withoutOverlapping()`
|
||||
- [X] T042 [P] [US3] Implement delivery job `app/Jobs/Alerts/DeliverAlertsJob.php` (sends due deliveries; bounded retries + exponential backoff; records `OperationRun` type `alerts.deliver`)
|
||||
- [X] T043 [P] [US3] Implement send service `app/Services/Alerts/AlertSender.php` (shared send orchestration for Teams/email; safe error capture; no secret logging)
|
||||
- [X] T044 [US3] Add deliveries Filament resource `app/Filament/Resources/AlertDeliveryResource.php` (read-only; inspection affordance; no secrets; list/query must not reveal deliveries for non-entitled tenants)
|
||||
- [X] T045 [US3] Register Deliveries resource under Alerts cluster navigation (no manual `NavigationItem` duplicates)
|
||||
|
||||
**Checkpoint**: Delivery pipeline works with retries, suppression, quiet-hours deferral, and safe history.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T046 Update quickstart queue command in `specs/099-alerts-v1-teams-email/quickstart.md` (use `queue:work`)
|
||||
- [X] T047 Run formatting `vendor/bin/sail bin pint --dirty` on `app/**` and `tests/**`
|
||||
- [X] T048 Run focused tests `vendor/bin/sail artisan test --compact` for `tests/Feature/Filament/Alerts/**` and `tests/Unit/Alerts/**`
|
||||
|
||||
## Phase 7: Navigation UX (Monitoring)
|
||||
|
||||
- [X] T049 Restructure Alerts navigation under Monitoring (no Overview page; no content sub-navigation; Deliveries is default landing)
|
||||
- [X] T050 Update OperateHubShell tests to use Alerts cluster landing and follow redirects
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- Setup (Phase 1) → Foundational (Phase 2) → User stories → Polish
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- US1 → US2 → US3
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1
|
||||
|
||||
- Tests: T008 + T009
|
||||
- Model/migration/policy: T010 + T011 + T012
|
||||
|
||||
### US2
|
||||
|
||||
- Tests: T018 + T019
|
||||
- Model/migrations/policy: T020 + T021 + T022
|
||||
|
||||
### US3
|
||||
|
||||
- Tests: T027–T030
|
||||
- Building blocks: T031 + T032 + T035 + T037 + T038 + T039–T041
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1 + Phase 2
|
||||
2. Complete US1
|
||||
3. Validate via `tests/Feature/Filament/Alerts/AlertDestination*`
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
- Add US2 next, then US3; each story remains independently demoable.
|
||||
100
tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php
Normal file
100
tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php
Normal file
@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
|
||||
it('lists only deliveries for entitled tenants', function (): void {
|
||||
[$user, $tenantA] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
$workspaceId = (int) $tenantA->workspace_id;
|
||||
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
AlertDelivery::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'alert_rule_id' => (int) $rule->getKey(),
|
||||
'alert_destination_id' => (int) $destination->getKey(),
|
||||
'event_type' => 'high_drift',
|
||||
]);
|
||||
|
||||
AlertDelivery::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'alert_rule_id' => (int) $rule->getKey(),
|
||||
'alert_destination_id' => (int) $destination->getKey(),
|
||||
'event_type' => 'compare_failed',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(AlertDeliveryResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('high_drift')
|
||||
->assertDontSee('compare_failed');
|
||||
});
|
||||
|
||||
it('returns 404 when a member from another workspace tries to view a delivery', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $otherWorkspace->getKey(),
|
||||
]);
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => (int) $otherWorkspace->getKey(),
|
||||
]);
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => (int) $otherWorkspace->getKey(),
|
||||
]);
|
||||
$delivery = AlertDelivery::factory()->create([
|
||||
'workspace_id' => (int) $otherWorkspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'alert_rule_id' => (int) $rule->getKey(),
|
||||
'alert_destination_id' => (int) $destination->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 for members missing alerts view capability on deliveries index', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||
$resolver->shouldReceive('can')->andReturnFalse();
|
||||
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
||||
|
||||
session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(AlertDeliveryResource::getUrl(panel: 'admin'))
|
||||
->assertForbidden();
|
||||
});
|
||||
60
tests/Feature/Filament/Alerts/AlertDestinationAccessTest.php
Normal file
60
tests/Feature/Filament/Alerts/AlertDestinationAccessTest.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
|
||||
it('returns 404 when a workspace member tries to edit a destination from another workspace', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => (int) $otherWorkspace->getKey(),
|
||||
]);
|
||||
|
||||
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
|
||||
expect($workspaceId)->not->toBe(0);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(AlertDestinationResource::getUrl('edit', ['record' => $destination], panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 for members missing alerts view capability', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||
$resolver->shouldReceive('can')->andReturnFalse();
|
||||
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
||||
|
||||
session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(AlertDestinationResource::getUrl(panel: 'admin'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('allows members with alerts view but forbids create for members without alerts manage', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(AlertDestinationResource::getUrl(panel: 'admin'))
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(AlertDestinationResource::getUrl('create', panel: 'admin'))
|
||||
->assertForbidden();
|
||||
});
|
||||
67
tests/Feature/Filament/Alerts/AlertDestinationCrudTest.php
Normal file
67
tests/Feature/Filament/Alerts/AlertDestinationCrudTest.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Filament\Resources\AlertDestinationResource\Pages\CreateAlertDestination;
|
||||
use App\Filament\Resources\AlertDestinationResource\Pages\EditAlertDestination;
|
||||
use App\Models\AlertDestination;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('creates teams and email destinations, hides secrets, and allows edit/disable', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(CreateAlertDestination::class)
|
||||
->fillForm([
|
||||
'name' => 'Ops Teams',
|
||||
'type' => 'teams_webhook',
|
||||
'is_enabled' => true,
|
||||
'teams_webhook_url' => 'https://example.invalid/teams-webhook',
|
||||
])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
Livewire::test(CreateAlertDestination::class)
|
||||
->fillForm([
|
||||
'name' => 'Ops Email',
|
||||
'type' => 'email',
|
||||
'is_enabled' => true,
|
||||
'email_recipients' => ['ops@example.com', 'oncall@example.com'],
|
||||
])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
expect(AlertDestination::query()->count())->toBe(2);
|
||||
|
||||
$teams = AlertDestination::query()->where('name', 'Ops Teams')->first();
|
||||
$email = AlertDestination::query()->where('name', 'Ops Email')->first();
|
||||
|
||||
expect($teams)->not->toBeNull();
|
||||
expect($email)->not->toBeNull();
|
||||
|
||||
expect($teams->config['webhook_url'] ?? null)->toBe('https://example.invalid/teams-webhook');
|
||||
expect($email->config['recipients'] ?? null)->toBe(['ops@example.com', 'oncall@example.com']);
|
||||
|
||||
expect(array_key_exists('config', $teams->toArray()))->toBeFalse();
|
||||
expect(array_key_exists('config', $email->toArray()))->toBeFalse();
|
||||
|
||||
$this->get(AlertDestinationResource::getUrl('edit', ['record' => $teams], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertDontSee('https://example.invalid/teams-webhook');
|
||||
|
||||
Livewire::test(EditAlertDestination::class, ['record' => $teams->getRouteKey()])
|
||||
->fillForm([
|
||||
'name' => 'Ops Teams Updated',
|
||||
'is_enabled' => false,
|
||||
'teams_webhook_url' => '',
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$teams->refresh();
|
||||
|
||||
expect($teams->name)->toBe('Ops Teams Updated');
|
||||
expect((bool) $teams->is_enabled)->toBeFalse();
|
||||
});
|
||||
57
tests/Feature/Filament/Alerts/AlertRuleAccessTest.php
Normal file
57
tests/Feature/Filament/Alerts/AlertRuleAccessTest.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
|
||||
it('returns 404 when a workspace member tries to edit a rule from another workspace', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => (int) $otherWorkspace->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(AlertRuleResource::getUrl('edit', ['record' => $rule], panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 for members missing alerts view capability on rules index', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||
$resolver->shouldReceive('can')->andReturnFalse();
|
||||
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
||||
|
||||
session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('allows members with alerts view but forbids create for members without alerts manage', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(AlertRuleResource::getUrl('create', panel: 'admin'))
|
||||
->assertForbidden();
|
||||
});
|
||||
66
tests/Feature/Filament/Alerts/AlertRuleCrudTest.php
Normal file
66
tests/Feature/Filament/Alerts/AlertRuleCrudTest.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource\Pages\CreateAlertRule;
|
||||
use App\Filament\Resources\AlertRuleResource\Pages\EditAlertRule;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('creates and edits alert rules with attached destinations', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$destinationA = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'name' => 'Teams destination',
|
||||
]);
|
||||
$destinationB = AlertDestination::factory()->email()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'name' => 'Email destination',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(CreateAlertRule::class)
|
||||
->fillForm([
|
||||
'name' => 'Critical drift alerts',
|
||||
'is_enabled' => true,
|
||||
'event_type' => 'high_drift',
|
||||
'minimum_severity' => 'high',
|
||||
'tenant_scope_mode' => 'allowlist',
|
||||
'tenant_allowlist' => [(int) $tenant->getKey()],
|
||||
'cooldown_seconds' => 900,
|
||||
'quiet_hours_enabled' => true,
|
||||
'quiet_hours_start' => '22:00',
|
||||
'quiet_hours_end' => '06:00',
|
||||
'quiet_hours_timezone' => 'UTC',
|
||||
'destination_ids' => [(int) $destinationA->getKey(), (int) $destinationB->getKey()],
|
||||
])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$rule = AlertRule::query()->where('name', 'Critical drift alerts')->first();
|
||||
expect($rule)->not->toBeNull();
|
||||
expect($rule->tenant_allowlist)->toBe([(int) $tenant->getKey()]);
|
||||
expect($rule->destinations()->count())->toBe(2);
|
||||
|
||||
Livewire::test(EditAlertRule::class, ['record' => $rule->getRouteKey()])
|
||||
->fillForm([
|
||||
'name' => 'Critical drift alerts updated',
|
||||
'is_enabled' => false,
|
||||
'destination_ids' => [(int) $destinationB->getKey()],
|
||||
'tenant_allowlist' => [],
|
||||
'tenant_scope_mode' => 'all',
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$rule->refresh();
|
||||
expect($rule->name)->toBe('Critical drift alerts updated');
|
||||
expect((bool) $rule->is_enabled)->toBeFalse();
|
||||
expect($rule->tenant_scope_mode)->toBe('all');
|
||||
expect($rule->destinations()->pluck('alert_destinations.id')->all())->toBe([(int) $destinationB->getKey()]);
|
||||
});
|
||||
@ -38,8 +38,12 @@
|
||||
->assertOk();
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->followingRedirects()
|
||||
->get('/admin/alerts')
|
||||
->assertOk();
|
||||
->assertOk()
|
||||
->assertSee('Alert targets')
|
||||
->assertSee('Alert rules')
|
||||
->assertSee('Alert deliveries');
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/audit-log')
|
||||
|
||||
@ -148,6 +148,7 @@
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->followingRedirects()
|
||||
->get('/admin/alerts')
|
||||
->assertOk();
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\AuditLog;
|
||||
@ -47,7 +48,8 @@
|
||||
->assertSee('Scope: Workspace — all tenants');
|
||||
|
||||
$this->withSession($session)
|
||||
->get(route('admin.monitoring.alerts'))
|
||||
->followingRedirects()
|
||||
->get(AlertsCluster::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Scope: Workspace — all tenants');
|
||||
|
||||
@ -82,7 +84,13 @@
|
||||
->assertOk()
|
||||
->assertSee('← Back to '.$tenant->name)
|
||||
->assertSee(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), false)
|
||||
->assertSee('Show all operations')
|
||||
->assertDontSee('Back to Operations');
|
||||
|
||||
$this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])->followingRedirects()
|
||||
->get(AlertsCluster::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertDontSee('Back to Operations');
|
||||
|
||||
expect(substr_count((string) $response->getContent(), '← Back to '.$tenant->name))->toBe(1);
|
||||
@ -253,7 +261,8 @@
|
||||
->assertOk();
|
||||
|
||||
$this->withSession($session)
|
||||
->get(route('admin.monitoring.alerts'))
|
||||
->followingRedirects()
|
||||
->get(AlertsCluster::getUrl(panel: 'admin'))
|
||||
->assertOk();
|
||||
|
||||
$this->withSession($session)
|
||||
|
||||
63
tests/Unit/Alerts/AlertQuietHoursTest.php
Normal file
63
tests/Unit/Alerts/AlertQuietHoursTest.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('defers delivery during quiet hours', function (): void {
|
||||
$now = CarbonImmutable::parse('2026-02-16 23:30:00', 'UTC');
|
||||
CarbonImmutable::setTestNow($now);
|
||||
|
||||
try {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'type' => AlertDestination::TYPE_TEAMS_WEBHOOK,
|
||||
'config' => ['webhook_url' => 'https://example.invalid/hook'],
|
||||
]);
|
||||
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'event_type' => AlertRule::EVENT_HIGH_DRIFT,
|
||||
'minimum_severity' => 'high',
|
||||
'cooldown_seconds' => 0,
|
||||
'quiet_hours_enabled' => true,
|
||||
'quiet_hours_start' => '22:00',
|
||||
'quiet_hours_end' => '06:00',
|
||||
'quiet_hours_timezone' => 'UTC',
|
||||
]);
|
||||
$rule->destinations()->syncWithPivotValues([(int) $destination->getKey()], ['workspace_id' => $workspaceId]);
|
||||
|
||||
/** @var AlertDispatchService $dispatch */
|
||||
$dispatch = app(AlertDispatchService::class);
|
||||
|
||||
$dispatch->dispatchEvent($rule->workspace, [
|
||||
'event_type' => AlertRule::EVENT_HIGH_DRIFT,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => 'critical',
|
||||
'fingerprint_key' => 'finding:quiet-1',
|
||||
'title' => 'High drift detected',
|
||||
'body' => 'Quiet hours test',
|
||||
]);
|
||||
|
||||
$delivery = AlertDelivery::query()->where('workspace_id', $workspaceId)->first();
|
||||
expect($delivery)->not->toBeNull();
|
||||
expect($delivery->status)->toBe(AlertDelivery::STATUS_DEFERRED);
|
||||
expect($delivery->send_after)->not->toBeNull();
|
||||
expect($delivery->send_after?->greaterThan($now))->toBeTrue();
|
||||
} finally {
|
||||
CarbonImmutable::setTestNow();
|
||||
}
|
||||
});
|
||||
70
tests/Unit/Alerts/AlertRetryPolicyTest.php
Normal file
70
tests/Unit/Alerts/AlertRetryPolicyTest.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Alerts\DeliverAlertsJob;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Services\Alerts\AlertSender;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('marks delivery as failed after bounded retry attempts', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'type' => AlertDestination::TYPE_TEAMS_WEBHOOK,
|
||||
'config' => ['webhook_url' => 'https://example.invalid/hook'],
|
||||
]);
|
||||
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
$rule->destinations()->syncWithPivotValues([(int) $destination->getKey()], ['workspace_id' => $workspaceId]);
|
||||
|
||||
$delivery = AlertDelivery::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'alert_rule_id' => (int) $rule->getKey(),
|
||||
'alert_destination_id' => (int) $destination->getKey(),
|
||||
'status' => AlertDelivery::STATUS_QUEUED,
|
||||
'attempt_count' => 0,
|
||||
'send_after' => null,
|
||||
]);
|
||||
|
||||
$sender = \Mockery::mock(AlertSender::class);
|
||||
$sender->shouldReceive('send')
|
||||
->times(3)
|
||||
->andThrow(new RuntimeException('simulated sender failure'));
|
||||
app()->instance(AlertSender::class, $sender);
|
||||
|
||||
$runJob = static function (int $workspaceId): void {
|
||||
$job = new DeliverAlertsJob($workspaceId);
|
||||
app()->call([$job, 'handle']);
|
||||
};
|
||||
|
||||
$runJob($workspaceId);
|
||||
$delivery->refresh();
|
||||
expect($delivery->attempt_count)->toBe(1);
|
||||
expect($delivery->status)->toBe(AlertDelivery::STATUS_QUEUED);
|
||||
expect($delivery->send_after)->not->toBeNull();
|
||||
|
||||
$delivery->forceFill(['send_after' => now()->subSecond()])->save();
|
||||
$runJob($workspaceId);
|
||||
$delivery->refresh();
|
||||
expect($delivery->attempt_count)->toBe(2);
|
||||
expect($delivery->status)->toBe(AlertDelivery::STATUS_QUEUED);
|
||||
expect($delivery->send_after)->not->toBeNull();
|
||||
|
||||
$delivery->forceFill(['send_after' => now()->subSecond()])->save();
|
||||
$runJob($workspaceId);
|
||||
$delivery->refresh();
|
||||
expect($delivery->attempt_count)->toBe(3);
|
||||
expect($delivery->status)->toBe(AlertDelivery::STATUS_FAILED);
|
||||
expect($delivery->last_error_message)->toContain('simulated sender failure');
|
||||
});
|
||||
57
tests/Unit/Alerts/AlertSuppressionTest.php
Normal file
57
tests/Unit/Alerts/AlertSuppressionTest.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('writes a suppressed delivery when cooldown fingerprint matches', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'type' => AlertDestination::TYPE_TEAMS_WEBHOOK,
|
||||
'config' => ['webhook_url' => 'https://example.invalid/hook'],
|
||||
]);
|
||||
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'event_type' => AlertRule::EVENT_HIGH_DRIFT,
|
||||
'minimum_severity' => 'high',
|
||||
'cooldown_seconds' => 3600,
|
||||
]);
|
||||
$rule->destinations()->syncWithPivotValues([(int) $destination->getKey()], ['workspace_id' => $workspaceId]);
|
||||
|
||||
/** @var AlertDispatchService $dispatch */
|
||||
$dispatch = app(AlertDispatchService::class);
|
||||
|
||||
$event = [
|
||||
'event_type' => AlertRule::EVENT_HIGH_DRIFT,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => 'critical',
|
||||
'fingerprint_key' => 'finding:123',
|
||||
'title' => 'High drift detected',
|
||||
'body' => 'Test drift',
|
||||
];
|
||||
|
||||
$dispatch->dispatchEvent($rule->workspace, $event);
|
||||
$dispatch->dispatchEvent($rule->workspace, $event);
|
||||
|
||||
$deliveries = AlertDelivery::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
expect($deliveries)->toHaveCount(2);
|
||||
expect($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED);
|
||||
expect($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user