diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 310d6a9..f1ecfec 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -11,6 +11,7 @@ ## Active Technologies - PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes) - PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes) - PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) (056-remove-legacy-bulkops) +- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish) - PHP 8.4.15 (feat/005-bulk-operations) @@ -30,9 +31,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 +- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A] - 056-remove-legacy-bulkops: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 -- feat/047-inventory-foundations-nodes: Added PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 -- feat/042-inventory-dependencies-graph: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 diff --git a/app/Filament/Pages/TenantDashboard.php b/app/Filament/Pages/TenantDashboard.php new file mode 100644 index 0000000..53bf2d9 --- /dev/null +++ b/app/Filament/Pages/TenantDashboard.php @@ -0,0 +1,34 @@ + | WidgetConfiguration> + */ + public function getWidgets(): array + { + return [ + DashboardKpis::class, + NeedsAttention::class, + RecentDriftFindings::class, + RecentOperations::class, + ]; + } + + public function getColumns(): int|array + { + return 2; + } +} diff --git a/app/Filament/Widgets/Dashboard/DashboardKpis.php b/app/Filament/Widgets/Dashboard/DashboardKpis.php new file mode 100644 index 0000000..6df636d --- /dev/null +++ b/app/Filament/Widgets/Dashboard/DashboardKpis.php @@ -0,0 +1,73 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'openDriftFindings' => 0, + 'highSeverityDriftFindings' => 0, + 'activeRuns' => 0, + 'inventoryActiveRuns' => 0, + ]; + } + + $tenantId = (int) $tenant->getKey(); + + $openDriftFindings = (int) Finding::query() + ->where('tenant_id', $tenantId) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('status', Finding::STATUS_NEW) + ->count(); + + $highSeverityDriftFindings = (int) Finding::query() + ->where('tenant_id', $tenantId) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('status', Finding::STATUS_NEW) + ->where('severity', Finding::SEVERITY_HIGH) + ->count(); + + $activeRuns = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->active() + ->count(); + + $inventoryActiveRuns = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('type', 'inventory.sync') + ->active() + ->count(); + + return [ + 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, + 'openDriftFindings' => $openDriftFindings, + 'highSeverityDriftFindings' => $highSeverityDriftFindings, + 'activeRuns' => $activeRuns, + 'inventoryActiveRuns' => $inventoryActiveRuns, + ]; + } +} diff --git a/app/Filament/Widgets/Dashboard/NeedsAttention.php b/app/Filament/Widgets/Dashboard/NeedsAttention.php new file mode 100644 index 0000000..4c7b76d --- /dev/null +++ b/app/Filament/Widgets/Dashboard/NeedsAttention.php @@ -0,0 +1,117 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'items' => [], + ]; + } + + $tenantId = (int) $tenant->getKey(); + + $items = []; + + $highSeverityCount = (int) Finding::query() + ->where('tenant_id', $tenantId) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('status', Finding::STATUS_NEW) + ->where('severity', Finding::SEVERITY_HIGH) + ->count(); + + if ($highSeverityCount > 0) { + $items[] = [ + 'title' => 'High severity drift findings', + 'body' => "{$highSeverityCount} finding(s) need review.", + 'url' => FindingResource::getUrl('index', tenant: $tenant), + ]; + } + + $latestDriftSuccess = OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('type', 'drift.generate') + ->where('status', 'completed') + ->where('outcome', 'succeeded') + ->whereNotNull('completed_at') + ->latest('completed_at') + ->first(); + + if (! $latestDriftSuccess) { + $items[] = [ + 'title' => 'No drift scan yet', + 'body' => 'Generate drift after you have at least two successful inventory runs.', + 'url' => DriftLanding::getUrl(tenant: $tenant), + ]; + } else { + $isStale = $latestDriftSuccess->completed_at?->lt(now()->subDays(7)) ?? true; + + if ($isStale) { + $items[] = [ + 'title' => 'Drift stale', + 'body' => 'Last drift scan is older than 7 days.', + 'url' => DriftLanding::getUrl(tenant: $tenant), + ]; + } + } + + $latestDriftFailure = OperationRun::query() + ->where('tenant_id', $tenantId) + ->where('type', 'drift.generate') + ->where('status', 'completed') + ->where('outcome', 'failed') + ->latest('id') + ->first(); + + if ($latestDriftFailure instanceof OperationRun) { + $items[] = [ + 'title' => 'Drift generation failed', + 'body' => 'Investigate the latest failed run.', + 'url' => OperationRunLinks::view($latestDriftFailure, $tenant), + ]; + } + + $activeRuns = (int) OperationRun::query() + ->where('tenant_id', $tenantId) + ->active() + ->count(); + + if ($activeRuns > 0) { + $items[] = [ + 'title' => 'Operations in progress', + 'body' => "{$activeRuns} run(s) are active.", + 'url' => OperationRunLinks::index($tenant), + ]; + } + + return [ + 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, + 'items' => $items, + ]; + } +} diff --git a/app/Filament/Widgets/Dashboard/RecentDriftFindings.php b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php new file mode 100644 index 0000000..8483da0 --- /dev/null +++ b/app/Filament/Widgets/Dashboard/RecentDriftFindings.php @@ -0,0 +1,49 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'findings' => collect(), + ]; + } + + $tenantId = (int) $tenant->getKey(); + + /** @var Collection $findings */ + $findings = Finding::query() + ->where('tenant_id', $tenantId) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->latest('created_at') + ->limit(10) + ->get(); + + return [ + 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, + 'findings' => $findings, + ]; + } +} diff --git a/app/Filament/Widgets/Dashboard/RecentOperations.php b/app/Filament/Widgets/Dashboard/RecentOperations.php new file mode 100644 index 0000000..a5640b6 --- /dev/null +++ b/app/Filament/Widgets/Dashboard/RecentOperations.php @@ -0,0 +1,57 @@ + + */ + protected function getViewData(): array + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + return [ + 'pollingInterval' => null, + 'runs' => collect(), + 'viewRunBaseUrl' => null, + ]; + } + + $tenantId = (int) $tenant->getKey(); + + /** @var Collection $runs */ + $runs = OperationRun::query() + ->where('tenant_id', $tenantId) + ->latest('created_at') + ->limit(10) + ->get() + ->each(function (OperationRun $run) use ($tenant): void { + $run->setAttribute('type_label', OperationCatalog::label((string) $run->type)); + $run->setAttribute('view_url', OperationRunLinks::view($run, $tenant)); + }); + + return [ + 'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, + 'runs' => $runs, + ]; + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 76385ed..6684dfe 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -3,12 +3,12 @@ namespace App\Providers\Filament; use App\Filament\Pages\Tenancy\RegisterTenant; +use App\Filament\Pages\TenantDashboard; use App\Models\Tenant; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; -use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; @@ -51,7 +51,7 @@ public function panel(Panel $panel): Panel ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->pages([ - Dashboard::class, + TenantDashboard::class, ]) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') ->widgets([ diff --git a/app/Support/OpsUx/ActiveRuns.php b/app/Support/OpsUx/ActiveRuns.php new file mode 100644 index 0000000..3b8989d --- /dev/null +++ b/app/Support/OpsUx/ActiveRuns.php @@ -0,0 +1,19 @@ +where('tenant_id', $tenant->getKey()) + ->active() + ->exists(); + } +} diff --git a/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php b/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php new file mode 100644 index 0000000..da6c92a --- /dev/null +++ b/resources/views/filament/widgets/dashboard/dashboard-kpis.blade.php @@ -0,0 +1,28 @@ +
+
+
+
Open drift findings
+
{{ $openDriftFindings }}
+
+ +
+
High severity drift
+
{{ $highSeverityDriftFindings }}
+
+ +
+
Active operations
+
{{ $activeRuns }}
+
+ +
+
Inventory active
+
{{ $inventoryActiveRuns }}
+
+
+
diff --git a/resources/views/filament/widgets/dashboard/needs-attention.blade.php b/resources/views/filament/widgets/dashboard/needs-attention.blade.php new file mode 100644 index 0000000..e450088 --- /dev/null +++ b/resources/views/filament/widgets/dashboard/needs-attention.blade.php @@ -0,0 +1,26 @@ +
+
+
Needs Attention
+ + @if (count($items) === 0) +
Nothing urgent right now.
+ @else +
+ @foreach ($items as $item) + +
{{ $item['title'] }}
+
{{ $item['body'] }}
+
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php b/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php new file mode 100644 index 0000000..ca3b325 --- /dev/null +++ b/resources/views/filament/widgets/dashboard/recent-drift-findings.blade.php @@ -0,0 +1,34 @@ +
+
+
Recent Drift Findings
+
+ +
+ @if ($findings->isEmpty()) +
No drift findings yet.
+ @else +
+ @foreach ($findings as $finding) +
+
+
+ {{ $finding->subject_type }} · {{ $finding->subject_external_id }} +
+
+ {{ $finding->created_at?->diffForHumans() }} +
+
+
+ Severity: {{ $finding->severity }} · Status: {{ $finding->status }} +
+
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/filament/widgets/dashboard/recent-operations.blade.php b/resources/views/filament/widgets/dashboard/recent-operations.blade.php new file mode 100644 index 0000000..8aaa690 --- /dev/null +++ b/resources/views/filament/widgets/dashboard/recent-operations.blade.php @@ -0,0 +1,37 @@ +
+
+
Recent Operations
+
+ + +
diff --git a/scripts/mcp_gitea_smoke.py b/scripts/mcp_gitea_smoke.py new file mode 100644 index 0000000..0906ead --- /dev/null +++ b/scripts/mcp_gitea_smoke.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 + +import json +import subprocess +import sys +import time + + +def send(proc: subprocess.Popen[bytes], payload: dict) -> None: + proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8")) + proc.stdin.flush() + + +def read_line(proc: subprocess.Popen[bytes], timeout: float = 10.0) -> str: + start = time.time() + while time.time() - start < timeout: + line = proc.stdout.readline() + if line: + return line.decode("utf-8", errors="replace").strip() + time.sleep(0.05) + return "" + + +def main() -> int: + proc = subprocess.Popen( + ["python3", "scripts/run-gitea-mcp.py"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + try: + send( + proc, + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "smoke", "version": "0.0.0"}, + }, + }, + ) + init_resp = read_line(proc, timeout=15.0) + if not init_resp: + err = proc.stderr.read(4096).decode("utf-8", errors="replace") + sys.stderr.write("No initialize response. stderr:\n" + err + "\n") + return 1 + + print("initialize:", init_resp[:400]) + + send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + tools_resp = read_line(proc, timeout=15.0) + if not tools_resp: + err = proc.stderr.read(4096).decode("utf-8", errors="replace") + sys.stderr.write("No tools/list response. stderr:\n" + err + "\n") + return 1 + + print("tools/list:", tools_resp[:400]) + + send( + proc, + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "get_my_user_info", "arguments": {}}, + }, + ) + me_resp = read_line(proc, timeout=20.0) + if not me_resp: + err = proc.stderr.read(4096).decode("utf-8", errors="replace") + sys.stderr.write("No get_my_user_info response. stderr:\n" + err + "\n") + return 1 + + print("get_my_user_info:", me_resp[:500]) + return 0 + finally: + proc.terminate() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run-gitea-mcp.py b/scripts/run-gitea-mcp.py new file mode 100644 index 0000000..302ed88 --- /dev/null +++ b/scripts/run-gitea-mcp.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 + +import os +import subprocess +import sys +from pathlib import Path + + +def load_dotenv(path: Path) -> dict[str, str]: + env: dict[str, str] = {} + + if not path.exists(): + return env + + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + + if value and value[0] == "'" and value.endswith("'"): + value = value[1:-1] + elif value and value[0] == '"' and value.endswith('"'): + value = value[1:-1] + + env[key] = value + + return env + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[1] + dotenv_path = repo_root / ".env" + + dotenv = load_dotenv(dotenv_path) + + required = ["GITEA_HOST", "GITEA_ACCESS_TOKEN"] + missing = [k for k in required if not dotenv.get(k)] + if missing: + sys.stderr.write( + "Missing required env vars in .env: " + ", ".join(missing) + "\n" + ) + return 2 + + env = os.environ.copy() + + # Prefer values from .env to keep VS Code config simple. + for key in [ + "GITEA_HOST", + "GITEA_ACCESS_TOKEN", + "GITEA_DEBUG", + "GITEA_READONLY", + "GITEA_INSECURE", + ]: + if key in dotenv: + env[key] = dotenv[key] + + cmd = [ + "docker", + "run", + "-i", + "--rm", + "-e", + "GITEA_HOST", + "-e", + "GITEA_ACCESS_TOKEN", + ] + + # Optional flags. + for optional in ["GITEA_DEBUG", "GITEA_READONLY", "GITEA_INSECURE"]: + if env.get(optional): + cmd.extend(["-e", optional]) + + cmd.append("docker.gitea.com/gitea-mcp-server:latest") + + # Inherit stdin/stdout/stderr for MCP stdio. + completed = subprocess.run(cmd, env=env, check=False) + return int(completed.returncode) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/specs/057-filament-v5-upgrade/checklists/requirements.md b/specs/057-filament-v5-upgrade/checklists/requirements.md index 53ae050..53e00ba 100644 --- a/specs/057-filament-v5-upgrade/checklists/requirements.md +++ b/specs/057-filament-v5-upgrade/checklists/requirements.md @@ -30,6 +30,5 @@ ## Feature Readiness - [x] No implementation details leak into specification ## Notes - -- This is a technical upgrade, but the spec intentionally describes outcomes and guardrails rather than implementation steps. -- Proceed to planning: identify concrete dependencies, sequencing, and validation steps in plan/tasks. +- This is a technical upgrade, but the spec describes outcomes and guardrails rather than implementation steps. +- Planning can capture concrete versions, dependency changes, and migration steps. diff --git a/specs/057-filament-v5-upgrade/spec.md b/specs/057-filament-v5-upgrade/spec.md index bfac543..b60d6e3 100644 --- a/specs/057-filament-v5-upgrade/spec.md +++ b/specs/057-filament-v5-upgrade/spec.md @@ -3,7 +3,7 @@ # Feature Specification: Admin UI Framework Upgrade (Panel + Suite) **Feature Branch**: `057-filament-v5-upgrade` **Created**: 2026-01-20 **Status**: Draft -**Input**: Upgrade the existing admin UI framework to the next supported major release to maintain compatibility and support, and ensure no regressions for tenant isolation and Monitoring/Operations safety guardrails. +**Input**: Upgrade the existing admin UI stack to the next supported major release to maintain compatibility and support, and ensure no regressions for tenant isolation and Monitoring/Operations safety guardrails. ## Clarifications @@ -19,7 +19,7 @@ ## User Scenarios & Testing *(mandatory)* ### User Story 1 - Admin UI keeps working after upgrade (Priority: P1) -Administrators can sign in and use the admin panel (navigation, resources, forms, tables, actions) without runtime errors after the upgrade. +Administrators can sign in and use the admin panel (navigation, lists, forms, actions) without runtime errors after the upgrade. **Why this priority**: This is the minimum bar for the upgrade to be safe; if the admin UI is unstable, all operational work stops. @@ -74,19 +74,18 @@ ## Requirements *(mandatory)* All content rendered under the Monitoring → Operations navigation section remains “DB-only” and must not perform any outbound HTTP requests during render or during background/automatic UI requests (polling/auto-refresh/hydration). Tenant isolation remains mandatory. ### Functional Requirements - -- **FR-001**: The application MUST upgrade the admin panel framework to the next supported major release and remain fully functional for all in-scope admin workflows. -- **FR-002**: The application MUST continue to support the existing styling and asset build process without build failures. -- **FR-003**: All existing admin panel pages MUST load successfully for authorized users and preserve core interactions (navigation, lists, forms, actions, notifications, and widgets). -- **FR-004**: In-app navigation between admin pages MUST continue to work reliably, including global widget mounting and event-driven UI behavior. -- **FR-005**: Everything rendered under the Monitoring → Operations navigation section (including widgets/partials/tabs) MUST remain DB-only: no outbound HTTP requests are permitted during page render or during background/automatic UI requests (polling/auto-refresh/hydration). -- **FR-010**: Any remote work initiated from Monitoring/Operations pages MUST be triggered only by explicit user actions (e.g., buttons) and MUST enqueue a tracked operation with a persisted run record rather than performing outbound HTTP inline. +- **FR-001**: The system MUST upgrade the admin UI stack to the next supported major release and remain fully functional for all in-scope admin workflows. +- **FR-002**: The system MUST continue to support the existing styling and asset pipeline without build failures. +- **FR-003**: All existing admin pages MUST load successfully for authorized users and preserve core interactions (navigation, lists, forms, actions, notifications, and global UI elements). +- **FR-004**: In-app navigation between admin pages MUST continue to work reliably, including any global progress indicators and event-driven UI behavior. +- **FR-005**: Everything rendered under the Monitoring → Operations navigation section (including widgets/partials/tabs) MUST remain DB-only: no outbound HTTP requests are permitted during page render or during background/automatic requests (polling/auto-refresh/hydration). - **FR-006**: Tenant isolation MUST be preserved across requests and interactive UI behavior: all reads/writes/events/caches MUST scope to the active tenant. - **FR-007**: Compatibility risks MUST be managed by producing an explicit inventory of affected third-party dependencies and documenting upgrade/replacement decisions. -- **FR-011**: If a third-party package is incompatible with the upgraded stack, the system MUST preserve equivalent functionality by upgrading or replacing the package; mixed-version pinning is not allowed. Any unavoidable feature loss MUST be handled as an explicit scope/decision change. -- **FR-012**: Database migrations are allowed only if strictly required for compatibility; they MUST be reversible and non-destructive (no data loss) and MUST be mentioned in release notes. - **FR-008**: The upgrade MUST not introduce new Microsoft Graph read/write behavior; if any Graph-touching behavior changes are required, they MUST be explicitly specified with safety gates and observability updates. - **FR-009**: The upgrade MUST include a documented rollback procedure that restores the previous working state. +- **FR-010**: Any remote work initiated from Monitoring/Operations pages MUST be triggered only by explicit user actions and MUST enqueue a tracked operation (with an observable run record) rather than performing outbound HTTP inline. +- **FR-011**: If a third-party dependency is incompatible with the upgraded stack, the system MUST preserve equivalent functionality by upgrading or replacing the dependency; mixed-version pinning is not allowed. Any unavoidable feature loss MUST be handled as an explicit scope/decision change. +- **FR-012**: Database migrations are allowed only if strictly required for compatibility; they MUST be reversible and non-destructive (no data loss) and MUST be mentioned in release notes. ### Assumptions & Dependencies @@ -98,7 +97,7 @@ ### Key Entities *(include if feature involves data)* - **Tenant**: The active tenant context that scopes all data access and UI state. - **Run Record**: The persisted record of long-running operations shown in Monitoring/Operations views. -- **Audit Log**: The tenant-scoped audit trail used to retain accountability for sensitive actions. +- **AuditLog**: The tenant-scoped audit trail used to retain accountability for sensitive actions. ## Success Criteria *(mandatory)* diff --git a/specs/058-tenant-ui-polish/checklists/requirements.md b/specs/058-tenant-ui-polish/checklists/requirements.md new file mode 100644 index 0000000..e4bf746 --- /dev/null +++ b/specs/058-tenant-ui-polish/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Tenant UI Polish (v1) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-20 +**Feature**: [specs/058-tenant-ui-polish/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 + +- Validation run: 2026-01-20 +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` diff --git a/specs/058-tenant-ui-polish/contracts/polling.md b/specs/058-tenant-ui-polish/contracts/polling.md new file mode 100644 index 0000000..30ee404 --- /dev/null +++ b/specs/058-tenant-ui-polish/contracts/polling.md @@ -0,0 +1,22 @@ +# Polling Contract — Calm UI Rules + +## Principle +Polling is allowed only when it materially improves UX (active operations). It must be DB-only and must stop when no longer needed. + +## Dashboard +- Polling is enabled only while active runs exist (queued/running) for the current tenant. +- Polling is disabled when: + - No active runs exist. + +## Operations index +- Polling is enabled only while active runs exist. +- Polling is disabled when: + - No active runs exist. + +## Modals +- No polling inside modals. +- When a modal is open, polling should not cause churn in the background. + +## Technical approach +- Widgets: use `$pollingInterval = null` to disable polling. +- Tables: apply `$table->poll('10s')` only when active runs exist. diff --git a/specs/058-tenant-ui-polish/contracts/ui.md b/specs/058-tenant-ui-polish/contracts/ui.md new file mode 100644 index 0000000..b6b6256 --- /dev/null +++ b/specs/058-tenant-ui-polish/contracts/ui.md @@ -0,0 +1,28 @@ +# UI Contracts — Tenant UI Polish + +This feature does not introduce HTTP APIs. These contracts describe UI routing, filters, and definitions that must remain stable. + +## Routes (tenant-scoped) +- Dashboard: tenant dashboard page (new custom page; replaces default dashboard entry). +- Inventory hub: Inventory cluster root (routes to first page/resource in cluster). +- Inventory items: Inventory items resource index, under cluster prefix. +- Inventory sync runs: Inventory sync runs resource index, under cluster prefix. +- Inventory coverage: Inventory coverage page, under cluster prefix. +- Operations index: `OperationRunResource` index (`/operations`). +- Operation run detail: `OperationRunResource` view page. + +## Operations Tabs (FR-009) +Tabs filter the Operations table by: +- All: no extra constraints. +- Active: `status IN ('queued','running')` +- Succeeded: `status = 'completed' AND outcome = 'succeeded'` +- Partial: `status = 'completed' AND outcome = 'partial'` +- Failed: `status = 'completed' AND outcome = 'failed'` + +## KPI Definitions +- Inventory coverage % = Restorable / Total (Partial is separate, does not inflate %). +- Drift stale threshold = 7 days. +- “Recent” lists default size = 10. +- “Active operations” shows two counts: + - All active runs (queued + running) + - Inventory-active runs (type = `inventory.sync`, queued + running) diff --git a/specs/058-tenant-ui-polish/data-model.md b/specs/058-tenant-ui-polish/data-model.md new file mode 100644 index 0000000..d328232 --- /dev/null +++ b/specs/058-tenant-ui-polish/data-model.md @@ -0,0 +1,76 @@ +# Data Model — Tenant UI Polish + +This feature is read-only. It introduces no schema changes. + +## Entities + +### Tenant +- **Role**: scope boundary for all queries. +- **Source**: `Tenant::current()` (Filament tenancy). + +### OperationRun +- **Role**: operations feed, KPIs, and canonical “View run” destinations. +- **Key fields used** (existing): + - `tenant_id` + - `type` + - `status` (`queued|running|completed`) + - `outcome` (`succeeded|partial|failed|...`) + - `created_at`, `started_at`, `completed_at` + - `summary_counts`, `failure_summary` (JSONB) + +**Derived values**: +- **Active**: `status IN ('queued','running')` +- **Terminal**: `status = 'completed'` +- **Avg duration (7 days)**: only terminal runs with `started_at` and `completed_at`. + +### InventoryItem +- **Role**: inventory totals and coverage chips. +- **Key fields used** (existing, inferred from resources): + - `tenant_id` + - coverage-related flags / fields used to categorize: Restorable, Partial, Risk, Dependencies + +**Derived values**: +- Total items +- Coverage % = `restorable / total` (if total > 0) +- Chip counts: Restorable, Partial, Risk, Dependencies + +### InventorySyncRun +- **Role**: “Last Inventory Sync” and “Sync Runs” list. +- **Key fields used**: + - `tenant_id` + - status + timestamps + - any “selection_hash / selection payload” metadata used for display + +### Finding (Drift Finding) +- **Role**: drift KPIs and “Needs Attention”. +- **Key fields used** (existing migration): + - `tenant_id` + - `severity` (enum-like string) + - `status` (open/closed) + - timestamps + - `scope_key` for grouping + +**Derived values**: +- Open findings by severity +- Staleness: last drift scan older than 7 days + +## KPI Queries (read-only) + +### Dashboard +- Drift KPIs: counts of open findings by severity + stale drift indicator. +- Operations health: counts of active runs + failed/partial recent. +- Recent lists: latest 10 findings + latest 10 operation runs. + +### Inventory hub +- Total items +- Coverage % (restorable/total) +- Last inventory sync (status + timestamp) +- Active operations: (all active runs) + (inventory.sync active runs) + +### Operations index +- Total runs (30d) +- Active runs (queued + running) +- Failed/partial (7d) +- Avg duration (7d, terminal runs only) + +All queries must be tenant-scoped. diff --git a/specs/058-tenant-ui-polish/plan.md b/specs/058-tenant-ui-polish/plan.md new file mode 100644 index 0000000..540c767 --- /dev/null +++ b/specs/058-tenant-ui-polish/plan.md @@ -0,0 +1,164 @@ +# Implementation Plan: Tenant UI Polish (Dashboard + Inventory Hub + Operations) + +**Branch**: `058-tenant-ui-polish` | **Date**: 2026-01-20 | **Spec**: `specs/058-tenant-ui-polish/spec.md` +**Input**: Feature specification from `specs/058-tenant-ui-polish/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +- Build a drift-first, tenant-scoped dashboard with “Needs Attention” and recent lists. +- Make Inventory a hub using a Filament cluster to provide consistent left-side sub-navigation across Items / Sync Runs / Coverage. +- Upgrade Operations index to “orders-style” with KPIs + status tabs filtering the existing `OperationRunResource` table. +- Enforce DB-only renders (and DB-only polling) and a calm UI: polling only while active runs exist, and no polling churn in modals. + +## Technical Context + + + +**Language/Version**: PHP 8.4.15 (Laravel 12.47.0) +**Primary Dependencies**: Filament v5.0.0, Livewire v4.0.1 +**Storage**: PostgreSQL +**Testing**: Pest v4 (+ PHPUnit v12 runtime) +**Target Platform**: Web application (Filament admin panel) +**Project Type**: Web (Laravel monolith) +**Performance Goals**: Dashboard/Inventory/Operations render quickly (target <2s for typical tenants) with efficient tenant-scoped queries and no N+1. +**Constraints**: DB-only for all page renders and any polling/auto-refresh; avoid UI churn in modals. +**Scale/Scope**: Tenant-scoped surfaces; KPI math on existing `operation_runs`, `findings`, inventory tables. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: clarify what is “last observed” vs snapshots/backups +- Read/write separation: any writes require preview + confirmation + audit + tests +- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php` +- Deterministic capabilities: capability derivation is testable (snapshot/golden tests) +- 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 +- 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 + +Status: ✅ No constitution violations for this feature (read-only, DB-only, tenant-scoped; no Graph calls added). + +## Project Structure + +### Documentation (this feature) + +```text +specs/058-tenant-ui-polish/ +├── 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/ +├── Filament/ +│ ├── Clusters/ # New: Inventory cluster +│ ├── Pages/ # New/updated: tenant dashboard, inventory landing, coverage +│ ├── Resources/ # Updated: attach inventory resources to cluster; operations tabs/KPIs +│ └── Widgets/ # New/updated: KPI header widgets +├── Models/ # Existing: Tenant, OperationRun, Finding, InventoryItem, InventorySyncRun +└── Providers/Filament/ + └── AdminPanelProvider.php # Update: discoverClusters(), dashboard page class + +resources/ +└── views/ # Optional: partials/views for dashboard sections + +tests/ +└── Feature/ + ├── Monitoring/ # Existing: Operations DB-only + tenant scope tests + └── Filament/ # Existing + new: Inventory/Dashboard page tests +``` + +**Structure Decision**: Laravel monolith + Filament (v5) conventions. Implement UI changes via: +- Filament Pages (dashboard + inventory pages) +- Filament Clusters (inventory sub-navigation) +- Filament Widgets (KPI headers / recent lists) +- Filament Resource list tabs (operations index filtering) + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +None. + +## Phase 0 — Outline & Research (complete) + +- Output: `specs/058-tenant-ui-polish/research.md` +- Key decisions captured: + - Use Filament clusters for the Inventory hub sub-navigation. + - Use Filament widgets for KPI headers. + - Enable polling only while active runs exist. + +## Phase 1 — Design & Contracts (complete) + +### Data model +- Output: `specs/058-tenant-ui-polish/data-model.md` +- No schema changes required. + +### UI contracts +- Output: `specs/058-tenant-ui-polish/contracts/ui.md` +- Output: `specs/058-tenant-ui-polish/contracts/polling.md` + +### Provider registration (Laravel 11+) +- Panel providers remain registered in `bootstrap/providers.php` (no changes required for this feature unless adding a new provider). + +### Livewire / Filament version safety +- Livewire v4.0+ (required by Filament v5) is in use. + +### Asset strategy +- Prefer existing Filament theme CSS and hook classes; avoid publishing Filament internal views. +- No heavy assets expected; if any new panel assets are added, ensure deployment runs `php artisan filament:assets`. + +### Destructive actions +- None introduced in this feature. + +### Constitution re-check (post-design) +- ✅ Inventory-first: dashboard uses Inventory/Findings/OperationRun as last-observed state. +- ✅ Read/write separation: this feature is read-only. +- ✅ Graph contract path: no Graph calls added. +- ✅ Tenant isolation: all queries remain tenant-scoped. +- ✅ Run observability: only consumes existing `OperationRun` records; no new long-running work is introduced. +- ✅ Data minimization: no new payload storage. + +## Phase 2 — Implementation Plan (next) + +### Story 1 (P1): Drift-first tenant dashboard +- Create a custom Filament dashboard page (tenant-scoped) and wire it in `AdminPanelProvider` instead of the default `Dashboard::class`. +- Implement drift + ops KPIs and “Needs Attention” + recent lists using DB-only Eloquent queries. +- Implement conditional polling (only while active runs exist) using widget polling controls. +- Tests: + - Add DB-only coverage tests for the dashboard (no outbound HTTP; no queued jobs on render). + - Add tenant scope tests for the dashboard. + +### Story 2 (P2): Inventory becomes a hub +- Add `discoverClusters()` to `AdminPanelProvider`. +- Create `InventoryCluster` and assign `$cluster` on inventory pages/resources. +- Add a shared inventory KPI header (widget) across the cluster surfaces. +- Tests: + - Extend existing inventory page tests to assert cluster pages load and remain tenant-scoped. + +### Story 3 (P3): Operations index “orders-style” +- Update `OperationRunResource` list page to: + - Add KPI header widgets. + - Add tabs: All / Active / Succeeded / Partial / Failed. + - Enable table polling only while active runs exist. +- Tests: + - Extend operations tests to assert page renders with tabs and remains DB-only/tenant-scoped. diff --git a/specs/058-tenant-ui-polish/quickstart.md b/specs/058-tenant-ui-polish/quickstart.md new file mode 100644 index 0000000..70a0ffb --- /dev/null +++ b/specs/058-tenant-ui-polish/quickstart.md @@ -0,0 +1,27 @@ +# Quickstart — Tenant UI Polish + +## Prereqs +- Run everything via Sail. + +## Setup +- `vendor/bin/sail up -d` +- `vendor/bin/sail composer install` + +## Run tests (targeted) +- `vendor/bin/sail artisan test tests/Feature/Monitoring/OperationsDbOnlyTest.php` +- `vendor/bin/sail artisan test tests/Feature/Monitoring/OperationsTenantScopeTest.php` +- `vendor/bin/sail artisan test tests/Feature/Filament/InventoryPagesTest.php` + +When the feature is implemented, add + run: +- Dashboard DB-only + tenant scope tests (new). + +## Manual QA (tenant-scoped) +- Sign in, select a tenant. +- Visit Dashboard: verify drift/ops KPIs, needs attention, and recent lists. +- Visit Inventory cluster: Items / Sync Runs / Coverage share left sub-navigation and KPI header. +- Visit Operations (`/operations`): KPI header + tabs filter table. + +## Frontend assets +If UI changes don’t show: +- `vendor/bin/sail npm run dev` +- or `vendor/bin/sail npm run build` diff --git a/specs/058-tenant-ui-polish/research.md b/specs/058-tenant-ui-polish/research.md new file mode 100644 index 0000000..b436fe7 --- /dev/null +++ b/specs/058-tenant-ui-polish/research.md @@ -0,0 +1,66 @@ +# Research — Tenant UI Polish (Dashboard + Inventory Hub + Operations) + +## Goal +Deliver a drift-first, tenant-scoped UI polish pass that is: +- DB-only on render and on any auto-refresh. +- Calm (polling only when needed; no modal churn). +- Consistent IA (Inventory hub sub-navigation; canonical Operations). + +## Existing Code & Patterns (to reuse) + +### Operations +- Canonical list/detail already exist via `OperationRunResource` (`/operations`). +- Tenant scoping already enforced in `OperationRunResource::getEloquentQuery()`. +- Detail view already uses conditional polling with safeguards (tab hidden / modal open) via `RunDetailPolling`. + +### Inventory +- Inventory entry page exists as `InventoryLanding`. +- Inventory “Items” and “Sync Runs” are currently resources (`InventoryItemResource`, `InventorySyncRunResource`). +- Inventory sync “start surface” already follows constitution rules: authorize → create/reuse `OperationRun` → enqueue job → “View run”. + +### Monitoring DB-only + Tenant isolation tests +- Monitoring/Operations has DB-only tests and tenant scope tests. +- Inventory landing + coverage have basic smoke tests. + +## Key Decisions + +### Decision: Use Filament clusters to implement the Inventory “hub” navigation +- **Decision**: Create an Inventory cluster and attach: + - `InventoryLanding` (page) + - Inventory items resource + - Inventory sync runs resource + - `InventoryCoverage` (page) +- **Rationale**: Filament clusters are designed for “common sub-navigation between pages”, including mixing pages and resources. +- **Notes**: + - Requires enabling cluster discovery in the panel provider. + - Sub-navigation position will be set to `Start` to achieve left-side navigation. + +### Decision: Implement KPI headers as widgets (StatsOverviewWidget / TableWidget) +- **Decision**: Use Filament widgets for KPI headers on: + - Tenant dashboard (drift + ops) + - Inventory hub (inventory KPIs) + - Operations index (ops KPIs) +- **Rationale**: Widgets are first-class, composable, and can optionally poll (with `$pollingInterval`) while remaining DB-only. + +### Decision: “Calm UI” auto-refresh strategy +- **Decision**: + - Dashboard + Operations index: enable polling only while active runs exist. + - Widgets/tables: polling is disabled when no active runs exist. + - No polling inside modals. +- **Rationale**: Matches FR-012 and avoids background churn. +- **Implementation approach**: + - Use Filament polling mechanisms: + - Widgets: `$pollingInterval = null | '10s'` depending on “active runs exist”. + - Tables: enable `$table->poll('10s')` only when “active runs exist”. + +### Decision: No Graph / remote dependencies +- **Decision**: All queries for this feature are Eloquent/PostgreSQL queries. +- **Rationale**: Matches constitution and SC-005. + +## Alternatives Considered +- **Custom Blade layouts for hub navigation**: Rejected because clusters provide consistent sub-nav across resources/pages without fragile view overrides. +- **Always-on polling**: Rejected to comply with calm UI rules and avoid waste. +- **Keep `Monitoring/Operations` as canonical**: Rejected because `OperationRunResource` is already the canonical Operations surface with correct routing and detail pages. + +## Open Questions +None — all “NEEDS CLARIFICATION” items are resolved for planning. diff --git a/specs/058-tenant-ui-polish/spec.md b/specs/058-tenant-ui-polish/spec.md new file mode 100644 index 0000000..eed7ac4 --- /dev/null +++ b/specs/058-tenant-ui-polish/spec.md @@ -0,0 +1,218 @@ +# Feature Specification: Tenant UI Polish (Dashboard + Inventory Hub + Operations) + +**Feature Branch**: `058-tenant-ui-polish` +**Created**: 2026-01-20 +**Status**: Draft +**Input**: User description: "Feature 058 — Tenant UI Polish: Dashboard + Inventory Hub + Operations \"Orders-style\" (v1)" + +## Clarifications + +### Session 2026-01-20 + +- Q: Coverage % definition for Inventory KPI header? → A: Coverage % = Restorable / Total (Partial remains a separate chip/number; main % stays conservative) +- Q: Drift stale threshold (last scan older than X days)? → A: 7 days +- Q: Inventory KPI “Active Operations” definition? → A: Show both counts: All active runs (queued + running) and Inventory-active runs (queued + running) +- Q: How many rows in “Recent” lists by default? → A: 10 + +### Session 2026-01-21 + +- Q: Operations index "Stuck" tab in v1? -> A: No "Stuck" tab in v1 + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Drift-first tenant dashboard (Priority: P1) + +As a tenant admin, I can open a tenant-scoped dashboard that immediately surfaces drift risk and operations health, without triggering any remote calls. + +**Why this priority**: This is the primary entry point for day-to-day operations and should be actionable at a glance. + +**Independent Test**: Visiting the dashboard shows drift + operations KPIs, a “needs attention” list with working CTAs, and recent lists, while confirming no outbound HTTP happens during render and any background UI updates. + +**Acceptance Scenarios**: + +1. **Given** I am signed in to a tenant, **When** I open the Dashboard, **Then** I see tenant-scoped drift KPIs, operations health KPIs, and recent lists. +2. **Given** there are urgent drift issues (e.g., high severity open findings), **When** I view the Dashboard, **Then** they appear in the “Needs Attention” section with a CTA that navigates to a filtered view. +3. **Given** drift generation has a recent failed run, **When** I view the Dashboard, **Then** I can navigate from “Needs Attention” to the related operation run details. +4. **Given** there is no drift data yet, **When** I view the Dashboard, **Then** the dashboard renders calmly with empty-state messaging and no errors. +5. **Given** the last drift scan is older than 7 days, **When** I view the Dashboard, **Then** “Needs Attention” includes a “Drift stale” item with a CTA to investigate. +6. **Given** there are more than 10 drift findings and operation runs, **When** I view the Dashboard, **Then** each “Recent” list shows the 10 most recent items. + +--- + +### User Story 2 - Inventory becomes a hub module (Priority: P2) + +As a tenant admin, I can use Inventory as a “hub” with consistent sub-navigation and a shared KPI header across Inventory subpages. + +**Why this priority**: Inventory is a high-traffic area; a hub layout reduces cognitive load and makes it easier to find the right view quickly. + +**Independent Test**: Navigating Inventory Items / Sync Runs / Coverage keeps the same shared KPI header, the left sub-navigation is consistent, and all data remains tenant-scoped and DB-only. + +**Acceptance Scenarios**: + +1. **Given** I am signed in to a tenant, **When** I open Inventory, **Then** I see hub navigation (Items / Sync Runs / Coverage) and a shared KPI header. +2. **Given** I switch between Inventory subpages, **When** I navigate Items → Sync Runs → Coverage, **Then** the KPI header remains visible and consistent. +3. **Given** the tenant has an inventory sync run history, **When** I open “Sync Runs”, **Then** I see only sync runs relevant to inventory synchronization. + +--- + +### User Story 3 - Operations index “Orders-style” (Priority: P3) + +As a tenant admin, I can view Operations in an “orders-style” overview (KPIs + status tabs + table) to quickly assess activity and failures. + +**Why this priority**: Operations is the canonical place to investigate work; better scanning and filtering reduces time-to-triage. + +**Independent Test**: Visiting Operations index shows KPI cards and status tabs that correctly filter the table without introducing polling churn or any remote calls. + +**Acceptance Scenarios**: + +1. **Given** I am signed in to a tenant, **When** I open Operations, **Then** I see KPIs, status tabs, and the operations table. +2. **Given** there are active runs, **When** I click the “Active” tab, **Then** the table filters to queued + running runs only. +3. **Given** there are failed runs, **When** I click the “Failed” tab, **Then** the table filters to failed runs only. +4. **Given** I navigate away and back, **When** I return to Operations, **Then** the UI remains calm (no refresh loops) and loads quickly. + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + +- No data yet: dashboard/inventory/operations render with empty states and helpful CTAs. +- Large tenants: KPI calculations remain fast enough to keep pages responsive. +- Mixed outcomes: partial/failed/succeeded runs are correctly categorized and discoverable via tabs/filters. +- Tenant switching: no cross-tenant leakage of KPIs, lists, or links. +- Time windows: KPI windows (e.g., last 7/30 days) handle timezones consistently. +- “Unknown” states: missing duration/end time renders gracefully (e.g., avg duration excludes non-terminal runs). + +## Requirements *(mandatory)* + +**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior, +or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates +(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests. +If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries. + + + +### Functional Requirements + +- **FR-001 (Tenant scope)**: System MUST ensure the Dashboard, Inventory hub, and Operations views are tenant-scoped, with no cross-tenant visibility. +- **FR-002 (DB-only surfaces)**: System MUST keep Dashboard, Inventory hub header, and Operations index DB-only during render and any background UI updates. +- **FR-003 (Placement policy)**: System MUST show KPI cards only on these entry-point pages: Dashboard, Inventory hub (shared header), and Operations index. +- **FR-004 (Inventory hub layout)**: System MUST provide an Inventory hub with left sub-navigation for Items, Sync Runs, and Coverage. +- **FR-005 (Inventory KPIs)**: Inventory hub MUST show a shared KPI header across Inventory subpages with: + - Total Items + - Coverage % (restorable items / total items; partial shown separately) + - Last Inventory Sync (status + timestamp) + - Active Operations (queued + running), showing both: + - All active runs + - Inventory-active runs +- **FR-006 (Inventory sync runs view)**: System MUST provide a “Sync Runs” view that lists only inventory synchronization runs. +- **FR-007 (Coverage chips)**: System MUST standardize coverage chips to this set only: Restorable, Partial, Risk, Dependencies. +- **FR-008 (Operations index KPIs)**: Operations index MUST show tenant-scoped KPIs: + - Total Runs (30 days) + - Active Runs (queued + running) + - Failed/Partial (7 days) + - Avg Duration (7 days, terminal runs only) +- **FR-009 (Operations tabs)**: Operations index MUST provide status tabs that filter the operations table: All, Active, Succeeded, Partial, Failed. No "Stuck" tab in v1. +- **FR-010 (Canonical terminology)**: System MUST use “Operations” as the canonical label (no legacy naming on these surfaces). +- **FR-011 (Canonical links)**: “View run” links MUST always navigate to the canonical operation run detail view. +- **FR-012 (Calm UI rules)**: System MUST avoid polling/churn in modals and avoid refresh loops; background updates should be used only where clearly necessary. Auto-refresh on Dashboard and Operations index is allowed only while active runs (queued/running) exist, and MUST stop when there are no active runs. +- **FR-013 (Drift stale rule)**: System MUST flag drift as “stale” when the last drift scan is older than 7 days and surface it in “Needs Attention” with an investigation CTA. +- **FR-014 (Recent list sizing)**: System MUST show 10 rows by default for “Recent Drift Findings” and “Recent Operations”. + +### OperationRun status mapping (for tabs and KPIs) + +OperationRun uses two canonical fields that drive UI filters: + +- `status`: execution lifecycle (e.g., queued/running/completed) +- `outcome`: terminal result (e.g., succeeded/partially_succeeded/failed/cancelled) + +Tab filters MUST map exactly as: + +- **All**: no status/outcome filter +- **Active**: `status IN (queued, running)` +- **Succeeded**: `status = completed AND outcome = succeeded` +- **Partial**: `status = completed AND outcome = partially_succeeded` +- **Failed**: `status = completed AND outcome = failed` + +Notes: + +- No “Stuck” tab in v1. +- Runs with `outcome = cancelled` appear under **All** only (unless a future “Cancelled” tab is added). +- Any legacy status/outcome values must already be normalized before reaching this UI (out of scope for this feature). + +### KPI window definitions (timestamp basis) + +All KPI windows are tenant-scoped and DB-only. + +- **Total Runs (30 days)**: count OperationRuns by `created_at` within the last 30 days (includes all statuses/outcomes). +- **Active Runs**: current count where `status IN (queued, running)` (no time window). +- **Failed/Partial (7 days)**: count terminal runs where `status = completed AND outcome IN (failed, partially_succeeded)` and `completed_at` is within the last 7 days. +- **Avg Duration (7 days)**: average of `(completed_at - started_at)` for runs where `status = completed`, `started_at` and `completed_at` are present, and `completed_at` is within the last 7 days. + +### Inventory coverage classification (Restorable/Partial/Risk/Dependencies) + +Coverage chips and KPI aggregation MUST derive from the existing “policy type meta” and dependency capability signals (DB-only): + +- `inventory_items.policy_type` +- `config('tenantpilot.supported_policy_types')` meta fields: + - `restore` (e.g., enabled / preview-only) + - `risk` (e.g., medium / medium-high / high) +- Dependency support computed via the existing coverage dependency resolver (based on contracts/config). + +Definitions: + +- **Restorable**: inventory items whose policy type meta has `restore = enabled` +- **Partial**: inventory items whose policy type meta has `restore = preview-only` +- **Risk**: inventory items whose policy type meta has `risk IN (medium-high, high)` +- **Dependencies**: inventory items whose policy type supports dependencies per the existing dependency capability resolver + +Notes: + +- This feature does not redefine coverage semantics; it standardizes UI rendering and KPI aggregation based on the existing policy type meta. +- If a policy type is unknown/missing meta, it MUST be treated conservatively (non-restorable) for KPI aggregation. + +**Assumptions**: +- Drift findings, inventory items, and operation runs already exist as tenant-scoped data sources. +- “Coverage %” is Restorable/Total; Partial is shown separately (e.g., chips/secondary metric). If total is 0, coverage shows as not available. +- “Drift stale” default threshold is 7 days. +- “Recent” list default size is 10. +- Auto-refresh behavior (DB-only): Dashboard and Operations index auto-refresh only while active runs exist; otherwise it stops. +- Creating/generating drift is out of scope unless it can be performed as an explicit, enqueue-only user action that results in an operation run. + +### Key Entities *(include if feature involves data)* + +- **Tenant**: The scope boundary for all dashboards and lists in this feature. +- **Operation Run**: A tenant-scoped record of work execution, including status, timestamps, and outcomes used for Operations KPIs and recent lists. +- **Drift Finding**: A tenant-scoped record representing detected drift, including severity and state (open/closed) used for Dashboard KPIs and “Needs Attention”. +- **Inventory Item**: A tenant-scoped record representing inventory coverage and totals used in the Inventory hub. + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001 (Task speed)**: A tenant admin can reach “what needs attention” (drift/ops) from the dashboard within 30 seconds. +- **SC-002 (Discoverability)**: A tenant admin can find inventory sync runs within 2 clicks from Inventory. +- **SC-003 (Triage efficiency)**: A tenant admin can filter Operations to “Active” or “Failed” within 1 click and identify a run to investigate within 60 seconds. +- **SC-004 (Calm UI)**: No refresh loops are observed on Dashboard, Inventory hub pages, or Operations index during normal navigation. +- **SC-005 (Safety)**: Viewing Dashboard, Inventory hub pages, and Operations index does not trigger any outbound HTTP. diff --git a/specs/058-tenant-ui-polish/tasks.md b/specs/058-tenant-ui-polish/tasks.md new file mode 100644 index 0000000..c4ba39c --- /dev/null +++ b/specs/058-tenant-ui-polish/tasks.md @@ -0,0 +1,192 @@ +--- +description: "Task list for feature implementation" +--- + +# Tasks: Tenant UI Polish (Dashboard + Inventory Hub + Operations) + +**Input**: Design documents from `specs/058-tenant-ui-polish/` + +**Tests**: Required (Pest) — this feature changes runtime UI behavior. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +- [ ] T001 Confirm feature inputs exist: specs/058-tenant-ui-polish/spec.md, specs/058-tenant-ui-polish/plan.md +- [ ] T002 Confirm Phase 0/1 artifacts exist: specs/058-tenant-ui-polish/research.md, specs/058-tenant-ui-polish/data-model.md, specs/058-tenant-ui-polish/contracts/ui.md, specs/058-tenant-ui-polish/contracts/polling.md, specs/058-tenant-ui-polish/quickstart.md + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +- [ ] T003 Create shared helper to detect “active runs exist” for tenant polling in app/Support/OpsUx/ActiveRuns.php +- [ ] T004 [P] Add focused tests for the helper in tests/Feature/OpsUx/ActiveRunsTest.php + +**Checkpoint**: Shared polling predicate exists and is covered. + +--- + +## Phase 3: User Story 1 — Drift-first tenant dashboard (Priority: P1) 🎯 MVP + +**Goal**: Tenant-scoped dashboard that surfaces drift + ops KPIs and “Needs Attention”, DB-only. + +**Independent Test**: Visiting the dashboard renders drift KPIs, ops KPIs, needs-attention CTAs, and recent lists (10 rows), with no outbound HTTP and no background work dispatched. + +### Tests (US1) + +- [ ] T005 [P] [US1] Add DB-only render test (no outbound HTTP, no background work) in tests/Feature/Filament/TenantDashboardDbOnlyTest.php +- [ ] T006 [P] [US1] Add tenant isolation test (no cross-tenant leakage) in tests/Feature/Filament/TenantDashboardTenantScopeTest.php + +### Implementation (US1) + +- [ ] T007 [US1] Create tenant dashboard page in app/Filament/Pages/TenantDashboard.php +- [ ] T008 [US1] Register the tenant dashboard page in app/Providers/Filament/AdminPanelProvider.php (replace default Dashboard page entry) +- [ ] T009 [P] [US1] Create dashboard KPI widget(s) in app/Filament/Widgets/Dashboard/DashboardKpis.php +- [ ] T010 [P] [US1] Create “Needs Attention” widget in app/Filament/Widgets/Dashboard/NeedsAttention.php +- [ ] T011 [P] [US1] Create “Recent Drift Findings” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentDriftFindings.php +- [ ] T012 [P] [US1] Create “Recent Operations” widget (10 rows) in app/Filament/Widgets/Dashboard/RecentOperations.php +- [ ] T013 [US1] Wire widgets into the dashboard page in app/Filament/Pages/TenantDashboard.php (header/sections) and implement conditional polling per specs/058-tenant-ui-polish/contracts/polling.md +- [ ] T014 [US1] Implement drift stale rule (7 days) + CTA wiring in app/Filament/Widgets/Dashboard/NeedsAttention.php +- [ ] T015 [US1] Ensure all dashboard queries are tenant-scoped + DB-only in app/Filament/Pages/TenantDashboard.php and app/Filament/Widgets/Dashboard/*.php + +**Checkpoint**: US1 is shippable as an MVP. + +--- + +## Phase 4: User Story 2 — Inventory becomes a hub module (Priority: P2) + +**Goal**: Inventory becomes a cluster “hub” with consistent left sub-navigation and a shared KPI header across Items / Sync Runs / Coverage. + +**Independent Test**: Navigating Items → Sync Runs → Coverage keeps consistent sub-navigation and shared KPI header, tenant-scoped and DB-only. + +### Tests (US2) + +- [ ] T016 [P] [US2] Add DB-only render test for Inventory hub surfaces in tests/Feature/Filament/InventoryHubDbOnlyTest.php +- [ ] T017 [P] [US2] Extend/adjust inventory navigation smoke coverage in tests/Feature/Filament/InventoryPagesTest.php + +### Implementation (US2) + +- [ ] T018 [US2] Enable cluster discovery in app/Providers/Filament/AdminPanelProvider.php (add `discoverClusters(...)`) +- [ ] T019 [US2] Create Inventory cluster class in app/Filament/Clusters/Inventory/InventoryCluster.php +- [ ] T020 [US2] Assign Inventory cluster to inventory pages in app/Filament/Pages/InventoryLanding.php and app/Filament/Pages/InventoryCoverage.php +- [ ] T021 [US2] Assign Inventory cluster to inventory resources in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/InventorySyncRunResource.php +- [ ] T022 [P] [US2] Create shared Inventory KPI header widget in app/Filament/Widgets/Inventory/InventoryKpiHeader.php +- [ ] T023 [US2] Add Inventory KPI header widget to InventoryLanding in app/Filament/Pages/InventoryLanding.php +- [ ] T024 [US2] Add Inventory KPI header widget to InventoryCoverage in app/Filament/Pages/InventoryCoverage.php +- [ ] T025 [US2] Add Inventory KPI header widget to Inventory items list in app/Filament/Resources/InventoryItemResource.php (or its list page) +- [ ] T026 [US2] Add Inventory KPI header widget to Inventory sync runs list in app/Filament/Resources/InventorySyncRunResource.php (or its list page) +- [ ] T027 [US2] Ensure Inventory KPI definitions match specs/058-tenant-ui-polish/contracts/ui.md (coverage % restorable/total; partial separate; two active operations counts) +- [ ] T041 [US2] Inventory coverage semantics reference (A2) + - Identify and document the exact source-of-truth fields for Inventory KPI aggregation: + - `inventory_items.policy_type` + - `config('tenantpilot.supported_policy_types')` meta fields (`restore`, `risk`) + - Dependency support via existing dependency capability resolver + - Ensure KPI and chips read from this source only (DB-only). + - DoD: + - One canonical place documented and referenced by inventory KPIs. + - No “magic” or duplicated classification logic across pages/widgets. +- [ ] T028 [US2] Ensure “Sync Runs” view is inventory-only per spec in app/Filament/Resources/InventorySyncRunResource.php (query/filter by run type/intent if needed) +- [ ] T029 [US2] Standardize coverage chips set on coverage-related surfaces in app/Filament/Pages/InventoryCoverage.php (Restorable, Partial, Risk, Dependencies only) + +**Checkpoint**: Inventory hub behaves as a module with consistent sub-navigation + header. + +--- + +## Phase 5: User Story 3 — Operations index “Orders-style” (Priority: P3) + +**Goal**: Operations index shows KPIs + status tabs + table, with calm conditional polling. + +**Independent Test**: Visiting Operations index shows KPIs and tabs that filter runs per specs/058-tenant-ui-polish/contracts/ui.md, DB-only, calm. + +### Tests (US3) + +- [ ] T030 [P] [US3] Extend Operations DB-only test assertions in tests/Feature/Monitoring/OperationsDbOnlyTest.php (assert tabs/KPI labels appear) +- [ ] T031 [P] [US3] Extend Operations tenant isolation coverage in tests/Feature/Monitoring/OperationsTenantScopeTest.php (assert tab views don’t leak) + +### Implementation (US3) + +- [ ] T032 [P] [US3] Create Operations KPI header widget in app/Filament/Widgets/Operations/OperationsKpiHeader.php +- [ ] T033 [US3] Add KPIs to the Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [ ] T034 [US3] Implement status tabs (All/Active/Succeeded/Partial/Failed) on Operations list page in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [ ] T035 [US3] Ensure tab filter logic matches specs/058-tenant-ui-polish/contracts/ui.md by adjusting queries in app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [ ] T036 [US3] Implement conditional polling for Operations list (only while active runs exist) by wiring table polling in app/Filament/Resources/OperationRunResource.php and/or app/Filament/Resources/OperationRunResource/Pages/ListOperationRuns.php +- [ ] T037 [US3] Ensure canonical “View run” links still route to OperationRunResource view pages (no legacy routes) + - Verify existing canonical link helper `App\Support\OperationRunLinks` is used consistently. + - If no suitable helper exists for a given surface, add a minimal equivalent and use it everywhere. + +- [ ] T042 [US3] Operations terminology sweep (FR-010) + - Goal: The UI uses the canonical label “Operations” consistently; no legacy naming remains. + - Audit + fix in: + - Navigation label(s) + - Page titles / breadcrumbs + - Resource titles / headings + - Any links mentioning “Bulk Operation Runs” or legacy run naming + - DoD: + - No occurrences of legacy labels remain in UI surfaces for Monitoring/Operations. + - `rg -n "Bulk Operation|BulkOperationRun|Bulk Operation Runs" app resources` returns 0 matches (or matches only in tests explicitly allowed). + - If ripgrep is unavailable, use `grep -R` with the same patterns. + +**Checkpoint**: Operations index is “orders-style” with calm refresh behavior. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [ ] T038 [P] Run formatting on changed files in app/** and tests/** via `vendor/bin/sail bin pint --dirty` +- [ ] T039 Run targeted tests from specs/058-tenant-ui-polish/quickstart.md and ensure green +- [ ] T040 [P] Smoke-check key pages render for a tenant in tests/Feature/Filament/AdminSmokeTest.php (add assertions only if gaps are found) + +--- + +## Dependencies & Execution Order + +### User Story Dependencies + +- US1 (P1) is standalone and can ship first. +- US2 (P2) can be implemented after foundational polling helper; it touches the panel provider and inventory pages/resources. +- US3 (P3) can be implemented after foundational polling helper; it touches the operations resource list page. + +Suggested order (MVP-first): Phase 1 → Phase 2 → US1 → US2 → US3 → Polish. + +### Parallel Opportunities (examples) + +- US1: T009–T012 can be developed in parallel (different widget files). +- US2: T022 can be developed in parallel while T018–T021 are in review. +- US3: T032 can be developed in parallel with test updates (T030–T031). + +--- + +## Parallel Execution Examples (per user story) + +### US1 +- T005 [P] [US1] tests/Feature/Filament/TenantDashboardDbOnlyTest.php +- T006 [P] [US1] tests/Feature/Filament/TenantDashboardTenantScopeTest.php +- T009 [P] [US1] app/Filament/Widgets/Dashboard/DashboardKpis.php +- T010 [P] [US1] app/Filament/Widgets/Dashboard/NeedsAttention.php +- T011 [P] [US1] app/Filament/Widgets/Dashboard/RecentDriftFindings.php +- T012 [P] [US1] app/Filament/Widgets/Dashboard/RecentOperations.php + +### US2 +- T016 [P] [US2] tests/Feature/Filament/InventoryHubDbOnlyTest.php +- T022 [P] [US2] app/Filament/Widgets/Inventory/InventoryKpiHeader.php + +### US3 +- T030 [P] [US3] tests/Feature/Monitoring/OperationsDbOnlyTest.php +- T031 [P] [US3] tests/Feature/Monitoring/OperationsTenantScopeTest.php +- T032 [P] [US3] app/Filament/Widgets/Operations/OperationsKpiHeader.php + +--- + +## Implementation Strategy + +### MVP First (US1 only) + +1. Complete Phase 1 + Phase 2 +2. Implement US1 (dashboard) +3. Run: `vendor/bin/sail artisan test tests/Feature/Filament/TenantDashboardDbOnlyTest.php` +4. Run: `vendor/bin/sail artisan test tests/Feature/Filament/TenantDashboardTenantScopeTest.php` + +### Incremental Delivery + +- Add US2 next (Inventory hub), then US3 (Operations index). +- After each story, run its targeted tests + the cross-cutting DB-only tests. diff --git a/tests/Feature/Filament/TenantDashboardDbOnlyTest.php b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php new file mode 100644 index 0000000..150e4f9 --- /dev/null +++ b/tests/Feature/Filament/TenantDashboardDbOnlyTest.php @@ -0,0 +1,41 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'severity' => Finding::SEVERITY_HIGH, + 'status' => Finding::STATUS_NEW, + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + $this->actingAs($user); + + Bus::fake(); + + assertNoOutboundHttp(function () use ($tenant): void { + $this->get(TenantDashboard::getUrl(tenant: $tenant)) + ->assertOk() + ->assertSee('Needs Attention') + ->assertSee('Recent Operations') + ->assertSee('Recent Drift Findings'); + }); + + Bus::assertNothingDispatched(); +}); diff --git a/tests/Feature/Filament/TenantDashboardTenantScopeTest.php b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php new file mode 100644 index 0000000..9f6310c --- /dev/null +++ b/tests/Feature/Filament/TenantDashboardTenantScopeTest.php @@ -0,0 +1,33 @@ +create(); + + Finding::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'subject_external_id' => 'other-tenant-finding', + ]); + + OperationRun::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'running', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + $this->actingAs($user); + + $this->get(TenantDashboard::getUrl(tenant: $tenant)) + ->assertOk() + ->assertDontSee('other-tenant-finding'); +}); diff --git a/tests/Feature/OpsUx/ActiveRunsTest.php b/tests/Feature/OpsUx/ActiveRunsTest.php new file mode 100644 index 0000000..ba61df8 --- /dev/null +++ b/tests/Feature/OpsUx/ActiveRunsTest.php @@ -0,0 +1,64 @@ +create(); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'initiator_name' => 'System', + ]); + + expect(ActiveRuns::existForTenant($tenant))->toBeFalse(); +}); + +it('returns true when tenant has queued runs', function (): void { + $tenant = Tenant::factory()->create(); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + expect(ActiveRuns::existForTenant($tenant))->toBeTrue(); +}); + +it('returns true when tenant has running runs', function (): void { + $tenant = Tenant::factory()->create(); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory.sync', + 'status' => 'running', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + expect(ActiveRuns::existForTenant($tenant))->toBeTrue(); +}); + +it('is tenant scoped (other tenant active runs do not count)', function (): void { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + OperationRun::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'type' => 'inventory.sync', + 'status' => 'running', + 'outcome' => 'pending', + 'initiator_name' => 'System', + ]); + + expect(ActiveRuns::existForTenant($tenantA))->toBeFalse(); +});