feat(110): Ops-UX enterprise start/dedup standard (repo-wide) (#134)
Implements Spec 110 Ops‑UX Enforcement and applies the repo‑wide “enterprise” standard for operation start + dedup surfaces. Key points - Start surfaces: only ephemeral queued toast (no DB notifications for started/queued/running). - Dedup paths: canonical “already queued” toast. - Progress refresh: dispatch run-enqueued browser event so the global widget updates immediately. - Completion: exactly-once terminal DB notification on completion (per Ops‑UX contract). Tests & formatting - Full suite: 1738 passed, 8 skipped (8477 assertions). - Pint: `vendor/bin/sail bin pint --dirty --format agent` (pass). Notable change - Removed legacy `RunStatusChangedNotification` (replaced by the terminal-only completion notification policy). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #134
This commit is contained in:
parent
9f5c99317b
commit
f13a4ce409
2
.github/agents/copilot-instructions.md
vendored
2
.github/agents/copilot-instructions.md
vendored
@ -58,8 +58,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 110-ops-ux-enforcement: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
||||
- 109-review-pack-export: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12
|
||||
- 109-review-pack-export: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
- 109-review-pack-export: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 1.8.2 → 1.9.0
|
||||
- Version change: 1.9.0 → 1.10.0
|
||||
- Modified principles:
|
||||
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (tightened UX requirements; added layout/view/empty-state rules)
|
||||
- Operations / Run Observability Standard (clarified as non-negotiable 3-surface contract; added lifecycle, summary, guards, system-run policy)
|
||||
- Added sections:
|
||||
- Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
- Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
|
||||
- OperationRun lifecycle is service-owned (OPS-UX-LC-001)
|
||||
- Summary counts contract (OPS-UX-SUM-001)
|
||||
- Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
|
||||
- Scheduled/system runs (OPS-UX-SYS-001)
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .specify/templates/plan-template.md
|
||||
@ -158,6 +162,72 @@ ### Operations / Run Observability Standard
|
||||
- Monitoring pages MUST be DB-only at render time (no external calls).
|
||||
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
|
||||
confirm + “View run”.
|
||||
|
||||
### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
|
||||
|
||||
If a feature creates/reuses `OperationRun`, it MUST use exactly three feedback surfaces — no others:
|
||||
|
||||
1) Toast (intent only / queued-only)
|
||||
- A toast MAY be shown only when the run is accepted/queued (intent feedback).
|
||||
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
|
||||
- Feature code MUST NOT craft ad-hoc operation toasts.
|
||||
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
|
||||
|
||||
2) Progress (active awareness only)
|
||||
- Live progress MUST exist only in:
|
||||
- the global active-ops widget, and
|
||||
- Monitoring → Operation Run Detail.
|
||||
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
|
||||
- Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
|
||||
- Determinate progress MUST be clamped to 0–100. Otherwise render indeterminate + elapsed time.
|
||||
- The widget MUST NOT show percentage text (optional `processed/total` is allowed).
|
||||
|
||||
3) Terminal DB Notification (audit outcome only)
|
||||
- Each run MUST emit exactly one persistent terminal DB notification when it becomes terminal.
|
||||
- Delivery MUST be initiator-only (no tenant-wide fan-out).
|
||||
- Completion notifications MUST be `OperationRunCompleted` only.
|
||||
- Feature code MUST NOT send custom completion DB notifications for operations (no `sendToDatabase()` for completion/abort).
|
||||
|
||||
Canonical navigation:
|
||||
- All “View run” links MUST use the canonical helper and MUST point to Monitoring → Operations → Run Detail.
|
||||
|
||||
### OperationRun lifecycle is service-owned (OPS-UX-LC-001)
|
||||
|
||||
Any change to `OperationRun.status` or `OperationRun.outcome` MUST go through `OperationRunService` (canonical transition method).
|
||||
This is the only allowed path because it enforces normalization, summary sanitization, idempotency, and terminal notification emission.
|
||||
|
||||
Forbidden outside `OperationRunService`:
|
||||
- `$operationRun->update(['status' => ...])` / `$operationRun->update(['outcome' => ...])`
|
||||
- `$operationRun->status = ...` / `$operationRun->outcome = ...`
|
||||
- Query-based updates that transition `status`/`outcome`
|
||||
|
||||
Allowed outside the service:
|
||||
- Updates to `context`, `message`, `reason_code` that do not change `status`/`outcome`.
|
||||
|
||||
### Summary counts contract (OPS-UX-SUM-001)
|
||||
|
||||
- `operation_runs.summary_counts` is the canonical metrics source for Ops-UX.
|
||||
- All keys MUST come from `OperationSummaryKeys::all()` (single source of truth).
|
||||
- Values MUST be flat numeric-only; no nested objects/arrays; no free-text.
|
||||
- Producers MUST NOT introduce new keys without:
|
||||
1) updating `OperationSummaryKeys::all()`,
|
||||
2) updating the spec canonical list,
|
||||
3) adding/adjusting tests.
|
||||
|
||||
### Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
|
||||
|
||||
The repo MUST include automated guards (Pest) that fail CI if:
|
||||
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
|
||||
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
|
||||
- deprecated legacy operation notification classes are referenced again.
|
||||
|
||||
These guards MUST fail with actionable output (file + snippet).
|
||||
|
||||
### Scheduled/system runs (OPS-UX-SYS-001)
|
||||
|
||||
- If a run has no initiator user, no terminal DB notification is emitted (initiator-only policy).
|
||||
- Outcomes remain auditable via Monitoring → Operations / Run Detail.
|
||||
- Any tenant-wide alerting MUST go through the Alerts system (not `OperationRun` notifications).
|
||||
- Active-run dedupe MUST be enforced at DB level (partial unique index/constraint for active states).
|
||||
- Failures MUST be stored as stable reason codes + sanitized messages; never persist secrets/tokens/PII/raw payload dumps
|
||||
in failures or notifications.
|
||||
@ -274,4 +344,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 1.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-19
|
||||
**Version**: 1.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-23
|
||||
|
||||
@ -41,6 +41,11 @@ ## Constitution Check
|
||||
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
||||
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
||||
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
|
||||
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
|
||||
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
|
||||
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
|
||||
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||
|
||||
@ -94,6 +94,13 @@ ## Requirements *(mandatory)*
|
||||
(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 (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
||||
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
||||
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
||||
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
|
||||
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
|
||||
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
|
||||
|
||||
**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),
|
||||
|
||||
@ -14,6 +14,13 @@ # Tasks: [FEATURE NAME]
|
||||
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
|
||||
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
|
||||
without an `OperationRun`.
|
||||
If this feature creates/reuses an `OperationRun`, tasks MUST also include:
|
||||
- enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only),
|
||||
- ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code),
|
||||
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
|
||||
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
|
||||
- adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress,
|
||||
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system).
|
||||
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
||||
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
||||
- explicit 404 vs 403 semantics:
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
@ -243,10 +245,14 @@ private function compareNowAction(): Action
|
||||
|
||||
$this->state = 'comparing';
|
||||
|
||||
Notification::make()
|
||||
->title('Baseline comparison started')
|
||||
->body('A background job will compute drift against the baseline snapshot.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : 'baseline_compare')
|
||||
->actions($run instanceof OperationRun ? [
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($run, $tenant)),
|
||||
] : [])
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
@ -20,7 +20,6 @@
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
@ -240,10 +239,8 @@ public function mount(): void
|
||||
$this->state = 'generating';
|
||||
|
||||
if (! $opRun->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->title('Drift generation already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -33,6 +33,8 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Verification\VerificationCheckStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
@ -1444,6 +1446,8 @@ public function startVerification(): void
|
||||
);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active run to finish.')
|
||||
@ -1501,23 +1505,27 @@ public function startVerification(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started')
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
||||
]);
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
$notification
|
||||
->body('A verification run is already queued or running.')
|
||||
->warning();
|
||||
} else {
|
||||
$notification->success();
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
public function refreshVerificationStatus(): void
|
||||
@ -1609,7 +1617,7 @@ public function startBootstrap(array $operationTypes): void
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var array{status: 'started', runs: array<string, int>}|array{status: 'scope_busy', run: OperationRun} $result */
|
||||
/** @var array{status: 'started', runs: array<string, int>, created: array<string, bool>}|array{status: 'scope_busy', run: OperationRun} $result */
|
||||
$result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array {
|
||||
$lockedConnection = ProviderConnection::query()
|
||||
->whereKey($connection->getKey())
|
||||
@ -1633,6 +1641,7 @@ public function startBootstrap(array $operationTypes): void
|
||||
$runsService = app(OperationRunService::class);
|
||||
|
||||
$bootstrapRuns = [];
|
||||
$bootstrapCreated = [];
|
||||
|
||||
foreach ($types as $operationType) {
|
||||
$definition = $registry->get($operationType);
|
||||
@ -1671,15 +1680,19 @@ public function startBootstrap(array $operationTypes): void
|
||||
}
|
||||
|
||||
$bootstrapRuns[$operationType] = (int) $run->getKey();
|
||||
$bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'started',
|
||||
'runs' => $bootstrapRuns,
|
||||
'created' => $bootstrapCreated,
|
||||
];
|
||||
});
|
||||
|
||||
if ($result['status'] === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active run to finish.')
|
||||
@ -1711,10 +1724,27 @@ public function startBootstrap(array $operationTypes): void
|
||||
$this->onboardingSession->save();
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Bootstrap started')
|
||||
->success()
|
||||
->send();
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
foreach ($types as $operationType) {
|
||||
$runId = (int) ($bootstrapRuns[$operationType] ?? 0);
|
||||
$runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null;
|
||||
$wasCreated = (bool) ($result['created'][$operationType] ?? false);
|
||||
|
||||
$toast = $wasCreated
|
||||
? OperationUxPresenter::queuedToast($operationType)
|
||||
: OperationUxPresenter::alreadyQueuedToast($operationType);
|
||||
|
||||
if ($runUrl !== null) {
|
||||
$toast->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchBootstrapJob(
|
||||
|
||||
@ -765,7 +765,7 @@ public static function table(Table $table): Table
|
||||
Action::make('view_runs')
|
||||
->label('View in Operations')
|
||||
->url(OperationRunLinks::index($tenant)),
|
||||
])->sendToDatabase($user);
|
||||
]);
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
@ -862,7 +862,7 @@ public static function table(Table $table): Table
|
||||
Action::make('view_runs')
|
||||
->label('View in Operations')
|
||||
->url(OperationRunLinks::index($tenant)),
|
||||
])->sendToDatabase($user);
|
||||
]);
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
@ -105,10 +104,9 @@ public function table(Table $table): Table
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Removal already queued')
|
||||
->body('A matching remove operation is already queued or running.')
|
||||
->info()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -196,10 +194,9 @@ public function table(Table $table): Table
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Removal already queued')
|
||||
->body('A matching remove operation is already queued or running.')
|
||||
->info()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -11,6 +11,9 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\BaselineCaptureService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\EditAction;
|
||||
@ -88,10 +91,36 @@ private function captureAction(): Action
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Capture enqueued')
|
||||
->body('Baseline snapshot capture has been started.')
|
||||
->success()
|
||||
$run = $result['run'] ?? null;
|
||||
|
||||
if (! $run instanceof \App\Models\OperationRun) {
|
||||
Notification::make()
|
||||
->title('Cannot start capture')
|
||||
->body('Reason: missing operation run')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$viewAction = Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($run, $sourceTenant));
|
||||
|
||||
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,9 +10,10 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEntraGroups extends ListRecords
|
||||
@ -57,10 +58,8 @@ protected function getHeaderActions(): array
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||
Notification::make()
|
||||
->title('Group sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View Run')
|
||||
@ -80,16 +79,13 @@ protected function getHeaderActions(): array
|
||||
operationRun: $opRun
|
||||
));
|
||||
|
||||
Notification::make()
|
||||
->title('Group sync started')
|
||||
->body('Sync dispatched.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
})
|
||||
)
|
||||
|
||||
@ -167,10 +167,7 @@ protected function getHeaderActions(): array
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Inventory sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View Run')
|
||||
|
||||
@ -471,10 +471,8 @@ public static function table(Table $table): Table
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -603,7 +601,7 @@ public static function table(Table $table): Table
|
||||
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records): void {
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
@ -643,19 +641,30 @@ public static function table(Table $table): Table
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Policy delete queued')
|
||||
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->body("Queued deletion for {$count} policies.")
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->body("Queued deletion for {$count} policies.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
->url($runUrl),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
@ -730,18 +739,6 @@ public static function table(Table $table): Table
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
if ($count >= 20) {
|
||||
Notification::make()
|
||||
->title('Bulk restore started')
|
||||
->body("Restoring {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
@ -803,10 +800,8 @@ public static function table(Table $table): Table
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -900,18 +895,6 @@ public static function table(Table $table): Table
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
if ($count >= 20) {
|
||||
Notification::make()
|
||||
->title('Bulk export started')
|
||||
->body("Exporting {$count} policies to backup '{$data['backup_name']}' in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPolicies extends ListRecords
|
||||
@ -68,10 +67,8 @@ private function makeSyncAction(): Actions\Action
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -303,20 +303,6 @@ public static function table(Table $table): Table
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version prune queued')
|
||||
->body("Queued prune for {$count} policy versions.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($initiator);
|
||||
|
||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
@ -476,20 +462,6 @@ public static function table(Table $table): Table
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version force delete queued')
|
||||
->body("Queued force delete for {$count} policy versions.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($initiator);
|
||||
|
||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -586,7 +588,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, StartVerification $verification): void {
|
||||
->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
@ -623,10 +625,9 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('A connection check is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -662,10 +663,9 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Connection check queued')
|
||||
->body('Health check was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -684,7 +684,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('info')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
@ -725,10 +725,9 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('An inventory sync is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -758,10 +757,9 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync queued')
|
||||
->body('Inventory sync was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -780,7 +778,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('info')
|
||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantForRecord($record);
|
||||
$user = auth()->user();
|
||||
|
||||
@ -821,10 +819,9 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('A compliance snapshot is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -854,10 +851,9 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Compliance snapshot queued')
|
||||
->body('Compliance snapshot was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
use App\Services\Verification\StartVerification;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\Action;
|
||||
@ -254,10 +256,9 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('A connection check is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -293,10 +294,9 @@ protected function getHeaderActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Connection check queued')
|
||||
->body('Health check was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -493,10 +493,9 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('An inventory sync is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -526,10 +525,9 @@ protected function getHeaderActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync queued')
|
||||
->body('Inventory sync was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -606,10 +604,9 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('A compliance snapshot is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -639,10 +636,9 @@ protected function getHeaderActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Compliance snapshot queued')
|
||||
->body('Compliance snapshot was queued and will run in the background.')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -1535,11 +1535,23 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
->title('Restore already queued')
|
||||
->body('Reusing the active restore run.')
|
||||
->info()
|
||||
->send();
|
||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||
$existingOpRun = $existingOpRunId > 0
|
||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||
: null;
|
||||
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||
->body('Reusing the active restore run.');
|
||||
|
||||
if ($existingOpRun) {
|
||||
$toast->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
|
||||
return $existing;
|
||||
}
|
||||
@ -1561,11 +1573,23 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
->title('Restore already queued')
|
||||
->body('Reusing the active restore run.')
|
||||
->info()
|
||||
->send();
|
||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||
$existingOpRun = $existingOpRunId > 0
|
||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||
: null;
|
||||
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||
->body('Reusing the active restore run.');
|
||||
|
||||
if ($existingOpRun) {
|
||||
$toast->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
|
||||
return $existing;
|
||||
}
|
||||
@ -1904,11 +1928,25 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
->title('Restore already queued')
|
||||
->body('Reusing the active restore run.')
|
||||
->info()
|
||||
->send();
|
||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||
$existingOpRun = $existingOpRunId > 0
|
||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||
: null;
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||
->body('Reusing the active restore run.');
|
||||
|
||||
if ($existingOpRun) {
|
||||
$toast->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -1930,11 +1968,25 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
->title('Restore already queued')
|
||||
->body('Reusing the active restore run.')
|
||||
->info()
|
||||
->send();
|
||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
||||
$existingOpRun = $existingOpRunId > 0
|
||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
||||
: null;
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
||||
->body('Reusing the active restore run.');
|
||||
|
||||
if ($existingOpRun) {
|
||||
$toast->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -354,10 +354,8 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View Run')
|
||||
@ -472,6 +470,7 @@ public static function table(Table $table): Table
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
StartVerification $verification,
|
||||
\Filament\Tables\Contracts\HasTable $livewire,
|
||||
): void {
|
||||
$user = auth()->user();
|
||||
|
||||
@ -496,6 +495,8 @@ public static function table(Table $table): Table
|
||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active run to finish.')
|
||||
@ -511,10 +512,9 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Verification already running')
|
||||
->body('A verification run is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -568,9 +568,9 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Verification started')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -753,7 +753,6 @@ public static function table(Table $table): Table
|
||||
->body('No eligible tenants selected.')
|
||||
->icon('heroicon-o-information-circle')
|
||||
->info()
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
return;
|
||||
@ -1606,10 +1605,7 @@ public static function syncRoleDefinitionsAction(): Actions\Action
|
||||
$runUrl = OperationRunLinks::tenantlessView($opRun);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Role definitions sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -107,6 +109,8 @@ protected function getHeaderActions(): array
|
||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active run to finish.')
|
||||
@ -122,10 +126,9 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Verification already running')
|
||||
->body('A verification run is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -179,9 +182,9 @@ protected function getHeaderActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Verification started')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -228,10 +231,9 @@ protected function getHeaderActions(): array
|
||||
$runUrl = OperationRunLinks::tenantlessView($opRun);
|
||||
|
||||
if ($opRun->wasRecentlyCreated === false) {
|
||||
Notification::make()
|
||||
->title('RBAC health check already running')
|
||||
->body('A check is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -248,9 +250,9 @@ protected function getHeaderActions(): array
|
||||
$opRun,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('RBAC health check started')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -8,9 +8,13 @@
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class AdminRolesSummaryWidget extends Widget
|
||||
@ -54,16 +58,56 @@ public function scanNow(): void
|
||||
abort(403);
|
||||
}
|
||||
|
||||
ScanEntraAdminRolesJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
/** @var OperationRunService $operationRuns */
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
|
||||
$opRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'entra.admin_roles.scan',
|
||||
identityInputs: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'trigger' => 'scan',
|
||||
],
|
||||
context: [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiator_user_id' => (int) $user->getKey(),
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Entra admin roles scan queued')
|
||||
$runUrl = OperationRunLinks::tenantlessView($opRun);
|
||||
|
||||
if ($opRun->wasRecentlyCreated === false) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void {
|
||||
ScanEntraAdminRolesJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->body('The scan will run in the background. Results appear once complete.')
|
||||
->success()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
|
||||
@ -4,14 +4,19 @@
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class TenantReviewPackCard extends Widget
|
||||
@ -58,26 +63,53 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
if ($service->checkActiveRun($tenant)) {
|
||||
Notification::make()
|
||||
->title('Generation already in progress')
|
||||
->body('A review pack is currently being generated for this tenant.')
|
||||
->warning()
|
||||
$activeRun = $service->checkActiveRun($tenant)
|
||||
? OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||
->active()
|
||||
->orderByDesc('id')
|
||||
->first()
|
||||
: null;
|
||||
|
||||
if ($activeRun) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $activeRun->type)
|
||||
->body('A review pack is already queued or running for this tenant.')
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::tenantlessView($activeRun)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service->generate($tenant, $user, [
|
||||
$reviewPack = $service->generate($tenant, $user, [
|
||||
'include_pii' => $includePii,
|
||||
'include_operations' => $includeOperations,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Review pack generation started')
|
||||
->body('The pack will be generated in the background. You will be notified when it is ready.')
|
||||
->success()
|
||||
->send();
|
||||
$runUrl = $reviewPack->operationRun
|
||||
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
|
||||
: null;
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
$toast = OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
||||
->body('The pack will be generated in the background. You will be notified when it is ready.');
|
||||
|
||||
if ($runUrl !== null) {
|
||||
$toast->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
$toast->send();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -13,6 +13,8 @@
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -68,6 +70,8 @@ public function startVerification(StartVerification $verification): void
|
||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||
|
||||
if ($result->status === 'scope_busy') {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
Notification::make()
|
||||
->title('Another operation is already running')
|
||||
->body('Please wait for the active run to finish.')
|
||||
@ -83,10 +87,9 @@ public function startVerification(StartVerification $verification): void
|
||||
}
|
||||
|
||||
if ($result->status === 'deduped') {
|
||||
Notification::make()
|
||||
->title('Verification already running')
|
||||
->body('A verification run is already queued or running.')
|
||||
->warning()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
@ -140,9 +143,9 @@ public function startVerification(StartVerification $verification): void
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Verification started')
|
||||
->success()
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -13,9 +13,7 @@
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\Intune\SnapshotValidator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
@ -465,48 +463,6 @@ public function handle(
|
||||
failures: $runFailuresForOperationRun,
|
||||
);
|
||||
|
||||
if (! $initiator instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = "Added {$succeeded} policies";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if ($includeFoundations) {
|
||||
$message .= ". Foundations: {$foundationMutations} items";
|
||||
|
||||
if ($foundationFailures > 0) {
|
||||
$message .= " ({$foundationFailures} failed)";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
$partial = $outcome === 'partially_succeeded' || $foundationFailures > 0;
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed')
|
||||
->body($message)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
]);
|
||||
|
||||
if ($partial) {
|
||||
$notification->warning();
|
||||
} else {
|
||||
$notification->success();
|
||||
}
|
||||
|
||||
$notification
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
} catch (Throwable $throwable) {
|
||||
$this->failRun(
|
||||
operationRunService: $operationRunService,
|
||||
@ -554,31 +510,6 @@ private function failRun(
|
||||
]],
|
||||
);
|
||||
|
||||
$this->notifyRunFailed($initiator, $tenant, $safeMessage);
|
||||
}
|
||||
|
||||
private function notifyRunFailed(?User $initiator, ?Tenant $tenant, string $reason): void
|
||||
{
|
||||
if (! $initiator instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Add Policies Failed')
|
||||
->body($reason);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$notification->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$notification
|
||||
->danger()
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
}
|
||||
|
||||
private function mapGraphFailureReasonCode(?int $status): string
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -20,7 +21,7 @@ class ApplyBackupScheduleRetentionJob implements ShouldQueue
|
||||
|
||||
public function __construct(public int $backupScheduleId) {}
|
||||
|
||||
public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResolver): void
|
||||
public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResolver, OperationRunService $operationRunService): void
|
||||
{
|
||||
$schedule = BackupSchedule::query()
|
||||
->with(['tenant.workspace'])
|
||||
@ -132,18 +133,18 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol
|
||||
});
|
||||
}
|
||||
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'summary_counts' => [
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'total' => (int) $deleteBackupSetIds->count(),
|
||||
'processed' => (int) $deleteBackupSetIds->count(),
|
||||
'succeeded' => $deletedCount,
|
||||
'failed' => max(0, (int) $deleteBackupSetIds->count() - $deletedCount),
|
||||
'updated' => $deletedCount,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $schedule->tenant,
|
||||
|
||||
@ -10,10 +10,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -116,21 +114,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -165,21 +148,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -229,21 +197,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -278,27 +231,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($succeeded > 0 || $failed > 0) {
|
||||
$message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'";
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Export Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
@ -311,21 +243,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($user) && $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Failed')
|
||||
->body($e->getMessage())
|
||||
->icon('heroicon-o-x-circle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,10 +8,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -129,32 +127,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
$message = "Restored {$succeeded} policies";
|
||||
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Restore Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
@ -167,21 +139,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($user) && $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Failed')
|
||||
->body($e->getMessage())
|
||||
->icon('heroicon-o-x-circle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,10 +8,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -104,21 +102,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -158,21 +141,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -205,39 +173,5 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
$message = "Force deleted {$succeeded} restore runs";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,10 +8,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -103,21 +101,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -156,21 +139,6 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -202,39 +170,5 @@ public function handle(OperationRunService $operationRunService): void
|
||||
);
|
||||
}
|
||||
|
||||
$message = "Restored {$succeeded} restore runs";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Restore Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,6 @@
|
||||
use App\Listeners\SyncRestoreRunToOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\User;
|
||||
use App\Notifications\RunStatusChangedNotification;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
@ -60,7 +58,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notifyStatus($restoreRun, 'queued');
|
||||
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun);
|
||||
|
||||
$tenant = $restoreRun->tenant;
|
||||
@ -75,8 +72,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
|
||||
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
|
||||
|
||||
$this->notifyStatus($restoreRun->refresh(), 'failed');
|
||||
|
||||
if ($tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -134,8 +129,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
// code performs restore-run updates without firing model events.
|
||||
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
|
||||
|
||||
$this->notifyStatus($restoreRun->refresh(), 'running');
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'restore.started',
|
||||
@ -163,7 +156,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
|
||||
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
|
||||
|
||||
$this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status);
|
||||
} catch (Throwable $throwable) {
|
||||
$restoreRun->refresh();
|
||||
|
||||
@ -179,8 +171,6 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
|
||||
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
|
||||
|
||||
$this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status);
|
||||
|
||||
if ($tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -203,45 +193,4 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyStatus(RestoreRun $restoreRun, string $status): void
|
||||
{
|
||||
$email = $this->actorEmail;
|
||||
|
||||
if (! is_string($email) || $email === '') {
|
||||
$email = is_string($restoreRun->requested_by) ? $restoreRun->requested_by : null;
|
||||
}
|
||||
|
||||
if (! is_string($email) || $email === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
|
||||
$counts = [];
|
||||
|
||||
foreach (['total', 'succeeded', 'failed', 'skipped'] as $key) {
|
||||
if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) {
|
||||
$counts[$key] = (int) $metadata[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'tenant_id' => (int) $restoreRun->tenant_id,
|
||||
'run_type' => 'restore',
|
||||
'run_id' => (int) $restoreRun->getKey(),
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
if ($counts !== []) {
|
||||
$payload['counts'] = $counts;
|
||||
}
|
||||
|
||||
$user->notify(new RunStatusChangedNotification($payload));
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,9 +10,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -180,14 +178,6 @@ public function handle(
|
||||
);
|
||||
}
|
||||
|
||||
$this->notifyCompleted(
|
||||
initiator: $initiator,
|
||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||
removed: $removed,
|
||||
requested: $requestedCount,
|
||||
missing: count($missingIds),
|
||||
outcome: $outcome,
|
||||
);
|
||||
} catch (Throwable $throwable) {
|
||||
if ($tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
@ -212,100 +202,7 @@ public function handle(
|
||||
$opService->failRun($this->operationRun, $throwable);
|
||||
}
|
||||
|
||||
$this->notifyFailed(
|
||||
initiator: $initiator,
|
||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||
reason: RunFailureSanitizer::sanitizeMessage($throwable->getMessage()),
|
||||
);
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyCompleted(
|
||||
?User $initiator,
|
||||
?Tenant $tenant,
|
||||
int $removed,
|
||||
int $requested,
|
||||
int $missing,
|
||||
?string $outcome,
|
||||
): void {
|
||||
if (! $initiator instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->operationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = "Removed {$removed} policies";
|
||||
|
||||
if ($missing > 0) {
|
||||
$message .= " ({$missing} missing)";
|
||||
}
|
||||
|
||||
if ($requested !== $removed && $missing === 0) {
|
||||
$skipped = max(0, $requested - $removed);
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} not removed)";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
$partial = in_array((string) $outcome, ['partially_succeeded'], true) || $missing > 0;
|
||||
$failed = in_array((string) $outcome, ['failed'], true);
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($failed ? 'Removal failed' : ($partial ? 'Removal completed (partial)' : 'Removal completed'))
|
||||
->body($message);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$notification->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($failed) {
|
||||
$notification->danger();
|
||||
} elseif ($partial) {
|
||||
$notification->warning();
|
||||
} else {
|
||||
$notification->success();
|
||||
}
|
||||
|
||||
$notification
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
}
|
||||
|
||||
private function notifyFailed(?User $initiator, ?Tenant $tenant, string $reason): void
|
||||
{
|
||||
if (! $initiator instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->operationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Removal failed')
|
||||
->body($reason);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$notification->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$notification
|
||||
->danger()
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\RunErrorMapper;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
@ -14,11 +13,8 @@
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -162,13 +158,6 @@ public function handle(
|
||||
],
|
||||
);
|
||||
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: self::STATUS_SKIPPED,
|
||||
errorMessage: 'Schedule is archived; run will not execute.',
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_skipped',
|
||||
@ -217,13 +206,6 @@ public function handle(
|
||||
],
|
||||
);
|
||||
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: self::STATUS_SKIPPED,
|
||||
errorMessage: 'Another run is already in progress for this schedule.',
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_skipped',
|
||||
@ -245,8 +227,6 @@ public function handle(
|
||||
try {
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
$this->notifyScheduleRunStarted(tenant: $tenant, schedule: $schedule);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_started',
|
||||
@ -291,13 +271,6 @@ public function handle(
|
||||
],
|
||||
);
|
||||
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: self::STATUS_SKIPPED,
|
||||
errorMessage: 'All configured policy types are unknown.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -421,13 +394,6 @@ public function handle(
|
||||
nowUtc: $nowUtc,
|
||||
);
|
||||
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: $status,
|
||||
errorMessage: $errorMessage,
|
||||
);
|
||||
|
||||
if (in_array($status, [self::STATUS_SUCCESS, self::STATUS_PARTIAL], true)) {
|
||||
Bus::dispatch(new ApplyBackupScheduleRetentionJob((int) $schedule->getKey()));
|
||||
}
|
||||
@ -485,13 +451,6 @@ public function handle(
|
||||
],
|
||||
);
|
||||
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: self::STATUS_FAILED,
|
||||
errorMessage: (string) $mapped['error_message'],
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_failed',
|
||||
@ -522,80 +481,6 @@ private function resolveBackupScheduleId(): int
|
||||
return is_numeric($contextScheduleId) ? (int) $contextScheduleId : 0;
|
||||
}
|
||||
|
||||
private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedule): void
|
||||
{
|
||||
$userId = $this->operationRun?->user_id;
|
||||
|
||||
if (! $userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::query()->find($userId);
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup started')
|
||||
->body(sprintf('Schedule "%s" has started.', $schedule->name))
|
||||
->info()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
])
|
||||
->sendToDatabase($user);
|
||||
}
|
||||
|
||||
private function notifyScheduleRunFinished(
|
||||
Tenant $tenant,
|
||||
BackupSchedule $schedule,
|
||||
string $status,
|
||||
?string $errorMessage,
|
||||
): void {
|
||||
$userId = $this->operationRun?->user_id;
|
||||
|
||||
if (! $userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::query()->find($userId);
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$title = match ($status) {
|
||||
self::STATUS_SUCCESS => 'Backup completed',
|
||||
self::STATUS_PARTIAL => 'Backup completed (partial)',
|
||||
self::STATUS_SKIPPED => 'Backup skipped',
|
||||
default => 'Backup failed',
|
||||
};
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($title)
|
||||
->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $status));
|
||||
|
||||
if (is_string($errorMessage) && $errorMessage !== '') {
|
||||
$notification->body($notification->getBody()."\n".$errorMessage);
|
||||
}
|
||||
|
||||
match ($status) {
|
||||
self::STATUS_SUCCESS => $notification->success(),
|
||||
self::STATUS_PARTIAL, self::STATUS_SKIPPED => $notification->warning(),
|
||||
default => $notification->danger(),
|
||||
};
|
||||
|
||||
$notification
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
])
|
||||
->sendToDatabase($user);
|
||||
}
|
||||
|
||||
private function finishSchedule(
|
||||
BackupSchedule $schedule,
|
||||
string $status,
|
||||
|
||||
@ -320,15 +320,12 @@ public function table(Table $table): Table
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Add policies already queued')
|
||||
->body('A matching run is already queued or running. Open the run to monitor progress.')
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->info()
|
||||
->send();
|
||||
|
||||
return;
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class RunStatusChangedNotification extends Notification
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* tenant_id:int,
|
||||
* run_type:string,
|
||||
* run_id:int,
|
||||
* status:string,
|
||||
* counts?:array{total?:int, processed?:int, succeeded?:int, failed?:int, skipped?:int}
|
||||
* } $metadata
|
||||
*/
|
||||
public function __construct(public array $metadata) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$status = (string) ($this->metadata['status'] ?? 'queued');
|
||||
$runType = (string) ($this->metadata['run_type'] ?? 'run');
|
||||
$tenantId = (int) ($this->metadata['tenant_id'] ?? 0);
|
||||
$runId = (int) ($this->metadata['run_id'] ?? 0);
|
||||
|
||||
$title = match ($status) {
|
||||
'queued' => 'Run queued',
|
||||
'running' => 'Run started',
|
||||
'completed', 'succeeded' => 'Run completed',
|
||||
'partial', 'partially succeeded', 'completed_with_errors' => 'Run completed (partial)',
|
||||
'failed' => 'Run failed',
|
||||
default => 'Run updated',
|
||||
};
|
||||
|
||||
$body = sprintf('A %s run changed status to: %s.', str_replace('_', ' ', $runType), $status);
|
||||
|
||||
$color = match ($status) {
|
||||
'queued', 'running' => 'gray',
|
||||
'completed', 'succeeded' => 'success',
|
||||
'partial', 'partially succeeded', 'completed_with_errors' => 'warning',
|
||||
'failed' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
|
||||
$actions = [];
|
||||
|
||||
if ($tenantId > 0 && $runId > 0) {
|
||||
$tenant = Tenant::query()->find($tenantId);
|
||||
|
||||
if ($tenant) {
|
||||
$url = $runType === 'restore'
|
||||
? RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant)
|
||||
: OperationRunLinks::view($runId, $tenant);
|
||||
|
||||
if (! $url) {
|
||||
return [
|
||||
'format' => 'filament',
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'color' => $color,
|
||||
'duration' => 'persistent',
|
||||
'actions' => [],
|
||||
'icon' => null,
|
||||
'iconColor' => null,
|
||||
'status' => null,
|
||||
'view' => null,
|
||||
'viewData' => [
|
||||
'metadata' => $this->metadata,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$actions[] = Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($url)
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'format' => 'filament',
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'color' => $color,
|
||||
'duration' => 'persistent',
|
||||
'actions' => $actions,
|
||||
'icon' => null,
|
||||
'iconColor' => null,
|
||||
'status' => null,
|
||||
'view' => null,
|
||||
'viewData' => [
|
||||
'metadata' => $this->metadata,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -30,6 +31,7 @@ public function __construct(
|
||||
private readonly InventoryConcurrencyLimiter $concurrencyLimiter,
|
||||
private readonly ProviderConnectionResolver $providerConnections,
|
||||
private readonly ProviderGateway $providerGateway,
|
||||
private readonly OperationRunService $operationRuns,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -100,9 +102,14 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
|
||||
];
|
||||
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => $operationOutcome,
|
||||
'summary_counts' => [
|
||||
'context' => $updatedContext,
|
||||
]);
|
||||
|
||||
$this->operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $operationOutcome,
|
||||
summaryCounts: [
|
||||
'total' => count($policyTypes),
|
||||
'processed' => count($policyTypes),
|
||||
'succeeded' => $status === 'success' ? count($policyTypes) : max(0, count($policyTypes) - (int) ($result['errors_count'] ?? 0)),
|
||||
@ -110,10 +117,8 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
|
||||
'items' => (int) ($result['items_observed_count'] ?? 0),
|
||||
'updated' => (int) ($result['items_upserted_count'] ?? 0),
|
||||
],
|
||||
'failure_summary' => $failureSummary,
|
||||
'context' => $updatedContext,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
failures: $failureSummary,
|
||||
);
|
||||
|
||||
return $operationRun->refresh();
|
||||
}
|
||||
|
||||
@ -329,7 +329,7 @@ public function enqueueBulkOperation(
|
||||
callable $dispatcher,
|
||||
?User $initiator = null,
|
||||
array $extraContext = [],
|
||||
bool $emitQueuedNotification = true
|
||||
bool $emitQueuedNotification = false
|
||||
): OperationRun {
|
||||
$targetScope = BulkRunContext::normalizeTargetScope($targetScope);
|
||||
|
||||
@ -543,7 +543,7 @@ public function incrementSummaryCounts(OperationRun $run, array $delta): Operati
|
||||
* If dispatch fails synchronously (misconfiguration, serialization errors, etc.),
|
||||
* the OperationRun is marked terminal failed so we do not leave a misleading queued run behind.
|
||||
*/
|
||||
public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void
|
||||
public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = false): void
|
||||
{
|
||||
try {
|
||||
$this->invokeDispatcher($dispatcher, $run);
|
||||
|
||||
@ -30,6 +30,20 @@ public static function queuedToast(string $operationType): FilamentNotification
|
||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical dedupe feedback when a matching run is already active.
|
||||
*/
|
||||
public static function alreadyQueuedToast(string $operationType): FilamentNotification
|
||||
{
|
||||
$operationLabel = OperationCatalog::label($operationType);
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} already queued")
|
||||
->body('A matching run is already queued or running.')
|
||||
->info()
|
||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal DB notification payload.
|
||||
*
|
||||
|
||||
@ -19,7 +19,7 @@ ## Summary
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v4, Livewire v3
|
||||
**Primary Dependencies**: Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL (JSONB for `operation_runs.summary_counts`)
|
||||
**Testing**: Pest v4 (Laravel test runner), PHPUnit 12 (via Pest)
|
||||
**Target Platform**: Docker/Sail for local dev, container-based deploy (Dokploy)
|
||||
|
||||
37
specs/110-ops-ux-enforcement/checklists/requirements.md
Normal file
37
specs/110-ops-ux-enforcement/checklists/requirements.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Specification Quality Checklist: Ops-UX Enforcement & Cleanup (Enterprise Standard Rollout)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-23
|
||||
**Feature**: [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
|
||||
|
||||
- All items pass.
|
||||
- Spec includes the mandatory UI Action Matrix and explicitly states “no new screens” while allowing targeted start-surface cleanup.
|
||||
- Remediation targets are enumerated in the spec’s “Known Violations” tables; the executable task list is the single source of truth in `tasks.md`.
|
||||
- Guard tests (FR-012) are specced as static analysis (filesystem scan) with explicit allowlist, so they fail fast with actionable output.
|
||||
9
specs/110-ops-ux-enforcement/contracts/no-api-changes.md
Normal file
9
specs/110-ops-ux-enforcement/contracts/no-api-changes.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Contracts: No API Changes
|
||||
|
||||
This feature introduces **no new HTTP endpoints**, no new API resources, and no changes to existing request/response contracts.
|
||||
|
||||
Scope is limited to internal operation flow behavior:
|
||||
|
||||
- OperationRun status/outcome transitions must go through `OperationRunService`.
|
||||
- Queued/running DB notifications are banned.
|
||||
- Terminal completion notification is initiator-only and emitted exactly once.
|
||||
39
specs/110-ops-ux-enforcement/data-model.md
Normal file
39
specs/110-ops-ux-enforcement/data-model.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Phase 1 Design: Data Model (No Schema Changes)
|
||||
|
||||
This feature does not introduce schema changes. It enforces consistent usage of existing entities.
|
||||
|
||||
## Entity: OperationRun (`operation_runs`)
|
||||
|
||||
**Ownership/scoping**:
|
||||
|
||||
- Tenant-scoped operational artifact.
|
||||
- Initiator user is optional (system/scheduled runs).
|
||||
|
||||
**Key fields (existing)**:
|
||||
|
||||
- `id`
|
||||
- `workspace_id` / `tenant_id` (scoping)
|
||||
- `user_id` (initiator; nullable)
|
||||
- `type` (operation type string)
|
||||
- `status` (`queued`/`running`/`completed`)
|
||||
- `outcome` (terminal outcome; nullable until completed)
|
||||
- `started_at`, `completed_at`
|
||||
- `summary_counts` (JSON/array of numeric-only whitelisted keys)
|
||||
- `failure_summary` (sanitized bounded array)
|
||||
- `context` (additional metadata; mutable)
|
||||
|
||||
**Invariants enforced by this feature**:
|
||||
|
||||
- All transitions of `status` and `outcome` happen through `OperationRunService::updateRun()`.
|
||||
- The only operation-related DB notification is the terminal `OperationRunCompleted`, emitted when transitioning into `completed` and only when `user_id` exists.
|
||||
|
||||
## Entity: Database Notifications (`notifications`)
|
||||
|
||||
**Ownership/scoping**:
|
||||
|
||||
- User-scoped records (`notifiable_type=User`), used for persistent notification audit.
|
||||
|
||||
**Invariants enforced by this feature**:
|
||||
|
||||
- No queued/running state notifications are persisted.
|
||||
- Exactly one terminal operation completion notification is persisted per OperationRun + initiator.
|
||||
143
specs/110-ops-ux-enforcement/plan.md
Normal file
143
specs/110-ops-ux-enforcement/plan.md
Normal file
@ -0,0 +1,143 @@
|
||||
# Implementation Plan: Ops-UX Enforcement & Cleanup (Enterprise Standard Rollout)
|
||||
|
||||
**Branch**: `110-ops-ux-enforcement` | **Date**: 2026-02-23 | **Spec**: [specs/110-ops-ux-enforcement/spec.md](specs/110-ops-ux-enforcement/spec.md)
|
||||
**Input**: Feature specification from `/specs/110-ops-ux-enforcement/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Enforce the Ops-UX “3-surface contract” by removing non-canonical operation-run status/outcome transitions and banning queued/running DB notifications across in-scope operation flows.
|
||||
|
||||
Implementation is split into:
|
||||
|
||||
- Cleanup: move all terminal transitions to `OperationRunService`, remove job-level `sendToDatabase()` completion/queued notifications, and delete the legacy `RunStatusChangedNotification`.
|
||||
- Enforcement: add Pest guard tests (filesystem scans) that fail CI when these patterns reappear.
|
||||
- Verification: add focused regression tests asserting “exactly one terminal `OperationRunCompleted` notification for initiator; none for system runs; and zero queued/running notifications”.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.x
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL (Sail)
|
||||
**Testing**: Pest v4 (`vendor/bin/sail artisan test --compact`)
|
||||
**Target Platform**: Docker via Laravel Sail (local); Dokploy (staging/prod)
|
||||
**Project Type**: Laravel monolith (Filament admin panel)
|
||||
**Performance Goals**: Guard tests run in < 1s locally; overall targeted test pack < 30s
|
||||
**Constraints**:
|
||||
|
||||
- No schema changes and no new routes.
|
||||
- Terminal DB notification is initiator-only and emitted exactly once.
|
||||
- No queued/running DB notifications anywhere (including `OperationRunQueued`).
|
||||
- Existing RBAC/capability gates for starting operations remain unchanged.
|
||||
|
||||
**Scale/Scope**: Tenant-runtime remediation for enumerated violating flows + repo-wide enforcement via guard tests.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
This spec is a cleanup/enforcement layer and does not introduce new screens, Graph calls, or operation types.
|
||||
|
||||
- Inventory-first / Read-write separation: PASS (no new inventory/backups semantics; no new write UX)
|
||||
- Graph contract path: PASS (no Graph call additions)
|
||||
- Deterministic capabilities: PASS (no capability model changes)
|
||||
- RBAC-UX / isolation: PASS (no new routes or cross-plane access)
|
||||
- Run observability: PASS (this spec strengthens dedupe + terminal notifications by removing ad-hoc notifications)
|
||||
- Automation: PASS (no scheduling behavior changes; only notification/transition handling)
|
||||
- Data minimization: PASS (removes notification spam; no payload storage changes)
|
||||
- Badge semantics: PASS (no new badges)
|
||||
- Filament UI contracts: PASS (no new screens; targeted updates to existing start surfaces only). The spec includes a mandatory UI Action Matrix.
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/110-ops-ux-enforcement/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
```text
|
||||
app/
|
||||
├── Jobs/
|
||||
├── Notifications/
|
||||
├── Services/
|
||||
└── Support/
|
||||
|
||||
config/
|
||||
|
||||
database/
|
||||
├── migrations/
|
||||
└── factories/
|
||||
|
||||
resources/
|
||||
├── views/
|
||||
└── css/
|
||||
|
||||
routes/
|
||||
|
||||
tests/
|
||||
└── Feature/
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith. Enforcement is implemented as:
|
||||
|
||||
- Targeted production code edits under `app/` (jobs/services/notifications).
|
||||
- Guard + regression tests under `tests/Feature/OpsUx/**`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| None | N/A | N/A |
|
||||
|
||||
## Phase 0 — Outline & Research
|
||||
|
||||
**Inputs**: [specs/110-ops-ux-enforcement/spec.md](specs/110-ops-ux-enforcement/spec.md), [constitution](.specify/memory/constitution.md)
|
||||
|
||||
Research is captured in [specs/110-ops-ux-enforcement/research.md](specs/110-ops-ux-enforcement/research.md) and resolves:
|
||||
|
||||
- Guard-test heuristics (regex/scan approach) to keep false positives low.
|
||||
- Where/how queued DB notifications are emitted today, and how to disable them by default.
|
||||
- Preferred test strategy for “exactly once terminal notification”.
|
||||
|
||||
## Phase 1 — Design & Contracts
|
||||
|
||||
Design artifacts are captured in:
|
||||
|
||||
- [specs/110-ops-ux-enforcement/data-model.md](specs/110-ops-ux-enforcement/data-model.md) (no schema changes; key entity behavior)
|
||||
- [specs/110-ops-ux-enforcement/contracts/](specs/110-ops-ux-enforcement/contracts/) (no new API endpoints; documented as “no contract changes”)
|
||||
- [specs/110-ops-ux-enforcement/quickstart.md](specs/110-ops-ux-enforcement/quickstart.md) (how to run the focused tests)
|
||||
|
||||
**Design highlights**:
|
||||
|
||||
- The only terminal DB notification is `OperationRunCompleted`, emitted from `OperationRunService` when an initiator exists.
|
||||
- Guard A scans `app/**/*.php` for `->update([...])` arrays that include `status` and/or `outcome` keys, excluding `OperationRunService`.
|
||||
- Guard B scans `app/**/*.php` for files that both reference an OperationRun signal and emit DB notifications (`sendToDatabase(` or `->notify(`), with a strict allowlist.
|
||||
- Guard C ensures the legacy notification class is fully removed.
|
||||
|
||||
## Phase 2 — Execution Plan (Implementation)
|
||||
|
||||
Work is implemented in small, reviewable steps:
|
||||
|
||||
1. **P0 fixes (silent completions)**: replace direct terminal updates with `OperationRunService` transitions in the enumerated services/jobs.
|
||||
2. **P0 fixes (notification spam)**: remove queued/running and custom completion DB notifications from the enumerated jobs.
|
||||
3. **P0 fixes (legacy removal)**: delete `RunStatusChangedNotification` and remove any invocation.
|
||||
4. **Enforcement**: add Pest guard tests (A/B/C) + minimal allowlist.
|
||||
5. **Verification**: add focused regression tests for the key flows (inventory sync, retention, backup schedule run) proving the “exactly once terminal notification” contract.
|
||||
|
||||
**Out of scope**: new UI pages, schema changes, new operation types, Graph contract changes.
|
||||
|
||||
## Constitution Check (Post-Phase 1 Re-check)
|
||||
|
||||
- PASS: No new routes, no Graph calls, no new Filament screens.
|
||||
- PASS: Strengthens Run Observability by centralizing terminal notification emission.
|
||||
- PASS: RBAC-UX/isolation unaffected (no new access paths).
|
||||
24
specs/110-ops-ux-enforcement/quickstart.md
Normal file
24
specs/110-ops-ux-enforcement/quickstart.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Quickstart: Ops-UX Enforcement & Cleanup
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Sail running: `vendor/bin/sail up -d`
|
||||
- Sail running: `./vendor/bin/sail up -d`
|
||||
|
||||
## Run the focused guard tests
|
||||
|
||||
Once implemented, run the guard tests only:
|
||||
|
||||
- `./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Constitution`
|
||||
|
||||
## Run the focused regression tests
|
||||
|
||||
- `./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Regression`
|
||||
|
||||
## Run the combined Ops-UX pack
|
||||
|
||||
- `./vendor/bin/sail artisan test --compact --group=ops-ux`
|
||||
|
||||
## Format touched files
|
||||
|
||||
- `./vendor/bin/sail bin pint --dirty --format agent`
|
||||
89
specs/110-ops-ux-enforcement/research.md
Normal file
89
specs/110-ops-ux-enforcement/research.md
Normal file
@ -0,0 +1,89 @@
|
||||
# Phase 0 Research: Ops-UX Enforcement & Cleanup
|
||||
|
||||
This research document resolves implementation uncertainties for the enforcement spec and records key decisions.
|
||||
|
||||
## Decision 1 — Implement guards as Pest “architecture tests” (filesystem scan)
|
||||
|
||||
**Decision**: Implement Guard A/B/C as Pest tests that scan PHP source files in `app/` (and for Guard C also `tests/`) and fail CI with actionable file + snippet output.
|
||||
|
||||
**Rationale**:
|
||||
|
||||
- Runs in the same CI pipeline as the rest of the test suite (no new tooling step).
|
||||
- Enforcement is repo-wide and does not rely on developer discipline or code review memory.
|
||||
- Can produce highly actionable failure output (file path + snippet) without external dependencies.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- PHPStan custom rules: higher precision, but adds configuration + toolchain scope.
|
||||
- `nikic/php-parser` AST scanning: high precision but adds a dependency (out of bounds per repo constraints).
|
||||
|
||||
## Decision 2 — Guard A detection uses tokenizer-assisted scanning (not pure regex)
|
||||
|
||||
**Decision**: Implement Guard A using PHP’s built-in tokenizer (`token_get_all()`) to detect `->update([...])` call sites and then inspect the array literal for `status` / `outcome` keys.
|
||||
|
||||
**Rationale**:
|
||||
|
||||
- Pure multi-line regex is brittle with nested arrays, comments, and strings.
|
||||
- Tokenization gives a dependency-free way to avoid common false positives and to compute an approximate line number.
|
||||
- The spec’s intended heuristic (“`->update([...])` block contains status/outcome keys”) maps naturally to tokens.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Regex-only `DOTALL` matching with string/comment skipping (`(*SKIP)(*F)`): simpler, but can still drift across the wrong closing bracket and become noisy.
|
||||
- Manual char-by-char scanner after a simple “find start” regex: workable, but token-based is typically easier to maintain in PHP.
|
||||
|
||||
**Repo evidence**:
|
||||
|
||||
- Canonical status/outcome transitions live in `OperationRunService::updateRun()`.
|
||||
- Violations exist in jobs/services that call `$operationRun->update([... 'status' => ..., 'outcome' => ...])` directly (enumerated in the spec).
|
||||
|
||||
## Decision 3 — Guard B flags “OperationRun in scope” + “DB notification emission”
|
||||
|
||||
**Decision**: Implement Guard B as a two-signal scan:
|
||||
|
||||
- **OperationRun signal**: file contains any of: `use App\\Models\\OperationRun;`, `OperationRun`, `$this->operationRun`, `$operationRun`.
|
||||
- **DB emission signal**: file contains either `sendToDatabase(` or `->notify(`.
|
||||
- **Allowlist**: only `app/Services/OperationRunService.php` and `app/Notifications/OperationRunCompleted.php`.
|
||||
|
||||
**Rationale**:
|
||||
|
||||
- Enforces the constitution: completion notifications are centralized, queued/running DB notifications are banned.
|
||||
- Intentionally errs on the side of flagging new ad-hoc notification producers.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- “DB emission only” = `sendToDatabase(` and ignore `->notify(`: lower noise, but would miss notify-based DB notifications.
|
||||
- Restrict scan to `app/Jobs/**` and `app/Services/**`: lower noise, but spec requires `app/**/*.php` for defense-in-depth.
|
||||
|
||||
**Repo evidence (high-level)**:
|
||||
|
||||
- `OperationRunService` currently emits both `OperationRunCompleted` and `OperationRunQueued` via `->notify(...)`.
|
||||
- There are multiple job files with `->sendToDatabase(...)` in code paths that also handle an OperationRun.
|
||||
- There are Filament resource actions that both dispatch jobs with `$operationRun` and call `->sendToDatabase(...)` for queued feedback. These are queued DB notifications and conflict with FR-004.
|
||||
|
||||
## Decision 4 — Ban queued DB notifications by changing service defaults
|
||||
|
||||
**Decision**: Change `OperationRunService::dispatchOrFail(..., bool $emitQueuedNotification = true)` (and any upstream helper that takes `emitQueuedNotification`) so the default is `false`, and ensure no call site opts back into queued DB notifications.
|
||||
|
||||
**Rationale**:
|
||||
|
||||
- Matches FR-004 / FR-012b: queued/running DB notifications are forbidden repo-wide.
|
||||
- Prevents new call sites from accidentally enabling queued DB notifications by omission.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Delete `OperationRunQueued` entirely: higher churn; leaving the class unused is acceptable as long as it’s never emitted.
|
||||
- Keep default `true` but require opt-out everywhere: violates FR-012b and is easy to regress.
|
||||
|
||||
## Decision 5 — “Exactly once terminal notification” relies on `previousStatus` guard
|
||||
|
||||
**Decision**: Rely on `OperationRunService::updateRun()` behavior that emits `OperationRunCompleted` only when transitioning into `Completed` from a non-completed status.
|
||||
|
||||
**Rationale**:
|
||||
|
||||
- Centralizes “exactly once” semantics in a single place.
|
||||
- Keeps jobs/services free of notification-specific logic.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Add additional DB-level dedupe: unnecessary if the service is the only producer and transitions are canonical.
|
||||
274
specs/110-ops-ux-enforcement/spec.md
Normal file
274
specs/110-ops-ux-enforcement/spec.md
Normal file
@ -0,0 +1,274 @@
|
||||
# Feature Specification: Ops-UX Enforcement & Cleanup (Enterprise Standard Rollout)
|
||||
|
||||
**Feature Branch**: `110-ops-ux-enforcement`
|
||||
**Created**: 2026-02-23
|
||||
**Status**: Draft
|
||||
**Depends On**: `specs/055-ops-ux-rollout/spec.md`
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-23
|
||||
|
||||
- Q: For Guard A, what detection strategy should we use to catch forbidden status/outcome transitions while allowing context-only updates? → A: Scan `app/` for `->update([...])` blocks that include `status` or `outcome` keys (same call block, multi-line), excluding `app/Services/OperationRunService.php`.
|
||||
- Q: Should queued/running DB notifications be allowed (e.g., `OperationRunQueued`) or is the DB notification surface terminal-only? → A: Ban queued/running DB notifications entirely (including `OperationRunQueued`); the only DB notification for operations is the terminal `OperationRunCompleted` notification.
|
||||
- Q: For Guard B (DB-notification guard), what scanning scope/heuristic should we use? → A: Scan `app/**/*.php` and fail when a file both references an OperationRun (import or variable/property usage) and emits any database notification (`sendToDatabase(` or `->notify(`), except for an explicit allowlist.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant runtime remediation + repo-wide enforcement guards (CI/static scan)
|
||||
- **Primary Routes**: No new routes. Affected internal flows: Inventory Sync, Backup Schedule Retention, Backup Schedule Run, Bulk Policy Export, Bulk Restore Run Restore, Bulk Restore Run Force Delete, Bulk Policy Unignore, Entra Group manual sync, Add/Remove Policies to Backup Set, Restore Run execution.
|
||||
- **Data Ownership**: `operation_runs` (tenant-scoped), `notifications` (tenant user-scoped). No schema changes required.
|
||||
- **RBAC**: No new RBAC surfaces. Existing capability gates on triggering operations remain unchanged. Notification delivery is limited to the initiator user. System/scheduled runs with no initiator receive no DB notification.
|
||||
|
||||
*Canonical-view fields not applicable — no new views introduced.*
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — No Silent Completions (Priority: P1)
|
||||
|
||||
As a tenant admin, when I trigger any tracked operation (Inventory Sync, Retention, Backup Schedule Run), I always receive exactly one terminal DB notification upon completion, so I can audit the outcome without checking the Monitoring hub manually.
|
||||
|
||||
**Why this priority**: Silent completions break auditability — the core promise of the Ops-UX system. Missing terminal notifications mean admins have no persistent outcome record outside the Monitoring hub.
|
||||
|
||||
**Independent Test**: Trigger (or simulate) an inventory sync run to terminal state and assert exactly one `OperationRunCompleted` DB notification exists for the initiator.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an `inventory_sync` OperationRun with an initiator, **When** `InventorySyncService` transitions it to a terminal outcome, **Then** exactly one `OperationRunCompleted` DB notification is persisted for the initiator user.
|
||||
2. **Given** an `apply_backup_retention` OperationRun with an initiator, **When** `ApplyBackupScheduleRetentionJob` completes (success or failure), **Then** exactly one `OperationRunCompleted` DB notification is persisted for the initiator user.
|
||||
3. **Given** an OperationRun with **no initiator** (system/scheduled run), **When** the run transitions to terminal, **Then** zero DB notifications are emitted.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — No Notification Spam (Priority: P1)
|
||||
|
||||
As a tenant admin, I never receive duplicate completion DB notifications for a single run, and I never receive queued/running state DB notifications for any operation.
|
||||
|
||||
**Why this priority**: Notification spam erodes trust in the notification surface. Even one duplicate makes admins ignore notifications — defeating the entire audit layer.
|
||||
|
||||
**Independent Test**: Simulate a `RunBackupScheduleJob` to completion and assert zero queued/running DB notifications exist, and exactly one terminal DB notification.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a backup schedule run job enqueued and completed, **When** all job code paths execute, **Then** zero queued/running DB notifications are persisted, and exactly one terminal `OperationRunCompleted` exists for the initiator.
|
||||
2. **Given** a `BulkPolicyExportJob` that reaches terminal state via any path (success / abort / circuit-break), **Then** exactly one terminal `OperationRunCompleted` DB notification exists and zero notifications from job-level `sendToDatabase()` calls.
|
||||
3. **Given** any bulk job that previously sent custom completion DB notifications (`BulkRestoreRunForceDeleteJob`, `AddPoliciesToBackupSetJob`, `RemovePoliciesFromBackupSetJob`), **When** the job completes, **Then** no job-level DB notifications are emitted.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Legacy Notification Removed (Priority: P1)
|
||||
|
||||
As a tenant admin running a restore operation, my completion feedback comes exclusively from the canonical `OperationRunCompleted` notification, not from a legacy `RunStatusChangedNotification` with inconsistent copy or link behavior.
|
||||
|
||||
**Why this priority**: The legacy class is an out-of-system notification that bypasses canonical delivery, creating inconsistent UX copy and a second notification channel that cannot be centrally controlled.
|
||||
|
||||
**Independent Test**: Confirm `RunStatusChangedNotification` class does not exist in `app/` and `ExecuteRestoreRunJob` no longer references it. Restore run completion produces exactly one `OperationRunCompleted`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** `ExecuteRestoreRunJob` completes a restore run to terminal state, **Then** no `RunStatusChangedNotification` is dispatched, and exactly one `OperationRunCompleted` DB notification is persisted for the initiator.
|
||||
2. **Given** a developer searches `app/` and `tests/` for `RunStatusChangedNotification`, **Then** no results are found (class deleted, all references removed).
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Regression Guards Enforce the Constitution (Priority: P1)
|
||||
|
||||
As a developer working on the repo, if I accidentally introduce a direct `$operationRun->update(['status' => ...])` outside `OperationRunService`, a CI guard test immediately fails with a clear file + snippet report so I know exactly what to fix.
|
||||
|
||||
**Why this priority**: Without automated guards, the enforcement degrades over time as new features are added. Guards are the only scalable way to maintain the constitution without manual code review on every PR.
|
||||
|
||||
**Independent Test**: Introduce a synthetic violation in a temp file, run the guard test, confirm it fails with actionable output. Remove the violation, confirm the test passes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the codebase contains a direct `$operationRun->update(['status' => ...])` outside `OperationRunService`, **When** the guard test runs, **Then** it fails and outputs the violating file path and snippet.
|
||||
2. **Given** a job file contains both an OperationRun reference and a `sendToDatabase()` call (not on the allowlist), **When** the DB-notification guard test runs, **Then** it fails with the file path.
|
||||
3. **Given** any file in `app/` or `tests/` references `RunStatusChangedNotification`, **When** the legacy-class guard test runs, **Then** it fails.
|
||||
4. **Given** the codebase has no violations, **When** all guard tests run, **Then** all pass green.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 — Canonical "Already Queued" Toast (Priority: P2)
|
||||
|
||||
As a tenant admin triggering an operation that is already queued, I receive a consistent, canonical "already queued" toast message rather than ad-hoc copy from individual feature components, so the experience is uniform across all operation types.
|
||||
|
||||
**Why this priority**: P2 polish — non-blocking, but addresses the last remaining ad-hoc UX copy point identified in the audit.
|
||||
|
||||
**Independent Test**: Trigger the "already queued" dedup path in `BackupSetPolicyPickerTable` and assert the toast uses the canonical presenter output.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an operation is already queued, **When** a user attempts to queue it again via the policy picker, **Then** a toast is shown using `OperationUxPresenter::alreadyQueuedToast(...)` with canonical copy.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a job fails with an unhandled exception — does the OperationRun get stuck in a non-terminal state? *(Assumption: existing job `failed()` callback or `finally` block already transitions via service; confirm during implementation per-file.)*
|
||||
- What happens when `OperationRunService` itself throws during terminal transition? *(Assumption: run stays non-terminal; pre-existing behavior outside scope of this spec.)*
|
||||
- What if an OperationRun has no initiator and a job previously sent a DB notification — will removing that path cause silence? *(Confirmed acceptable: system runs are auditable via Monitoring hub only.)*
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment — OPS-UX-001**: This spec enforces the Ops-UX 3-surface contract. All status/outcome transitions must be routed through `OperationRunService`. Terminal DB notifications are sent exclusively via `OperationRunCompleted` from within the service. No job or feature code may send its own completion DB notification.
|
||||
|
||||
**Constitution alignment — Filament scope**: This spec introduces **no new Filament screens**. It DOES include targeted updates to existing Filament start surfaces (Resources/Pages) to remove queued/running DB notifications and ensure start-surface feedback is toast-only.
|
||||
|
||||
**UI Action Matrix (mandatory)**
|
||||
|
||||
| Surface | User action | Authorization | Start surface feedback | Progress surface | Terminal surface |
|
||||
|---------|-------------|---------------|------------------------|------------------|-----------------|
|
||||
| `PolicyResource` (tenant context) | Trigger an operation that creates/reuses an OperationRun | Existing capability checks (unchanged) | Toast-only (no DB notification) + optional “View run” link | Monitoring → Operations + run detail | Exactly one `OperationRunCompleted` DB notification to initiator (service-owned) |
|
||||
| `PolicyVersionResource` (tenant context) | Trigger an operation that creates/reuses an OperationRun | Existing capability checks (unchanged) | Toast-only (no DB notification) + optional “View run” link | Monitoring → Operations + run detail | Exactly one `OperationRunCompleted` DB notification to initiator (service-owned) |
|
||||
| `BackupScheduleResource` (tenant context) | Run / queue backup schedule operation | Existing capability checks (unchanged) | Toast-only (no DB notification) + optional “View run” link | Monitoring → Operations + run detail | Exactly one `OperationRunCompleted` DB notification to initiator (service-owned) |
|
||||
| `TenantResource` (tenant context) | Trigger an operation that creates/reuses an OperationRun | Existing capability checks (unchanged) | Toast-only (no DB notification) + optional “View run” link | Monitoring → Operations + run detail | Exactly one `OperationRunCompleted` DB notification to initiator (service-owned) |
|
||||
| `EntraGroupResource` → `ListEntraGroups` (tenant context) | Start manual sync operation | Existing capability checks (unchanged) | Toast-only (no DB notification) + optional “View run” link | Monitoring → Operations + run detail | Exactly one `OperationRunCompleted` DB notification to initiator (service-owned) |
|
||||
| Scheduled/system operations | Jobs run without an initiator | Existing schedule/lock semantics (unchanged) | N/A | Monitoring → Operations + run detail | No DB notification (initiator is null); Monitoring remains canonical |
|
||||
|
||||
**Constitution alignment — No new Graph calls / OperationRun types**: This spec modifies existing operation flows only; it does not introduce new operation types or Graph calls.
|
||||
|
||||
**Constitution alignment — BADGE-001**: No new status badge values introduced.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: All OperationRun `status` and `outcome` field transitions MUST go through `OperationRunService` canonical transition methods. Direct `$operationRun->update(['status' => ...])`, `$operationRun->status = ...`, `$operationRun->outcome = ...`, or bulk query updates on status/outcome are forbidden outside `OperationRunService`.
|
||||
- **FR-002**: `OperationRunService` MUST emit exactly one `OperationRunCompleted` DB notification to the initiator when transitioning a run to a terminal outcome (when an initiator exists).
|
||||
- **FR-003**: No job or service code outside `OperationRunService` MAY emit a DB notification representing operation completion, abort, or terminal state.
|
||||
- **FR-004**: No code anywhere MAY emit a DB notification for queued or running operation states. This includes `OperationRunQueued` and any other “queued” / “started” / “running” database notifications.
|
||||
- **FR-005**: `RunStatusChangedNotification` MUST be deleted. No references to it may remain in `app/` or `tests/`.
|
||||
- **FR-006**: `InventorySyncService` MUST transition to terminal state exclusively via `OperationRunService`, not via direct model updates.
|
||||
- **FR-007**: `ApplyBackupScheduleRetentionJob` MUST transition to terminal state exclusively via `OperationRunService`.
|
||||
- **FR-008**: `TenantpilotBackfillWorkspaceIds` console command MUST use the canonical transition if it transitions OperationRun status (initiator may be null; no DB notification emitted in that case).
|
||||
- **FR-009**: `RunBackupScheduleJob` MUST NOT emit any queued or completion DB notifications; outcome notification is handled by `OperationRunService` terminal transition.
|
||||
- **FR-010**: `BulkPolicyExportJob`, `BulkRestoreRunForceDeleteJob`, `BulkRestoreRunRestoreJob`, `BulkPolicyUnignoreJob`, `AddPoliciesToBackupSetJob`, and `RemovePoliciesFromBackupSetJob` MUST NOT call `sendToDatabase()` for operation completion, abort, or queued/running feedback.
|
||||
- **FR-010b**: Filament start surfaces that initiate operation-run-producing flows MUST NOT persist queued/running DB notifications (including any `sendToDatabase()` “queued” notifications). Start feedback is toast-only.
|
||||
- **FR-011**: Context-only updates (e.g., updating `context`, `message`, `reason_code` fields without touching `status` or `outcome`) are permitted directly on the model outside `OperationRunService`.
|
||||
- **FR-012**: Three Pest guard tests MUST exist and pass in CI:
|
||||
- Guard A: Detects direct status/outcome transitions outside `OperationRunService`; reports file + snippet. Implementation MUST scan `app/**/*.php` for forbidden transition patterns, excluding `app/Services/OperationRunService.php`, including:
|
||||
- `->update(` calls whose update array includes a `status` and/or `outcome` key (multi-line block match allowed)
|
||||
- direct assignments to `->status` / `->outcome`
|
||||
- query/builder/bulk `update([...])` calls that set `status` and/or `outcome`
|
||||
Context-only updates without `status`/`outcome` MUST NOT fail this guard.
|
||||
- Guard B: Detects DB-notification emissions in operation-flow code; reports file path. Implementation MUST scan `app/**/*.php` and fail when a file contains BOTH (a) an OperationRun signal (`use App\\Models\\OperationRun;` OR `OperationRun` token OR `$this->operationRun` OR `$operationRun`) AND (b) a DB-notification emission (`sendToDatabase(` OR `->notify(`). Allowed exceptions (explicit allowlist): `app/Services/OperationRunService.php`, `app/Notifications/OperationRunCompleted.php`.
|
||||
- Guard C: Detects any reference to `RunStatusChangedNotification` in `app/` or `tests/`.
|
||||
- **FR-012b**: Operation enqueue helpers MUST NOT emit queued DB notifications by default. Any helper param like `emitQueuedNotification` MUST default to `false`, and all current call sites MUST comply.
|
||||
- **FR-013** *(P2)*: `OperationUxPresenter` MUST expose an `alreadyQueuedToast(...)` static helper returning canonical copy + duration (+ optional "View run" action).
|
||||
- **FR-014** *(P2)*: `BackupSetPolicyPickerTable` dedup toast MUST use `OperationUxPresenter::alreadyQueuedToast(...)`.
|
||||
|
||||
## Scope (Known Violations — Remediation Targets)
|
||||
|
||||
### Status transition bypass (direct model update — silent completion)
|
||||
|
||||
| File | Violation | Priority |
|
||||
|------|-----------|----------|
|
||||
| `app/Services/Inventory/InventorySyncService.php` | Direct `update([status/outcome])` — silent completion | P0 |
|
||||
| `app/Jobs/ApplyBackupScheduleRetentionJob.php` | Direct `update([...])` — silent completion | P0 |
|
||||
| `app/Console/Commands/TenantpilotBackfillWorkspaceIds.php` | Direct status update | P1 |
|
||||
|
||||
### Job-level DB notifications (duplicates / queued spam)
|
||||
|
||||
| File | Violation | Priority |
|
||||
|------|-----------|----------|
|
||||
| `app/Jobs/RunBackupScheduleJob.php` | Queued DB notification + custom finished notification | P0 |
|
||||
| `app/Jobs/BulkPolicyExportJob.php` | Multiple `sendToDatabase()` paths | P1 |
|
||||
| `app/Jobs/BulkRestoreRunForceDeleteJob.php` | Multiple `sendToDatabase()` paths | P1 |
|
||||
| `app/Jobs/BulkRestoreRunRestoreJob.php` | Multiple `sendToDatabase()` paths | P1 |
|
||||
| `app/Jobs/BulkPolicyUnignoreJob.php` | Multiple `sendToDatabase()` paths | P1 |
|
||||
| `app/Jobs/AddPoliciesToBackupSetJob.php` | Custom completion DB notifications | P1 |
|
||||
| `app/Jobs/RemovePoliciesFromBackupSetJob.php` | Custom completion DB notifications | P1 |
|
||||
|
||||
### Start-surface queued DB notifications (forbidden)
|
||||
|
||||
| File | Violation | Priority |
|
||||
|------|-----------|----------|
|
||||
| `app/Filament/Resources/PolicyResource.php` | Queued/running DB notification emitted from a start surface | P1 |
|
||||
| `app/Filament/Resources/PolicyVersionResource.php` | Queued/running DB notification emitted from a start surface | P1 |
|
||||
| `app/Filament/Resources/BackupScheduleResource.php` | Queued/running DB notification emitted from a start surface | P1 |
|
||||
| `app/Filament/Resources/TenantResource.php` | Queued/running DB notification emitted from a start surface | P1 |
|
||||
| `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` | Queued/running DB notification emitted from a start surface | P1 |
|
||||
|
||||
### Legacy notification outside system
|
||||
|
||||
| File | Violation | Priority |
|
||||
|------|-----------|----------|
|
||||
| `app/Jobs/ExecuteRestoreRunJob.php` | References `RunStatusChangedNotification` | P0 |
|
||||
| `app/Notifications/RunStatusChangedNotification.php` | Class to delete | P0 |
|
||||
|
||||
### Optional polish (P2)
|
||||
|
||||
| File | Violation | Priority |
|
||||
|------|-----------|----------|
|
||||
| `app/Livewire/BackupSetPolicyPickerTable.php` | Ad-hoc "already queued" toast (non-canonical copy) | P2 |
|
||||
|
||||
---
|
||||
|
||||
## Execution Plan Reference
|
||||
|
||||
The executable work breakdown for this spec lives in [specs/110-ops-ux-enforcement/tasks.md](specs/110-ops-ux-enforcement/tasks.md). The spec intentionally avoids duplicating a task list to prevent drift.
|
||||
|
||||
## Testing Plan (Pest)
|
||||
|
||||
### Guard tests (mandatory — CI enforcement layer)
|
||||
- `tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php`
|
||||
- `tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php`
|
||||
- `tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`
|
||||
|
||||
### Regression tests (mandatory for P0 fixes)
|
||||
- `tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php` — exactly one `OperationRunCompleted` for initiator; none for system run
|
||||
- `tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php` — exactly one `OperationRunCompleted`; no direct model transitions
|
||||
- `tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php` — zero queued DB notifications; exactly one terminal notification
|
||||
- `tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php` — circuit-breaker / abort path yields exactly one terminal `OperationRunCompleted` and zero job-level DB notifications (representative bulk job)
|
||||
|
||||
### Recommended test locations
|
||||
- Guards: `tests/Feature/OpsUx/Constitution/`
|
||||
- Regressions: `tests/Feature/OpsUx/Regression/`
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Every tracked operation with an initiator produces exactly one persistent DB notification upon terminal completion — zero operations complete silently.
|
||||
- **SC-002**: Zero queued/running DB notifications are emitted across all operation flows.
|
||||
- **SC-003**: Zero duplicate completion DB notifications for any single operation run across all exit paths (success, failure, partial, circuit-break).
|
||||
- **SC-004**: `RunStatusChangedNotification` class is fully deleted — zero references remain in the codebase.
|
||||
- **SC-005**: All three CI guard tests pass green on a clean codebase and fail with actionable output when a synthetic violation is introduced.
|
||||
- **SC-006**: All existing OpsUx test suites and related test files pass without regression after changes.
|
||||
- **SC-007** *(P2)*: All "already queued" dedup feedback paths use identical canonical copy, verifiable by a single source-of-truth presenter call.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done (DoD)
|
||||
|
||||
- [ ] All in-scope files no longer directly update status/outcome outside `OperationRunService`
|
||||
- [ ] All in-scope jobs no longer emit completion/abort/queued DB notifications
|
||||
- [ ] `RunStatusChangedNotification` deleted; zero references in `app/` and `tests/`
|
||||
- [ ] Three guard tests exist and pass in CI (and fail with actionable output on synthetic violations)
|
||||
- [ ] All OpsUx regression tests pass
|
||||
- [ ] Full test suite green (no regressions)
|
||||
- [ ] Pint formatting clean for all touched files
|
||||
- [ ] `AGENTS.md` / constitution updated to reference the non-negotiable Ops-UX rule if not already present from 055 follow-up
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. `OperationRunService` already exposes a canonical terminal transition method (from Spec 055); this spec calls it, not redeclares it.
|
||||
2. The `failed()` job lifecycle callback or try/finally blocks in affected jobs already exist (or will be added) to ensure terminal transitions even on unhandled exceptions — confirmed during implementation per-file.
|
||||
3. Guard tests are static analysis (filesystem grep-based) Pest tests, not runtime tests. They do not require a running application.
|
||||
4. The allowlist for Guard B (job DB notifications) is intentionally minimal. Any new entry requires justification in a spec comment.
|
||||
5. P2 tasks (`T026`/`T027`) are optional and do not gate release of P0/P1 work.
|
||||
|
||||
---
|
||||
|
||||
## Rollout / PR Slicing
|
||||
|
||||
PR slicing is phase/story-based. `tasks.md` remains the source of truth for exact task IDs and sequencing.
|
||||
|
||||
| PR | Scope Slice | Priority |
|
||||
|----|-------------|----------|
|
||||
| PR-A | Phase 1–3 (Setup + Foundational + US1 “No Silent Completions”) | P0 |
|
||||
| PR-B | Phase 4 (US2 “No Notification Spam” cleanup: jobs + start surfaces) | P0/P1 |
|
||||
| PR-C | Phase 5 (US3 legacy notification removal) | P0 |
|
||||
| PR-D | Phase 6 (US4 constitution guard tests A/B/C) | P0 |
|
||||
| PR-E | Phase 7 (US5 canonical “already queued” toast polish) | P2 |
|
||||
| PR-F | Phase 8 (docs alignment + validation execution) | P1/P2 |
|
||||
225
specs/110-ops-ux-enforcement/tasks.md
Normal file
225
specs/110-ops-ux-enforcement/tasks.md
Normal file
@ -0,0 +1,225 @@
|
||||
---
|
||||
|
||||
description: "Executable task list for Ops-UX Enforcement & Cleanup"
|
||||
|
||||
---
|
||||
|
||||
# Tasks: Ops-UX Enforcement & Cleanup (Enterprise Standard Rollout)
|
||||
|
||||
**Input**: Design documents from `/specs/110-ops-ux-enforcement/`
|
||||
|
||||
- plan.md: [specs/110-ops-ux-enforcement/plan.md](specs/110-ops-ux-enforcement/plan.md)
|
||||
- spec.md: [specs/110-ops-ux-enforcement/spec.md](specs/110-ops-ux-enforcement/spec.md)
|
||||
- research.md: [specs/110-ops-ux-enforcement/research.md](specs/110-ops-ux-enforcement/research.md)
|
||||
- data-model.md: [specs/110-ops-ux-enforcement/data-model.md](specs/110-ops-ux-enforcement/data-model.md)
|
||||
- contracts/: [specs/110-ops-ux-enforcement/contracts/](specs/110-ops-ux-enforcement/contracts/)
|
||||
|
||||
**Tests**: REQUIRED (Pest) — both guard tests and focused regressions.
|
||||
|
||||
## Phase 1: Setup (Shared Test Infrastructure)
|
||||
|
||||
**Purpose**: Create minimal shared helpers for guard tests and keep failure output consistent.
|
||||
|
||||
- [X] T001 [P] Add source scanning helper in tests/Support/OpsUx/SourceFileScanner.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Cross-cutting invariants that must be true before user story work can be considered “done”.
|
||||
|
||||
- [X] T002 Update queued notification defaults in app/Services/OperationRunService.php (dispatchOrFail + enqueue helpers default emitQueuedNotification=false)
|
||||
- [X] T003 Confirm repo-wide call sites do not opt into queued DB notifications (remove/forbid `emitQueuedNotification: true` usages in `app/**`)
|
||||
|
||||
**Checkpoint**: No queued/running DB notifications can be emitted by default.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — No Silent Completions (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Terminal transitions always go through `OperationRunService`, producing exactly one terminal `OperationRunCompleted` notification for initiators.
|
||||
|
||||
**Independent Test**: Inventory sync + retention flow transitions to terminal and persists exactly one `OperationRunCompleted` notification for the initiator; system runs persist none.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T004 [P] [US1] Add inventory sync terminal notification regression test in tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php
|
||||
- [X] T005 [P] [US1] Add retention terminal notification regression test in tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T006 [US1] Refactor terminal transition in app/Services/Inventory/InventorySyncService.php to use OperationRunService::updateRun()
|
||||
- [X] T007 [US1] Refactor terminal transition in app/Jobs/ApplyBackupScheduleRetentionJob.php to use OperationRunService::updateRun()
|
||||
- [X] T008 [US1] Refactor OperationRun status/outcome update in app/Console/Commands/TenantpilotBackfillWorkspaceIds.php to use OperationRunService::updateRun() (initiator may be null)
|
||||
|
||||
**Checkpoint**: US1 regressions pass, with no silent completions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — No Notification Spam (Priority: P1)
|
||||
|
||||
**Goal**: Remove job-level queued/completion DB notifications and eliminate queued DB notifications from start surfaces.
|
||||
|
||||
**Independent Test**: Backup schedule run + representative bulk flow complete with exactly one terminal `OperationRunCompleted` and zero queued/running DB notifications.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T009 [P] [US2] Add backup schedule run notification regression test in tests/Feature/OpsUx/Regression/BackupScheduleRunNotificationTest.php
|
||||
- [X] T010 [P] [US2] Add bulk job “abort/circuit-break” regression test in tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php
|
||||
|
||||
### Implementation (Jobs)
|
||||
|
||||
- [X] T011 [P] [US2] Remove queued + custom finished DB notifications in app/Jobs/RunBackupScheduleJob.php
|
||||
- [X] T012 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkPolicyExportJob.php
|
||||
- [X] T013 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkRestoreRunForceDeleteJob.php
|
||||
- [X] T029 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkRestoreRunRestoreJob.php
|
||||
- [X] T030 [P] [US2] Remove completion/abort sendToDatabase branches in app/Jobs/BulkPolicyUnignoreJob.php
|
||||
- [X] T014 [P] [US2] Remove custom completion/failure DB notifications in app/Jobs/AddPoliciesToBackupSetJob.php
|
||||
- [X] T015 [P] [US2] Remove custom completion/failure DB notifications in app/Jobs/RemovePoliciesFromBackupSetJob.php
|
||||
|
||||
### Implementation (Start surfaces / Filament)
|
||||
|
||||
- [X] T016 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/PolicyResource.php (remove sendToDatabase for queued ops)
|
||||
- [X] T017 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/BackupScheduleResource.php (remove sendToDatabase for queued ops)
|
||||
- [X] T018 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/TenantResource.php (remove sendToDatabase for queued ops)
|
||||
- [X] T019 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/PolicyVersionResource.php (remove sendToDatabase for queued ops)
|
||||
- [X] T031 [P] [US2] Replace queued DB notification with toast-only queued feedback in app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php (remove sendToDatabase for queued ops)
|
||||
|
||||
**Checkpoint**: US2 regressions pass and notifications remain terminal-only.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Legacy Notification Removed (Priority: P1)
|
||||
|
||||
**Goal**: Remove the out-of-system `RunStatusChangedNotification` and rely exclusively on canonical terminal `OperationRunCompleted`.
|
||||
|
||||
**Independent Test**: Restore run completion produces exactly one `OperationRunCompleted` notification and there are zero references to `RunStatusChangedNotification` in `app/` and `tests/`.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T020 [P] [US3] Add restore run terminal notification regression test in tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T021 [US3] Remove legacy notification invocation in app/Jobs/ExecuteRestoreRunJob.php
|
||||
- [X] T022 [US3] Delete legacy notification class app/Notifications/RunStatusChangedNotification.php
|
||||
|
||||
**Checkpoint**: US3 regression passes; no legacy notification remains.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Regression Guards Enforce the Constitution (Priority: P1)
|
||||
|
||||
**Goal**: CI guard tests fail fast when forbidden patterns reappear.
|
||||
|
||||
**Independent Test**: Guards fail with actionable output on a synthetic violation and pass on a clean codebase.
|
||||
|
||||
### Guard tests
|
||||
|
||||
- [X] T023 [P] [US4] Implement Guard A in tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php (scan app/** for forbidden status/outcome transitions: ->update([...]) arrays, direct ->status/->outcome assignments, and query/bulk updates; exclude app/Services/OperationRunService.php; print snippet)
|
||||
- [X] T024 [P] [US4] Implement Guard B in tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php (scan app/** for OperationRun signal + DB notify emission; allowlist app/Services/OperationRunService.php and app/Notifications/OperationRunCompleted.php)
|
||||
- [X] T025 [P] [US4] Implement Guard C in tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php (scan app/** and tests/** for RunStatusChangedNotification)
|
||||
|
||||
**Checkpoint**: Guard tests pass green and provide clear failure output.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: User Story 5 — Canonical "Already Queued" Toast (Priority: P2)
|
||||
|
||||
**Goal**: Dedup “already queued” messaging is canonical and consistent.
|
||||
|
||||
**Independent Test**: Trigger dedup path and confirm toast uses `OperationUxPresenter::alreadyQueuedToast(...)`.
|
||||
|
||||
- [X] T026 [P] [US5] Add OperationUxPresenter::alreadyQueuedToast(...) helper in app/Support/OpsUx/OperationUxPresenter.php
|
||||
- [X] T027 [US5] Migrate dedup toast to canonical helper in app/Livewire/BackupSetPolicyPickerTable.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final documentation alignment plus execution validation (guards, regressions, full suite, formatting).
|
||||
|
||||
- [X] T028 [P] Update quickstart commands/paths if needed in specs/110-ops-ux-enforcement/quickstart.md
|
||||
- [X] T032 Run focused Ops-UX regression pack (including `tests/Feature/OpsUx/Regression/*`) and confirm green (SC-006 / DoD)
|
||||
- [X] T033 Run constitution guard tests (`tests/Feature/OpsUx/Constitution/*`) and verify actionable failure output on synthetic violation + green on clean codebase (SC-005)
|
||||
- [X] T034 Run full test suite (or CI-equivalent command used by this repo) and confirm green (DoD)
|
||||
- [X] T035 [P] Run Pint for touched files via Sail (`./vendor/bin/sail bin pint --dirty`) and confirm clean (DoD)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Follow-up — Repo-wide Start/Dedup Toast Standardization
|
||||
|
||||
**Purpose**: Ensure all remaining Filament start/dedup surfaces use canonical Ops-UX toasts and trigger immediate progress refresh.
|
||||
|
||||
- [X] T036 Standardize tenant verification start/dedup toasts + progress refresh (Tenant list row, tenant view header, verification widget)
|
||||
- [X] T037 Standardize review pack generation widget to canonical queued/already queued toasts + view-run action
|
||||
- [X] T038 Ensure admin roles scan creates/dedupes OperationRun at enqueue time + canonical toasts + progress refresh
|
||||
- [X] T039 Standardize backup set removal dedupe notifications to canonical already queued toast + progress refresh
|
||||
- [X] T040 Standardize restore run idempotency “already queued” to canonical already queued toast (with view-run when available)
|
||||
- [X] T041 Standardize policy bulk delete queued/dedup toast to canonical queued/already queued + progress refresh
|
||||
- [X] T042 Standardize onboarding wizard verification + bootstrap start/dedup toasts + progress refresh
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### User Story Completion Order
|
||||
|
||||
- US1 → US2 → US3 → US4 → US5
|
||||
|
||||
Rationale:
|
||||
|
||||
- US1–US3 remove known violations so the guard suite (US4) can pass on a clean codebase.
|
||||
- US5 is optional polish and can land after enforcement is stable.
|
||||
|
||||
### Dependency Graph
|
||||
|
||||
```text
|
||||
Phase 1 (Setup) ─┬─> Phase 2 (Foundational) ─┬─> US1 ─┬─> US2 ─┬─> US3 ─┬─> US4 ─┬─> US5
|
||||
│ │ │ │ │ └─> Polish
|
||||
└───────────────────────────┴────────┴────────┴────────┴───────────────
|
||||
```
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1 (tests + implementation)
|
||||
|
||||
```bash
|
||||
T004: tests/Feature/OpsUx/Regression/InventorySyncTerminalNotificationTest.php
|
||||
T005: tests/Feature/OpsUx/Regression/BackupRetentionTerminalNotificationTest.php
|
||||
```
|
||||
|
||||
### US2 (jobs + start surfaces)
|
||||
|
||||
```bash
|
||||
T011: app/Jobs/RunBackupScheduleJob.php
|
||||
T012: app/Jobs/BulkPolicyExportJob.php
|
||||
T013: app/Jobs/BulkRestoreRunForceDeleteJob.php
|
||||
T014: app/Jobs/AddPoliciesToBackupSetJob.php
|
||||
T015: app/Jobs/RemovePoliciesFromBackupSetJob.php
|
||||
T016–T019: app/Filament/Resources/*Resource.php
|
||||
```
|
||||
|
||||
### US4 (guards)
|
||||
|
||||
```bash
|
||||
T023: tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php
|
||||
T024: tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php
|
||||
T025: tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php
|
||||
```
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP Scope (recommended)
|
||||
|
||||
- Foundational (Phase 2) + US1 only.
|
||||
|
||||
This yields the highest-value guarantee quickly: “no silent completions” and exactly-once terminal notifications for the most critical flows.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Foundational → US1 → validate
|
||||
2. US2 → validate
|
||||
3. US3 → validate
|
||||
4. US4 guards → enforce
|
||||
5. US5 polish
|
||||
@ -4,11 +4,8 @@
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@ -25,7 +22,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
test('operator can run now and it persists a database notification', function () {
|
||||
test('operator can run now without persisting a database notification', function () {
|
||||
Queue::fake([RunBackupScheduleJob::class]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
@ -67,19 +64,7 @@
|
||||
&& $job->operationRun->is($operationRun);
|
||||
});
|
||||
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'notifiable_type' => User::class,
|
||||
'type' => OperationRunQueued::class,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Backup schedule run queued',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($operationRun, $tenant));
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('run now is unique per click (no dedupe)', function () {
|
||||
@ -119,10 +104,10 @@
|
||||
expect($runs[0])->not->toBe($runs[1]);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 2);
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('operator can retry and it persists a database notification', function () {
|
||||
test('operator can retry without persisting a database notification', function () {
|
||||
Queue::fake([RunBackupScheduleJob::class]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
@ -163,19 +148,7 @@
|
||||
&& $job->operationRun instanceof OperationRun
|
||||
&& $job->operationRun->is($operationRun);
|
||||
});
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'notifiable_type' => User::class,
|
||||
'type' => OperationRunQueued::class,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Backup schedule run queued',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($operationRun, $tenant));
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('retry is unique per click (no dedupe)', function () {
|
||||
@ -215,7 +188,7 @@
|
||||
expect($runs[0])->not->toBe($runs[1]);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 2);
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('readonly cannot dispatch run now or retry', function () {
|
||||
@ -258,7 +231,7 @@
|
||||
->toBe(0);
|
||||
});
|
||||
|
||||
test('operator can bulk run now and it persists a database notification', function () {
|
||||
test('operator can bulk run now without persisting a database notification', function () {
|
||||
Queue::fake([RunBackupScheduleJob::class]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
@ -311,20 +284,10 @@
|
||||
->toBe([$user->id]);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Runs dispatched',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::index($tenant));
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('operator can bulk retry and it persists a database notification', function () {
|
||||
test('operator can bulk retry without persisting a database notification', function () {
|
||||
Queue::fake([RunBackupScheduleJob::class]);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
@ -377,17 +340,7 @@
|
||||
->toBe([$user->id]);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Retries dispatched',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::index($tenant));
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
});
|
||||
|
||||
test('operator can bulk retry even if a previous canonical run exists', function () {
|
||||
|
||||
@ -4,11 +4,11 @@
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Notifications\DatabaseNotification;
|
||||
|
||||
it('remove policies job sends completion notification with view link', function () {
|
||||
it('remove policies job sends canonical terminal notification with view link', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
@ -54,7 +54,7 @@
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => DatabaseNotification::class,
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@ -125,6 +126,13 @@
|
||||
->count())->toBe(1);
|
||||
|
||||
Queue::assertPushed(AddPoliciesToBackupSetJob::class, 1);
|
||||
|
||||
$notifications = session('filament.notifications', []);
|
||||
$expectedToast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies');
|
||||
|
||||
expect($notifications)->not->toBeEmpty();
|
||||
expect(collect($notifications)->last()['title'] ?? null)->toBe($expectedToast->getTitle());
|
||||
expect(collect($notifications)->last()['body'] ?? null)->toBe($expectedToast->getBody());
|
||||
});
|
||||
|
||||
test('policy picker table forbids readonly users from starting add policies (403)', function () {
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Livewire\BulkOperationProgress;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('dispatches ops-ux run-enqueued after starting baseline compare', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->callAction('compareNow')
|
||||
->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey());
|
||||
|
||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'baseline_compare')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run?->status)->toBe('queued');
|
||||
});
|
||||
@ -222,9 +222,9 @@ function getHeaderAction(Testable $component, string $name): ?Action
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
|
||||
|
||||
$component = Livewire::test(ListTenants::class)
|
||||
->assertTableEmptyStateActionsExistInOrder(['create']);
|
||||
->assertTableEmptyStateActionsExistInOrder(['add_tenant']);
|
||||
|
||||
$headerCreate = getHeaderAction($component, 'create');
|
||||
$headerCreate = getHeaderAction($component, 'add_tenant');
|
||||
expect($headerCreate)->not->toBeNull();
|
||||
expect($headerCreate?->isVisible())->toBeFalse();
|
||||
});
|
||||
@ -237,7 +237,7 @@ function getHeaderAction(Testable $component, string $name): ?Action
|
||||
$component = Livewire::test(ListTenants::class)
|
||||
->assertCountTableRecords(1);
|
||||
|
||||
$headerCreate = getHeaderAction($component, 'create');
|
||||
$headerCreate = getHeaderAction($component, 'add_tenant');
|
||||
expect($headerCreate)->not->toBeNull();
|
||||
expect($headerCreate?->isVisible())->toBeTrue();
|
||||
});
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
$service->dispatchOrFail($run, function (): void {
|
||||
// no-op (dispatch succeeded)
|
||||
});
|
||||
}, emitQueuedNotification: true);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
@ -58,7 +58,7 @@
|
||||
|
||||
$service->dispatchOrFail($run, function (): void {
|
||||
// no-op
|
||||
});
|
||||
}, emitQueuedNotification: true);
|
||||
|
||||
expect($user->notifications()->count())->toBe(0);
|
||||
});
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Support\OpsUx\SourceFileScanner;
|
||||
|
||||
it('does not allow direct OperationRun status or outcome transitions outside OperationRunService', function (): void {
|
||||
$root = SourceFileScanner::projectRoot();
|
||||
$excluded = [$root.'/app/Services/OperationRunService.php'];
|
||||
|
||||
$files = SourceFileScanner::phpFiles([$root.'/app'], $excluded);
|
||||
|
||||
$patterns = [
|
||||
'direct update() with status/outcome' => '/(?:\$this->operationRun|\$operationRun|\$opRun)\s*->\s*update\s*\(\s*\[(?:(?!\)\s*;).)*?(?:[\'"]status[\'"]|[\'"]outcome[\'"])\s*=>/s',
|
||||
'direct fill()/forceFill() with status/outcome' => '/(?:\$this->operationRun|\$operationRun|\$opRun)\s*->\s*(?:fill|forceFill)\s*\(\s*\[(?:(?!\)\s*;).)*?(?:[\'"]status[\'"]|[\'"]outcome[\'"])\s*=>/s',
|
||||
'direct property assignment' => '/(?:\$this->operationRun|\$operationRun|\$opRun)\s*->\s*(?:status|outcome)\s*=(?!=)/',
|
||||
'OperationRun query/bulk update with status/outcome' => '/OperationRun::(?:(?!;).){0,800}?->\s*update\s*\(\s*\[(?:(?!\)\s*;).)*?(?:[\'"]status[\'"]|[\'"]outcome[\'"])\s*=>/s',
|
||||
];
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$source = SourceFileScanner::read($file);
|
||||
|
||||
foreach ($patterns as $label => $pattern) {
|
||||
if (! preg_match_all($pattern, $source, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($matches[0] as [$snippetMatch, $offset]) {
|
||||
if (! is_int($offset)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = substr_count(substr($source, 0, $offset), "\n") + 1;
|
||||
|
||||
$violations[] = [
|
||||
'file' => SourceFileScanner::relativePath($file),
|
||||
'line' => $line,
|
||||
'label' => $label,
|
||||
'snippet' => SourceFileScanner::snippet($source, $line),
|
||||
'match' => trim((string) $snippetMatch),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($violations !== []) {
|
||||
$messages = array_map(static function (array $violation): string {
|
||||
return sprintf(
|
||||
"%s:%d [%s]\n%s",
|
||||
$violation['file'],
|
||||
$violation['line'],
|
||||
$violation['label'],
|
||||
$violation['snippet'],
|
||||
);
|
||||
}, $violations);
|
||||
|
||||
$this->fail(
|
||||
"Forbidden direct OperationRun status/outcome transition(s) found outside OperationRunService:\n\n"
|
||||
.implode("\n\n", $messages)
|
||||
);
|
||||
}
|
||||
|
||||
expect($violations)->toBe([]);
|
||||
})->group('ops-ux');
|
||||
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Support\OpsUx\SourceFileScanner;
|
||||
|
||||
it('does not emit database notifications from OperationRun-producing jobs or start surfaces', function (): void {
|
||||
$root = SourceFileScanner::projectRoot();
|
||||
|
||||
$allowlist = [
|
||||
$root.'/app/Services/OperationRunService.php',
|
||||
$root.'/app/Notifications/OperationRunCompleted.php',
|
||||
];
|
||||
|
||||
$files = SourceFileScanner::phpFiles([$root.'/app'], $allowlist);
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$source = SourceFileScanner::read($file);
|
||||
|
||||
if (! str_contains($source, 'sendToDatabase(')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasOperationRunSignal = str_contains($source, 'OperationRun')
|
||||
|| str_contains($source, 'operationRun')
|
||||
|| str_contains($source, 'OperationRunLinks')
|
||||
|| str_contains($source, 'OperationUxPresenter');
|
||||
|
||||
if (! $hasOperationRunSignal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! preg_match_all('/sendToDatabase\s*\(/', $source, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($matches[0] as [, $offset]) {
|
||||
if (! is_int($offset)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = substr_count(substr($source, 0, $offset), "\n") + 1;
|
||||
|
||||
$violations[] = [
|
||||
'file' => SourceFileScanner::relativePath($file),
|
||||
'line' => $line,
|
||||
'snippet' => SourceFileScanner::snippet($source, $line),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($violations !== []) {
|
||||
$messages = array_map(static function (array $violation): string {
|
||||
return sprintf(
|
||||
"%s:%d\n%s",
|
||||
$violation['file'],
|
||||
$violation['line'],
|
||||
$violation['snippet'],
|
||||
);
|
||||
}, $violations);
|
||||
|
||||
$this->fail(
|
||||
"Forbidden OperationRun-related database notification emission found (use canonical OperationRunService terminal notification / toast-only start feedback):\n\n"
|
||||
.implode("\n\n", $messages)
|
||||
);
|
||||
}
|
||||
|
||||
expect($violations)->toBe([]);
|
||||
})->group('ops-ux');
|
||||
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Support\OpsUx\SourceFileScanner;
|
||||
|
||||
it('does not reference the removed legacy run status notification', function (): void {
|
||||
$root = SourceFileScanner::projectRoot();
|
||||
$files = SourceFileScanner::phpFiles([$root.'/app', $root.'/tests'], [
|
||||
__FILE__,
|
||||
]);
|
||||
|
||||
$needle = 'RunStatus'.'ChangedNotification';
|
||||
$violations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$source = SourceFileScanner::read($file);
|
||||
|
||||
if (! str_contains($source, $needle)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$offset = 0;
|
||||
|
||||
while (($position = strpos($source, $needle, $offset)) !== false) {
|
||||
$line = substr_count(substr($source, 0, $position), "\n") + 1;
|
||||
|
||||
$violations[] = [
|
||||
'file' => SourceFileScanner::relativePath($file),
|
||||
'line' => $line,
|
||||
'snippet' => SourceFileScanner::snippet($source, $line),
|
||||
];
|
||||
|
||||
$offset = $position + strlen($needle);
|
||||
}
|
||||
}
|
||||
|
||||
if ($violations !== []) {
|
||||
$messages = array_map(static function (array $violation): string {
|
||||
return sprintf(
|
||||
"%s:%d\n%s",
|
||||
$violation['file'],
|
||||
$violation['line'],
|
||||
$violation['snippet'],
|
||||
);
|
||||
}, $violations);
|
||||
|
||||
$this->fail(
|
||||
"Legacy notification reference(s) found:\n\n".implode("\n\n", $messages)
|
||||
);
|
||||
}
|
||||
|
||||
expect($violations)->toBe([]);
|
||||
})->group('ops-ux');
|
||||
@ -6,7 +6,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('emits at most one queued database notification per newly created run', function (): void {
|
||||
it('emits at most one queued database notification per newly created run when explicitly enabled', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
|
||||
$service->dispatchOrFail($run, function (): void {
|
||||
// no-op (dispatch succeeded)
|
||||
});
|
||||
}, emitQueuedNotification: true);
|
||||
|
||||
expect($user->notifications()->count())->toBe(1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Notifications\RunStatusChangedNotification;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
@ -42,8 +41,11 @@
|
||||
->toBe(OperationRunLinks::view($run, $tenant));
|
||||
})->group('ops-ux');
|
||||
|
||||
it('does not link to legacy bulk run resources in status-change notifications', function (): void {
|
||||
it('does not link to legacy bulk run resources in canonical terminal notifications', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
@ -55,12 +57,16 @@
|
||||
'context' => ['operation' => ['type' => 'policy.delete']],
|
||||
]);
|
||||
|
||||
$user->notify(new RunStatusChangedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'run_type' => 'bulk_operation',
|
||||
'run_id' => (int) $run->getKey(),
|
||||
'status' => 'completed',
|
||||
]));
|
||||
/** @var OperationRunService $service */
|
||||
$service = app(OperationRunService::class);
|
||||
|
||||
$service->updateRun(
|
||||
$run,
|
||||
status: 'completed',
|
||||
outcome: 'succeeded',
|
||||
summaryCounts: ['total' => 1],
|
||||
failures: [],
|
||||
);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
|
||||
@ -20,3 +20,10 @@
|
||||
expect($duration)->toBeGreaterThanOrEqual(3000);
|
||||
expect($duration)->toBeLessThanOrEqual(5000);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('builds canonical already-queued toast copy', function (): void {
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies');
|
||||
|
||||
expect($toast->getTitle())->toBe('Backup set update already queued');
|
||||
expect($toast->getBody())->toBe('A matching run is already queued or running.');
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ApplyBackupScheduleRetentionJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
|
||||
it('completes backup retention runs without persisting terminal notifications for system runs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Retention Regression',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 2,
|
||||
]);
|
||||
|
||||
$sets = collect(range(1, 4))->map(function (int $index) use ($tenant): BackupSet {
|
||||
return BackupSet::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Retention Set '.$index,
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
'completed_at' => now()->subMinutes(10 - $index),
|
||||
]);
|
||||
});
|
||||
|
||||
$completedAt = now('UTC')->startOfMinute()->subMinutes(8);
|
||||
|
||||
foreach ($sets as $set) {
|
||||
OperationRun::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => 'backup_schedule_run',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'run_identity_hash' => hash('sha256', 'ops-ux-retention-regression:'.$schedule->id.':'.$set->id),
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
'context' => [
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
'backup_set_id' => (int) $set->id,
|
||||
],
|
||||
'started_at' => $completedAt,
|
||||
'completed_at' => $completedAt,
|
||||
]);
|
||||
|
||||
$completedAt = $completedAt->addMinute();
|
||||
}
|
||||
|
||||
ApplyBackupScheduleRetentionJob::dispatchSync((int) $schedule->getKey());
|
||||
|
||||
$retentionRun = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'backup_schedule_retention')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($retentionRun)->not->toBeNull();
|
||||
expect($retentionRun?->status)->toBe('completed');
|
||||
expect($retentionRun?->outcome)->toBe('succeeded');
|
||||
expect((int) ($retentionRun?->summary_counts['processed'] ?? 0))->toBe(2);
|
||||
expect($user->notifications()->count())->toBe(0);
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
})->group('ops-ux');
|
||||
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ApplyBackupScheduleRetentionJob;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\RunErrorMapper;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\DatabaseNotification;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
it('persists only the canonical terminal notification for initiated backup schedule runs', function (): void {
|
||||
Bus::fake();
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'OpsUx Regression Schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '10:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => null,
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $operationRuns */
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$run = $operationRuns->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule_run',
|
||||
inputs: ['backup_schedule_id' => (int) $schedule->getKey()],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
app()->bind(PolicySyncService::class, fn (): PolicySyncService => new class extends PolicySyncService
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array
|
||||
{
|
||||
return ['synced' => [], 'failures' => []];
|
||||
}
|
||||
});
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
app()->bind(BackupService::class, fn (): BackupService => new class($backupSet) extends BackupService
|
||||
{
|
||||
public function __construct(private readonly BackupSet $backupSet) {}
|
||||
|
||||
public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet
|
||||
{
|
||||
return $this->backupSet;
|
||||
}
|
||||
});
|
||||
|
||||
Cache::flush();
|
||||
|
||||
(new RunBackupScheduleJob(operationRun: $run, backupScheduleId: (int) $schedule->getKey()))->handle(
|
||||
app(PolicySyncService::class),
|
||||
app(BackupService::class),
|
||||
app(PolicyTypeResolver::class),
|
||||
app(ScheduleTimeService::class),
|
||||
app(AuditLogger::class),
|
||||
app(RunErrorMapper::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
expect($user->notifications()->count())->toBe(1);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => DatabaseNotification::class,
|
||||
]);
|
||||
|
||||
Bus::assertDispatched(ApplyBackupScheduleRetentionJob::class);
|
||||
})->group('ops-ux');
|
||||
55
tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php
Normal file
55
tests/Feature/OpsUx/Regression/BulkJobCircuitBreakerTest.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\BulkPolicyExportJob;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\OperationRunService;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\DatabaseNotification;
|
||||
|
||||
it('persists only the canonical terminal notification when bulk export aborts via circuit breaker', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
/** @var OperationRunService $operationRuns */
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$run = $operationRuns->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.export',
|
||||
inputs: ['scope' => 'subset', 'policy_ids' => [999_999_991]],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$job = new BulkPolicyExportJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: [999_999_991],
|
||||
backupName: 'OpsUx Circuit Breaker Regression',
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
$job->handle($operationRuns);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('failed');
|
||||
expect((int) ($run->summary_counts['failed'] ?? 0))->toBeGreaterThan(0);
|
||||
expect($user->notifications()->count())->toBe(1);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => DatabaseNotification::class,
|
||||
]);
|
||||
})->group('ops-ux');
|
||||
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RunInventorySyncJob;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
|
||||
it('persists exactly one terminal notification for initiated inventory sync runs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$sync = app(InventorySyncService::class);
|
||||
$selectionPayload = $sync->defaultSelectionPayload();
|
||||
$computed = $sync->normalizeAndHashSelection($selectionPayload);
|
||||
|
||||
$mockSync = \Mockery::mock(InventorySyncService::class);
|
||||
$mockSync
|
||||
->shouldReceive('executeSelection')
|
||||
->once()
|
||||
->andReturn([
|
||||
'status' => 'success',
|
||||
'had_errors' => false,
|
||||
'error_codes' => [],
|
||||
'error_context' => [],
|
||||
'errors_count' => 0,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'skipped_policy_types' => [],
|
||||
'processed_policy_types' => $computed['selection']['policy_types'],
|
||||
'failed_policy_types' => [],
|
||||
'selection_hash' => $computed['selection_hash'],
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $operationRuns */
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$run = $operationRuns->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'inventory_sync',
|
||||
inputs: $computed['selection'],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$job = new RunInventorySyncJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
expect($user->notifications()->count())->toBe(0);
|
||||
|
||||
$job->handle($mockSync, app(AuditLogger::class), $operationRuns);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
expect($user->notifications()->count())->toBe(1);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('does not persist terminal notifications for system-run inventory syncs without initiator', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$sync = app(InventorySyncService::class);
|
||||
$run = $sync->syncNow($tenant, $sync->defaultSelectionPayload());
|
||||
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($tenant->users()->firstOrFail()->notifications()->count())->toBe(0);
|
||||
$this->assertDatabaseCount('notifications', 0);
|
||||
})->group('ops-ux');
|
||||
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ExecuteRestoreRunJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Services\OperationRunService;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('persists exactly one canonical terminal notification for initiated restore execution runs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'requested_by' => $user->email,
|
||||
'status' => 'queued',
|
||||
'started_at' => null,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $operationRuns */
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$operationRun = $operationRuns->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'restore.execute',
|
||||
inputs: [
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$operationRun->forceFill([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
])->save();
|
||||
|
||||
$this->mock(RestoreService::class, function ($mock) use ($restoreRun): void {
|
||||
$mock->shouldReceive('executeForRun')
|
||||
->once()
|
||||
->andReturnUsing(function () use ($restoreRun): RestoreRun {
|
||||
RestoreRun::query()->whereKey($restoreRun->getKey())->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
return RestoreRun::query()->findOrFail($restoreRun->getKey());
|
||||
});
|
||||
});
|
||||
|
||||
$job = new ExecuteRestoreRunJob(
|
||||
restoreRunId: (int) $restoreRun->getKey(),
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
|
||||
expect($user->notifications()->count())->toBe(0);
|
||||
|
||||
$job->handle(app(RestoreService::class), app(AuditLogger::class));
|
||||
|
||||
$operationRun->refresh();
|
||||
|
||||
expect($operationRun->status)->toBe('completed');
|
||||
expect($operationRun->outcome)->toBe('succeeded');
|
||||
expect($user->notifications()->count())->toBe(1);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
]);
|
||||
})->group('ops-ux');
|
||||
@ -303,7 +303,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
});
|
||||
});
|
||||
|
||||
it('sends queued database notification when review pack generation is requested', function (): void {
|
||||
it('does not send queued database notification when review pack generation is requested', function (): void {
|
||||
Queue::fake();
|
||||
Notification::fake();
|
||||
|
||||
@ -313,7 +313,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
$service = app(ReviewPackService::class);
|
||||
$service->generate($tenant, $user);
|
||||
|
||||
Notification::assertSentTo($user, OperationRunQueued::class);
|
||||
Notification::assertNotSentTo($user, OperationRunQueued::class);
|
||||
});
|
||||
|
||||
// ─── OperationRun Type ──────────────────────────────────────────
|
||||
|
||||
103
tests/Support/OpsUx/SourceFileScanner.php
Normal file
103
tests/Support/OpsUx/SourceFileScanner.php
Normal file
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support\OpsUx;
|
||||
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
|
||||
final class SourceFileScanner
|
||||
{
|
||||
/**
|
||||
* @param list<string> $roots
|
||||
* @param list<string> $excludedAbsolutePaths
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function phpFiles(array $roots, array $excludedAbsolutePaths = []): array
|
||||
{
|
||||
$files = [];
|
||||
$excluded = array_fill_keys(array_map(self::normalizePath(...), $excludedAbsolutePaths), true);
|
||||
|
||||
foreach ($roots as $root) {
|
||||
$root = self::normalizePath($root);
|
||||
|
||||
if (! is_dir($root)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
/** @var SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = self::normalizePath($file->getPathname());
|
||||
|
||||
if (pathinfo($path, PATHINFO_EXTENSION) !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($excluded[$path])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
return array_values(array_unique($files));
|
||||
}
|
||||
|
||||
public static function projectRoot(): string
|
||||
{
|
||||
return self::normalizePath(dirname(__DIR__, 3));
|
||||
}
|
||||
|
||||
public static function relativePath(string $absolutePath): string
|
||||
{
|
||||
$absolutePath = self::normalizePath($absolutePath);
|
||||
$root = self::projectRoot();
|
||||
|
||||
if (str_starts_with($absolutePath, $root.'/')) {
|
||||
return substr($absolutePath, strlen($root) + 1);
|
||||
}
|
||||
|
||||
return $absolutePath;
|
||||
}
|
||||
|
||||
public static function read(string $path): string
|
||||
{
|
||||
return (string) file_get_contents($path);
|
||||
}
|
||||
|
||||
public static function snippet(string $source, int $line, int $contextLines = 2): string
|
||||
{
|
||||
$allLines = preg_split('/\R/', $source) ?: [];
|
||||
$line = max(1, $line);
|
||||
|
||||
$start = max(1, $line - $contextLines);
|
||||
$end = min(count($allLines), $line + $contextLines);
|
||||
|
||||
$snippet = [];
|
||||
|
||||
for ($index = $start; $index <= $end; $index++) {
|
||||
$prefix = $index === $line ? '>' : ' ';
|
||||
$snippet[] = sprintf('%s%4d | %s', $prefix, $index, $allLines[$index - 1] ?? '');
|
||||
}
|
||||
|
||||
return implode("\n", $snippet);
|
||||
}
|
||||
|
||||
private static function normalizePath(string $path): string
|
||||
{
|
||||
return str_replace('\\', '/', $path);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user