feat: implement operation run phase composite progress and baseline capture resume
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 57s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 57s
This commit is contained in:
parent
58c0064cb0
commit
4097778d96
@ -950,9 +950,12 @@ private function resumeCaptureAction(): Action
|
||||
$viewAction = Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url(OperationRunLinks::tenantlessView($run));
|
||||
$runTenantId = is_numeric($run->managed_environment_id)
|
||||
? (int) $run->managed_environment_id
|
||||
: null;
|
||||
|
||||
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this, $runTenantId);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
@ -961,7 +964,7 @@ private function resumeCaptureAction(): Action
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this, $runTenantId);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
|
||||
@ -138,7 +138,7 @@ private function captureAction(): Action
|
||||
->url(OperationRunLinks::view($run, $sourceTenant));
|
||||
|
||||
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this, $sourceTenant);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
@ -147,7 +147,7 @@ private function captureAction(): Action
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this, $sourceTenant);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
@ -291,7 +291,7 @@ private function compareNowAction(): Action
|
||||
->url(OperationRunLinks::view($run, $targetTenant));
|
||||
|
||||
if (! $run->wasRecentlyCreated && in_array((string) $run->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this, $targetTenant);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
@ -300,7 +300,7 @@ private function compareNowAction(): Action
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this, $targetTenant);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $run->type)
|
||||
->actions([$viewAction])
|
||||
|
||||
@ -111,7 +111,11 @@ public function startCapture(
|
||||
);
|
||||
|
||||
if ($run->wasRecentlyCreated) {
|
||||
CaptureBaselineSnapshotJob::dispatch($run);
|
||||
$this->runs->dispatchOrFail(
|
||||
$run,
|
||||
fn () => CaptureBaselineSnapshotJob::dispatch($run),
|
||||
emitQueuedNotification: true,
|
||||
);
|
||||
}
|
||||
|
||||
return ['ok' => true, 'run' => $run];
|
||||
|
||||
@ -118,11 +118,12 @@ public function resume(OperationRun $priorRun, User $initiator): array
|
||||
);
|
||||
|
||||
if ($run->wasRecentlyCreated) {
|
||||
match ($runType) {
|
||||
OperationRunType::BaselineCapture->value => CaptureBaselineSnapshotJob::dispatch($run),
|
||||
OperationRunType::BaselineCompare->value => CompareBaselineToTenantJob::dispatch($run),
|
||||
default => null,
|
||||
$dispatcher = match ($runType) {
|
||||
OperationRunType::BaselineCapture->value => fn () => CaptureBaselineSnapshotJob::dispatch($run),
|
||||
OperationRunType::BaselineCompare->value => fn () => CompareBaselineToTenantJob::dispatch($run),
|
||||
};
|
||||
|
||||
$this->runs->dispatchOrFail($run, $dispatcher, emitQueuedNotification: true);
|
||||
}
|
||||
|
||||
$this->auditLogger->log(
|
||||
|
||||
@ -6,13 +6,14 @@
|
||||
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Livewire\BulkOperationProgress;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
final class OpsUxBrowserEvents
|
||||
{
|
||||
public const RunEnqueued = 'ops-ux:run-enqueued';
|
||||
|
||||
public static function dispatchRunEnqueued(mixed $livewire): void
|
||||
public static function dispatchRunEnqueued(mixed $livewire, ManagedEnvironment|int|null $tenant = null): void
|
||||
{
|
||||
if (! is_object($livewire)) {
|
||||
return;
|
||||
@ -22,12 +23,33 @@ public static function dispatchRunEnqueued(mixed $livewire): void
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantId = Filament::getTenant()?->getKey();
|
||||
$tenantId = self::resolveTenantId($tenant);
|
||||
|
||||
// In Livewire v3, dispatch() emits a DOM event that bubbles.
|
||||
// In Livewire v4, dispatch() emits a DOM event that bubbles.
|
||||
// Our progress widget is mounted outside the initiating component's DOM tree,
|
||||
// so we target it explicitly to ensure it receives the event immediately.
|
||||
$livewire->dispatch(self::RunEnqueued, tenantId: $tenantId)->to(BulkOperationProgress::class);
|
||||
$livewire->dispatch(self::RunEnqueued, tenantId: $tenantId)->to(InventoryKpiHeader::class);
|
||||
}
|
||||
|
||||
private static function resolveTenantId(ManagedEnvironment|int|null $tenant): ?int
|
||||
{
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return (int) $tenant->getKey();
|
||||
}
|
||||
|
||||
if (is_int($tenant) && $tenant > 0) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$currentTenantId = Filament::getTenant()?->getKey();
|
||||
|
||||
if (! is_numeric($currentTenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$currentTenantId = (int) $currentTenantId;
|
||||
|
||||
return $currentTenantId > 0 ? $currentTenantId : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,11 +5,15 @@
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Livewire\BulkOperationProgress;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -106,6 +110,7 @@ function seedCaptureProfileForTenant(
|
||||
->assertActionHasLabel('capture', 'Capture baseline (full content)')
|
||||
->assertActionEnabled('capture')
|
||||
->callAction('capture', data: ['source_environment_id' => (int) $tenant->getKey()])
|
||||
->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey())
|
||||
->assertStatus(200);
|
||||
|
||||
$topLevelActionNames = collect(baselineProfileCaptureHeaderActions($component))
|
||||
@ -129,6 +134,19 @@ function seedCaptureProfileForTenant(
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run?->status)->toBe('queued');
|
||||
expect(data_get($run?->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunQueued::class,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Baseline capture queued',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
|
||||
expect(data_get($notification?->data, 'actions.0.url'))->toBe(OperationRunLinks::view($run, $tenant))
|
||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('admin_operation_run');
|
||||
});
|
||||
|
||||
it('shows the shared capture block on the start surface when no credible inventory basis exists', function (): void {
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Jobs\CaptureBaselineSnapshotJob;
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Livewire\BulkOperationProgress;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -52,6 +57,7 @@
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertActionVisible('resumeCapture')
|
||||
->callAction('resumeCapture')
|
||||
->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey())
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||
@ -66,4 +72,72 @@
|
||||
expect($resumed)->not->toBeNull();
|
||||
$context = is_array($resumed?->context) ? $resumed->context : [];
|
||||
expect($context['baseline_compare']['resume_token'] ?? null)->toBe($token);
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunQueued::class,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Baseline compare queued',
|
||||
]);
|
||||
});
|
||||
|
||||
it('notifies and refreshes operation activity after resuming a baseline capture run', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$token = BaselineEvidenceResumeToken::encode(['offset' => 1]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'type' => OperationRunType::BaselineCapture->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_environment_id' => (int) $tenant->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'baseline_capture' => [
|
||||
'resume_token' => $token,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertActionVisible('resumeCapture')
|
||||
->callAction('resumeCapture')
|
||||
->assertDispatchedTo(BulkOperationProgress::class, OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey())
|
||||
->assertStatus(200);
|
||||
|
||||
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
||||
|
||||
$resumed = OperationRun::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('type', OperationRunType::BaselineCapture->value)
|
||||
->where('status', OperationRunStatus::Queued->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($resumed)->not->toBeNull();
|
||||
$context = is_array($resumed?->context) ? $resumed->context : [];
|
||||
expect($context['baseline_capture']['resume_token'] ?? null)->toBe($token);
|
||||
|
||||
$notification = $user->notifications()
|
||||
->where('type', OperationRunQueued::class)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($notification)->not->toBeNull()
|
||||
->and(data_get($notification?->data, 'title'))->toBe('Baseline capture queued')
|
||||
->and(data_get($notification?->data, 'actions.0.url'))->toBe(OperationRunLinks::view($resumed, $tenant))
|
||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('admin_operation_run');
|
||||
});
|
||||
|
||||
@ -22,6 +22,7 @@ ### Explicit delta in this plan
|
||||
|
||||
- add canonical operator-safe phase metadata and label derivation for `baseline_capture` and `baseline_compare`
|
||||
- add one bounded composite summary path for `tenant.review.compose` from current aggregate operation truth
|
||||
- ensure workspace/canonical baseline capture, compare, and resume launch paths pass the concrete tenant id into the existing `run-enqueued` event and opt newly created human-initiated baseline capture/resume runs into the existing queued database notification path
|
||||
- keep counted, terminal, and generic activity semantics unchanged except for consuming more truthful non-counted detail
|
||||
- document the narrowed candidate boundary explicitly: review-pack and evidence-snapshot overlap remain with Spec 271, while provider health and support diagnostics remain deferred until repo-real queued progress truth exists
|
||||
|
||||
@ -120,9 +121,9 @@ ## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, for active progress meaning only
|
||||
- **Central contract reused**: existing OperationRun Start UX Contract plus `OperationRunProgressContract`
|
||||
- **Delegated UX behaviors**: queued toast, canonical run links, `run-enqueued` event, and terminal-notification lifecycle remain delegated and unchanged
|
||||
- **Delegated UX behaviors**: queued toast, canonical run links, `run-enqueued` event, and terminal-notification lifecycle remain delegated. Workspace/canonical baseline launch and resume surfaces pass the concrete tenant id into the existing event path because `Filament::getTenant()` is unavailable on those routes.
|
||||
- **Surface-owned behavior kept local**: current launch inputs, detailed diagnostics, and domain-specific result explanations stay local to their current surfaces
|
||||
- **Queued DB-notification policy**: `N/A` - unchanged
|
||||
- **Queued DB-notification policy**: explicit opt-in only. Baseline capture start and baseline evidence resume reuse the existing `OperationRunQueued` database notification via `OperationRunService`; no new notification class, queue policy, or sidebar notification path is introduced.
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism
|
||||
- **Exception path**: none
|
||||
|
||||
@ -257,4 +258,4 @@ ## Proportionality Review
|
||||
- **Narrowest correct implementation**: update only selected repo-real phase/composite families and keep all other work on current generic fallback or counted semantics.
|
||||
- **Ownership cost created**: selected context writes in existing jobs/services, focused tests, and one standards update.
|
||||
- **Alternative intentionally rejected**: a workflow engine, child-run graph, or fake percentage model was rejected because all would be structurally heavier and less truthful than the current-release need.
|
||||
- **Release truth**: current-release truth. The repo already contains the contract, the shell adopter, the baseline phase hints, and the tenant-review aggregate truth needed for this rollout.
|
||||
- **Release truth**: current-release truth. The repo already contains the contract, the shell adopter, the baseline phase hints, and the tenant-review aggregate truth needed for this rollout.
|
||||
|
||||
@ -51,9 +51,9 @@ ## OperationRun UX Impact *(mandatory)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Shared OperationRun UX contract/layer reused**: existing OperationRun Start UX Contract plus `App\Support\OpsUx\OperationRunProgressContract` and `App\Support\OpsUx\OperationUxPresenter`
|
||||
- **Delegated start/completion UX behaviors**: queued toast wording, canonical `View operation` links, tenant-safe URL resolution, current `run-enqueued` browser events, and existing terminal notifications remain delegated to the current shared path and are unchanged in this slice
|
||||
- **Delegated start/completion UX behaviors**: queued toast wording, canonical `View operation` links, tenant-safe URL resolution, current `run-enqueued` browser events, and existing terminal notifications remain delegated to the current shared path. Workspace/canonical baseline launch and resume surfaces must pass the concrete tenant id into the existing event path because `Filament::getTenant()` is absent outside tenant-scoped routes.
|
||||
- **Local surface-owned behavior that remains**: current baseline and tenant-review launch inputs plus current run-detail diagnostics stay local to their existing surfaces; non-counted progress semantics do not
|
||||
- **Queued DB-notification policy**: `N/A` - unchanged
|
||||
- **Queued DB-notification policy**: explicit opt-in only. Baseline capture start and baseline evidence resume reuse the existing `OperationRunQueued` database notification via `OperationRunService`; no new notification class, queue policy, or sidebar notification path is introduced.
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism
|
||||
- **Exception required?**: none
|
||||
|
||||
@ -228,6 +228,8 @@ ### Functional Requirements
|
||||
- **FR-012**: This slice MUST NOT add new `summary_counts` keys, new progress capabilities, a new dashboard card, a child-run graph, a workflow engine, or provider health/support-diagnostics progress behavior.
|
||||
- **FR-013**: This slice MUST NOT reopen review-pack or evidence-snapshot progress semantics owned by Spec 271 unless current repo truth proves they still need non-counted treatment in a later follow-up spec.
|
||||
- **FR-014**: The feature MUST update `docs/ui/tenantpilot-enterprise-ui-standards.md` so maintainers can see which current run families may claim phase/composite progress and which families remain activity-only, counted, or deferred.
|
||||
- **FR-015**: Workspace/canonical baseline capture, compare, and resume launch surfaces MUST pass the concrete tenant id into the existing `run-enqueued` event so tenant-bound operation activity feedback refreshes even when `Filament::getTenant()` is null.
|
||||
- **FR-016**: Newly created human-initiated baseline capture and baseline evidence resume runs MUST use `OperationRunService::dispatchOrFail(..., emitQueuedNotification: true)` so the existing queued sidebar database notification path is written consistently.
|
||||
|
||||
### Authorization and Safety Requirements
|
||||
|
||||
@ -307,4 +309,4 @@ ## Risks
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking safe implementation. If a selected run family cannot produce stable operator-safe non-counted detail without speculative copy, that family must drop back to current generic fallback behavior rather than widening the slice.
|
||||
- None blocking safe implementation. If a selected run family cannot produce stable operator-safe non-counted detail without speculative copy, that family must drop back to current generic fallback behavior rather than widening the slice.
|
||||
|
||||
@ -130,6 +130,9 @@ ## Phase 7: Polish & Cross-Cutting Validation
|
||||
- [x] T022 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php tests/Feature/TenantReview/TenantReviewOperationsUxTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/TenantReview/TenantReviewRbacTest.php tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`.
|
||||
- [x] T023 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files.
|
||||
- [x] T024 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no new assets were registered, no new globally searchable resource behavior was introduced, no new parallel polling loop was added, and the full precedence chain `phased > composite > counted > activity` still holds with counted mode requiring truthful `processed` plus `total`.
|
||||
- [x] T025 [P] [Bugfix] Update `apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php`, baseline profile actions, and canonical operation-detail resume actions so workspace/canonical baseline capture, compare, and resume starts pass the concrete tenant id into the existing `run-enqueued` event.
|
||||
- [x] T026 [P] [Bugfix] Update `apps/platform/app/Services/Baselines/BaselineCaptureService.php` and `apps/platform/app/Services/Baselines/BaselineEvidenceCaptureResumeService.php` so newly created human-initiated baseline capture and evidence-resume runs use `OperationRunService::dispatchOrFail(..., emitQueuedNotification: true)`.
|
||||
- [x] T027 [P] [Bugfix] Extend `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php` and `apps/platform/tests/Feature/Filament/OperationRunResumeCaptureActionTest.php` to prove tenant-bound progress refresh events and queued sidebar database notifications for baseline capture, compare, and resume starts.
|
||||
|
||||
---
|
||||
|
||||
@ -189,4 +192,4 @@ ## Deferred Follow-Ups / Non-Goals
|
||||
- review-pack or evidence-snapshot non-counted progress overlap with Spec 271
|
||||
- child-run graph persistence through `child_run_ids` or `operation_run_ids`
|
||||
- `273 - Tenant Dashboard Active Operations Summary Card`
|
||||
- any workflow-engine or AI-generated progress explanation layer
|
||||
- any workflow-engine or AI-generated progress explanation layer
|
||||
|
||||
Loading…
Reference in New Issue
Block a user