Compare commits
2 Commits
183-websit
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f1a73490e4 | |||
| 03b1beb616 |
@ -1,5 +1,8 @@
|
||||
node_modules/
|
||||
apps/platform/node_modules/
|
||||
apps/website/node_modules/
|
||||
apps/website/.astro/
|
||||
apps/website/dist/
|
||||
dist/
|
||||
build/
|
||||
vendor/
|
||||
|
||||
31
.github/agents/copilot-instructions.md
vendored
31
.github/agents/copilot-instructions.md
vendored
@ -6,6 +6,8 @@ ## Relocation override
|
||||
- The authoritative Laravel application root is `apps/platform`.
|
||||
- Human-facing commands should use `cd apps/platform && ...`.
|
||||
- Repo-root tooling may delegate via `./scripts/platform-sail` when it cannot set a nested working directory.
|
||||
- Repo-root JavaScript orchestration uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||
- If any generated technology note below conflicts with the current repo, trust `apps/platform/composer.json`, `apps/platform/package.json`, and the live Laravel application metadata over stale generated entries.
|
||||
|
||||
## Active Technologies
|
||||
@ -149,27 +151,46 @@ ## Active Technologies
|
||||
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, and `backup_schedules` records plus existing JSON-backed backup metadata; no schema change planned (180-tenant-backup-health)
|
||||
- PHP 8.4.15, Laravel 12, Blade, Livewire v4, Filament v5.2.x, Tailwind CSS v4, Vite 7 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `laravel-vite-plugin`, `tailwindcss`, `vite`, `pestphp/pest`, `drizzle-kit`, PostgreSQL, Redis, Docker Compose (182-platform-relocation)
|
||||
- PostgreSQL, Redis, filesystem storage under the Laravel app `storage/` tree, plus existing Vite build artifacts in `public/build`; no new database persistence planned (182-platform-relocation)
|
||||
- PHP 8.4.15 and Laravel 12 for `apps/platform`; Node.js 20+ with pnpm 10 workspace tooling; Astro v6 for `apps/website`; Bash and Docker Compose for root orchestration + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `vite`, `tailwindcss`, `pnpm` workspaces, Astro, existing `./scripts/platform-sail` wrapper, repo-root Docker Compose (183-website-workspace-foundation)
|
||||
- Existing PostgreSQL, Redis, and filesystem storage for `apps/platform`; static build artifacts for `apps/website`; repository-managed workspace manifests and docs; no new database persistence (183-website-workspace-foundation)
|
||||
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers (184-dashboard-recovery-honesty)
|
||||
- PostgreSQL with existing tenant-owned `backup_sets`, `restore_runs`, and linked `operation_runs`; no schema change planned (184-dashboard-recovery-honesty)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
tests/
|
||||
apps/
|
||||
platform/
|
||||
website/
|
||||
docs/
|
||||
specs/
|
||||
scripts/
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
# Add commands for PHP 8.4.15
|
||||
- Root workspace:
|
||||
- `corepack pnpm install`
|
||||
- `corepack pnpm dev:platform`
|
||||
- `corepack pnpm dev:website`
|
||||
- `corepack pnpm dev`
|
||||
- `corepack pnpm build:website`
|
||||
- `corepack pnpm build:platform`
|
||||
- Platform app:
|
||||
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||
- `cd apps/platform && ./vendor/bin/sail pnpm build`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||
|
||||
## Code Style
|
||||
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 184-dashboard-recovery-honesty: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers
|
||||
- 183-website-workspace-foundation: Added PHP 8.4.15 and Laravel 12 for `apps/platform`; Node.js 20+ with pnpm 10 workspace tooling; Astro v6 for `apps/website`; Bash and Docker Compose for root orchestration + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `vite`, `tailwindcss`, `pnpm` workspaces, Astro, existing `./scripts/platform-sail` wrapper, repo-root Docker Compose
|
||||
- 182-platform-relocation: Added PHP 8.4.15, Laravel 12, Blade, Livewire v4, Filament v5.2.x, Tailwind CSS v4, Vite 7 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `laravel-vite-plugin`, `tailwindcss`, `vite`, `pestphp/pest`, `drizzle-kit`, PostgreSQL, Redis, Docker Compose
|
||||
- 180-tenant-backup-health: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, `BackupQualityResolver`, `BackupQualitySummary`, `ScheduleTimeService`, shared badge infrastructure, and existing RBAC helpers
|
||||
- 176-backup-quality-truth: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
10
.github/copilot-instructions.md
vendored
10
.github/copilot-instructions.md
vendored
@ -291,8 +291,12 @@ ## Application Structure & Architecture
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Workspace Commands
|
||||
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
@ -378,7 +382,7 @@ ## Laravel Sail
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||
|
||||
@ -432,7 +436,7 @@ ### Testing
|
||||
- When creating tests, make use of `cd apps/platform && ./vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail pnpm build` or ask the user to run `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -16,11 +16,15 @@
|
||||
/auth.json
|
||||
/node_modules
|
||||
/apps/platform/node_modules
|
||||
/apps/website/node_modules
|
||||
/.pnpm-store
|
||||
/apps/website/.astro
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
/public/build
|
||||
/apps/platform/public/build
|
||||
/apps/website/dist
|
||||
/public/hot
|
||||
/apps/platform/public/hot
|
||||
/public/storage
|
||||
@ -48,3 +52,5 @@ Thumbs.db
|
||||
*.swp
|
||||
/apps/platform/.env
|
||||
/apps/platform/.env.*
|
||||
/apps/website/.env
|
||||
/apps/website/.env.*
|
||||
|
||||
@ -4,6 +4,9 @@ public/build/
|
||||
apps/platform/public/build/
|
||||
node_modules/
|
||||
apps/platform/node_modules/
|
||||
apps/website/node_modules/
|
||||
apps/website/.astro/
|
||||
apps/website/dist/
|
||||
vendor/
|
||||
apps/platform/vendor/
|
||||
*.log
|
||||
|
||||
@ -11,6 +11,9 @@ coverage/
|
||||
vendor/
|
||||
apps/platform/vendor/
|
||||
apps/platform/node_modules/
|
||||
apps/website/node_modules/
|
||||
apps/website/.astro/
|
||||
apps/website/dist/
|
||||
storage/
|
||||
apps/platform/storage/
|
||||
bootstrap/cache/
|
||||
|
||||
@ -721,7 +721,9 @@ ## Application Structure & Architecture
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
@ -818,7 +820,7 @@ # Laravel Sail
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||
|
||||
@ -882,7 +884,7 @@ ## Testing
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail pnpm build` or ask the user to run `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
|
||||
@ -559,7 +559,9 @@ ## Application Structure & Architecture
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail npm run build`, `cd apps/platform && ./vendor/bin/sail npm run dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
|
||||
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
|
||||
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
@ -656,7 +658,7 @@ # Laravel Sail
|
||||
- Always prefix PHP, Artisan, Composer, and Node commands with `cd apps/platform && ./vendor/bin/sail`. Examples:
|
||||
- Run Artisan Commands: `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
- Install Composer packages: `cd apps/platform && ./vendor/bin/sail composer install`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail npm run dev`
|
||||
- Execute Node commands: `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||
- Execute PHP scripts: `cd apps/platform && ./vendor/bin/sail php [script]`
|
||||
- View all available Sail commands by running `cd apps/platform && ./vendor/bin/sail` without arguments.
|
||||
|
||||
@ -720,7 +722,7 @@ ## Testing
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail npm run build` or ask the user to run `cd apps/platform && ./vendor/bin/sail npm run dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `cd apps/platform && ./vendor/bin/sail pnpm build` or ask the user to run `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
|
||||
117
README.md
117
README.md
@ -1,26 +1,50 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
# TenantPilot Workspace
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
TenantPilot is an Intune management platform built around a stable Laravel application in
|
||||
`apps/platform` and, starting with Spec 183, a standalone public Astro website in
|
||||
`apps/website`. The repository root is now the official JavaScript workspace entry point and
|
||||
orchestrates app-local commands without becoming a runtime itself.
|
||||
|
||||
## TenantPilot setup
|
||||
## Multi-App Topology
|
||||
|
||||
- `apps/platform`: the Laravel 12 + Filament v5 + Livewire v4 product runtime
|
||||
- `apps/website`: the Astro v6 public website runtime
|
||||
- repo root: workspace manifests, documentation, scripts, editor tooling, and `docker-compose.yml`
|
||||
- `./scripts/platform-sail`: platform-only compatibility helper for tooling that cannot set `cwd`
|
||||
|
||||
## Official Root Commands
|
||||
|
||||
- Install workspace-managed JavaScript dependencies: `corepack pnpm install`
|
||||
- Start the platform stack: `corepack pnpm dev:platform`
|
||||
- Start the website dev server: `corepack pnpm dev:website`
|
||||
- Start platform + website together: `corepack pnpm dev`
|
||||
- Build the website: `corepack pnpm build:website`
|
||||
- Build platform frontend assets: `corepack pnpm build:platform`
|
||||
|
||||
## App-Local Commands
|
||||
|
||||
### Platform
|
||||
|
||||
- Install PHP dependencies: `cd apps/platform && composer install`
|
||||
- Start Sail: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- Generate the app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
|
||||
- Run migrations and seeders: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
|
||||
- Run frontend watch/build inside Sail: `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail pnpm build`
|
||||
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||
|
||||
### Website
|
||||
|
||||
- Start the dev server: `cd apps/website && pnpm dev`
|
||||
- Build the static site: `cd apps/website && pnpm build`
|
||||
|
||||
## Port Overrides
|
||||
|
||||
- Platform HTTP and Vite ports: set `APP_PORT` and or `VITE_PORT` before `corepack pnpm dev:platform` or `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- Website dev server port: set `WEBSITE_PORT` before `corepack pnpm dev:website` or pass `--port <port>` to `cd apps/website && pnpm dev`
|
||||
- Parallel local development keeps both apps isolated, even when one or both ports are overridden
|
||||
|
||||
## Platform Setup Notes
|
||||
|
||||
- Platform app root: `apps/platform`
|
||||
- Repo-root ownership: specs, docs, scripts, editor config, agent config, orchestration, and `docker-compose.yml`
|
||||
- App-root ownership: Laravel runtime, tests, Vite assets, public entrypoints, `composer.json`, `package.json`, `drizzle.config.ts`, and app-local `.env*`
|
||||
- Local dev (Sail-first, canonical workflow):
|
||||
- Install: `cd apps/platform && composer install`
|
||||
- Env bootstrap: `cd apps/platform && cp .env.example .env`
|
||||
- Start stack: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- Generate app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
|
||||
- Init DB: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
|
||||
- Tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||
- Policy sync: `cd apps/platform && ./vendor/bin/sail artisan intune:sync-policies`
|
||||
- Compatibility helper for tooling that cannot set a nested working directory: `./scripts/platform-sail ...`
|
||||
- Filament admin: `/admin` (seed user `test@example.com`, set password via factory or `artisan tinker`).
|
||||
- Microsoft Graph (Intune) env vars:
|
||||
- `GRAPH_TENANT_ID`
|
||||
@ -41,7 +65,7 @@ ## Platform relocation rollout notes
|
||||
|
||||
- Open branches that still touch legacy root app paths should merge `dev` first, then remap file moves from `app/`, `bootstrap/`, `config/`, `database/`, `lang/`, `public/`, `resources/`, `routes/`, `storage/`, and `tests/` into `apps/platform/...`.
|
||||
- Keep using merge-based catch-up on shared feature branches; do not rebase long-lived shared branches just to absorb the relocation.
|
||||
- VS Code tasks and MCP launchers now delegate through `./scripts/platform-sail` from the repo root. Human-facing docs remain `apps/platform`-first.
|
||||
- VS Code tasks expose the official root workspace commands, while MCP launchers remain platform-only and delegate through `./scripts/platform-sail`.
|
||||
|
||||
## Bulk operations (Feature 005)
|
||||
|
||||
@ -118,54 +142,3 @@ ## Policy JSON Viewer (Feature 002)
|
||||
- Scrollable container with max height to prevent page overflow
|
||||
- **Usage**: See `specs/002-filament-json/quickstart.md` for detailed examples and configuration
|
||||
- **Performance**: Optimized for payloads up to 1 MB; auto-collapse improves initial render for large snapshots
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
@ -30,6 +31,7 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?
|
||||
public function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
RecoveryReadiness::class,
|
||||
DashboardKpis::class,
|
||||
NeedsAttention::class,
|
||||
BaselineCompareNow::class,
|
||||
|
||||
@ -96,6 +96,22 @@ public static function shouldRegisterNavigation(): bool
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $tenant)
|
||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -249,7 +265,7 @@ public static function makeCreateAction(): Actions\CreateAction
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery())
|
||||
->with('backupSet');
|
||||
->with(['backupSet', 'operationRun']);
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
@ -930,6 +946,10 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('result_attention_summary')
|
||||
->label('Result attention')
|
||||
->state(fn (RestoreRun $record): string => static::restoreSafetyResolver()->resultAttentionForRun($record)->summary)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('summary_total')
|
||||
->label('Total')
|
||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
||||
|
||||
@ -51,4 +51,16 @@ protected function getHeaderActions(): array
|
||||
->visible(fn (): bool => $this->tableHasRecords()),
|
||||
];
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return match (request()->string('recovery_posture_reason')->toString()) {
|
||||
'no_history' => 'No executed restore history is visible in the latest tenant restore records.',
|
||||
'failed' => 'The dashboard opened restore history because the latest executed restore failed and a specific detail is not available.',
|
||||
'partial' => 'The dashboard opened restore history because the latest executed restore completed partially and a specific detail is not available.',
|
||||
'completed_with_follow_up' => 'The dashboard opened restore history because skipped or non-applied work still needs follow-up.',
|
||||
'no_recent_issues_visible' => 'The dashboard opened restore history because no recent restore issues are visible, but tenant-wide recovery is still not proven.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,4 +14,15 @@ protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return RestoreRunResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return match (request()->string('recovery_posture_reason')->toString()) {
|
||||
'failed' => 'The dashboard opened this restore run because the latest executed restore failed.',
|
||||
'partial' => 'The dashboard opened this restore run because the latest executed restore completed partially.',
|
||||
'completed_with_follow_up' => 'The dashboard opened this restore run because skipped or non-applied work still needs follow-up.',
|
||||
'no_recent_issues_visible' => 'The dashboard opened this restore run because no recent restore issues are visible, but tenant-wide recovery is still not proven.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,25 +4,18 @@
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\BackupHealthActionTarget;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DashboardKpis extends StatsOverviewWidget
|
||||
{
|
||||
@ -45,8 +38,6 @@ protected function getStats(): array
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$backupHealth = $this->backupHealthAssessment($tenant);
|
||||
$backupHealthAction = $this->resolveBackupHealthAction($tenant, $backupHealth->primaryActionTarget);
|
||||
|
||||
$openDriftFindings = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
@ -88,10 +79,6 @@ protected function getStats(): array
|
||||
$findingsHelperText = $this->findingsHelperText($tenant);
|
||||
|
||||
return [
|
||||
Stat::make('Backup posture', Str::headline($backupHealth->posture))
|
||||
->description($this->backupHealthDescription($backupHealth, $backupHealthAction['helperText']))
|
||||
->color($backupHealth->tone())
|
||||
->url($backupHealthAction['actionUrl']),
|
||||
Stat::make('Open drift findings', $openDriftFindings)
|
||||
->description($openDriftUrl === null && $openDriftFindings > 0
|
||||
? $findingsHelperText
|
||||
@ -137,7 +124,6 @@ protected function getStats(): array
|
||||
private function emptyStats(): array
|
||||
{
|
||||
return [
|
||||
Stat::make('Backup posture', '—'),
|
||||
Stat::make('Open drift findings', 0),
|
||||
Stat::make('High severity active findings', 0),
|
||||
Stat::make('Active operations', 0),
|
||||
@ -173,106 +159,4 @@ private function canOpenFindings(Tenant $tenant): bool
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
|
||||
}
|
||||
|
||||
private function backupHealthAssessment(Tenant $tenant): TenantBackupHealthAssessment
|
||||
{
|
||||
/** @var TenantBackupHealthResolver $resolver */
|
||||
$resolver = app(TenantBackupHealthResolver::class);
|
||||
|
||||
return $resolver->assess($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actionUrl: string|null, helperText: string|null}
|
||||
*/
|
||||
private function resolveBackupHealthAction(Tenant $tenant, ?BackupHealthActionTarget $target): array
|
||||
{
|
||||
if (! $target instanceof BackupHealthActionTarget) {
|
||||
return [
|
||||
'actionUrl' => null,
|
||||
'helperText' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if (! $this->canOpenBackupSurfaces($tenant)) {
|
||||
return [
|
||||
'actionUrl' => null,
|
||||
'helperText' => UiTooltips::INSUFFICIENT_PERMISSION,
|
||||
];
|
||||
}
|
||||
|
||||
return match ($target->surface) {
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
],
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => [
|
||||
'actionUrl' => BackupScheduleResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
],
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $this->resolveBackupSetAction($tenant, $target),
|
||||
default => [
|
||||
'actionUrl' => null,
|
||||
'helperText' => null,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actionUrl: string|null, helperText: string|null}
|
||||
*/
|
||||
private function resolveBackupSetAction(Tenant $tenant, BackupHealthActionTarget $target): array
|
||||
{
|
||||
if (! is_numeric($target->recordId)) {
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => 'The latest backup detail is no longer available.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
BackupSetResource::resolveScopedRecordOrFail($target->recordId);
|
||||
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('view', [
|
||||
'record' => $target->recordId,
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
];
|
||||
} catch (ModelNotFoundException) {
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => 'The latest backup detail is no longer available.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function backupHealthDescription(TenantBackupHealthAssessment $assessment, ?string $helperText): string
|
||||
{
|
||||
$description = $assessment->supportingMessage ?? $assessment->headline;
|
||||
|
||||
if ($helperText === null) {
|
||||
return $description;
|
||||
}
|
||||
|
||||
return trim($description.' '.$helperText);
|
||||
}
|
||||
|
||||
private function canOpenBackupSurfaces(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,8 +8,10 @@
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -22,6 +24,9 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
@ -49,9 +54,14 @@ protected function getViewData(): array
|
||||
$aggregate = $this->governanceAggregate($tenant);
|
||||
$compareAssessment = $aggregate->summaryAssessment;
|
||||
$backupHealth = $this->backupHealthAssessment($tenant);
|
||||
$recoveryEvidence = $this->recoveryEvidence($tenant);
|
||||
|
||||
$items = [];
|
||||
|
||||
if (($recoveryItem = $this->recoveryEvidenceAttentionItem($tenant, $backupHealth, $recoveryEvidence)) !== null) {
|
||||
$items[] = $recoveryItem;
|
||||
}
|
||||
|
||||
if (($backupHealthItem = $this->backupHealthAttentionItem($tenant, $backupHealth)) instanceof BackupHealthDashboardSignal) {
|
||||
$items[] = array_merge(
|
||||
$backupHealthItem->toArray(),
|
||||
@ -195,6 +205,7 @@ protected function getViewData(): array
|
||||
if ($items === []) {
|
||||
$healthyChecks = [
|
||||
...array_filter([$this->backupHealthHealthyCheck($backupHealth)]),
|
||||
...array_filter([$this->recoveryEvidenceHealthyCheck($recoveryEvidence)]),
|
||||
[
|
||||
'title' => 'Baseline compare looks trustworthy',
|
||||
'body' => $aggregate->headline,
|
||||
@ -276,6 +287,17 @@ private function backupHealthAssessment(Tenant $tenant): TenantBackupHealthAsses
|
||||
return $resolver->assess($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function recoveryEvidence(Tenant $tenant): array
|
||||
{
|
||||
/** @var RestoreSafetyResolver $resolver */
|
||||
$resolver = app(RestoreSafetyResolver::class);
|
||||
|
||||
return $resolver->dashboardRecoveryEvidence($tenant);
|
||||
}
|
||||
|
||||
private function backupHealthAttentionItem(Tenant $tenant, TenantBackupHealthAssessment $assessment): ?BackupHealthDashboardSignal
|
||||
{
|
||||
if (! $assessment->hasActiveReason()) {
|
||||
@ -304,7 +326,84 @@ private function backupHealthHealthyCheck(TenantBackupHealthAssessment $assessme
|
||||
|
||||
return [
|
||||
'title' => 'Backups are recent and healthy',
|
||||
'body' => $assessment->supportingMessage ?? 'The latest completed backup is recent and shows no material degradation.',
|
||||
'body' => trim(implode(' ', array_filter([
|
||||
$assessment->supportingMessage ?? 'The latest completed backup is recent and shows no material degradation.',
|
||||
$assessment->positiveClaimBoundary,
|
||||
], static fn (?string $part): bool => filled($part)))),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function recoveryEvidenceAttentionItem(Tenant $tenant, TenantBackupHealthAssessment $backupHealth, array $recoveryEvidence): ?array
|
||||
{
|
||||
$overviewState = is_string($recoveryEvidence['overview_state'] ?? null)
|
||||
? $recoveryEvidence['overview_state']
|
||||
: null;
|
||||
|
||||
if ($overviewState === 'unvalidated') {
|
||||
return [
|
||||
'key' => 'recovery_evidence_unvalidated',
|
||||
'title' => 'Recovery evidence is unvalidated',
|
||||
'body' => (string) ($recoveryEvidence['summary'] ?? 'No executed restore history is visible in the latest tenant restore records.'),
|
||||
'supportingMessage' => $backupHealth->positiveClaimBoundary,
|
||||
'badge' => 'Recovery',
|
||||
'badgeColor' => 'warning',
|
||||
'actionElevated' => true,
|
||||
...$this->recoveryActionPayload($tenant, $recoveryEvidence, 'Open restore history'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($overviewState !== 'weakened') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attention = $recoveryEvidence['latest_relevant_attention'] ?? null;
|
||||
$attentionState = $attention instanceof RestoreResultAttention
|
||||
? $attention->state
|
||||
: (is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
|
||||
? $recoveryEvidence['latest_relevant_attention_state']
|
||||
: null);
|
||||
$primaryNextAction = $attention instanceof RestoreResultAttention
|
||||
? RestoreSafetyCopy::primaryNextAction($attention->primaryNextAction)
|
||||
: null;
|
||||
$claimBoundary = is_string($recoveryEvidence['claim_boundary'] ?? null)
|
||||
? $recoveryEvidence['claim_boundary']
|
||||
: null;
|
||||
|
||||
return [
|
||||
'key' => 'recovery_evidence_'.$attentionState,
|
||||
'title' => $this->recoveryAttentionTitle($attentionState),
|
||||
'body' => (string) ($recoveryEvidence['summary'] ?? 'Recent restore history weakens confidence.'),
|
||||
'supportingMessage' => trim(implode(' ', array_filter([
|
||||
$primaryNextAction,
|
||||
$claimBoundary,
|
||||
], static fn (?string $part): bool => filled($part)))),
|
||||
'badge' => 'Recovery',
|
||||
'badgeColor' => $attentionState === RestoreResultAttention::STATE_FAILED ? 'danger' : 'warning',
|
||||
'actionElevated' => true,
|
||||
...$this->recoveryActionPayload($tenant, $recoveryEvidence, 'Open restore run'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
* @return array{title: string, body: string}|null
|
||||
*/
|
||||
private function recoveryEvidenceHealthyCheck(array $recoveryEvidence): ?array
|
||||
{
|
||||
if (($recoveryEvidence['overview_state'] ?? null) !== 'no_recent_issues_visible') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => 'No recent restore issues visible',
|
||||
'body' => trim(implode(' ', array_filter([
|
||||
is_string($recoveryEvidence['summary'] ?? null) ? $recoveryEvidence['summary'] : null,
|
||||
is_string($recoveryEvidence['claim_boundary'] ?? null) ? $recoveryEvidence['claim_boundary'] : null,
|
||||
], static fn (?string $part): bool => filled($part)))),
|
||||
];
|
||||
}
|
||||
|
||||
@ -417,4 +516,81 @@ private function canOpenBackupSurfaces(Tenant $tenant): bool
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
|
||||
private function recoveryAttentionTitle(?string $attentionState): string
|
||||
{
|
||||
return match ($attentionState) {
|
||||
RestoreResultAttention::STATE_FAILED => 'Recent restore failed',
|
||||
RestoreResultAttention::STATE_PARTIAL => 'Recent restore is partial',
|
||||
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP => 'Recent restore needs follow-up',
|
||||
default => 'Recent restore history weakens confidence',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
* @return array{actionLabel: string, actionUrl: string|null, actionDisabled: bool, helperText: string|null}
|
||||
*/
|
||||
private function recoveryActionPayload(Tenant $tenant, array $recoveryEvidence, string $label): array
|
||||
{
|
||||
if (! $this->canOpenRestoreHistory($tenant)) {
|
||||
return [
|
||||
'actionLabel' => $label,
|
||||
'actionUrl' => null,
|
||||
'actionDisabled' => true,
|
||||
'helperText' => UiTooltips::INSUFFICIENT_PERMISSION,
|
||||
];
|
||||
}
|
||||
|
||||
$reason = is_string($recoveryEvidence['reason'] ?? null) && $recoveryEvidence['reason'] !== ''
|
||||
? $recoveryEvidence['reason']
|
||||
: 'no_history';
|
||||
$latestRun = $recoveryEvidence['latest_relevant_restore_run'] ?? null;
|
||||
|
||||
if (! $latestRun instanceof RestoreRun) {
|
||||
return [
|
||||
'actionLabel' => $label,
|
||||
'actionUrl' => $this->restoreRunListUrl($tenant, $reason),
|
||||
'actionDisabled' => false,
|
||||
'helperText' => null,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
RestoreRunResource::resolveScopedRecordOrFail($latestRun->getKey());
|
||||
|
||||
return [
|
||||
'actionLabel' => $label,
|
||||
'actionUrl' => RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $latestRun->getKey(),
|
||||
'recovery_posture_reason' => $reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'actionDisabled' => false,
|
||||
'helperText' => null,
|
||||
];
|
||||
} catch (ModelNotFoundException) {
|
||||
return [
|
||||
'actionLabel' => 'Open restore history',
|
||||
'actionUrl' => $this->restoreRunListUrl($tenant, $reason),
|
||||
'actionDisabled' => false,
|
||||
'helperText' => 'The latest restore detail is no longer available.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function canOpenRestoreHistory(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
|
||||
private function restoreRunListUrl(Tenant $tenant, string $reason): string
|
||||
{
|
||||
return RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => $reason,
|
||||
], panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\BackupHealthActionTarget;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RecoveryReadiness extends Widget
|
||||
{
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected string $view = 'filament.widgets.dashboard.recovery-readiness';
|
||||
|
||||
protected function getPollingInterval(): ?string
|
||||
{
|
||||
return ActiveRuns::pollingIntervalForTenant(Filament::getTenant());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
'pollingInterval' => null,
|
||||
'backupPosture' => $this->emptyStatPayload('Backup posture'),
|
||||
'recoveryEvidence' => $this->emptyStatPayload('Recovery evidence'),
|
||||
];
|
||||
}
|
||||
|
||||
$backupHealth = $this->backupHealthAssessment($tenant);
|
||||
$backupHealthAction = $this->resolveBackupHealthAction($tenant, $backupHealth->primaryActionTarget);
|
||||
$recoveryEvidence = $this->recoveryEvidence($tenant);
|
||||
$recoveryAction = $this->resolveRecoveryAction($tenant, $recoveryEvidence);
|
||||
|
||||
return [
|
||||
'pollingInterval' => ActiveRuns::pollingIntervalForTenant($tenant),
|
||||
'backupPosture' => [
|
||||
'label' => 'Backup posture',
|
||||
'value' => Str::headline($backupHealth->posture),
|
||||
'description' => $this->backupHealthDescription($backupHealth, $backupHealthAction['helperText']),
|
||||
'color' => $backupHealth->tone(),
|
||||
'url' => $backupHealthAction['actionUrl'],
|
||||
],
|
||||
'recoveryEvidence' => [
|
||||
'label' => 'Recovery evidence',
|
||||
'value' => $this->recoveryEvidenceValue($recoveryEvidence['overview_state']),
|
||||
'description' => $this->recoveryEvidenceDescription($recoveryEvidence, $recoveryAction['helperText']),
|
||||
'color' => $this->recoveryEvidenceTone($recoveryEvidence),
|
||||
'url' => $recoveryAction['actionUrl'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, value: string, description: string|null, color: string, url: string|null}
|
||||
*/
|
||||
private function emptyStatPayload(string $label): array
|
||||
{
|
||||
return [
|
||||
'label' => $label,
|
||||
'value' => '—',
|
||||
'description' => null,
|
||||
'color' => 'gray',
|
||||
'url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function backupHealthAssessment(Tenant $tenant): TenantBackupHealthAssessment
|
||||
{
|
||||
/** @var TenantBackupHealthResolver $resolver */
|
||||
$resolver = app(TenantBackupHealthResolver::class);
|
||||
|
||||
return $resolver->assess($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function recoveryEvidence(Tenant $tenant): array
|
||||
{
|
||||
/** @var RestoreSafetyResolver $resolver */
|
||||
$resolver = app(RestoreSafetyResolver::class);
|
||||
|
||||
return $resolver->dashboardRecoveryEvidence($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actionUrl: string|null, helperText: string|null}
|
||||
*/
|
||||
private function resolveBackupHealthAction(Tenant $tenant, ?BackupHealthActionTarget $target): array
|
||||
{
|
||||
if (! $target instanceof BackupHealthActionTarget) {
|
||||
return ['actionUrl' => null, 'helperText' => null];
|
||||
}
|
||||
|
||||
if (! $this->canOpenBackupSurfaces($tenant)) {
|
||||
return ['actionUrl' => null, 'helperText' => UiTooltips::INSUFFICIENT_PERMISSION];
|
||||
}
|
||||
|
||||
return match ($target->surface) {
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
],
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => [
|
||||
'actionUrl' => BackupScheduleResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
],
|
||||
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $this->resolveBackupSetAction($tenant, $target),
|
||||
default => ['actionUrl' => null, 'helperText' => null],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actionUrl: string|null, helperText: string|null}
|
||||
*/
|
||||
private function resolveBackupSetAction(Tenant $tenant, BackupHealthActionTarget $target): array
|
||||
{
|
||||
if (! is_numeric($target->recordId)) {
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => 'The latest backup detail is no longer available.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
BackupSetResource::resolveScopedRecordOrFail($target->recordId);
|
||||
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('view', [
|
||||
'record' => $target->recordId,
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
];
|
||||
} catch (ModelNotFoundException) {
|
||||
return [
|
||||
'actionUrl' => BackupSetResource::getUrl('index', [
|
||||
'backup_health_reason' => $target->reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => 'The latest backup detail is no longer available.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
* @return array{actionUrl: string|null, helperText: string|null}
|
||||
*/
|
||||
private function resolveRecoveryAction(Tenant $tenant, array $recoveryEvidence): array
|
||||
{
|
||||
if (! $this->canOpenRestoreHistory($tenant)) {
|
||||
return ['actionUrl' => null, 'helperText' => UiTooltips::INSUFFICIENT_PERMISSION];
|
||||
}
|
||||
|
||||
$reason = is_string($recoveryEvidence['reason'] ?? null) && $recoveryEvidence['reason'] !== ''
|
||||
? $recoveryEvidence['reason']
|
||||
: 'no_history';
|
||||
$latestRun = $recoveryEvidence['latest_relevant_restore_run'] ?? null;
|
||||
|
||||
if (! $latestRun instanceof RestoreRun) {
|
||||
return [
|
||||
'actionUrl' => $this->restoreRunListUrl($tenant, $reason),
|
||||
'helperText' => null,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
RestoreRunResource::resolveScopedRecordOrFail($latestRun->getKey());
|
||||
|
||||
return [
|
||||
'actionUrl' => RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $latestRun->getKey(),
|
||||
'recovery_posture_reason' => $reason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
'helperText' => null,
|
||||
];
|
||||
} catch (ModelNotFoundException) {
|
||||
return [
|
||||
'actionUrl' => $this->restoreRunListUrl($tenant, $reason),
|
||||
'helperText' => 'The latest restore detail is no longer available.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function recoveryEvidenceValue(string $overviewState): string
|
||||
{
|
||||
return match ($overviewState) {
|
||||
'unvalidated' => 'Unvalidated',
|
||||
'weakened' => 'Weakened',
|
||||
'no_recent_issues_visible' => 'No recent issues visible',
|
||||
default => Str::headline($overviewState),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
*/
|
||||
private function recoveryEvidenceDescription(array $recoveryEvidence, ?string $helperText): string
|
||||
{
|
||||
$parts = [
|
||||
is_string($recoveryEvidence['summary'] ?? null) ? $recoveryEvidence['summary'] : null,
|
||||
is_string($recoveryEvidence['claim_boundary'] ?? null) ? $recoveryEvidence['claim_boundary'] : null,
|
||||
$helperText,
|
||||
];
|
||||
|
||||
return trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $recoveryEvidence
|
||||
*/
|
||||
private function recoveryEvidenceTone(array $recoveryEvidence): string
|
||||
{
|
||||
$attentionState = is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
|
||||
? $recoveryEvidence['latest_relevant_attention_state']
|
||||
: null;
|
||||
|
||||
return match ($recoveryEvidence['overview_state'] ?? null) {
|
||||
'unvalidated' => 'warning',
|
||||
'weakened' => $attentionState === RestoreResultAttention::STATE_FAILED ? 'danger' : 'warning',
|
||||
'no_recent_issues_visible' => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function backupHealthDescription(TenantBackupHealthAssessment $assessment, ?string $helperText): string
|
||||
{
|
||||
$parts = [
|
||||
$assessment->supportingMessage ?? $assessment->headline,
|
||||
];
|
||||
|
||||
if ($assessment->posture === TenantBackupHealthAssessment::POSTURE_HEALTHY) {
|
||||
$parts[] = $assessment->positiveClaimBoundary;
|
||||
}
|
||||
|
||||
if ($helperText !== null) {
|
||||
$parts[] = $helperText;
|
||||
}
|
||||
|
||||
return trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
|
||||
}
|
||||
|
||||
private function canOpenBackupSurfaces(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
|
||||
private function canOpenRestoreHistory(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||
}
|
||||
|
||||
private function restoreRunListUrl(Tenant $tenant, string $reason): string
|
||||
{
|
||||
return RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => $reason,
|
||||
], panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
}
|
||||
@ -10,10 +10,13 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
|
||||
final readonly class RestoreSafetyResolver
|
||||
{
|
||||
private const int DASHBOARD_RECOVERY_CANDIDATE_LIMIT = 10;
|
||||
|
||||
public function __construct(
|
||||
private CapabilityResolver $capabilityResolver,
|
||||
private WriteGateInterface $writeGate,
|
||||
@ -477,6 +480,75 @@ public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAtte
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* backup_posture: string,
|
||||
* overview_state: string,
|
||||
* headline: string,
|
||||
* summary: string,
|
||||
* claim_boundary: string,
|
||||
* latest_relevant_restore_run_id: ?int,
|
||||
* latest_relevant_attention_state: ?string,
|
||||
* latest_relevant_restore_run: ?RestoreRun,
|
||||
* latest_relevant_attention: ?RestoreResultAttention,
|
||||
* reason: string
|
||||
* }
|
||||
*/
|
||||
public function dashboardRecoveryEvidence(Tenant $tenant): array
|
||||
{
|
||||
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
|
||||
$relevantRestoreHistory = $this->latestRelevantRestoreHistory($tenant);
|
||||
$relevantRun = $relevantRestoreHistory['run'];
|
||||
$relevantAttention = $relevantRestoreHistory['attention'];
|
||||
|
||||
if (! $relevantRun instanceof RestoreRun || ! $relevantAttention instanceof RestoreResultAttention) {
|
||||
return [
|
||||
'backup_posture' => $backupHealth->posture,
|
||||
'overview_state' => 'unvalidated',
|
||||
'headline' => 'Recovery evidence is unvalidated',
|
||||
'summary' => 'No executed restore history is visible in the latest tenant restore records.',
|
||||
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary('run_completed_not_recovery_proven'),
|
||||
'latest_relevant_restore_run_id' => null,
|
||||
'latest_relevant_attention_state' => null,
|
||||
'latest_relevant_restore_run' => null,
|
||||
'latest_relevant_attention' => null,
|
||||
'reason' => 'no_history',
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($relevantAttention->state, [
|
||||
RestoreResultAttention::STATE_FAILED,
|
||||
RestoreResultAttention::STATE_PARTIAL,
|
||||
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
|
||||
], true)) {
|
||||
return [
|
||||
'backup_posture' => $backupHealth->posture,
|
||||
'overview_state' => 'weakened',
|
||||
'headline' => 'Recent restore history weakens confidence',
|
||||
'summary' => $relevantAttention->summary,
|
||||
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary($relevantAttention->recoveryClaimBoundary),
|
||||
'latest_relevant_restore_run_id' => (int) $relevantRun->getKey(),
|
||||
'latest_relevant_attention_state' => $relevantAttention->state,
|
||||
'latest_relevant_restore_run' => $relevantRun,
|
||||
'latest_relevant_attention' => $relevantAttention,
|
||||
'reason' => $relevantAttention->state,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'backup_posture' => $backupHealth->posture,
|
||||
'overview_state' => 'no_recent_issues_visible',
|
||||
'headline' => 'No recent restore issues visible',
|
||||
'summary' => 'Recent executed restore history exists without a current follow-up signal.',
|
||||
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary($relevantAttention->recoveryClaimBoundary),
|
||||
'latest_relevant_restore_run_id' => (int) $relevantRun->getKey(),
|
||||
'latest_relevant_attention_state' => $relevantAttention->state,
|
||||
'latest_relevant_restore_run' => $relevantRun,
|
||||
'latest_relevant_attention' => $relevantAttention,
|
||||
'reason' => 'no_recent_issues_visible',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $basis
|
||||
* @return list<string>
|
||||
@ -534,6 +606,44 @@ public function invalidationReasonsForBasis(
|
||||
return $derivedReasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{run: ?RestoreRun, attention: ?RestoreResultAttention}
|
||||
*/
|
||||
private function latestRelevantRestoreHistory(Tenant $tenant): array
|
||||
{
|
||||
foreach ($this->dashboardRecoveryCandidates($tenant) as $candidate) {
|
||||
$attention = $this->resultAttentionForRun($candidate);
|
||||
|
||||
if ($attention->state === RestoreResultAttention::STATE_NOT_EXECUTED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'run' => $candidate,
|
||||
'attention' => $attention,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'run' => null,
|
||||
'attention' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, RestoreRun>
|
||||
*/
|
||||
private function dashboardRecoveryCandidates(Tenant $tenant)
|
||||
{
|
||||
return RestoreRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->with('operationRun:id,outcome')
|
||||
->orderByRaw('COALESCE(completed_at, started_at, created_at) DESC')
|
||||
->orderByDesc('id')
|
||||
->limit(self::DASHBOARD_RECOVERY_CANDIDATE_LIMIT)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function primaryCauseFamilyForRun(RestoreRun $restoreRun): string
|
||||
{
|
||||
$operationContext = is_array($restoreRun->operationRun?->context) ? $restoreRun->operationRun->context : [];
|
||||
|
||||
@ -45,12 +45,12 @@
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
"pnpm install",
|
||||
"pnpm build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"pnpm dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
|
||||
@ -27,4 +27,20 @@ public function definition(): array
|
||||
'metadata' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function recentCompleted(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'completed',
|
||||
'completed_at' => now()->subMinutes(20),
|
||||
]);
|
||||
}
|
||||
|
||||
public function staleCompleted(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'completed',
|
||||
'completed_at' => now()->subDays(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,4 +32,109 @@ public function definition(): array
|
||||
'completed_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function previewOnly(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'previewed',
|
||||
'is_dry_run' => true,
|
||||
'preview' => [
|
||||
[
|
||||
'policy_identifier' => 'preview-policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows',
|
||||
'action' => 'update',
|
||||
],
|
||||
],
|
||||
'results' => [],
|
||||
'metadata' => [],
|
||||
'completed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function failedOutcome(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'failed',
|
||||
'is_dry_run' => false,
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [
|
||||
[
|
||||
'status' => 'failed',
|
||||
'policy_identifier' => 'failed-policy',
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function partialOutcome(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => false,
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [
|
||||
[
|
||||
'status' => 'partial',
|
||||
'policy_identifier' => 'partial-policy',
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function completedWithFollowUp(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => false,
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [
|
||||
[
|
||||
'status' => 'applied',
|
||||
'policy_identifier' => 'follow-up-policy',
|
||||
'assignment_outcomes' => [
|
||||
['status' => 'skipped', 'assignment' => []],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'non_applied' => 1,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function completedOutcome(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'status' => 'completed',
|
||||
'is_dry_run' => false,
|
||||
'results' => [
|
||||
'foundations' => [],
|
||||
'items' => [
|
||||
[
|
||||
'status' => 'applied',
|
||||
'policy_identifier' => 'completed-policy',
|
||||
'assignment_outcomes' => [
|
||||
['status' => 'success', 'assignment' => []],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'non_applied' => 0,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
3745
apps/platform/package-lock.json
generated
3745
apps/platform/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,13 @@
|
||||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"name": "@tenantatlas/platform",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"sail:build": "./vendor/bin/sail npm run build",
|
||||
"sail:dev": "./vendor/bin/sail npm run dev"
|
||||
"sail:build": "./vendor/bin/sail pnpm build",
|
||||
"sail:dev": "./vendor/bin/sail pnpm dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
|
||||
@ -27,45 +27,68 @@ class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col gap-3">
|
||||
@foreach ($items as $item)
|
||||
<div class="rounded-lg bg-gray-50 p-4 dark:bg-white/5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
|
||||
@php
|
||||
$domainOrder = ['Recovery', 'Backups', 'Governance', 'Findings', 'Baseline', 'Operations'];
|
||||
$grouped = collect($items)->groupBy('badge')->sortBy(fn ($group, $badge) => array_search($badge, $domainOrder, true) !== false ? array_search($badge, $domainOrder, true) : 999);
|
||||
@endphp
|
||||
|
||||
@if (filled($item['supportingMessage'] ?? null))
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $item['supportingMessage'] }}
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex flex-col gap-5">
|
||||
@foreach ($grouped as $domain => $domainItems)
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ $domain }}
|
||||
</div>
|
||||
|
||||
@if (filled($item['actionLabel'] ?? null))
|
||||
<div class="mt-3">
|
||||
@if (filled($item['actionUrl'] ?? null))
|
||||
<x-filament::link :href="$item['actionUrl']" size="sm" class="font-medium">
|
||||
{{ $item['actionLabel'] }}
|
||||
</x-filament::link>
|
||||
@else
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $item['actionLabel'] }}
|
||||
@foreach ($domainItems as $item)
|
||||
<div class="rounded-lg bg-gray-50 p-4 dark:bg-white/5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $item['title'] }}</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $item['body'] }}</div>
|
||||
|
||||
@if (filled($item['supportingMessage'] ?? null))
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $item['supportingMessage'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($item['actionLabel'] ?? null))
|
||||
<div class="mt-3">
|
||||
@if (($item['actionElevated'] ?? false) && filled($item['actionUrl'] ?? null))
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
:href="$item['actionUrl']"
|
||||
size="sm"
|
||||
:color="$item['badgeColor'] ?? 'primary'"
|
||||
outlined
|
||||
>
|
||||
{{ $item['actionLabel'] }}
|
||||
</x-filament::button>
|
||||
@elseif (filled($item['actionUrl'] ?? null))
|
||||
<x-filament::link :href="$item['actionUrl']" size="sm" class="font-medium">
|
||||
{{ $item['actionLabel'] }}
|
||||
</x-filament::link>
|
||||
@else
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $item['actionLabel'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($item['helperText'] ?? null))
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $item['helperText'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($item['helperText'] ?? null))
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $item['helperText'] }}
|
||||
</div>
|
||||
@endif
|
||||
<x-filament::badge :color="$item['badgeColor']" size="sm">
|
||||
{{ $item['badge'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$item['badgeColor']" size="sm">
|
||||
{{ $item['badge'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
<div
|
||||
@if ($pollingInterval)
|
||||
wire:poll.{{ $pollingInterval }}
|
||||
@endif
|
||||
>
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-filament::icon
|
||||
icon="heroicon-m-shield-check"
|
||||
class="h-5 w-5 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
Recovery Readiness
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@foreach ([$backupPosture, $recoveryEvidence] as $stat)
|
||||
@php
|
||||
$colorClasses = match ($stat['color']) {
|
||||
'danger' => 'border-danger-300 dark:border-danger-700',
|
||||
'warning' => 'border-warning-300 dark:border-warning-700',
|
||||
'success' => 'border-success-300 dark:border-success-700',
|
||||
'info' => 'border-info-300 dark:border-info-700',
|
||||
default => 'border-gray-200 dark:border-white/10',
|
||||
};
|
||||
$valueColorClasses = match ($stat['color']) {
|
||||
'danger' => 'text-danger-600 dark:text-danger-400',
|
||||
'warning' => 'text-warning-600 dark:text-warning-400',
|
||||
'success' => 'text-success-600 dark:text-success-400',
|
||||
'info' => 'text-info-600 dark:text-info-400',
|
||||
default => 'text-gray-950 dark:text-white',
|
||||
};
|
||||
$descriptionColorClasses = match ($stat['color']) {
|
||||
'danger' => 'text-danger-600 dark:text-danger-400',
|
||||
'warning' => 'text-warning-600 dark:text-warning-400',
|
||||
'success' => 'text-success-600 dark:text-success-400',
|
||||
default => 'text-gray-600 dark:text-gray-300',
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if ($stat['url'])
|
||||
<a
|
||||
href="{{ $stat['url'] }}"
|
||||
class="{{ $colorClasses }} block rounded-xl border-l-4 bg-white p-5 shadow-sm transition hover:shadow-md dark:bg-gray-900"
|
||||
>
|
||||
@else
|
||||
<div class="{{ $colorClasses }} rounded-xl border-l-4 bg-white p-5 shadow-sm dark:bg-gray-900">
|
||||
@endif
|
||||
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ $stat['label'] }}
|
||||
</div>
|
||||
|
||||
<div class="{{ $valueColorClasses }} mt-1 text-2xl font-bold tracking-tight">
|
||||
{{ $stat['value'] }}
|
||||
</div>
|
||||
|
||||
@if (filled($stat['description']))
|
||||
<div class="{{ $descriptionColorClasses }} mt-2 text-sm leading-relaxed">
|
||||
{{ $stat['description'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($stat['url'])
|
||||
</a>
|
||||
@else
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
@ -4,8 +4,8 @@
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
@ -60,7 +60,7 @@
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(DashboardKpis::class)
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Backup posture')
|
||||
->assertSee('Stale');
|
||||
|
||||
|
||||
@ -5,18 +5,22 @@
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
@ -44,14 +48,34 @@ function dashboardKpiStatPayloads($component): array
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{value:string,description:string|null,url:string|null}
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function backupPostureStatPayload(\App\Models\Tenant $tenant): array
|
||||
function recoveryReadinessViewData(\App\Models\Tenant $tenant): array
|
||||
{
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
return dashboardKpiStatPayloads(Livewire::test(DashboardKpis::class))['Backup posture'];
|
||||
$component = Livewire::test(RecoveryReadiness::class);
|
||||
$method = new ReflectionMethod(RecoveryReadiness::class, 'getViewData');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($component->instance());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{value:string,description:string|null,url:string|null}
|
||||
*/
|
||||
function backupPostureStatPayload(\App\Models\Tenant $tenant): array
|
||||
{
|
||||
return recoveryReadinessViewData($tenant)['backupPosture'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{value:string,description:string|null,url:string|null}
|
||||
*/
|
||||
function recoveryEvidenceStatPayload(\App\Models\Tenant $tenant): array
|
||||
{
|
||||
return recoveryReadinessViewData($tenant)['recoveryEvidence'];
|
||||
}
|
||||
|
||||
function makeBackupHealthScheduleForKpi(\App\Models\Tenant $tenant, array $attributes = []): BackupSchedule
|
||||
@ -75,6 +99,77 @@ function makeBackupHealthScheduleForKpi(\App\Models\Tenant $tenant, array $attri
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
function makeHealthyBackupForRecoveryKpi(\App\Models\Tenant $tenant, array $attributes = []): BackupSet
|
||||
{
|
||||
$backupSet = BackupSet::factory()->for($tenant)->recentCompleted()->create(array_merge([
|
||||
'name' => 'Healthy recovery KPI backup',
|
||||
'item_count' => 1,
|
||||
], $attributes));
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'healthy-recovery-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
return $backupSet;
|
||||
}
|
||||
|
||||
dataset('dashboard-recovery-evidence-cases', [
|
||||
'failed history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Weakened',
|
||||
'The restore did not complete successfully. Follow-up is still required.',
|
||||
'Tenant recovery is not proven.',
|
||||
RestoreResultAttention::STATE_FAILED,
|
||||
],
|
||||
'partial history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->partialOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Weakened',
|
||||
'The restore reached a terminal state, but some items or assignments still need follow-up.',
|
||||
'Tenant-wide recovery is not proven.',
|
||||
RestoreResultAttention::STATE_PARTIAL,
|
||||
],
|
||||
'follow-up history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedWithFollowUp()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Weakened',
|
||||
'The restore completed, but follow-up remains for skipped or non-applied work.',
|
||||
'Tenant-wide recovery is not proven.',
|
||||
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
|
||||
],
|
||||
'calm completed history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'No recent issues visible',
|
||||
'Recent executed restore history exists without a current follow-up signal.',
|
||||
'Tenant-wide recovery is not proven.',
|
||||
'no_recent_issues_visible',
|
||||
],
|
||||
]);
|
||||
|
||||
it('aligns dashboard KPI counts and drill-throughs to canonical findings and operations semantics', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
@ -330,6 +425,75 @@ function makeBackupHealthScheduleForKpi(\App\Models\Tenant $tenant, array $attri
|
||||
expect($stat['description'])->toContain('20 minutes');
|
||||
});
|
||||
|
||||
it('keeps healthy backups honest when no executed restore history exists yet', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = makeHealthyBackupForRecoveryKpi($tenant);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->previewOnly()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$backupStat = backupPostureStatPayload($tenant);
|
||||
$recoveryStat = recoveryEvidenceStatPayload($tenant);
|
||||
|
||||
expect($backupStat['value'])->toBe('Healthy')
|
||||
->and($backupStat['description'])->toContain('Backup health reflects backup inputs only and does not prove restore success.');
|
||||
|
||||
expect($recoveryStat)->toMatchArray([
|
||||
'value' => 'Unvalidated',
|
||||
'url' => RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => 'no_history',
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
]);
|
||||
|
||||
expect($recoveryStat['description'])
|
||||
->toContain('No executed restore history is visible in the latest tenant restore records.')
|
||||
->toContain('Tenant-wide recovery is not proven.');
|
||||
});
|
||||
|
||||
it('surfaces weak and calm restore history on the recovery evidence KPI', function (
|
||||
Closure $makeRestoreRun,
|
||||
string $expectedValue,
|
||||
string $expectedSummary,
|
||||
string $expectedBoundary,
|
||||
string $expectedReason,
|
||||
): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
makeHealthyBackupForRecoveryKpi($tenant);
|
||||
|
||||
$restoreBackupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Restore evidence backup',
|
||||
]);
|
||||
|
||||
$restoreRun = $makeRestoreRun($tenant, $restoreBackupSet);
|
||||
|
||||
$recoveryStat = recoveryEvidenceStatPayload($tenant);
|
||||
|
||||
expect($recoveryStat)->toMatchArray([
|
||||
'value' => $expectedValue,
|
||||
'url' => RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
'recovery_posture_reason' => $expectedReason,
|
||||
], panel: 'tenant', tenant: $tenant),
|
||||
]);
|
||||
|
||||
expect($recoveryStat['description'])
|
||||
->toContain($expectedSummary)
|
||||
->toContain($expectedBoundary);
|
||||
})->with('dashboard-recovery-evidence-cases');
|
||||
|
||||
it('keeps the posture healthy but routes the KPI to schedules when backup automation needs follow-up', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
|
||||
@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function makeHealthyBackupForRecoveryPerformance(\App\Models\Tenant $tenant): BackupSet
|
||||
{
|
||||
$backupSet = BackupSet::factory()->for($tenant)->recentCompleted()->create([
|
||||
'name' => 'Performance healthy backup',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'performance-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
return $backupSet;
|
||||
}
|
||||
|
||||
it('caps dashboard recovery evidence derivation to the latest 10 restore-run candidates', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
makeHealthyBackupForRecoveryPerformance($tenant);
|
||||
|
||||
$historyBackupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Candidate cap backup',
|
||||
]);
|
||||
|
||||
foreach (range(1, 10) as $minutesAgo) {
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($historyBackupSet)
|
||||
->previewOnly()
|
||||
->create([
|
||||
'created_at' => now()->subMinutes($minutesAgo),
|
||||
'started_at' => now()->subMinutes($minutesAgo),
|
||||
'completed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($historyBackupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'created_at' => now()->subMinutes(11),
|
||||
'started_at' => now()->subMinutes(11),
|
||||
'completed_at' => now()->subMinutes(11),
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Recovery evidence')
|
||||
->assertSee('Unvalidated')
|
||||
->assertDontSee('Weakened');
|
||||
});
|
||||
|
||||
it('renders dashboard recovery posture and restore-history list with bounded query volume', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
makeHealthyBackupForRecoveryPerformance($tenant);
|
||||
|
||||
$historyBackupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Query-shape backup',
|
||||
]);
|
||||
|
||||
foreach (range(1, 5) as $offset) {
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($historyBackupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes($offset),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach (range(6, 10) as $offset) {
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($historyBackupSet)
|
||||
->completedWithFollowUp()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes($offset),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach (range(11, 15) as $offset) {
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($historyBackupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes($offset),
|
||||
]);
|
||||
}
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
assertNoOutboundHttp(function (): void {
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Recovery evidence')
|
||||
->assertSee('Weakened');
|
||||
});
|
||||
|
||||
$dashboardQueries = count(DB::getQueryLog());
|
||||
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
assertNoOutboundHttp(function () use ($tenant): void {
|
||||
$this->get(RestoreRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Result attention')
|
||||
->assertSee('The restore did not complete successfully. Follow-up is still required.');
|
||||
});
|
||||
|
||||
$listQueries = count(DB::getQueryLog());
|
||||
|
||||
expect($dashboardQueries)->toBeLessThanOrEqual(20)
|
||||
->and($listQueries)->toBeLessThanOrEqual(40);
|
||||
});
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
@ -15,12 +16,14 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
@ -68,6 +71,61 @@ function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, a
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function makeHealthyBackupForRecoveryNeedsAttention(\App\Models\Tenant $tenant, array $attributes = []): BackupSet
|
||||
{
|
||||
$backupSet = BackupSet::factory()->for($tenant)->recentCompleted()->create(array_merge([
|
||||
'name' => 'Healthy recovery needs-attention backup',
|
||||
'item_count' => 1,
|
||||
], $attributes));
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'healthy-recovery-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
return $backupSet;
|
||||
}
|
||||
|
||||
dataset('needs-attention-recovery-cases', [
|
||||
'failed history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Recent restore failed',
|
||||
'The restore did not complete successfully. Follow-up is still required.',
|
||||
'failed',
|
||||
],
|
||||
'partial history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->partialOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Recent restore is partial',
|
||||
'The restore reached a terminal state, but some items or assignments still need follow-up.',
|
||||
'partial',
|
||||
],
|
||||
'follow-up history' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedWithFollowUp()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'Recent restore needs follow-up',
|
||||
'The restore completed, but follow-up remains for skipped or non-applied work.',
|
||||
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
|
||||
],
|
||||
]);
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
@ -133,6 +191,14 @@ function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, a
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($healthyBackup)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
@ -161,6 +227,7 @@ function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, a
|
||||
$component = Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Current governance and findings signals look trustworthy.')
|
||||
->assertSee('Baseline compare looks trustworthy')
|
||||
->assertSee('No recent restore issues visible')
|
||||
->assertSee('No confirmed drift in the latest baseline compare.')
|
||||
->assertDontSee('Baseline compare posture');
|
||||
|
||||
@ -499,6 +566,14 @@ function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, a
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($healthyBackup)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
@ -526,11 +601,133 @@ function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, a
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Backups are recent and healthy')
|
||||
->assertSee('No recent restore issues visible')
|
||||
->assertSee('Baseline compare looks trustworthy')
|
||||
->assertDontSee('Backup schedules need follow-up')
|
||||
->assertDontSee('No usable backup basis');
|
||||
});
|
||||
|
||||
it('surfaces missing restore history and suppresses the healthy fallback when recovery evidence is unvalidated', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = makeHealthyBackupForRecoveryNeedsAttention($tenant);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->previewOnly()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Recovery evidence is unvalidated')
|
||||
->assertSee('No executed restore history is visible in the latest tenant restore records.')
|
||||
->assertSee('Backup health reflects backup inputs only and does not prove restore success.')
|
||||
->assertSee('Open restore history')
|
||||
->assertDontSee('Current governance and findings signals look trustworthy.')
|
||||
->assertDontSee('Backups are recent and healthy');
|
||||
|
||||
expect($component->html())->toContain(RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => 'no_history',
|
||||
], panel: 'tenant', tenant: $tenant));
|
||||
});
|
||||
|
||||
it('surfaces recent weak restore history in needs-attention with the matching restore drillthrough', function (
|
||||
Closure $makeRestoreRun,
|
||||
string $expectedTitle,
|
||||
string $expectedBody,
|
||||
string $expectedReason,
|
||||
): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
makeHealthyBackupForRecoveryNeedsAttention($tenant);
|
||||
|
||||
$restoreBackupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Recovery attention restore backup',
|
||||
]);
|
||||
|
||||
$restoreRun = $makeRestoreRun($tenant, $restoreBackupSet);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(NeedsAttention::class)
|
||||
->assertSee($expectedTitle)
|
||||
->assertSee($expectedBody)
|
||||
->assertSee('Open restore run')
|
||||
->assertDontSee('Current governance and findings signals look trustworthy.');
|
||||
|
||||
expect($component->html())->toContain(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
'recovery_posture_reason' => $expectedReason,
|
||||
], panel: 'tenant', tenant: $tenant));
|
||||
})->with('needs-attention-recovery-cases');
|
||||
|
||||
it('adds a calm recovery healthy-check without claiming tenant-wide recovery proof', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
makeHealthyBackupForRecoveryNeedsAttention($tenant);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now()->subHour(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => [
|
||||
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$restoreBackupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Calm recovery restore backup',
|
||||
]);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($restoreBackupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Current governance and findings signals look trustworthy.')
|
||||
->assertSee('Backups are recent and healthy')
|
||||
->assertSee('No recent restore issues visible')
|
||||
->assertSee('Recent executed restore history exists without a current follow-up signal.')
|
||||
->assertSee('Tenant-wide recovery is not proven.')
|
||||
->assertDontSee('Recovery evidence is unvalidated')
|
||||
->assertDontSee('Recent restore failed');
|
||||
});
|
||||
|
||||
it('keeps backup-health attention visible but non-clickable when the member lacks backup view access', function (): void {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\ViewRestoreRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
@ -12,6 +13,45 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
dataset('dashboard-linked-restore-result-reasons', [
|
||||
'failed' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'failed',
|
||||
'The dashboard opened this restore run because the latest executed restore failed.',
|
||||
'The restore did not complete successfully. Follow-up is still required.',
|
||||
],
|
||||
'partial' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->partialOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'partial',
|
||||
'The dashboard opened this restore run because the latest executed restore completed partially.',
|
||||
'The restore reached a terminal state, but some items or assignments still need follow-up.',
|
||||
],
|
||||
'completed with follow-up' => [
|
||||
fn (\App\Models\Tenant $tenant, BackupSet $backupSet): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedWithFollowUp()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]),
|
||||
'completed_with_follow_up',
|
||||
'The dashboard opened this restore run because skipped or non-applied work still needs follow-up.',
|
||||
'The restore completed, but follow-up remains for skipped or non-applied work.',
|
||||
],
|
||||
]);
|
||||
|
||||
it('elevates restore result attention above raw item diagnostics', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
@ -215,3 +255,26 @@
|
||||
->assertSee('Preview only. This foundation type is not applied during execution.')
|
||||
->assertDontSee('Unknown');
|
||||
});
|
||||
|
||||
it('keeps dashboard-linked restore-result confirmation aligned for problematic restore runs', function (
|
||||
Closure $makeRestoreRun,
|
||||
string $reason,
|
||||
string $expectedSubheading,
|
||||
string $expectedSummary,
|
||||
): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||
$restoreRun = $makeRestoreRun($tenant, $backupSet);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
'recovery_posture_reason' => $reason,
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee($expectedSubheading)
|
||||
->assertSee($expectedSummary);
|
||||
})->with('dashboard-linked-restore-result-reasons');
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('confirms missing relevant restore history on the restore-run list surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => 'no_history',
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('No executed restore history is visible in the latest tenant restore records.')
|
||||
->assertSee('No restore runs');
|
||||
});
|
||||
|
||||
it('keeps recovery-posture list fallback copy readable when a weak restore signal lands on history', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'name' => 'Weak history backup',
|
||||
]);
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedWithFollowUp()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('index', [
|
||||
'recovery_posture_reason' => 'completed_with_follow_up',
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('The dashboard opened restore history because skipped or non-applied work still needs follow-up.')
|
||||
->assertSee('The restore completed, but follow-up remains for skipped or non-applied work.');
|
||||
});
|
||||
@ -5,6 +5,8 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
@ -13,6 +15,8 @@
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
@ -127,3 +131,67 @@ function getRestoreRunEmptyStateAction(Testable $component, string $name): ?Acti
|
||||
expect($action->isDisabled())->toBeTrue();
|
||||
expect($action->getTooltip())->toBe(UiTooltips::insufficientPermission());
|
||||
});
|
||||
|
||||
test('readonly members can inspect restore-run history while mutations remain disabled', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'readonly');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::factory()->for($tenant)->for($backupSet)->completedOutcome()->create();
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListRestoreRuns::class)
|
||||
->assertCanSeeTableRecords([$restoreRun])
|
||||
->assertTableActionDisabled('archive', $restoreRun);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('in-scope members without restore-history view capability receive 403 on restore-run list and detail drillthroughs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::factory()->for($tenant)->for($backupSet)->failedOutcome()->create();
|
||||
|
||||
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
|
||||
$mock->shouldReceive('primeMemberships')->andReturnNull();
|
||||
$mock->shouldReceive('isMember')
|
||||
->andReturnUsing(static fn ($user, Tenant $resolvedTenant): bool => (int) $resolvedTenant->getKey() === (int) $tenant->getKey());
|
||||
|
||||
$mock->shouldReceive('can')
|
||||
->andReturnUsing(static function ($user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
|
||||
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
|
||||
|
||||
return match ($capability) {
|
||||
Capabilities::TENANT_VIEW => false,
|
||||
default => true,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(RestoreRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations as DashboardRecentOperations;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Finding;
|
||||
@ -59,9 +60,11 @@
|
||||
// lazy-loaded widgets and will not appear in the initial
|
||||
// server-rendered HTML.
|
||||
|
||||
Livewire::test(DashboardKpis::class)
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Backup posture')
|
||||
->assertSee('Healthy')
|
||||
->assertSee('Healthy');
|
||||
|
||||
Livewire::test(DashboardKpis::class)
|
||||
->assertSee('Active operations')
|
||||
->assertSee('healthy queued or running tenant work');
|
||||
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
function seedRecoveryVisibilityScenario(Tenant $tenant): RestoreRun
|
||||
{
|
||||
$backupSet = BackupSet::factory()->for($tenant)->recentCompleted()->create([
|
||||
'name' => 'Recovery visibility backup',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
BackupItem::factory()->for($tenant)->for($backupSet)->create([
|
||||
'payload' => ['id' => 'recovery-visibility-policy'],
|
||||
'metadata' => [],
|
||||
'assignments' => [],
|
||||
]);
|
||||
|
||||
return RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
}
|
||||
|
||||
it('keeps recovery drillthrough inspectable for readonly tenant members', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'readonly');
|
||||
$restoreRun = seedRecoveryVisibilityScenario($tenant);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Recent restore failed')
|
||||
->assertSee('Open restore run');
|
||||
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Recovery evidence')
|
||||
->assertSee('Weakened');
|
||||
|
||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertOk();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('keeps recovery summaries cautious while restore-history drillthrough is forbidden for in-scope members without view capability', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$restoreRun = seedRecoveryVisibilityScenario($tenant);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Gate::define(Capabilities::TENANT_VIEW, fn (): bool => false);
|
||||
|
||||
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
|
||||
$mock->shouldReceive('primeMemberships')->andReturnNull();
|
||||
$mock->shouldReceive('isMember')
|
||||
->andReturnUsing(static fn ($user, Tenant $resolvedTenant): bool => (int) $resolvedTenant->getKey() === (int) $tenant->getKey());
|
||||
|
||||
$mock->shouldReceive('can')
|
||||
->andReturnUsing(static function ($user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
|
||||
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
|
||||
|
||||
return match ($capability) {
|
||||
Capabilities::TENANT_VIEW => false,
|
||||
default => true,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Recent restore failed')
|
||||
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->assertSee('Open restore run');
|
||||
|
||||
Livewire::test(RecoveryReadiness::class)
|
||||
->assertSee('Recovery evidence')
|
||||
->assertSee('Weakened')
|
||||
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION);
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('keeps dashboard and restore-history recovery surfaces deny-as-not-found for non-members', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($otherTenant, role: 'owner');
|
||||
$restoreRun = seedRecoveryVisibilityScenario($tenant);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||
->assertNotFound();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('index', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
|
||||
$this->get(RestoreRunResource::getUrl('view', [
|
||||
'record' => (int) $restoreRun->getKey(),
|
||||
], panel: 'tenant', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\Filament\AdminPanelProvider;
|
||||
use App\Providers\Filament\SystemPanelProvider;
|
||||
use App\Providers\Filament\TenantPanelProvider;
|
||||
|
||||
it('keeps the platform health and admin login routes reachable', function () {
|
||||
$this->get('/up')->assertSuccessful();
|
||||
$this->get('/admin/login')->assertSuccessful();
|
||||
});
|
||||
|
||||
it('keeps panel providers registered from apps/platform/bootstrap/providers.php', function () {
|
||||
$providers = require base_path('bootstrap/providers.php');
|
||||
|
||||
expect($providers)
|
||||
->toContain(AdminPanelProvider::class)
|
||||
->toContain(TenantPanelProvider::class)
|
||||
->toContain(SystemPanelProvider::class);
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
function decodeJsonFile(string $path): array
|
||||
{
|
||||
$decoded = json_decode(file_get_contents($path), true);
|
||||
|
||||
expect($decoded)->toBeArray();
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
it('defines the official root workspace command model', function () {
|
||||
$package = decodeJsonFile(repo_path('package.json'));
|
||||
|
||||
expect($package['packageManager'] ?? null)->toBe('pnpm@10.33.0');
|
||||
expect($package['scripts'] ?? [])
|
||||
->toHaveKeys([
|
||||
'dev',
|
||||
'dev:platform',
|
||||
'dev:website',
|
||||
'build:platform',
|
||||
'build:website',
|
||||
]);
|
||||
expect($package['scripts']['dev'] ?? null)->toContain('corepack pnpm dev:website');
|
||||
expect($package['scripts']['dev:platform'] ?? null)->toBe('./scripts/platform-sail up -d');
|
||||
expect($package['scripts']['dev:website'] ?? null)->toContain('WEBSITE_PORT=${WEBSITE_PORT:-4321}');
|
||||
expect($package['scripts']['build:platform'] ?? null)->toContain('@tenantatlas/platform build');
|
||||
expect($package['scripts']['build:website'] ?? null)->toContain('@tenantatlas/website build');
|
||||
});
|
||||
|
||||
it('binds the repo to apps workspaces and removes the legacy npm lockfile', function () {
|
||||
$workspaceDefinition = file_get_contents(repo_path('pnpm-workspace.yaml'));
|
||||
|
||||
expect($workspaceDefinition)->toContain('apps/*');
|
||||
expect(file_exists(repo_path('apps/platform/package-lock.json')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps platform and website manifests aligned to the workspace boundary', function () {
|
||||
$platformPackage = decodeJsonFile(repo_path('apps/platform/package.json'));
|
||||
$websitePackage = decodeJsonFile(repo_path('apps/website/package.json'));
|
||||
|
||||
expect($platformPackage['name'] ?? null)->toBe('@tenantatlas/platform');
|
||||
expect($platformPackage['scripts']['sail:dev'] ?? null)->toBe('./vendor/bin/sail pnpm dev');
|
||||
expect($platformPackage['scripts']['sail:build'] ?? null)->toBe('./vendor/bin/sail pnpm build');
|
||||
|
||||
expect($websitePackage['name'] ?? null)->toBe('@tenantatlas/website');
|
||||
expect($websitePackage['scripts'] ?? [])
|
||||
->toHaveKeys(['dev', 'build', 'preview']);
|
||||
expect($websitePackage['scripts']['dev'] ?? null)->toContain('${WEBSITE_PORT:-4321}');
|
||||
});
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -68,3 +69,111 @@
|
||||
->and($attention->followUpRequired)->toBeTrue()
|
||||
->and($attention->primaryNextAction)->toBe('review_skipped_items');
|
||||
});
|
||||
|
||||
it('ignores preview-only runs when deriving tenant recovery evidence', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->previewOnly()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$overview = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
|
||||
expect($overview['overview_state'])->toBe('unvalidated')
|
||||
->and($overview['latest_relevant_restore_run_id'])->toBeNull()
|
||||
->and($overview['latest_relevant_attention_state'])->toBeNull()
|
||||
->and($overview['reason'])->toBe('no_history');
|
||||
});
|
||||
|
||||
it('uses the latest relevant restore run when calmer history is newer than older failures', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
$latestCompleted = RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$overview = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
|
||||
expect($overview['overview_state'])->toBe('no_recent_issues_visible')
|
||||
->and($overview['latest_relevant_restore_run_id'])->toBe((int) $latestCompleted->getKey())
|
||||
->and($overview['latest_relevant_attention_state'])->toBe(RestoreResultAttention::STATE_COMPLETED)
|
||||
->and($overview['reason'])->toBe('no_recent_issues_visible');
|
||||
});
|
||||
|
||||
it('keeps recent weak restore history ahead of older calmer history', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||
|
||||
RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
$latestFailed = RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->failedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$overview = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
|
||||
expect($overview['overview_state'])->toBe('weakened')
|
||||
->and($overview['latest_relevant_restore_run_id'])->toBe((int) $latestFailed->getKey())
|
||||
->and($overview['latest_relevant_attention_state'])->toBe(RestoreResultAttention::STATE_FAILED)
|
||||
->and($overview['claim_boundary'])->toBe('Tenant recovery is not proven.')
|
||||
->and($overview['reason'])->toBe(RestoreResultAttention::STATE_FAILED);
|
||||
});
|
||||
|
||||
it('falls back to calm completed history when no recent weak evidence is present', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$this->actingAs($user);
|
||||
|
||||
BackupSet::factory()->for($tenant)->recentCompleted()->create();
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create();
|
||||
|
||||
$latestCompleted = RestoreRun::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->completedOutcome()
|
||||
->create([
|
||||
'completed_at' => now()->subMinutes(15),
|
||||
]);
|
||||
|
||||
$overview = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
|
||||
|
||||
expect($overview['backup_posture'])->toBe(TenantBackupHealthAssessment::POSTURE_HEALTHY)
|
||||
->and($overview['overview_state'])->toBe('no_recent_issues_visible')
|
||||
->and($overview['latest_relevant_restore_run_id'])->toBe((int) $latestCompleted->getKey())
|
||||
->and($overview['latest_relevant_attention_state'])->toBe(RestoreResultAttention::STATE_COMPLETED)
|
||||
->and($overview['reason'])->toBe('no_recent_issues_visible');
|
||||
});
|
||||
|
||||
9
apps/website/astro.config.mjs
Normal file
9
apps/website/astro.config.mjs
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
server: {
|
||||
host: true,
|
||||
port: 4321,
|
||||
},
|
||||
});
|
||||
17
apps/website/package.json
Normal file
17
apps/website/package.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@tenantatlas/website",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev --host 0.0.0.0 --port ${WEBSITE_PORT:-4321}",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^6.0.0"
|
||||
}
|
||||
}
|
||||
11
apps/website/public/favicon.svg
Normal file
11
apps/website/public/favicon.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect width="64" height="64" rx="18" fill="#17120F" />
|
||||
<path
|
||||
d="M17 18H47V25H36V46H28V25H17V18Z"
|
||||
fill="#FFF7F1"
|
||||
/>
|
||||
<path
|
||||
d="M44 17C50.0751 17 55 21.9249 55 28C55 34.0751 50.0751 39 44 39H39V32H44C46.2091 32 48 30.2091 48 28C48 25.7909 46.2091 24 44 24H39V17H44Z"
|
||||
fill="#CC5F2C"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 413 B |
2
apps/website/public/robots.txt
Normal file
2
apps/website/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
27
apps/website/src/layouts/BaseLayout.astro
Normal file
27
apps/website/src/layouts/BaseLayout.astro
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
description?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
description = 'TenantPilot keeps Intune governance observable, reviewable, and safe to operate.',
|
||||
title = 'TenantPilot',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
66
apps/website/src/pages/index.astro
Normal file
66
apps/website/src/pages/index.astro
Normal file
@ -0,0 +1,66 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="TenantPilot | Workspace Foundation"
|
||||
description="The first public TenantPilot website surface for workspace-safe Intune operations."
|
||||
>
|
||||
<main class="page-shell">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">TenantPilot</p>
|
||||
<h1>One public website, one stable platform, one clear workspace model.</h1>
|
||||
<p class="lede">
|
||||
TenantPilot keeps Intune change management auditable for operators while the public
|
||||
website stays fast, static, and operationally separate from the Laravel platform.
|
||||
</p>
|
||||
|
||||
<div class="hero-actions">
|
||||
<a class="primary-action" href="#workspace-model">View the workspace model</a>
|
||||
<a class="secondary-action" href="#boundaries">Review the isolation rules</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="signal-grid" id="workspace-model" aria-label="Workspace foundations">
|
||||
<article class="signal-card">
|
||||
<p class="signal-label">Platform</p>
|
||||
<h2>Laravel stays in <code>apps/platform</code>.</h2>
|
||||
<p>
|
||||
Sail, Filament, Livewire, and deployment-sensitive runtime concerns remain
|
||||
platform-owned and unchanged.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="signal-card">
|
||||
<p class="signal-label">Website</p>
|
||||
<h2>Astro lives independently in <code>apps/website</code>.</h2>
|
||||
<p>
|
||||
Public pages build statically, run without Laravel, and keep their own dev and
|
||||
build outputs.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="signal-card">
|
||||
<p class="signal-label">Root</p>
|
||||
<h2>The repository root orchestrates without becoming an app.</h2>
|
||||
<p>
|
||||
Root scripts expose the official entry commands while app-local execution logic
|
||||
stays inside each app directory.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="boundary-panel" id="boundaries">
|
||||
<div>
|
||||
<p class="eyebrow">Isolation</p>
|
||||
<h2>Builds, ports, and ownership stay intentionally separate.</h2>
|
||||
</div>
|
||||
|
||||
<ul class="boundary-list">
|
||||
<li>Website dev defaults to port 4321 and supports explicit port overrides.</li>
|
||||
<li>Platform Docker, queues, and Filament assets stay under the existing Sail flow.</li>
|
||||
<li>No shared package layer, CMS, or extra app surface is introduced in this slice.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
221
apps/website/src/styles/global.css
Normal file
221
apps/website/src/styles/global.css
Normal file
@ -0,0 +1,221 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6efe5;
|
||||
--bg-accent: #fffdf9;
|
||||
--surface: rgba(255, 255, 255, 0.74);
|
||||
--surface-strong: rgba(255, 255, 255, 0.92);
|
||||
--ink: #17120f;
|
||||
--muted: #66584d;
|
||||
--line: rgba(23, 18, 15, 0.12);
|
||||
--accent: #cc5f2c;
|
||||
--accent-deep: #8b3820;
|
||||
--shadow: 0 30px 80px rgba(103, 52, 33, 0.16);
|
||||
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 201, 149, 0.55), transparent 34%),
|
||||
radial-gradient(circle at right 12% top 10%, rgba(255, 145, 96, 0.18), transparent 24%),
|
||||
linear-gradient(180deg, #fffaf3 0%, var(--bg) 58%, #efe3d5 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "SFMono-Regular", "SF Mono", "IBM Plex Mono", monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: min(1120px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 4.5rem 0 5rem;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.signal-card,
|
||||
.boundary-panel {
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: clamp(2rem, 4vw, 4.5rem);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 2rem;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 247, 239, 0.72)),
|
||||
linear-gradient(120deg, rgba(204, 95, 44, 0.08), rgba(255, 255, 255, 0));
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto -8rem -8rem auto;
|
||||
width: 18rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgba(204, 95, 44, 0.22), transparent 72%);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.signal-label {
|
||||
margin: 0 0 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.boundary-panel h2,
|
||||
.signal-card h2 {
|
||||
margin: 0;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
max-width: 13ch;
|
||||
font-size: clamp(3rem, 8vw, 6rem);
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 46rem;
|
||||
margin: 1.5rem 0 0;
|
||||
font-size: clamp(1.05rem, 2vw, 1.35rem);
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.9rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
background-color 180ms ease;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
background: var(--ink);
|
||||
color: #fff7f1;
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
border: 1px solid rgba(23, 18, 15, 0.12);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.primary-action:hover,
|
||||
.secondary-action:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.signal-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
margin-top: 1.4rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.signal-card,
|
||||
.boundary-panel {
|
||||
padding: 1.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.5rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.signal-card h2 {
|
||||
font-size: clamp(1.55rem, 3vw, 2.1rem);
|
||||
}
|
||||
|
||||
.signal-card p:last-child,
|
||||
.boundary-list {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.boundary-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 1.25rem;
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.boundary-panel h2 {
|
||||
font-size: clamp(2rem, 4vw, 3.1rem);
|
||||
}
|
||||
|
||||
.boundary-list {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.boundary-list li + li {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.signal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-shell {
|
||||
width: min(100% - 1.25rem, 1120px);
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.signal-card,
|
||||
.boundary-panel {
|
||||
border-radius: 1.3rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@ # TenantPilot / TenantAtlas — Handover Document
|
||||
## Executive Summary
|
||||
|
||||
- **Platform relocation addendum (Spec 182)**: The Laravel application now lives under `apps/platform`; repo root remains the orchestration and metadata layer.
|
||||
- **Canonical command model**: Human-facing app commands now start from `apps/platform`; repo-root launcher files such as VS Code MCP and task integrations delegate via `./scripts/platform-sail`.
|
||||
- **Canonical command model**: Human-facing platform commands still start from `apps/platform`, while repo-root pnpm commands now orchestrate `apps/platform` and `apps/website` for multi-app local development.
|
||||
- **Branch-impact warning**: Open branches that still modify legacy root app paths should merge `dev` and remap their changes into `apps/platform/...` before continuing.
|
||||
- **Rollout unknowns**: Dokploy build context, web/queue/scheduler working directories, and volume mappings for `public/` and `storage/` still require staging verification after the relocation.
|
||||
|
||||
@ -427,20 +427,26 @@ ## Deployment & Runbooks
|
||||
### Local development
|
||||
|
||||
```bash
|
||||
# Start services
|
||||
vendor/bin/sail up -d
|
||||
# Start platform services
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
|
||||
# Run migrations
|
||||
vendor/bin/sail artisan migrate
|
||||
cd apps/platform && ./vendor/bin/sail artisan migrate
|
||||
|
||||
# Start queue worker (or use docker-compose queue service)
|
||||
vendor/bin/sail artisan queue:work --tries=3 --timeout=300
|
||||
cd apps/platform && ./vendor/bin/sail artisan queue:work --tries=3 --timeout=300
|
||||
|
||||
# Build frontend assets
|
||||
vendor/bin/sail npm run build
|
||||
cd apps/platform && ./vendor/bin/sail pnpm build
|
||||
|
||||
# Install workspace JavaScript dependencies
|
||||
corepack pnpm install
|
||||
|
||||
# Start the website
|
||||
corepack pnpm dev:website
|
||||
|
||||
# Run tests
|
||||
vendor/bin/sail artisan test --compact
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact
|
||||
```
|
||||
|
||||
### Docker services ([docker-compose.yml](docker-compose.yml))
|
||||
@ -493,7 +499,7 @@ ## Next Actions (Top 5)
|
||||
|
||||
1. **Create `.env.example`** — Document all required env vars from `config/*.php` files. Low effort, high impact for onboarding.
|
||||
|
||||
2. **Add CI pipeline** — Gitea / runner config for `vendor/bin/sail artisan test --compact` + `vendor/bin/sail bin pint --test` on every PR. Critical for sustained quality.
|
||||
2. **Add CI pipeline** — Gitea / runner config for `cd apps/platform && ./vendor/bin/sail artisan test --compact` + `cd apps/platform && ./vendor/bin/sail bin pint --test` on every PR. Critical for sustained quality.
|
||||
|
||||
3. **Formal exception/risk-acceptance spec** — Design `FindingException` entity with approval chain, evidence attachment, and UI workflow. Blocks R2 "Evidence Packs" roadmap item.
|
||||
|
||||
@ -628,33 +634,35 @@ ### Key Commands
|
||||
|
||||
```bash
|
||||
# Local dev
|
||||
vendor/bin/sail up -d # Start all services
|
||||
vendor/bin/sail artisan migrate # Run migrations
|
||||
vendor/bin/sail artisan queue:work --tries=3 # Process queue
|
||||
vendor/bin/sail npm run build # Build frontend
|
||||
corepack pnpm install # Install workspace JS dependencies
|
||||
corepack pnpm dev:platform # Start the platform stack
|
||||
corepack pnpm dev:website # Start the website
|
||||
corepack pnpm build:website # Build the website
|
||||
cd apps/platform && ./vendor/bin/sail pnpm build # Build platform frontend
|
||||
|
||||
# Testing
|
||||
vendor/bin/sail artisan test --compact # Full suite
|
||||
vendor/bin/sail artisan test --compact --filter=testName # Single test
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Guards/ # Guard tests only
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact # Full suite
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=testName # Single test
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ # Guard tests only
|
||||
|
||||
# Code quality
|
||||
vendor/bin/sail bin pint --dirty # Format changed files
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty # Format changed files
|
||||
|
||||
# Deployment
|
||||
vendor/bin/sail artisan filament:assets # Publish Filament assets
|
||||
vendor/bin/sail artisan migrate --force # Production migration
|
||||
cd apps/platform && php artisan filament:assets # Publish Filament assets
|
||||
cd apps/platform && php artisan migrate --force # Production migration
|
||||
```
|
||||
|
||||
### Quickstart (exact steps)
|
||||
|
||||
1. Clone repo, `cd TenantAtlas`
|
||||
2. `cp .env.example .env` (MISSING — create from config files or request from team)
|
||||
3. `composer install`
|
||||
4. `vendor/bin/sail up -d`
|
||||
5. `vendor/bin/sail artisan key:generate`
|
||||
6. `vendor/bin/sail artisan migrate`
|
||||
7. `vendor/bin/sail npm install && vendor/bin/sail npm run build`
|
||||
8. `vendor/bin/sail artisan filament:assets`
|
||||
9. Login: Navigate to `/admin` — redirects to workspace chooser. For first-time setup, create a workspace and onboard a tenant via the wizard.
|
||||
10. System console: Navigate to `/system` — requires `platform_users` record with `platform` guard.
|
||||
3. `corepack pnpm install`
|
||||
4. `cd apps/platform && composer install`
|
||||
5. `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
6. `cd apps/platform && ./vendor/bin/sail artisan key:generate`
|
||||
7. `cd apps/platform && ./vendor/bin/sail artisan migrate`
|
||||
8. `cd apps/platform && ./vendor/bin/sail pnpm build`
|
||||
9. `cd apps/platform && php artisan filament:assets`
|
||||
10. Login: Navigate to `/admin` — redirects to workspace chooser. For first-time setup, create a workspace and onboard a tenant via the wizard.
|
||||
11. System console: Navigate to `/system` — requires `platform_users` record with `platform` guard.
|
||||
|
||||
@ -5,21 +5,27 @@
|
||||
**Overview:**
|
||||
- **Purpose:** Intune management tool (backup, restore, policy versioning, safe change management) built with Laravel + Filament.
|
||||
- **Primary goals:** policy version control (diff/history/rollback), reliable backup & restore of Microsoft Intune configuration, admin-focused productivity features, auditability and least-privilege operations.
|
||||
- **Workspace model:** repo-root pnpm workspace orchestration, Laravel platform in `apps/platform`, and standalone Astro website in `apps/website`.
|
||||
|
||||
**Tech Stack & Key Libraries:**
|
||||
- **Backend:** Laravel 12 (PHP 8.4)
|
||||
- **Admin UI:** Filament v4
|
||||
- **Realtime/UI:** Livewire v3
|
||||
- **Admin UI:** Filament v5
|
||||
- **Realtime/UI:** Livewire v4
|
||||
- **Public website:** Astro v6
|
||||
- **Workspace tooling:** pnpm 10 workspaces
|
||||
- **Testing:** Pest v4, PHPUnit
|
||||
- **Local dev:** Laravel Sail (Docker)
|
||||
- **Styling/tooling:** Tailwind v4, Vite
|
||||
|
||||
**Repository Layout (high level):**
|
||||
- `app/` — application code (Services, Models, Filament resources, Livewire components)
|
||||
- `config/` — runtime configuration (important: `tenantpilot.php`, `graph_contracts.php`)
|
||||
- `apps/platform/` — Laravel runtime, Filament panels, tests, Vite assets, and app-local PHP or Node manifests
|
||||
- `apps/website/` — Astro website source, public assets, and static build output
|
||||
- `package.json` + `pnpm-workspace.yaml` — official root workspace command model
|
||||
- `scripts/` — root compatibility helpers such as `platform-sail`
|
||||
- `specs/` — SpecKit feature specs (feature-by-feature directories, e.g. `011-restore-run-wizard`)
|
||||
- `docs/` — architecture, rollout, and handover notes
|
||||
- `tests/` — Pest tests (Feature / Unit)
|
||||
- `resources/`, `routes/`, `database/` — standard Laravel layout
|
||||
- `apps/platform/resources/`, `apps/platform/routes/`, `apps/platform/database/` — Laravel application structure
|
||||
|
||||
**Core Features (implemented / status):**
|
||||
- **Policy Backup & Versioning:** implemented — captures immutable snapshots (JSONB), tracks metadata (tenant, type, created_by, timestamps). (See `app/Services/Intune/*`, `database/migrations`.)
|
||||
@ -54,10 +60,11 @@
|
||||
- Added `Agents.md` section for a “Solo + Copilot Workflow” and created a small `chore/solo-copilot-workflow` branch/PR for that documentation change.
|
||||
|
||||
**Where to look first (entry points):**
|
||||
- Restore flows: `app/Services/Intune/RestoreService.php`, `app/Services/Intune/RestoreRiskChecker.php`, `app/Services/Intune/RestoreDiffGenerator.php`.
|
||||
- Graph contracts: `config/graph_contracts.php` and `app/Services/Graph/GraphContractRegistry.php`.
|
||||
- Policy type catalog and UX metadata: `config/tenantpilot.php` and `specs/*` for feature intentions.
|
||||
- Filament UI: `app/Filament/Resources/*` (PolicyVersionResource, restore wizard pages).
|
||||
- Root workspace entry: `package.json`, `pnpm-workspace.yaml`, and `README.md`
|
||||
- Restore flows: `apps/platform/app/Services/Intune/RestoreService.php`, `apps/platform/app/Services/Intune/RestoreRiskChecker.php`, `apps/platform/app/Services/Intune/RestoreDiffGenerator.php`
|
||||
- Graph contracts: `apps/platform/config/graph_contracts.php` and `apps/platform/app/Services/Graph/GraphContractRegistry.php`
|
||||
- Policy type catalog and UX metadata: `apps/platform/config/tenantpilot.php` and `specs/*` for feature intentions
|
||||
- Filament UI: `apps/platform/app/Filament/Resources/*` (PolicyVersionResource, restore wizard pages)
|
||||
|
||||
**Short list of known limitations / next work items:**
|
||||
- Convert more `preview-only` types to `enabled` where safe (requires implementation of restore flows and risk mitigation, e.g., Conditional Access, Enrollment subtypes, Security Baselines).
|
||||
|
||||
15
package.json
Normal file
15
package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "tenantatlas-workspace",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "./scripts/platform-sail up -d && corepack pnpm dev:website",
|
||||
"dev:platform": "./scripts/platform-sail up -d",
|
||||
"dev:website": "WEBSITE_PORT=${WEBSITE_PORT:-4321} corepack pnpm --filter @tenantatlas/website dev",
|
||||
"build:platform": "corepack pnpm --filter @tenantatlas/platform build",
|
||||
"build:website": "corepack pnpm --filter @tenantatlas/website build"
|
||||
}
|
||||
}
|
||||
4572
pnpm-lock.yaml
Normal file
4572
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- apps/*
|
||||
@ -2,6 +2,10 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Platform-only compatibility wrapper for repo-root tooling.
|
||||
# Official root workspace scripts may orchestrate through this helper, but the
|
||||
# authoritative Sail runtime still lives under apps/platform.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="${SCRIPT_DIR}/../apps/platform"
|
||||
|
||||
|
||||
175
specs/001-dashboard-recovery-honesty/spec.md
Normal file
175
specs/001-dashboard-recovery-honesty/spec.md
Normal file
@ -0,0 +1,175 @@
|
||||
# Feature Specification: Dashboard Recovery Posture Honesty
|
||||
|
||||
**Feature Branch**: `[001-dashboard-recovery-honesty]`
|
||||
**Created**: 2026-04-08
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 184 — Dashboard Recovery Posture Honesty"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**: `/admin/t/{tenant}`, `/admin/t/{tenant}/restore-runs`, `/admin/t/{tenant}/restore-runs/{record}`, `/admin/t/{tenant}/backup-sets`, `/admin/t/{tenant}/backup-sets/{record}`
|
||||
- **Data Ownership**: Tenant-owned `BackupSet`, `RestoreRun`, and linked `OperationRun` outcome context are read within the active workspace and tenant scope to derive a more honest overview statement. No new persisted recovery-confidence state is introduced.
|
||||
- **RBAC**: Workspace plus tenant membership remains required on every affected surface. Members who can open the tenant dashboard must see honest summary boundaries even when they cannot start or manage restore runs. Existing restore-run creation and mutation actions remain under current restore permissions. Non-members continue to receive deny-as-not-found semantics.
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard KPI strip | Dashboard / stats overview | Explicit stat click per signal | forbidden | Supporting text inside the stat description | none | `/admin/t/{tenant}` | Signal-specific drill-through to `/admin/t/{tenant}/restore-runs` or `/admin/t/{tenant}/restore-runs/{record}` | Workspace context plus tenant context | Dashboard KPIs / Backup posture | Backup health is separate from restore evidence | existing widget pattern |
|
||||
| Needs Attention / Healthy Checks panel | Dashboard / attention summary | Explicit card CTA per attention item; healthy state is read-only | forbidden | Card CTA and helper copy only | none | `/admin/t/{tenant}` | `/admin/t/{tenant}/restore-runs`, `/admin/t/{tenant}/restore-runs/{record}` | Workspace context plus tenant context | Needs attention / Healthy checks | Unknown and weakened recovery confidence are visible before drilldown | existing widget pattern |
|
||||
| Restore runs page | CRUD / list-first resource | Full-row click to restore-run detail | required | Existing header action plus More menu | Existing More and bulk More groups | `/admin/t/{tenant}/restore-runs` | `/admin/t/{tenant}/restore-runs/{record}` | Tenant context plus restore-run identity | Restore runs / Restore run | Recent restore outcome and follow-up reason confirm the overview claim | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard KPI strip | Tenant operator | Dashboard summary | Do healthy backups also have supporting restore evidence, or is that still unknown? | Backup posture, recovery-confidence qualifier, visible claim boundary, next step | Per-run causes, raw backup metadata, deeper restore evidence | backup health, recovery evidence availability, recent restore attention | None; read-only summary | Open restore history, open supporting backup context when backup health itself needs follow-up | none |
|
||||
| Needs Attention / Healthy Checks panel | Tenant operator | Dashboard attention and healthy-boundary surface | What recovery-confidence issue needs action now, and why? | No restore history, weakened recent restore history, boundary copy, concrete next action | Full restore results, preview or check details, low-level run metadata | backup health, recovery evidence availability, restore result attention, recency | None; read-only summary | Open restore history, open latest problematic restore run | none |
|
||||
| Restore runs page | Tenant operator | List and detail | Which restore runs explain the dashboard signal? | Recent restore status, result-attention reason, completed timing, related backup context | Assignment-level failures, preview detail, low-level result payloads | execution lifecycle, result attention, follow-up state | Existing restore-run maintenance actions only | Inspect restore run, create restore run | Existing rerun, archive, restore archived, and force-delete actions |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: A tenant dashboard can currently look calm or healthy even when restore history is absent or recent restore results weaken confidence, so operators can overread backup health as recovery posture.
|
||||
- **Existing structure is insufficient because**: Backup health, restore history, and restore result attention already exist as separate truths, but the summary surfaces do not yet combine them with an honest claim boundary. Operators must manually cross-check multiple pages to avoid an overclaim.
|
||||
- **Narrowest correct implementation**: Derive a small set of overview honesty signals from existing backup health assessment, restore history presence, and per-run restore result attention, then show them on the existing dashboard widgets and existing restore-run drilldowns.
|
||||
- **Ownership cost**: Additional widget copy, narrow derived-summary logic, and focused feature plus RBAC regression tests that keep overview language and drilldown continuity aligned.
|
||||
- **Alternative intentionally rejected**: A new recovery-confidence score, enum, page, or persisted posture state was rejected because it would introduce new truth and new ownership cost before the current overview surfaces tell the existing truth accurately.
|
||||
- **Release truth**: current-release truth hardening
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - See Unvalidated Recovery Confidence Early (Priority: P1)
|
||||
|
||||
A tenant operator opens the tenant dashboard and needs to know within seconds whether healthy-looking backups are backed by any relevant restore evidence or whether recovery confidence is still unvalidated.
|
||||
|
||||
**Why this priority**: This is the highest-risk trust gap. If the first overview screen quietly converts healthy backups into a healthy recovery impression, later detail truth arrives too late.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering the tenant dashboard with healthy backup fixtures and no relevant restore history, then verifying that the overview shows an explicit unvalidated or unknown recovery-confidence signal instead of an all-clear.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has healthy backup posture and no relevant restore history, **When** the operator opens the tenant dashboard, **Then** the summary shows healthy backups plus an explicit unvalidated or unknown recovery-confidence message and a next action.
|
||||
2. **Given** the same tenant has no other attention items, **When** the healthy-check state renders, **Then** the widget does not show an unqualified all-good message and instead keeps the recovery-confidence boundary visible.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Escalate Weak Restore History on Overview (Priority: P2)
|
||||
|
||||
A tenant operator reviewing the dashboard needs recent failed, partial, or follow-up restore results to affect the overview immediately instead of hiding inside restore history details.
|
||||
|
||||
**Why this priority**: Weak restore history is evidence that directly changes how much trust the operator should place in recovery posture. It cannot remain a drilldown-only fact.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering overview surfaces with recent failed, partial, and follow-up restore fixtures and verifying that each case creates a visible confidence-related attention signal with matching drilldown behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has healthy backups but a recent failed or partial restore run, **When** the operator opens the dashboard, **Then** Needs Attention shows a recovery-confidence issue that links to restore history explaining the same failure state.
|
||||
2. **Given** a tenant has a recent restore run that completed with follow-up required, **When** the operator opens the dashboard, **Then** the overview shows weakened confidence rather than a neutral or healthy-only message.
|
||||
3. **Given** recent restore history exists without a current confidence-weakening attention state, **When** the operator opens the dashboard, **Then** the overview may say that no recent restore issues are visible but does not claim that recovery is proven.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Preserve Honest Drilldowns and RBAC Boundaries (Priority: P3)
|
||||
|
||||
A tenant operator or read-only member needs the dashboard signal and the destination surface to tell the same story, while RBAC limits must never make the summary look stronger than the accessible evidence.
|
||||
|
||||
**Why this priority**: Overview honesty fails if the next click contradicts the dashboard or if authorization gaps hide weakness by omission.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening overview signals as different tenant members, verifying that the linked restore-history surface confirms the same reason, and ensuring restricted users still see cautious summary language.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the dashboard says recovery confidence is unvalidated because no relevant restore history exists, **When** the operator follows the dashboard action, **Then** the destination surface confirms that the tenant lacks relevant restore history.
|
||||
2. **Given** the dashboard says recovery confidence is weakened by a recent problematic restore, **When** the operator follows the dashboard action, **Then** the destination surface confirms the same failed, partial, or follow-up reason.
|
||||
3. **Given** a tenant member can see the dashboard but cannot open deeper restore evidence, **When** the dashboard renders, **Then** the summary remains cautious and truthful and does not replace missing evidence with a stronger claim.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A tenant has only draft, preview-only, or dry-run restore history; the overview treats recovery confidence as unvalidated rather than positive.
|
||||
- A tenant has both an older successful restore and a more recent failed or follow-up restore; the weakened signal takes precedence on the summary surface.
|
||||
- A summary signal points to a restore run that is no longer directly openable; the drilldown falls back to tenant-scoped restore history rather than a dead end.
|
||||
- A user can see the dashboard but lacks permission to inspect restore runs; the summary still states unknown or weakened confidence without suggesting that everything is healthy.
|
||||
- Healthy backup posture and backup-automation follow-up can coexist with unvalidated recovery confidence; the overview must not let one healthy-sounding statement erase the other caution.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
This feature introduces no new Microsoft Graph calls, no new background work, no new `OperationRun`, and no new persistence. It is a read-first truth-hardening slice that makes existing backup and restore evidence visible more honestly on tenant overview surfaces.
|
||||
|
||||
Authorization remains in the tenant/admin plane under `/admin/t/{tenant}/...`. Non-members must continue to receive 404 responses. Established members missing deeper restore capabilities must continue to receive 403 on execution paths, but summary visibility must not depend on restore-mutation rights.
|
||||
|
||||
This slice reuses existing Filament dashboard widgets, stat descriptions, attention cards, and existing restore-run resource surfaces. No new local badge framework, page-local status language, or extra action surface is introduced. UI-FIL-001 is satisfied by continuing to use existing Filament widget primitives and shared status language. UX-001 create, edit, and detail-form rules are not materially changed; the dashboard keeps its existing layout, and the restore-run resource keeps its existing list-and-view contract.
|
||||
|
||||
The affected Filament surfaces keep exactly one primary inspect or open model, add no redundant View actions, and introduce no new destructive actions. Existing destructive restore-run actions continue to follow the current placement and confirmation rules. Action Surface Contract expectations therefore remain satisfied.
|
||||
|
||||
Existing per-run restore result attention remains the authoritative signal for restore outcome quality. This feature may summarize or elevate that truth, but it must not duplicate it with a second scoring or status system.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-184-001**: The system MUST present tenant backup health and tenant recovery-confidence evidence as separate truths on tenant dashboard summary surfaces.
|
||||
- **FR-184-002**: When backup health is healthy but no relevant restore history exists, the system MUST display an explicit unknown or unvalidated recovery-confidence state and MUST NOT present an all-clear summary.
|
||||
- **FR-184-003**: When the system cannot determine recovery confidence from the available restore history, the system MUST say that limitation directly instead of inferring a positive recovery claim from backup health alone.
|
||||
- **FR-184-004**: Needs Attention or the healthy-boundary surface MUST surface absence of restore history as an overview-relevant condition with a clear next action.
|
||||
- **FR-184-005**: Recent restore history with `failed`, `partial`, `completed_with_follow_up`, or an equivalent confidence-weakening attention state MUST appear on overview surfaces as a recovery-confidence issue.
|
||||
- **FR-184-006**: Overview surfaces MUST distinguish unknown or unvalidated confidence from weakened confidence and MUST NOT collapse both states into one ambiguous bucket.
|
||||
- **FR-184-007**: Any positive backup-health summary on the dashboard MUST show a visible claim boundary that healthy backups reflect backup inputs only and do not prove restore success.
|
||||
- **FR-184-008**: Healthy checks MUST NOT render an unqualified healthy or all-clear state when recovery confidence is unknown, weakened, or not evaluated.
|
||||
- **FR-184-009**: When recovery confidence is unknown or weakened, overview copy MUST explain what is missing or concerning, why that affects confidence, and what the operator should do next.
|
||||
- **FR-184-010**: Overview signals about missing restore history MUST drill into a tenant-scoped restore-history surface that confirms the absence or insufficiency of relevant restore evidence.
|
||||
- **FR-184-011**: Overview signals about weakened restore history MUST drill into a tenant-scoped restore-history surface or restore-run detail that confirms the same failed, partial, or follow-up reason shown on the summary surface.
|
||||
- **FR-184-012**: The feature MUST reuse existing per-run restore result attention as the authoritative quality signal for restore outcomes and MUST NOT introduce a parallel positive-scoring or reason system.
|
||||
- **FR-184-013**: The feature MUST NOT introduce a new state or message that claims recovery is proven, guaranteed, or strongly confirmed beyond the evidence the current system already has.
|
||||
- **FR-184-014**: RBAC limits on restore history visibility MUST NOT cause summary surfaces to make stronger recovery claims than the visible evidence supports; when detailed restore evidence cannot be opened, the summary must remain cautious and truthful.
|
||||
- **FR-184-015**: Tenant-linked summaries shown outside the tenant dashboard, if they reuse this posture signal, MUST preserve the same meaning for unknown, weakened, and backup-only-positive states.
|
||||
- **FR-184-016**: The feature MUST derive its summary state from existing tenant backup health, restore history, and restore result attention records and MUST NOT add a new persisted recovery-confidence field, table, or scoring artifact.
|
||||
- **FR-184-017**: When recent restore history exists without a current confidence-weakening attention state, overview surfaces MAY state that no recent restore issues are visible, but MUST stop short of claiming recovery proof.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Relevant restore history means tenant-scoped restore runs that have reached an executed result state or another existing result-attention state that the current system can classify. Draft-only, preview-only, or dry-run-only history does not count as proven recovery evidence.
|
||||
- Existing restore history surfaces already show enough result detail to confirm failed, partial, and follow-up reasons once the operator drills down from the overview.
|
||||
- Workspace-level surfaces that later reuse this posture language should consume the same tenant-level semantics rather than creating a separate recovery-confidence vocabulary.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing tenant dashboard surfaces remain the operator entry point for this slice.
|
||||
- Existing `TenantBackupHealthAssessment` and `TenantBackupHealthResolver` remain the source of backup-input truth.
|
||||
- Existing `RestoreRun` history surfaces and `RestoreSafetyResolver::resultAttentionForRun(...)` remain the source of restore-outcome truth.
|
||||
- Existing RBAC helper-text and disabled-link patterns remain the fallback behavior when the operator cannot open deeper restore evidence.
|
||||
|
||||
## Out of Scope and Follow-up
|
||||
|
||||
- No new recovery-confidence engine, score, enum, or dedicated posture page.
|
||||
- No automatic restore validation, scheduled restore probes, or restore execution changes.
|
||||
- No new backup-health rules, restore-result-attention taxonomy changes, or restore-safety model redesign.
|
||||
- No new claim that a tenant is recovery-proven.
|
||||
- Reasonable follow-up work includes broader workspace-level recovery rollups after tenant-level overview honesty is stable.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard summary widgets | `app/Filament/Pages/TenantDashboard.php`, `app/Filament/Widgets/Dashboard/DashboardKpis.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php` | none added | Explicit stat and card CTA only; no row click | none | n/a | none | n/a | n/a | no new audit event | Action Surface Contract stays satisfied because the dashboard remains read-only. UI-FIL-001 stays satisfied through existing Filament widget primitives. UX-001 create and edit form rules are not applicable to this dashboard slice. |
|
||||
| RestoreRunResource list and detail | `app/Filament/Resources/RestoreRunResource.php`, `app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php`, `app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` | Existing `New restore run` action remains | `recordUrl()` clickable row to restore-run detail | Existing More-menu maintenance actions remain unchanged | Existing grouped bulk actions remain unchanged | Existing `New restore run` empty-state CTA remains | none added | Existing restore-run create flow remains unchanged | existing restore-run mutation audit behavior only | This spec reuses restore-run list and detail as canonical drilldowns and adds no new destructive action or placement exception. |
|
||||
|
||||
## Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Backup health assessment**: Tenant-level summary of backup freshness and input health that is useful but not sufficient to prove recovery success.
|
||||
- **Restore history**: Tenant-scoped record of restore runs whose presence, absence, and recent outcomes affect how strongly the product can speak about recovery confidence.
|
||||
- **Restore result attention**: Per-run classification that distinguishes completed, failed, partial, follow-up, and not-executed outcome states that matter for operator trust.
|
||||
- **Recovery posture summary**: Non-persisted dashboard statement that combines backup health, restore history presence, and restore-result attention without becoming a new score or stored state.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In acceptance testing, operators can identify within 10 seconds whether a tenant has healthy backups plus unvalidated or weakened recovery evidence from `/admin/t/{tenant}` without opening raw details.
|
||||
- **SC-002**: In 100% of tested tenants with no relevant restore history, the dashboard or healthy-boundary surface shows an explicit unvalidated or unknown recovery-confidence signal and never shows a healthy-only all-clear.
|
||||
- **SC-003**: In 100% of tested tenants with recent failed, partial, or follow-up restore runs, the overview shows a confidence-related attention item with a drilldown that confirms the same reason.
|
||||
- **SC-004**: In 100% of tested positive backup-health scenarios, summary-level copy includes the claim boundary that healthy backups do not prove restore success.
|
||||
- **SC-005**: In 100% of tested RBAC-restricted scenarios, summary surfaces remain cautious and truthful even when the user cannot open deeper restore evidence pages.
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Website / Workspace Foundation
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-08
|
||||
**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
|
||||
|
||||
- Validation completed on 2026-04-08.
|
||||
- The spec intentionally stays at the feature-contract level: it defines one official workspace standard, one standalone website app, and strict app-boundary rules without prescribing low-level implementation structure.
|
||||
@ -0,0 +1,72 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Website + Platform Coexistence Smoke Contract
|
||||
version: 1.0.0
|
||||
summary: Minimal smoke contract for the first multi-app TenantAtlas workspace
|
||||
servers:
|
||||
- url: http://localhost
|
||||
description: Platform app via Sail on the default host port
|
||||
- url: http://localhost:{platform_port}
|
||||
description: Platform app via Sail when APP_PORT overrides the default host port
|
||||
variables:
|
||||
platform_port:
|
||||
default: '8080'
|
||||
description: Non-default APP_PORT value used during smoke validation
|
||||
- url: http://localhost:4321
|
||||
description: Website app via Astro dev server on the default port
|
||||
- url: http://localhost:{website_port}
|
||||
description: Website app via Astro dev server when WEBSITE_PORT or --port overrides the default port
|
||||
variables:
|
||||
website_port:
|
||||
default: '4322'
|
||||
description: Non-default website port used during smoke validation
|
||||
paths:
|
||||
/up:
|
||||
get:
|
||||
summary: Platform health endpoint responds successfully
|
||||
operationId: platformHealth
|
||||
servers:
|
||||
- url: http://localhost
|
||||
description: Platform app via Sail on the default host port
|
||||
- url: http://localhost:{platform_port}
|
||||
description: Platform app via Sail when APP_PORT overrides the default host port
|
||||
variables:
|
||||
platform_port:
|
||||
default: '8080'
|
||||
description: Non-default APP_PORT value used during smoke validation
|
||||
responses:
|
||||
'200':
|
||||
description: Platform boot is healthy
|
||||
/admin/login:
|
||||
get:
|
||||
summary: Platform admin login shell remains reachable
|
||||
operationId: platformAdminLogin
|
||||
servers:
|
||||
- url: http://localhost
|
||||
description: Platform app via Sail on the default host port
|
||||
- url: http://localhost:{platform_port}
|
||||
description: Platform app via Sail when APP_PORT overrides the default host port
|
||||
variables:
|
||||
platform_port:
|
||||
default: '8080'
|
||||
description: Non-default APP_PORT value used during smoke validation
|
||||
responses:
|
||||
'200':
|
||||
description: Platform admin login page renders
|
||||
/:
|
||||
get:
|
||||
summary: Website home page renders independently
|
||||
operationId: websiteHome
|
||||
servers:
|
||||
- url: http://localhost:4321
|
||||
description: Website app via Astro dev server on the default port
|
||||
- url: http://localhost:{website_port}
|
||||
description: Website app via Astro dev server when WEBSITE_PORT or --port overrides the default port
|
||||
variables:
|
||||
website_port:
|
||||
default: '4322'
|
||||
description: Non-default website port used during smoke validation
|
||||
responses:
|
||||
'200':
|
||||
description: Website home page renders on its own dev server
|
||||
components: {}
|
||||
@ -0,0 +1,70 @@
|
||||
# Contract: Workspace Command Model
|
||||
|
||||
## Purpose
|
||||
|
||||
Define the official repo-root and app-local commands for the first multi-app TenantAtlas workspace.
|
||||
|
||||
## Ownership Rule
|
||||
|
||||
- Root commands orchestrate.
|
||||
- App-local commands execute.
|
||||
- The root must never re-implement platform or website runtime logic.
|
||||
|
||||
## Official Root Commands
|
||||
|
||||
| Command | Responsibility | Notes |
|
||||
|---------|----------------|-------|
|
||||
| `pnpm install` | Install workspace JavaScript dependencies | Root entry for workspace-managed JS dependencies |
|
||||
| `pnpm dev:platform` | Start the platform app | Delegates to the existing platform Sail flow |
|
||||
| `pnpm dev:website` | Start the website app | Runs the Astro dev server from `apps/website` |
|
||||
| `pnpm dev` | Start platform + website for parallel local development | Platform may background via Sail while website dev stays foreground |
|
||||
| `pnpm build:website` | Build the website app | Produces website-only artifacts |
|
||||
| `pnpm build:platform` | Build platform frontend assets | Delegates into `apps/platform` and preserves existing Laravel asset flow |
|
||||
|
||||
## Supported Port Override Path
|
||||
|
||||
- If the platform HTTP or Vite ports are already in use, the supported override path is environment-based: set `APP_PORT` and or `VITE_PORT` before invoking the root platform command, and pass the same variables through the app-local Sail flow when using `apps/platform` directly.
|
||||
- If the website port is already in use, the supported override path is environment-based: set `WEBSITE_PORT` before invoking the root website or parallel-dev command, and pass the equivalent `--port` flag through the app-local `apps/website` Astro dev command.
|
||||
- The root `pnpm dev:website` and `pnpm dev` scripts must forward `WEBSITE_PORT` to the Astro dev server.
|
||||
- The supported override path must be documented in the root README and validated in smoke checks.
|
||||
|
||||
## Valid App-local Commands
|
||||
|
||||
### Platform
|
||||
|
||||
- `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- `cd apps/platform && ./vendor/bin/sail stop`
|
||||
- `cd apps/platform && ./vendor/bin/sail pnpm dev`
|
||||
- `cd apps/platform && ./vendor/bin/sail pnpm build`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact`
|
||||
|
||||
### Website
|
||||
|
||||
- `cd apps/website && pnpm dev`
|
||||
- `cd apps/website && pnpm build`
|
||||
|
||||
## Compatibility Helpers
|
||||
|
||||
- `./scripts/platform-sail ...` remains valid for tooling that cannot set `cwd`.
|
||||
- This helper is platform-specific in this slice and must not be generalized into a multi-app runner yet.
|
||||
|
||||
## Tooling Scope Rules
|
||||
|
||||
- `.vscode/mcp.json` and `opencode.json` remain platform-only because Boost MCP is Laravel-specific.
|
||||
- VS Code tasks may expose official root entry tasks for platform, website, and parallel dev, but must not imply a second hidden workflow.
|
||||
|
||||
## Invariants
|
||||
|
||||
- The platform remains Sail-first.
|
||||
- The website remains independent of Sail, Laravel, Blade, Filament, and platform sessions.
|
||||
- Composer remains app-local PHP dependency management for `apps/platform`.
|
||||
- pnpm is the one official JavaScript workspace/package-manager standard.
|
||||
- No root command may require the website build to start the platform or vice versa.
|
||||
|
||||
## Failure Conditions
|
||||
|
||||
- A root command is undocumented but treated as official.
|
||||
- A website command depends on platform runtime state.
|
||||
- A platform command requires undocumented root-only state.
|
||||
- Root tooling suggests npm for one app and pnpm for another as equal official workflows.
|
||||
- Port conflicts exist but no documented, supported override path is available for the platform or website commands.
|
||||
154
specs/183-website-workspace-foundation/data-model.md
Normal file
154
specs/183-website-workspace-foundation/data-model.md
Normal file
@ -0,0 +1,154 @@
|
||||
# Data Model: Website / Workspace Foundation
|
||||
|
||||
This feature does not introduce database entities. The relevant model is the set of repository-owned artifacts and runtime boundaries that define how the multi-app workspace behaves.
|
||||
|
||||
## 1. RootWorkspace
|
||||
|
||||
**Purpose**: The canonical repo-level contract for JavaScript workspace orchestration and contributor entry paths.
|
||||
|
||||
**Backed by**:
|
||||
- Root `package.json`
|
||||
- Root `pnpm-workspace.yaml`
|
||||
- Root lockfile (`pnpm-lock.yaml`)
|
||||
- Root README and root tooling guidance
|
||||
|
||||
**Key fields / responsibilities**:
|
||||
- Official package-manager declaration
|
||||
- Workspace package globs (`apps/*`)
|
||||
- Official root scripts for platform start, website start, parallel dev, website build, and platform frontend build
|
||||
- Documentation of the root-versus-app ownership model
|
||||
|
||||
**Validation rules**:
|
||||
- Must include `apps/platform` and `apps/website` as workspace applications
|
||||
- Must not claim ownership of app-local runtime logic
|
||||
- Must not introduce `packages/` or additional app surfaces in this slice
|
||||
|
||||
## 2. PlatformApp
|
||||
|
||||
**Purpose**: The existing TenantPilot Laravel runtime under `apps/platform`.
|
||||
|
||||
**Backed by**:
|
||||
- `apps/platform/composer.json`
|
||||
- `apps/platform/package.json`
|
||||
- `apps/platform/artisan`
|
||||
- `apps/platform/bootstrap/`
|
||||
- `apps/platform/public/`
|
||||
- `apps/platform/resources/`
|
||||
- `apps/platform/tests/`
|
||||
- Existing Sail/Compose integration via `./scripts/platform-sail`
|
||||
|
||||
**Key fields / responsibilities**:
|
||||
- Laravel application runtime and PHP dependency management
|
||||
- Filament admin/system/tenant panels
|
||||
- Platform frontend assets and Vite configuration
|
||||
- Platform test surface and deployment-time asset publishing
|
||||
|
||||
**Validation rules**:
|
||||
- Must remain functional under the new root workspace model
|
||||
- Must stay on Filament v5 with Livewire v4
|
||||
- Provider registration remains in `apps/platform/bootstrap/providers.php`
|
||||
- Platform assets remain isolated under platform-owned paths
|
||||
|
||||
## 3. WebsiteApp
|
||||
|
||||
**Purpose**: The new standalone public website application under `apps/website`.
|
||||
|
||||
**Backed by**:
|
||||
- `apps/website/package.json`
|
||||
- `apps/website/astro.config.mjs`
|
||||
- `apps/website/src/`
|
||||
- `apps/website/public/`
|
||||
- Website build output directory (implementation-specific, app-local)
|
||||
|
||||
**Key fields / responsibilities**:
|
||||
- Public-facing pages and layouts
|
||||
- Static-first dev/build lifecycle
|
||||
- Website-owned public assets
|
||||
- No shared auth/session/runtime assumptions with the platform app
|
||||
|
||||
**Validation rules**:
|
||||
- Must run independently from the platform app
|
||||
- Must not depend on Blade, Filament, or Laravel sessions
|
||||
- Must not write into platform build-output directories
|
||||
|
||||
## 4. OfficialCommandModel
|
||||
|
||||
**Purpose**: The documented contract that tells contributors how to work from the repo root and from app-local directories.
|
||||
|
||||
**Backed by**:
|
||||
- Root `package.json` scripts
|
||||
- README command documentation
|
||||
- VS Code task entry points where relevant
|
||||
|
||||
**Key fields / responsibilities**:
|
||||
- Root install flow for JavaScript workspace dependencies
|
||||
- Root platform start command
|
||||
- Root website start command
|
||||
- Root combined parallel-dev command
|
||||
- Root website build command
|
||||
- Root platform frontend build command
|
||||
- App-local equivalents for `apps/platform` and `apps/website`
|
||||
|
||||
**Validation rules**:
|
||||
- Root commands orchestrate only and delegate to app-local logic
|
||||
- App-local commands remain valid and documented
|
||||
- No second undocumented “official” workflow is introduced
|
||||
|
||||
## 5. ToolingSurface
|
||||
|
||||
**Purpose**: Root-level automation and editor guidance that must understand the multi-app repo shape.
|
||||
|
||||
**Backed by**:
|
||||
- `README.md`
|
||||
- `Agents.md`
|
||||
- `GEMINI.md`
|
||||
- `.github/copilot-instructions.md`
|
||||
- `.vscode/tasks.json`
|
||||
- `.vscode/mcp.json`
|
||||
- `opencode.json`
|
||||
|
||||
**Key fields / responsibilities**:
|
||||
- Explain root entry model and app ownership
|
||||
- Keep Boost MCP explicitly scoped to the platform app
|
||||
- Provide official entry tasks or labels for platform, website, and parallel dev
|
||||
|
||||
**Validation rules**:
|
||||
- Tooling must not imply the website is a second Laravel app
|
||||
- Tooling must not hide the official root entry path
|
||||
- Platform-specific commands must remain clearly labeled as platform-specific
|
||||
|
||||
## 6. BuildBoundary
|
||||
|
||||
**Purpose**: The isolation contract between platform assets and website assets.
|
||||
|
||||
**Backed by**:
|
||||
- Platform Vite output paths
|
||||
- Website build output paths
|
||||
- Ignore files and docs
|
||||
|
||||
**Key fields / responsibilities**:
|
||||
- Platform frontend artifacts remain under `apps/platform`
|
||||
- Website build artifacts remain under `apps/website`
|
||||
- Root build commands invoke app-local builds without cross-writing outputs
|
||||
|
||||
**Validation rules**:
|
||||
- Building the website must not overwrite or depend on platform artifacts
|
||||
- Building platform frontend assets must not overwrite or depend on website artifacts
|
||||
- Ignore files must cover both apps' dependency and build outputs
|
||||
|
||||
## Relationships
|
||||
|
||||
- `RootWorkspace` orchestrates `PlatformApp` and `WebsiteApp`.
|
||||
- `OfficialCommandModel` is owned by `RootWorkspace` and delegates into `PlatformApp` and `WebsiteApp`.
|
||||
- `ToolingSurface` documents and automates `OfficialCommandModel`.
|
||||
- `BuildBoundary` constrains both `PlatformApp` and `WebsiteApp`.
|
||||
|
||||
## Out-of-scope Entities
|
||||
|
||||
The following are explicitly not introduced in this feature:
|
||||
|
||||
- Shared packages under `packages/`
|
||||
- A docs application
|
||||
- A customer or auditor application
|
||||
- Shared auth/session infrastructure
|
||||
- New database entities or runtime-persisted domain truth
|
||||
260
specs/183-website-workspace-foundation/plan.md
Normal file
260
specs/183-website-workspace-foundation/plan.md
Normal file
@ -0,0 +1,260 @@
|
||||
# Implementation Plan: Website / Workspace Foundation
|
||||
|
||||
**Branch**: `183-website-workspace-foundation` | **Date**: 2026-04-08 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/183-website-workspace-foundation/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/183-website-workspace-foundation/spec.md` and repo-context planning for the first real multi-app workspace slice.
|
||||
|
||||
## Summary
|
||||
|
||||
Introduce the first true multi-app foundation by adding a public website application under `apps/website` while keeping the existing Laravel platform stable under `apps/platform`. The implementation uses a minimal root orchestration model: one official Node workspace standard at repo root, one static-first website runtime, thin root scripts that orchestrate instead of re-implementing app logic, and a clear split between repo-level metadata, website runtime concerns, and platform runtime concerns.
|
||||
|
||||
Key approach: adopt `pnpm` workspaces as the official root package-manager standard, initialize the public website as an Astro app under `apps/website`, migrate the platform's Node command surface from npm to pnpm so the repo has one official JavaScript package-manager model, keep the platform Sail-first and Docker-backed, keep the website outside Sail in this first slice, and update only the root docs, tooling, and tasks that need multi-app awareness. The plan explicitly avoids shared packages, Turbo or Nx, a docs app, a customer app, a generalized app-runner framework, or any new TenantPilot product behavior.
|
||||
|
||||
The feature leaves Filament v5 on Livewire v4 unchanged, keeps panel providers registered in `apps/platform/bootstrap/providers.php`, does not add or alter globally searchable resources, introduces no destructive operator actions, and preserves the existing deploy-time `cd apps/platform && php artisan filament:assets` responsibility for platform assets.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15 and Laravel 12 for `apps/platform`; Node.js 20+ with pnpm 10 workspace tooling; Astro v6 for `apps/website`; Bash and Docker Compose for root orchestration
|
||||
**Primary Dependencies**: `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `vite`, `tailwindcss`, `pnpm` workspaces, Astro, existing `./scripts/platform-sail` wrapper, repo-root Docker Compose
|
||||
**Storage**: Existing PostgreSQL, Redis, and filesystem storage for `apps/platform`; static build artifacts for `apps/website`; repository-managed workspace manifests and docs; no new database persistence
|
||||
**Testing**: Existing Pest v4 platform tests through Sail; website boot/build smoke; root command smoke; parallel-run and build-isolation validation; targeted task and MCP smoke where tooling changes
|
||||
**Target Platform**: macOS and Linux developer machines, Dockerized Laravel platform runtime, Node-based static public website runtime, Dokploy-style container deployment for the platform and static-site deployment for the website
|
||||
**Project Type**: Multi-app repo with repo-root orchestration, one Laravel platform app, and one static-first public website app
|
||||
**Performance Goals**: Preserve current platform boot/build behavior; keep website dev/build lightweight; allow platform and website to run in parallel on distinct ports with no cross-app artifact pollution
|
||||
**Constraints**: One official workspace standard only; no shared packages; no docs/customer/API surfaces; no website embedding into Blade or Filament; no root-level monorepo framework; keep platform Sail-first; root orchestrates while apps execute
|
||||
**Scale/Scope**: Two first-class applications under `apps/`, root orchestration manifests and scripts, documentation and task updates, and smoke validation across local boot, coexistence, and build separation
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Inventory-first | Pass | No inventory, snapshot, or backup truth changes; this is repo topology and runtime orchestration only |
|
||||
| Read/write separation | Pass | No new product mutation flow, operator write surface, or external write behavior is introduced |
|
||||
| Graph contract path | Pass | No Microsoft Graph calls or contract-registry changes are in scope |
|
||||
| Deterministic capabilities | Pass | Capability derivation and registries remain unchanged |
|
||||
| RBAC-UX planes and 404 vs 403 | Pass | `/admin`, `/admin/t/{tenant}/...`, and `/system` authorization semantics remain unchanged |
|
||||
| Workspace isolation | Pass | No new workspace-scoped data access or workspace routing behavior is introduced |
|
||||
| Tenant isolation | Pass | No tenant-owned routes, queries, or UI semantics are broadened |
|
||||
| Global search safety | Pass | No resource or global-search behavior changes are planned |
|
||||
| Dangerous and destructive confirmations | Pass | No new destructive operator actions are introduced |
|
||||
| Run observability | Pass | No new long-running product operations or `OperationRun` flows are introduced |
|
||||
| Ops-UX 3-surface feedback | Pass | Not applicable because no operation UX is added or changed |
|
||||
| Data minimization | Pass | No new secrets, logs, payload mirrors, or persisted product truth are added |
|
||||
| Proportionality (PROP-001) | Pass | The design adds only one website app and the minimum root orchestration needed to support it |
|
||||
| No premature abstraction (ABSTR-001) | Pass | The plan explicitly rejects Turbo, Nx, packages, a generic app-runner wrapper, and a generalized Sail abstraction |
|
||||
| Persisted truth (PERSIST-001) | Pass | Only repository artifacts are added; no new application tables or persisted domain entities |
|
||||
| Behavioral state (STATE-001) | Pass | No new domain status or reason-code family is introduced |
|
||||
| UI semantics (UI-SEM-001) | Pass | No new operator semantic layer is introduced; the website is a separate public app |
|
||||
| Filament-native UI (UI-FIL-001) | Pass | Platform Filament surfaces stay unchanged |
|
||||
| Filament v5 / Livewire v4 compliance | Pass | Platform remains on Filament v5 with Livewire v4; the website app does not affect that stack |
|
||||
| Provider registration location | Pass | Provider registration remains in `apps/platform/bootstrap/providers.php` |
|
||||
| Global search hard rule | Pass | No globally searchable resource gains or losses are introduced |
|
||||
| Asset strategy | Pass | Platform assets remain app-local and keep the `filament:assets` deploy step; website assets build independently from `apps/website` |
|
||||
| Testing plan | Pass | The plan includes platform regression smoke, website boot/build smoke, root command smoke, and coexistence validation |
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/183-website-workspace-foundation/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Adopt `pnpm` workspaces as the official JavaScript package-manager standard for the multi-app repo. The root receives `package.json` plus `pnpm-workspace.yaml`, and the platform's Node command surface migrates from npm to pnpm so the repo has one official package-manager story.
|
||||
- Use Astro v6 as the website stack because the spec explicitly wants a public-surface, static-first foundation with minimal runtime coupling.
|
||||
- Keep the platform Sail-first and Docker-backed, but do not Dockerize the website in this first slice. The website should run as a plain Node app to keep the first multi-app step proportional.
|
||||
- Keep `./scripts/platform-sail` as a platform-only compatibility helper. Do not introduce a generic `scripts/sail <app>` abstraction because only one app uses Sail today.
|
||||
- Keep Boost MCP and Laravel-specific editor integration platform-only. The website app is not a second Laravel runtime in this slice, so `.vscode/mcp.json` and `opencode.json` only need clearer scoping, not a second Boost server.
|
||||
- Limit root task updates to the official entry tasks and any affected labels or notes. This slice does not justify a wholesale task-system rewrite.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/183-website-workspace-foundation/`:
|
||||
|
||||
- `data-model.md`: repository-artifact and runtime-boundary model for root orchestration, platform app, website app, and tooling surfaces
|
||||
- `contracts/workspace-command-model.md`: canonical root and app-local command contract for platform, website, and parallel local development
|
||||
- `contracts/coexistence-smoke.openapi.yaml`: HTTP and orchestration smoke contract for website/platform coexistence
|
||||
- `quickstart.md`: implementation, validation, and rollout checklist for the workspace foundation
|
||||
|
||||
Design decisions:
|
||||
|
||||
- The root owns JavaScript workspace orchestration, but PHP and Laravel runtime ownership stays inside `apps/platform`.
|
||||
- The official root command model is intentionally small: install workspace dependencies, start the platform, start the website, start both, build the website, and build platform frontend assets.
|
||||
- The platform retains its current Docker Compose topology and `./scripts/platform-sail` wrapper. The website does not join Docker Compose in this slice.
|
||||
- The root does not introduce `packages/`, shared tokens, shared components, or cross-app runtime modules.
|
||||
- Root documentation and tooling are updated only where multi-app awareness is now necessary: README, Agents/GEMINI/Copilot guidance, entry tasks, and root-level config that references the platform as the only app.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/183-website-workspace-foundation/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ ├── workspace-command-model.md
|
||||
│ └── coexistence-smoke.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
repo-root/
|
||||
├── apps/
|
||||
│ ├── platform/
|
||||
│ │ ├── app/
|
||||
│ │ ├── bootstrap/
|
||||
│ │ ├── config/
|
||||
│ │ ├── database/
|
||||
│ │ ├── public/
|
||||
│ │ ├── resources/
|
||||
│ │ ├── routes/
|
||||
│ │ ├── storage/
|
||||
│ │ ├── tests/
|
||||
│ │ ├── artisan
|
||||
│ │ ├── composer.json
|
||||
│ │ ├── package.json
|
||||
│ │ └── vite.config.js
|
||||
│ └── website/
|
||||
│ ├── package.json
|
||||
│ ├── astro.config.mjs
|
||||
│ ├── public/
|
||||
│ └── src/
|
||||
├── docs/
|
||||
├── specs/
|
||||
├── scripts/
|
||||
├── .specify/
|
||||
├── .github/
|
||||
├── .vscode/
|
||||
├── docker-compose.yml
|
||||
├── package.json
|
||||
├── pnpm-workspace.yaml
|
||||
├── README.md
|
||||
├── Agents.md
|
||||
├── GEMINI.md
|
||||
├── boost.json
|
||||
└── opencode.json
|
||||
```
|
||||
|
||||
**Structure Decision**: Repo-root workspace orchestration plus two app roots under `apps/`: the existing Laravel platform and a new Astro website. No `packages/` layer, no docs app, and no generalized monorepo framework are introduced.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Establish The Official Root Workspace Standard
|
||||
|
||||
**Goal**: Make the repo root the canonical JavaScript workspace entry point without turning it into an application runtime.
|
||||
|
||||
| Step | Area | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | Workspace manifests | Add a minimal root `package.json` and `pnpm-workspace.yaml` for `apps/*` |
|
||||
| A.2 | Package-manager contract | Declare pnpm as the official JS package-manager standard and remove the implicit npm-only assumption from root docs and tooling |
|
||||
| A.3 | Lockfile model | Replace per-app `package-lock.json` usage for maintained app packages with one root `pnpm-lock.yaml` |
|
||||
| A.4 | Root scripts | Define thin root scripts for platform start, website start, parallel dev, website build, and platform frontend build |
|
||||
|
||||
### Phase B — Align The Platform App With The Workspace Standard
|
||||
|
||||
**Goal**: Keep `apps/platform` stable while letting it participate in the official pnpm-based workspace model.
|
||||
|
||||
| Step | Area | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | Platform package manifest | Update `apps/platform/package.json` scripts to work under pnpm instead of npm-only assumptions |
|
||||
| B.2 | Composer scripts | Adjust `apps/platform/composer.json` Node-invoking scripts from `npm` to `pnpm` where they are part of the supported workflow |
|
||||
| B.3 | Sail runtime path | Keep `./scripts/platform-sail` and existing Compose wiring; rely on Corepack/pnpm availability inside Sail instead of building a new orchestration wrapper |
|
||||
| B.4 | Asset isolation | Preserve platform asset outputs under `apps/platform/public/build` and keep deploy-time `php artisan filament:assets` unchanged |
|
||||
|
||||
### Phase C — Create The Website App As A Separate Public Runtime
|
||||
|
||||
**Goal**: Introduce `apps/website` as a real standalone app with its own dev and build lifecycle.
|
||||
|
||||
| Step | Area | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | Website scaffold | Create `apps/website` with Astro config, package manifest, `src/`, and `public/` |
|
||||
| C.2 | Public pages | Add the narrow initial public site structure needed for the foundation slice, without CMS/blog/docs/customer scope |
|
||||
| C.3 | Dev/build scripts | Ensure website dev and build commands run from root orchestration and app-local commands alike |
|
||||
| C.4 | Runtime separation | Keep website ports, environment assumptions, and build output isolated from the platform app |
|
||||
|
||||
### Phase D — Update Root Orchestration, Tooling, And Docs
|
||||
|
||||
**Goal**: Make the repo entry path obvious and keep automation aligned with the new topology.
|
||||
|
||||
| Step | Area | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | README and architecture notes | Document the multi-app topology, official command model, and app-local ownership clearly |
|
||||
| D.2 | Agent and editor guidance | Update `Agents.md`, `GEMINI.md`, `.github/copilot-instructions.md`, and related guidance where root multi-app knowledge now matters |
|
||||
| D.3 | VS Code and root tooling | Add or adjust official entry tasks for platform, website, and parallel dev; keep Boost MCP platform-only but document that scope explicitly |
|
||||
| D.4 | Ignore and repo hygiene | Update ignore files and related repo metadata for `apps/website`, root lockfiles, and website build artifacts |
|
||||
|
||||
### Phase E — Validate Coexistence And Build Separation
|
||||
|
||||
**Goal**: Prove the multi-app repo behaves coherently before implementation is considered complete.
|
||||
|
||||
| Step | Area | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | Platform stability | Verify existing platform boot paths and focused regression tests still pass |
|
||||
| E.2 | Website boot | Verify the website starts independently and is reachable on its own dev port |
|
||||
| E.3 | Parallel dev | Verify the platform and website can run together without port or artifact collisions |
|
||||
| E.4 | Build separation | Verify website build outputs remain isolated and platform frontend build still works independently |
|
||||
| E.5 | Documentation usability | Verify a contributor can follow the updated root entry docs without hidden steps |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D-001 — pnpm Becomes The One Official JavaScript Workspace Standard
|
||||
|
||||
The repo currently has only `apps/platform/package.json` plus a tracked `package-lock.json`, but Spec 183 requires one clear workspace/package-manager standard. pnpm workspaces give the minimal root construct needed for multi-app orchestration without requiring a heavier monorepo framework.
|
||||
|
||||
### D-002 — The Website Is A Static-First Astro App, Not A Second Laravel Runtime
|
||||
|
||||
The spec calls for a public website foundation, not another back-office application. Astro fits the static-first, public-surface constraint and avoids accidental coupling to platform sessions, Blade, Filament, or Laravel-specific runtime assumptions.
|
||||
|
||||
### D-003 — The Platform Stays Sail-First; The Website Does Not Join Docker In V1
|
||||
|
||||
The current Compose file and wrapper are already platform-specific and stable. Dockerizing the website now would add extra service orchestration, port strategy, and deployment assumptions that the spec explicitly tries to avoid in the first slice.
|
||||
|
||||
### D-004 — Root Scripts Orchestrate; App Logic Stays In The Apps
|
||||
|
||||
The root will provide the official entry scripts, but the platform keeps its app-local Sail and Composer logic, and the website keeps its app-local Astro logic. Root scripts are thin delegators, not a new orchestration framework.
|
||||
|
||||
### D-005 — No Shared Packages Or Generic App Wrappers In This Slice
|
||||
|
||||
This slice deliberately rejects `packages/`, shared token libraries, a generalized `scripts/sail <app>` abstraction, Turbo, Nx, and any cross-app build framework. Those additions would solve hypothetical future variance rather than the current concrete need.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| pnpm migration breaks existing platform frontend commands or Composer scripts | High | Medium | Update platform `package.json` and `composer.json` together, and verify pnpm inside Sail before relying on it |
|
||||
| Contributors confuse root commands, app-local commands, and compatibility helpers | High | Medium | Publish one root command contract and keep app-local commands documented as secondary but valid |
|
||||
| Website dev port collides with current platform or Vite usage | Medium | Medium | Reserve a distinct website port and document override rules in quickstart and root scripts |
|
||||
| Root tooling remains platform-only in ways that confuse contributors after website arrives | Medium | High | Update README, task entry points, and agent guidance in the same slice as the website introduction |
|
||||
| The slice drifts into packages, shared tokens, or a docs app | High | Medium | Treat all extra surfaces and packages as explicit non-goals and block them in review |
|
||||
| Deployment assumptions for the website are overbuilt too early | Medium | Medium | Keep website deployment guidance minimal and static-site oriented, separate from the platform's Dokploy flow |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Keep platform regression coverage focused: run the minimum representative Sail-driven Pest tests that prove the platform remains stable after the workspace changes.
|
||||
- Validate platform boot via existing local health and login surfaces such as `/up` and `/admin/login` after root/workspace changes land.
|
||||
- Validate website boot independently on its own dev server and verify it remains reachable without the platform app running.
|
||||
- Validate root entry commands for website-only, platform-only, and combined local development flows.
|
||||
- Validate platform frontend build and website build independently, ensuring outputs remain in their app-local directories.
|
||||
- Validate at least one affected VS Code task or root tooling entry path so editor automation does not drift behind the repo topology.
|
||||
- Reconfirm Filament v5 and Livewire v4 compliance by leaving the platform stack untouched and ensuring provider registration remains in `apps/platform/bootstrap/providers.php`.
|
||||
- Preserve the platform asset deployment contract by keeping `cd apps/platform && php artisan filament:assets` in deployment guidance.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violation or exemption is planned. The feature intentionally limits complexity to one new app plus the minimum workspace contract needed to support it.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: The repo needs a public website surface now, but still behaves operationally like a single-app workspace. That makes local setup, docs, and tooling inconsistent the moment a second app is added.
|
||||
- **Existing structure is insufficient because**: `apps/platform` is correctly isolated, but the repo root still lacks a real workspace standard, root command model, and second-app boundary.
|
||||
- **Narrowest correct implementation**: Add one website app, one official root workspace standard, a thin root orchestration layer, and only the documentation and tooling updates required to make that structure usable.
|
||||
- **Ownership cost created**: One additional app lifecycle, one shared JS workspace lockfile, root script maintenance, and multi-app smoke validation.
|
||||
- **Alternative intentionally rejected**: Embedding the website into Laravel, keeping npm for the platform while adding pnpm only for the website, or introducing shared packages and monorepo frameworks immediately. Each alternative either violates the one-standard rule or adds speculative complexity.
|
||||
- **Release truth**: Current-release infrastructure truth for the first public website surface, not future-only preparation.
|
||||
72
specs/183-website-workspace-foundation/quickstart.md
Normal file
72
specs/183-website-workspace-foundation/quickstart.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Quickstart: Website / Workspace Foundation
|
||||
|
||||
## Goal
|
||||
|
||||
Implement and validate the first multi-app workspace slice with one stable platform app and one new public website app.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js with Corepack available on the host
|
||||
- PHP and Composer prerequisites for `apps/platform`
|
||||
- Docker / Docker Compose for the Sail-backed platform runtime
|
||||
|
||||
## 1. Bootstrap The Workspace
|
||||
|
||||
1. Enable Corepack on the host if needed.
|
||||
2. Install root workspace dependencies with pnpm.
|
||||
3. Install or confirm platform PHP dependencies in `apps/platform`.
|
||||
|
||||
## 2. Start The Platform App
|
||||
|
||||
1. Use the official root platform command, or the documented app-local equivalent.
|
||||
2. Confirm the platform health endpoint responds.
|
||||
3. Confirm the platform admin login route remains reachable.
|
||||
|
||||
## 3. Start The Website App
|
||||
|
||||
1. Use the official root website command, or the documented app-local equivalent.
|
||||
2. Confirm the website responds on its own dev port.
|
||||
3. Confirm the website does not require the platform app to serve its public pages.
|
||||
|
||||
## Port Override Path
|
||||
|
||||
1. If the platform port is already occupied, rerun the platform flow with `APP_PORT=<port>` and, if needed, `VITE_PORT=<port>`.
|
||||
2. If the website port is already occupied, rerun the website flow with `WEBSITE_PORT=<port>` from the root command path, or use the app-local Astro command with `--port <port>`.
|
||||
3. If both apps run together, confirm the overridden ports still keep platform and website separated and documented.
|
||||
|
||||
## 4. Validate Parallel Local Development
|
||||
|
||||
1. Start the platform and the website together using the official root orchestration path.
|
||||
2. Confirm there is no obvious port conflict between the platform and the website.
|
||||
3. If a default port is occupied, confirm the documented override path works for the affected app.
|
||||
4. Confirm editing one app does not require restarting the other app.
|
||||
|
||||
## 5. Validate Build Separation
|
||||
|
||||
1. Run the website build through the official root command.
|
||||
2. Run the platform frontend build through the official root command.
|
||||
3. Confirm each build writes only to its own app-owned output paths.
|
||||
|
||||
## 6. Validate Tooling And Docs
|
||||
|
||||
1. Read the updated root README and confirm the multi-app entry path is clear.
|
||||
2. Verify at least one affected VS Code task or root automation path still behaves correctly.
|
||||
3. Confirm MCP guidance remains platform-only and does not imply the website is a Laravel app.
|
||||
4. Confirm the documented port-override path is described consistently between the README and the command contract.
|
||||
|
||||
## 7. Validate Platform Stability
|
||||
|
||||
1. Run the minimum representative Sail-based platform regression tests needed to prove workspace changes did not destabilize the app.
|
||||
2. Confirm Filament v5 on Livewire v4 remains untouched.
|
||||
3. Confirm panel provider registration still lives in `apps/platform/bootstrap/providers.php`.
|
||||
4. Confirm no globally searchable resource behavior was changed by this slice.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- Root workspace manifests exist and are documented.
|
||||
- `apps/website` exists as a real standalone app.
|
||||
- Platform boot remains stable.
|
||||
- Website boot remains independent.
|
||||
- Root commands for platform, website, and parallel local development are clear and usable.
|
||||
- Website and platform builds remain isolated.
|
||||
- No shared package layer or extra product surface was introduced.
|
||||
86
specs/183-website-workspace-foundation/research.md
Normal file
86
specs/183-website-workspace-foundation/research.md
Normal file
@ -0,0 +1,86 @@
|
||||
# Research: Website / Workspace Foundation
|
||||
|
||||
## Decision 1: Use pnpm workspaces as the official JavaScript workspace standard
|
||||
|
||||
**Decision**: Adopt pnpm 10 workspaces with a root `package.json` and `pnpm-workspace.yaml` as the official JavaScript package-manager and workspace model for the repo.
|
||||
|
||||
**Rationale**:
|
||||
- Spec 183 requires exactly one official workspace/package-manager standard.
|
||||
- The repo currently has no root workspace manifest and only one tracked JavaScript package manifest under `apps/platform`, so the first multi-app slice needs an explicit root contract.
|
||||
- pnpm workspaces require only a root `pnpm-workspace.yaml` and support a single shared lockfile, which keeps the first multi-app step narrow.
|
||||
- Official pnpm workspace guidance confirms that workspaces are a built-in fit for multi-project repositories and that the root workspace file is the canonical entry point.
|
||||
- The current environment already supports the migration path: host Node has Corepack available, and the running Sail container exposes both Corepack and pnpm.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep npm for `apps/platform` and introduce pnpm only for `apps/website`: rejected because it violates the spec's one-official-standard rule and creates an ambiguous dual model.
|
||||
- Use npm workspaces: rejected because the spec's recommended direction is pnpm and pnpm offers the cleaner shared-lockfile monorepo path for this repo shape.
|
||||
- Use Yarn or Bun workspaces: rejected because they add a new toolchain direction with no repo-specific advantage here.
|
||||
- Introduce Turbo or Nx immediately: rejected as premature orchestration complexity for a two-app repo.
|
||||
|
||||
## Decision 2: Use Astro as the website stack
|
||||
|
||||
**Decision**: Introduce `apps/website` as an Astro v6 application.
|
||||
|
||||
**Rationale**:
|
||||
- The feature is explicitly a public website foundation, not a second authenticated product surface.
|
||||
- Astro is designed for public-facing sites and supports the static-first model the spec asks for.
|
||||
- Astro keeps the website runtime lightweight while avoiding accidental coupling to Blade, Filament, shared sessions, or Laravel panel assumptions.
|
||||
- The docs emphasize project structure, dev/build flows, layouts, pages, and static-site friendly features, which align with this slice.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Build the website inside Laravel Blade: rejected because the spec explicitly forbids platform embedding and wants separate runtimes.
|
||||
- Use another JavaScript SSR-heavy app shell such as Next.js or Nuxt: rejected because this slice does not need a heavier runtime or server coupling.
|
||||
- Use a raw Vite-only site: rejected because Astro provides a clearer public-site application structure without forcing a custom content/runtime foundation.
|
||||
|
||||
## Decision 3: Keep the platform Sail-first and do not Dockerize the website in V1
|
||||
|
||||
**Decision**: `apps/platform` remains the only Sail/Docker-backed application in this slice; `apps/website` runs as a plain Node application for local development and static builds.
|
||||
|
||||
**Rationale**:
|
||||
- The current root `docker-compose.yml` and `./scripts/platform-sail` wrapper are explicitly platform-specific and already stable.
|
||||
- Dockerizing the website immediately would require a second service strategy, new port policy, and broader deployment assumptions that the spec does not require.
|
||||
- The website does not need PostgreSQL, Redis, queues, or Laravel runtime services in this foundation slice.
|
||||
- Keeping the website outside Sail preserves the root-orchestrates/apps-execute model while minimizing cross-app runtime coupling.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add a second Docker Compose service for the website now: rejected as unnecessary orchestration growth in the first multi-app slice.
|
||||
- Move both apps to host-only local dev: rejected because the platform is already Sail-first and that contract should remain stable.
|
||||
|
||||
## Decision 4: Keep root orchestration thin and app-specific wrappers narrow
|
||||
|
||||
**Decision**: Add only thin root scripts for official entry points and keep `./scripts/platform-sail` as a platform-only compatibility wrapper instead of generalizing it to a multi-app runner.
|
||||
|
||||
**Rationale**:
|
||||
- The repo has one Sail-backed app today. A generic `scripts/sail <app>` wrapper would be an abstraction with only one concrete user.
|
||||
- Spec 183 explicitly prefers minimal root orchestration and rejects premature shared layers or frameworkization.
|
||||
- Root scripts can delegate to `./scripts/platform-sail` for the platform and to `pnpm --dir apps/website ...` or workspace-filtered pnpm commands for the website without inventing a new app-runner subsystem.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Introduce a generalized app-launch wrapper immediately: rejected under ABSTR-001 because there is not yet real variance across multiple Docker-backed apps.
|
||||
- Keep only app-local commands and document them: rejected because the spec requires an official root entry model.
|
||||
|
||||
## Decision 5: Update only the tooling that now needs multi-app awareness
|
||||
|
||||
**Decision**: Update root docs, entry tasks, and agent/editor guidance to reflect the new workspace model, while keeping Laravel-specific MCP integration platform-only.
|
||||
|
||||
**Rationale**:
|
||||
- `.vscode/mcp.json` and `opencode.json` currently point at the platform Boost MCP server only. That remains correct because the website is not a second Laravel runtime.
|
||||
- The repo's tasks and docs currently assume only the platform exists. Those surfaces need new multi-app awareness so contributors understand the official entry path.
|
||||
- A full task-system rewrite is not required to satisfy the spec. This slice only needs a clear, official root command surface and enough task/documentation updates to make it discoverable.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add a second Boost MCP server for the website: rejected because the website app is not a Laravel app.
|
||||
- Rename and rebuild every existing task in the repo: rejected because it is broader than the narrow workspace-foundation objective.
|
||||
|
||||
## Decision 6: Build isolation matters more than early shared code reuse
|
||||
|
||||
**Decision**: Accept small duplication between `apps/platform` and `apps/website`, and do not introduce `packages/`, shared design tokens, or shared UI primitives in this slice.
|
||||
|
||||
**Rationale**:
|
||||
- The current concrete problem is introducing a second app cleanly, not extracting shared code.
|
||||
- A shared layer would force versioning, ownership, and build-coupling decisions before the repo has even demonstrated repeated needs across more than one new app.
|
||||
- The spec explicitly states that duplication is acceptable in small amounts and that shared packages are a likely follow-up, not part of this slice.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create `packages/ui`, `packages/brand`, or shared Tailwind tokens now: rejected as future-facing complexity without proven repeated use.
|
||||
- Share platform assets or auth/session infrastructure with the website: rejected because the spec requires runtime separation.
|
||||
175
specs/183-website-workspace-foundation/spec.md
Normal file
175
specs/183-website-workspace-foundation/spec.md
Normal file
@ -0,0 +1,175 @@
|
||||
# Feature Specification: Website / Workspace Foundation
|
||||
|
||||
**Feature Branch**: `183-website-workspace-foundation`
|
||||
**Created**: 2026-04-08
|
||||
**Status**: Proposed
|
||||
**Input**: User description: "Introduce the first real multi-app foundation after the platform move to apps/platform by adding a public website app, minimal root orchestration, a clear command/workspace model, and strict separation between website, platform, and repo-wide meta-structures."
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**: Public website entry pages in the new website app; existing platform entry routes under `/admin` and `/system` remain unchanged; the root README and local development entry flow become the documented repo starting point.
|
||||
- **Data Ownership**: No new tenant-owned or workspace-owned application records are introduced; this slice adds repo/workspace configuration artifacts and a new website app source tree only.
|
||||
- **RBAC**: Public website pages are anonymous; existing platform and tenant membership/capability rules remain unchanged.
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: Yes. The repo root becomes the canonical multi-app workspace entry point for orchestration and discovery.
|
||||
- **New persisted entity/table/artifact?**: Yes. The feature adds root workspace manifests/configuration and a new website app as versioned repository artifacts, but no new runtime business tables or persisted domain records.
|
||||
- **New abstraction?**: Yes. The feature introduces an explicit root orchestration model for multiple apps.
|
||||
- **New enum/state/reason family?**: No.
|
||||
- **New cross-domain UI framework/taxonomy?**: No.
|
||||
- **Current operator problem**: The product now needs a public website surface, but the repo still behaves like a single-app workspace. That makes website introduction ad hoc, hard to document, and easy to couple accidentally to the platform app.
|
||||
- **Existing structure is insufficient because**: `apps/platform` is now correctly isolated, but there is no official root command model, no second app boundary, and no repo-level convention that tells contributors how multiple apps coexist or how to run them safely in parallel.
|
||||
- **Narrowest correct implementation**: Add one real website app, one explicit workspace/orchestration model, and only the minimum root files and commands needed to run the platform, the website, or both.
|
||||
- **Ownership cost**: The repo gains one more app to maintain, one more local run/build flow, root-level documentation upkeep, and a small amount of multi-app tooling review and smoke-test surface.
|
||||
- **Alternative intentionally rejected**: Embedding the website into the platform app or introducing shared packages, docs, customer, or API surfaces immediately was rejected because those approaches either couple runtimes prematurely or add speculative structure without current-release value.
|
||||
- **Release truth**: Current-release truth. The public website is the next concrete surface the product needs; this is not future-only preparation.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Start the Repo From One Clear Entry Model (Priority: P1)
|
||||
|
||||
A contributor needs to open the repository and immediately understand how to start the platform app, the website app, or both from one official documented entry path.
|
||||
|
||||
**Why this priority**: Without an official root entry model, every follow-up multi-app change becomes inconsistent, slows onboarding, and increases the risk of divergent local workflows.
|
||||
|
||||
**Independent Test**: A contributor can clone or open the repo, follow the root documentation, and use the official commands to start the platform, the website, or both without needing undocumented steps.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a contributor is at the repo root, **When** they follow the documented workspace entry flow, **Then** they can identify the official commands for platform-only, website-only, and parallel local development.
|
||||
2. **Given** both apps exist in the repo, **When** a contributor starts them through the official model, **Then** each app boots through its own app-local execution path while the root only orchestrates.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Work on the Website Independently (Priority: P2)
|
||||
|
||||
A contributor focused on the public website needs a real standalone website app that can run and build without requiring the platform runtime, shared sessions, or platform templating.
|
||||
|
||||
**Why this priority**: The website is the first new product surface after the platform extraction, so it must be able to evolve independently instead of being treated as a sidecar inside the platform app.
|
||||
|
||||
**Independent Test**: The website app can be started and built on its own, and the resulting public pages do not depend on the platform app being booted.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the platform app is not running, **When** a contributor starts the website app, **Then** the website is locally reachable and serves its public pages without requiring platform authentication or server-side platform rendering.
|
||||
2. **Given** the website app is built, **When** the platform app remains untouched, **Then** the platform build/runtime outputs are unchanged and do not depend on website artifacts.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Preserve Platform Stability During the Multi-App Transition (Priority: P3)
|
||||
|
||||
A platform contributor needs the new multi-app setup to leave `apps/platform` functionally stable while documenting how the platform fits into the broader repo layout.
|
||||
|
||||
**Why this priority**: The workspace foundation is only useful if it enables expansion without destabilizing TenantPilot itself.
|
||||
|
||||
**Independent Test**: Existing platform startup and smoke checks continue to work after the workspace changes, and contributors can still use app-local platform commands while understanding the new root entry model.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the repo has adopted the multi-app structure, **When** a contributor runs the documented platform workflow, **Then** the platform starts and behaves as before.
|
||||
2. **Given** the platform and website are developed in parallel, **When** both are started locally, **Then** there are no obvious runtime or build collisions between them.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A contributor has not installed the official workspace/package manager: the root documentation must identify the supported standard clearly and avoid leaving parallel unsupported root workflows ambiguous.
|
||||
- A default website port or platform-adjacent port is already in use: the official local-development guidance must make the conflict visible and describe the supported override path.
|
||||
- A contributor builds the website while stale platform frontend assets already exist: the build outputs must remain app-local and must not overwrite or rely on platform artifacts.
|
||||
- The platform app is temporarily unavailable: the website app must still start and build independently.
|
||||
- The website app is unavailable or not being worked on: the platform app must continue to support its established app-local workflow.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces repo-level orchestration and a new public application surface, but it does not add Microsoft Graph calls, new tenant/workspace business persistence, `OperationRun` workflows, or authorization-plane changes. The key constitutional obligations are proportionality, explicit ownership of root-versus-app responsibilities, preservation of platform stability, and testable proof that the new multi-app structure remains intentionally narrow.
|
||||
|
||||
### Scope
|
||||
|
||||
**In scope**
|
||||
|
||||
- A real `apps/website` application for the public website.
|
||||
- A minimal root workspace and orchestration model for multiple apps.
|
||||
- One official workspace/package-manager standard for root orchestration.
|
||||
- One official root command model covering the platform, the website, and parallel local development.
|
||||
- Clear separation of build and runtime responsibilities between the root, the platform app, and the website app.
|
||||
- Documentation and tooling updates required to make the multi-app repo understandable and usable.
|
||||
- Smoke validation for website boot, platform stability, parallel local development, and build separation.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- `apps/docs`
|
||||
- `packages/*` shared packages
|
||||
- Shared design-token, UI, or content packages
|
||||
- Customer or auditor surfaces
|
||||
- Public API or developer portal work
|
||||
- CMS, blog, or content-engine expansion
|
||||
- TenantPilot domain feature changes
|
||||
- Large CI/CD re-architecture beyond the minimum documentation and tooling implications of this slice
|
||||
|
||||
### Architectural Commitments
|
||||
|
||||
- The repository MUST use one official root workspace/package-manager standard for this slice, applied consistently across root orchestration and the website app.
|
||||
- The public website MUST be introduced as a static-first standalone application under `apps/website`.
|
||||
- Root orchestration MUST remain minimal: the root defines the workspace, documents the official commands, and coordinates multi-app entry paths, while app-specific execution logic stays inside each app.
|
||||
- No shared package layer is introduced in this slice; small, local duplication between the platform and the website is acceptable until a real repeated need exists.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The repository MUST expose a first-class multi-app topology with `apps/platform` and `apps/website` as the only application surfaces introduced by this slice.
|
||||
- **FR-002**: The root of the repository MUST provide the minimal workspace files required to make the official multi-app model explicit and usable.
|
||||
- **FR-003**: The repository MUST define a single official root command model that covers starting the platform app, starting the website app, starting both apps for parallel local development, building the website app, and building the platform frontend when needed.
|
||||
- **FR-004**: `apps/website` MUST be introduced as a real standalone app with its own source tree, public assets, local run flow, and build flow.
|
||||
- **FR-005**: The website app MUST remain a public-surface runtime and MUST NOT depend on Blade, Filament, platform sessions, shared platform cookies, or embedding inside the platform app.
|
||||
- **FR-006**: `apps/platform` MUST retain its existing app-local run and build workflows and MUST remain functionally stable under the new root model.
|
||||
- **FR-007**: Root orchestration MUST describe and coordinate app workflows, but app-local execution logic MUST continue to live inside the respective app directories.
|
||||
- **FR-008**: Website and platform local development MUST be able to run in parallel without shared build outputs, undocumented manual file shuffling, or cross-app runtime coupling.
|
||||
- **FR-009**: Website and platform build outputs MUST remain isolated so that building or cleaning one app does not overwrite, invalidate, or implicitly require artifacts from the other app.
|
||||
- **FR-010**: The root documentation MUST explain the repo structure, the purpose of `apps/platform` and `apps/website`, the official workspace standard, the official root commands, and the app-local command model.
|
||||
- **FR-011**: Tooling, editor, and agent instructions that rely on root-level repo knowledge MUST be updated where needed so they reflect the multi-app structure and the official root entry path.
|
||||
- **FR-012**: The website foundation introduced by this slice MUST remain intentionally narrow: public website pages and the supporting workspace foundation only, with no CMS or blog engine, docs surface, customer surface, API surface, or mandatory shared-package layer.
|
||||
- **FR-013**: The feature MUST not introduce any new shared package, additional top-level application surface, or domain feature change unless separately specified in a later spec.
|
||||
- **FR-014**: A smoke validation plan MUST verify platform stability, website boot, parallel local development, root orchestration usability, build separation, and documentation usability before the slice is considered complete.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- The repo has a clear multi-app structure with `apps/platform` and `apps/website`.
|
||||
- A single official workspace/package-manager standard is defined and documented.
|
||||
- The minimal root orchestration files and commands exist and are understandable from the repo root.
|
||||
- The website app is independently runnable and buildable.
|
||||
- The platform app remains stable and functionally unchanged by the workspace introduction.
|
||||
- Platform and website can be developed locally in parallel.
|
||||
- Root documentation explains the multi-app model and the official entry path clearly.
|
||||
- No shared packages, docs surface, customer surface, or public API surface are introduced in this slice.
|
||||
- Smoke validation demonstrates coexistence and build/runtime isolation.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Root Workspace**: The canonical repo-level contract that defines the official workspace standard, root orchestration files, and documented entry commands for the multi-app repo.
|
||||
- **Platform App**: The existing TenantPilot Laravel application under `apps/platform`, which remains operationally separate and functionally stable.
|
||||
- **Website App**: The new public-surface application under `apps/website`, intended for public product communication and independent local run/build workflows.
|
||||
- **Official Command Model**: The documented set of root and app-local commands that tells contributors how to start, build, and work on the platform app, the website app, or both together.
|
||||
- **Build/Runtime Boundary**: The separation rule that keeps dependencies, generated artifacts, ports, and runtime assumptions isolated between the platform and the website.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A contributor can identify where the platform lives, where the website lives, and which official commands to use from the repo root within 10 minutes using only the updated root documentation.
|
||||
- **SC-002**: Both apps can be started locally through the documented command model in the same development session with no unresolved port or runtime conflict beyond documented configuration overrides.
|
||||
- **SC-003**: The website can be built successfully without requiring the platform app to be running, and the platform can complete its documented build workflow without requiring website artifacts.
|
||||
- **SC-004**: Relevant existing platform startup and smoke checks continue to pass after the workspace foundation is introduced.
|
||||
- **SC-005**: Repo review after this slice shows exactly one new first-class application surface (`apps/website`) and zero newly introduced shared packages or adjacent product surfaces.
|
||||
- **SC-006**: A contributor can complete a platform-only workflow or a website-only workflow without starting the other app, except where the documentation explicitly states otherwise.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The public website starts as a lightweight public information surface rather than an authenticated customer, docs, or partner portal.
|
||||
- The platform application remains the canonical home for TenantPilot product logic; this slice only changes the repo and workspace foundation around it.
|
||||
- Parallel local development uses separate app runtimes and separate default ports.
|
||||
- Minor duplication between the platform and the website is acceptable until a real repeated need justifies a shared package.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- The platform extraction completed in Spec 182 remains intact and provides a stable `apps/platform` baseline.
|
||||
- Repo-level documentation, tasks, and agent/tooling files can be updated to reflect the multi-app entry model.
|
||||
- Local contributors can install the official workspace toolchain alongside the existing platform prerequisites.
|
||||
206
specs/183-website-workspace-foundation/tasks.md
Normal file
206
specs/183-website-workspace-foundation/tasks.md
Normal file
@ -0,0 +1,206 @@
|
||||
# Tasks: Website / Workspace Foundation
|
||||
|
||||
**Input**: Design documents from `/specs/183-website-workspace-foundation/`
|
||||
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature. Use focused Pest coverage under `apps/platform/tests/Feature/WorkspaceFoundation/` plus contract-backed smoke validation from `specs/183-website-workspace-foundation/contracts/workspace-command-model.md`, `specs/183-website-workspace-foundation/contracts/coexistence-smoke.openapi.yaml`, and `specs/183-website-workspace-foundation/quickstart.md`.
|
||||
**Operations**: This feature does not introduce a new `OperationRun` type or change existing operations UX semantics. Coexistence, root-command, and build-isolation smoke validation is still required because the repo gains a second app runtime.
|
||||
**RBAC**: No authorization model changes are allowed. Validation must preserve existing `/admin`, `/admin/t/{tenant}/...`, and `/system` behavior exactly as-is.
|
||||
**Operator Surfaces**: No operator-surface redesign is part of this slice. Existing public, admin, tenant, and system surfaces must continue to render with the same routing and authorization semantics.
|
||||
**Filament UI Action Surfaces**: No new Filament action surfaces are introduced. Existing resources and pages must continue to work unchanged once the workspace foundation lands.
|
||||
**Filament UI UX-001**: No layout or information-architecture redesign is part of this feature. Filament panels, themes, and existing surface contracts must remain intact.
|
||||
**Badges**: No badge semantics change is planned. Existing badge rendering must continue to work after the workspace changes.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and validated as an independent increment once the blocking workspace groundwork is complete.
|
||||
|
||||
## Phase 1: Setup (Shared Workspace Scaffolding)
|
||||
|
||||
**Purpose**: Prepare the repo root, the website app scaffold, and the focused test namespace before the blocking workspace conversion begins.
|
||||
|
||||
- [X] T001 Create the root workspace manifest scaffold in `package.json` and `pnpm-workspace.yaml`
|
||||
- [X] T002 [P] Create the website application scaffold directories in `apps/website/src/` and `apps/website/public/`
|
||||
- [X] T003 [P] Create the focused workspace foundation test namespace in `apps/platform/tests/Feature/WorkspaceFoundation/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Complete the shared package-manager and repo-boundary work that all user stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T004 Replace single-app npm assumptions with the shared pnpm workspace baseline by creating `pnpm-lock.yaml`, updating `apps/platform/package.json`, and removing tracked `apps/platform/package-lock.json`
|
||||
- [X] T005 [P] Align platform Composer Node scripts with pnpm in `apps/platform/composer.json`
|
||||
- [X] T006 [P] Align repo ignore and formatting rules for website and workspace artifacts in `.gitignore` and `.prettierignore`
|
||||
- [X] T007 [P] Add baseline platform boot smoke coverage for workspace changes in `apps/platform/tests/Feature/WorkspaceFoundation/PlatformBootSmokeTest.php`
|
||||
|
||||
**Checkpoint**: The repo now has one JavaScript workspace standard, one shared lockfile model, and baseline platform smoke coverage for the multi-app transition.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Start the Repo From One Clear Entry Model (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make the repo root the obvious and documented entry point for platform-only, website-only, and parallel local development.
|
||||
|
||||
**Independent Test**: From the repo root, a contributor can follow the updated docs and use the official commands to start the platform, the website, or both without undocumented steps.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T008 [US1] Implement the official root workspace scripts and package-manager declaration in `package.json`, aligned to `specs/183-website-workspace-foundation/contracts/workspace-command-model.md`
|
||||
- [X] T009 [P] [US1] Update the multi-app repo entry documentation and architecture overview in `README.md` and `docs/PROJECT_SUMMARY.md`
|
||||
- [X] T010 [P] [US1] Align human and assistant guidance with the multi-app command model in `Agents.md`, `GEMINI.md`, `.github/copilot-instructions.md`, and `.github/agents/copilot-instructions.md`
|
||||
- [X] T011 [US1] Add official root entry tasks and platform-only MCP scoping in `.vscode/tasks.json`, `.vscode/mcp.json`, and `opencode.json`, aligned to `specs/183-website-workspace-foundation/contracts/workspace-command-model.md`
|
||||
- [X] T012 [US1] Validate the platform-only, website-only, and parallel root entry flows, including the supported port-override path, against `specs/183-website-workspace-foundation/contracts/workspace-command-model.md` and `specs/183-website-workspace-foundation/quickstart.md`
|
||||
|
||||
**Checkpoint**: The repo root is now the documented primary entry path for the multi-app workspace.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Work on the Website Independently (Priority: P2)
|
||||
|
||||
**Goal**: Introduce a real standalone public website app that can run and build without the platform runtime.
|
||||
|
||||
**Independent Test**: `apps/website` can start and build on its own, and its public pages render without the platform app running.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T013 [US2] Implement the standalone Astro website manifest and app configuration in `apps/website/package.json` and `apps/website/astro.config.mjs`
|
||||
- [X] T014 [P] [US2] Build the initial public website structure in `apps/website/src/layouts/BaseLayout.astro`, `apps/website/src/pages/index.astro`, and `apps/website/src/styles/global.css`
|
||||
- [X] T015 [P] [US2] Add website public assets and metadata files in `apps/website/public/favicon.svg` and `apps/website/public/robots.txt`
|
||||
- [X] T016 [US2] Validate standalone website boot, website-only build behavior, and the supported website port-override path against `specs/183-website-workspace-foundation/contracts/coexistence-smoke.openapi.yaml` and `specs/183-website-workspace-foundation/quickstart.md`
|
||||
|
||||
**Checkpoint**: The public website now exists as a real app and can be developed independently from the platform.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Preserve Platform Stability During the Multi-App Transition (Priority: P3)
|
||||
|
||||
**Goal**: Keep `apps/platform` stable and prove the platform still boots, builds, and coexists cleanly with the website under the new workspace model.
|
||||
|
||||
**Independent Test**: Focused platform regression tests still pass, the platform dev/build workflow remains valid under pnpm, and platform plus website can run together without build or runtime collisions.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T017 [P] [US3] Add platform workspace compatibility smoke coverage in `apps/platform/tests/Feature/WorkspaceFoundation/PlatformWorkspaceCompatibilityTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T018 [US3] Finalize pnpm-based platform frontend scripts in `apps/platform/package.json` and `apps/platform/composer.json`
|
||||
- [X] T019 [P] [US3] Preserve platform compatibility-helper and deployment asset guidance in `scripts/platform-sail` and `README.md`
|
||||
- [X] T020 [P] [US3] Document multi-app platform isolation and rollout notes in `docs/HANDOVER.md` and `docs/PROJECT_SUMMARY.md`
|
||||
- [X] T021 [US3] Validate focused platform regression, parallel local development, build separation, and the supported platform or website port-override path against `specs/183-website-workspace-foundation/contracts/workspace-command-model.md`, `specs/183-website-workspace-foundation/contracts/coexistence-smoke.openapi.yaml`, and `specs/183-website-workspace-foundation/quickstart.md`
|
||||
|
||||
**Checkpoint**: The platform remains stable under the new workspace model, and coexistence with the website is proven.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Remove stale single-app assumptions, normalize manifests, and finish the final validation pass.
|
||||
|
||||
- [X] T022 [P] Remove stale npm-only and single-app references across `README.md`, `Agents.md`, `GEMINI.md`, `.github/copilot-instructions.md`, `.github/agents/copilot-instructions.md`, `.vscode/tasks.json`, `.vscode/mcp.json`, and `opencode.json`
|
||||
- [X] T023 [P] Normalize the final workspace manifests and app package files in `package.json`, `pnpm-workspace.yaml`, `apps/platform/package.json`, and `apps/website/package.json`
|
||||
- [X] T024 [P] Run the focused workspace foundation Pest suite in `apps/platform/tests/Feature/WorkspaceFoundation/`
|
||||
- [X] T025 Run the full contract-backed smoke validation, including the supported port-override path, and capture final coexistence evidence from `specs/183-website-workspace-foundation/contracts/workspace-command-model.md`, `specs/183-website-workspace-foundation/contracts/coexistence-smoke.openapi.yaml`, and `specs/183-website-workspace-foundation/quickstart.md`
|
||||
- [X] T026 [P] Audit the final repo topology and slice scope against `specs/183-website-workspace-foundation/spec.md`, `package.json`, `pnpm-workspace.yaml`, `apps/`, and `README.md` to confirm no `packages/*` shared package layer, no shared design-token, UI, or content packages, no `apps/docs`, no customer or auditor surface, no public API or developer portal work, no CMS, blog, or content-engine expansion, no TenantPilot domain feature changes, no large CI or CD re-architecture beyond this slice, and no additional app surfaces were introduced
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Starts immediately and prepares the repo and test scaffolding.
|
||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until the workspace standard, lockfile model, and baseline smoke coverage are in place.
|
||||
- **User Story 1 (Phase 3)**: Starts after Foundational and establishes the official root entry model.
|
||||
- **User Story 2 (Phase 4)**: Starts after Foundational and can proceed in parallel with User Story 1, but its final validation should use the root entry model created in User Story 1.
|
||||
- **User Story 3 (Phase 5)**: Starts after Foundational and should finish after User Stories 1 and 2 because it validates the final combined platform-plus-website setup.
|
||||
- **Polish (Phase 6)**: Starts after all desired user stories are complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: Depends only on Setup and Foundational work.
|
||||
- **US2**: Depends on Setup and Foundational work; final root-command validation reuses US1.
|
||||
- **US3**: Depends on Foundational work and validates the final integrated outcome of US1 and US2.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Shared workspace manifests and package-manager conversion must land before docs or tasks that declare them as canonical.
|
||||
- Website app config should land before website pages and assets.
|
||||
- Platform stability validation should run after root scripts, website app setup, and pnpm-based platform scripts are complete.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T002` and `T003` can run in parallel during Setup.
|
||||
- `T005`, `T006`, and `T007` can run in parallel once `T004` establishes the shared workspace baseline.
|
||||
- `T009` and `T010` can run in parallel for US1.
|
||||
- `T014` and `T015` can run in parallel for US2 once `T013` establishes the website config.
|
||||
- `T017`, `T019`, and `T020` can run in parallel for US3.
|
||||
- `T022`, `T023`, `T024`, and `T026` can run in parallel during Polish.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Story 1 documentation and tooling work in parallel:
|
||||
Task: T009 README.md and docs/PROJECT_SUMMARY.md
|
||||
Task: T010 Agents.md, GEMINI.md, .github/copilot-instructions.md, and .github/agents/copilot-instructions.md
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Story 2 website app work in parallel:
|
||||
Task: T014 apps/website/src/layouts/BaseLayout.astro, apps/website/src/pages/index.astro, and apps/website/src/styles/global.css
|
||||
Task: T015 apps/website/public/favicon.svg and apps/website/public/robots.txt
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Story 3 stability work in parallel:
|
||||
Task: T017 apps/platform/tests/Feature/WorkspaceFoundation/PlatformWorkspaceCompatibilityTest.php
|
||||
Task: T019 scripts/platform-sail and README.md
|
||||
Task: T020 docs/HANDOVER.md and docs/PROJECT_SUMMARY.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. **STOP and VALIDATE**: Confirm the repo root now exposes one clear command model for platform, website, and parallel local development.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Finish Setup and Foundational to establish the shared workspace baseline.
|
||||
2. Deliver US1 to make the repo root the canonical entry path.
|
||||
3. Deliver US2 to make the website a real standalone public app.
|
||||
4. Deliver US3 to prove platform stability and multi-app coexistence.
|
||||
5. Finish Polish to remove stale assumptions and run the full smoke pack.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers:
|
||||
|
||||
1. Complete Setup and Foundational together.
|
||||
2. After Foundational:
|
||||
- Developer A: US1 root scripts, docs, and tasks
|
||||
- Developer B: US2 website app structure and assets
|
||||
3. After US1 and US2 are stable:
|
||||
- Developer C: US3 platform stability validation and rollout notes
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Suggested MVP scope for this feature is **User Story 1** after Setup and Foundational are complete.
|
||||
- `[P]` tasks touch different files or can be validated independently.
|
||||
- Story labels map each implementation task back to the originating user story for traceability.
|
||||
- The website must remain outside Sail in this slice; the platform must remain Sail-first.
|
||||
- No shared package layer, shared design-token, UI, or content package, docs app, customer or auditor surface, public API or developer portal surface, CMS, blog, or content-engine expansion, TenantPilot domain feature change, or large CI/CD re-architecture should appear while executing these tasks.
|
||||
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Dashboard Recovery Posture Honesty
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-08
|
||||
**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
|
||||
|
||||
- Validated on 2026-04-08. The spec keeps behavior focused on dashboard honesty, restore-history evidence, and operator trust boundaries. Repository-specific route and surface references are retained only where this template and constitution require concrete scope identification.
|
||||
@ -0,0 +1,366 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Dashboard Recovery Posture Honesty Surface Contracts
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
Internal reference contract for tenant dashboard recovery-posture honesty.
|
||||
The application continues to return rendered HTML through Filament and
|
||||
Livewire. The vendor media types below document the structured dashboard and
|
||||
restore-history models that must be derivable before rendering. This is not
|
||||
a public API commitment.
|
||||
paths:
|
||||
/admin/t/{tenant}:
|
||||
get:
|
||||
summary: Tenant dashboard recovery-posture summary surface
|
||||
description: >-
|
||||
Returns the rendered tenant dashboard. The vendor media type documents
|
||||
the backup posture, recovery-evidence summary, and recovery-related
|
||||
attention or healthy-check state that must be available before rendering.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered tenant dashboard page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.dashboard-recovery-posture+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantDashboardRecoverySurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks the capability required for the dashboard surface
|
||||
'404':
|
||||
description: Tenant scope is not visible because workspace or tenant membership is missing
|
||||
/admin/t/{tenant}/restore-runs:
|
||||
get:
|
||||
summary: Restore history collection surface with recovery continuity context
|
||||
description: >-
|
||||
Returns the rendered restore-run list. The vendor media type documents
|
||||
the continuity subheading and result-attention facts needed to confirm a
|
||||
dashboard recovery signal.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: recovery_posture_reason
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/RecoveryPostureReason'
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered restore-run list page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.restore-history-collection+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RestoreHistoryCollectionSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks restore-history viewing capability
|
||||
'404':
|
||||
description: Restore history is not visible because workspace or tenant membership is missing
|
||||
/admin/t/{tenant}/restore-runs/{restoreRun}:
|
||||
get:
|
||||
summary: Restore history detail surface
|
||||
description: >-
|
||||
Returns the rendered restore-run detail page. The vendor media type
|
||||
documents the result-attention and claim-boundary facts that confirm a
|
||||
specific dashboard recovery signal.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: restoreRun
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered restore-run detail page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.restore-history-detail+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RestoreHistoryDetailSurface'
|
||||
'403':
|
||||
description: Viewer is in scope but lacks capability for the restore-run detail surface
|
||||
'404':
|
||||
description: Restore run is not visible because workspace or tenant membership is missing
|
||||
components:
|
||||
schemas:
|
||||
TenantDashboardRecoverySurface:
|
||||
type: object
|
||||
required:
|
||||
- backupPosture
|
||||
- recoveryEvidence
|
||||
properties:
|
||||
backupPosture:
|
||||
$ref: '#/components/schemas/BackupPostureStat'
|
||||
recoveryEvidence:
|
||||
$ref: '#/components/schemas/RecoveryEvidenceSummary'
|
||||
needsAttentionItems:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RecoveryAttentionItem'
|
||||
healthyChecks:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/HealthyCheck'
|
||||
BackupPostureStat:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- posture
|
||||
- summary
|
||||
- claimBoundary
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
posture:
|
||||
type: string
|
||||
enum:
|
||||
- absent
|
||||
- stale
|
||||
- degraded
|
||||
- healthy
|
||||
summary:
|
||||
type: string
|
||||
claimBoundary:
|
||||
type: string
|
||||
action:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionLink'
|
||||
- type: 'null'
|
||||
RecoveryEvidenceSummary:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- headline
|
||||
- summary
|
||||
- claimBoundary
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
description: Canonical recovery-evidence state key for the dashboard surface.
|
||||
enum:
|
||||
- unvalidated
|
||||
- weakened
|
||||
- no_recent_issues_visible
|
||||
headline:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
claimBoundary:
|
||||
type: string
|
||||
latestRelevantRestoreRunId:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
latestRelevantAttentionState:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum:
|
||||
- failed
|
||||
- partial
|
||||
- completed_with_follow_up
|
||||
- completed
|
||||
- null
|
||||
action:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionLink'
|
||||
- type: 'null'
|
||||
RecoveryAttentionItem:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- body
|
||||
- badge
|
||||
- badgeColor
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
supportingMessage:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
badge:
|
||||
type: string
|
||||
badgeColor:
|
||||
type: string
|
||||
action:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionLink'
|
||||
- type: 'null'
|
||||
HealthyCheck:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- body
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
RestoreHistoryCollectionSurface:
|
||||
type: object
|
||||
required:
|
||||
- rows
|
||||
properties:
|
||||
subheading:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RestoreHistoryRow'
|
||||
emptyState:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/EmptyState'
|
||||
- type: 'null'
|
||||
RestoreHistoryRow:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- status
|
||||
- resultAttention
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
backupSetName:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
status:
|
||||
type: string
|
||||
completedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
requestedBy:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
resultAttention:
|
||||
$ref: '#/components/schemas/ResultAttentionFact'
|
||||
action:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionLink'
|
||||
- type: 'null'
|
||||
RestoreHistoryDetailSurface:
|
||||
type: object
|
||||
required:
|
||||
- header
|
||||
- resultAttention
|
||||
properties:
|
||||
header:
|
||||
$ref: '#/components/schemas/RestoreHistoryHeader'
|
||||
resultAttention:
|
||||
$ref: '#/components/schemas/ResultAttentionFact'
|
||||
relatedLinks:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ActionLink'
|
||||
RestoreHistoryHeader:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- status
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
completedAt:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: date-time
|
||||
backupSetName:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
ResultAttentionFact:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
- summary
|
||||
- followUpRequired
|
||||
- claimBoundary
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- not_executed
|
||||
- failed
|
||||
- partial
|
||||
- completed_with_follow_up
|
||||
- completed
|
||||
summary:
|
||||
type: string
|
||||
followUpRequired:
|
||||
type: boolean
|
||||
claimBoundary:
|
||||
type: string
|
||||
primaryNextAction:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
EmptyState:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- body
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
action:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ActionLink'
|
||||
- type: 'null'
|
||||
ActionLink:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- disabled
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
url:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
disabled:
|
||||
type: boolean
|
||||
helperText:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
RecoveryPostureReason:
|
||||
type: string
|
||||
enum:
|
||||
- no_history
|
||||
- failed
|
||||
- partial
|
||||
- completed_with_follow_up
|
||||
- no_recent_issues_visible
|
||||
121
specs/184-dashboard-recovery-honesty/data-model.md
Normal file
121
specs/184-dashboard-recovery-honesty/data-model.md
Normal file
@ -0,0 +1,121 @@
|
||||
# Data Model: Dashboard Recovery Posture Honesty
|
||||
|
||||
## Existing Evidence Models
|
||||
|
||||
### TenantBackupHealthAssessment
|
||||
|
||||
Existing derived tenant-level backup-input assessment from `TenantBackupHealthResolver`.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|------|------|---------|
|
||||
| `tenantId` | integer | Tenant scope for the assessment |
|
||||
| `posture` | string | `absent`, `stale`, `degraded`, or `healthy` backup-input posture |
|
||||
| `primaryReason` | string nullable | Why the current backup-input posture is not calmly healthy |
|
||||
| `headline` | string | Operator-facing headline for the backup-input truth |
|
||||
| `supportingMessage` | string nullable | Supporting backup-health explanation |
|
||||
| `healthyClaimAllowed` | boolean | Whether the surface may speak positively about backup-input health |
|
||||
| `primaryActionTarget` | action target nullable | Canonical backup drillthrough |
|
||||
| `positiveClaimBoundary` | string | Canonical statement that backup health reflects backup inputs only and does not prove restore success |
|
||||
|
||||
### RestoreRun
|
||||
|
||||
Existing tenant-owned operational record of restore activity.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|------|------|---------|
|
||||
| `id` | integer | Restore-run identity |
|
||||
| `tenant_id` | integer | Tenant scope |
|
||||
| `backup_set_id` | integer nullable | Backup set used for the run |
|
||||
| `status` | string | Restore lifecycle status |
|
||||
| `is_dry_run` | boolean | Whether the record is preview-only |
|
||||
| `results` | array/json | Item-, foundation-, and assignment-level execution outcomes |
|
||||
| `metadata` | array/json | Additional execution and preview metadata, including `non_applied` and scope basis |
|
||||
| `completed_at` | datetime nullable | Terminal completion timestamp |
|
||||
| `operationRun` | relation nullable | Linked `OperationRun` for umbrella execution outcome |
|
||||
|
||||
### RestoreResultAttention
|
||||
|
||||
Existing per-run derived truth from `RestoreSafetyResolver::resultAttentionForRun(...)`.
|
||||
|
||||
| State | Follow-up required | Meaning |
|
||||
|------|--------------------|---------|
|
||||
| `not_executed` | no | Preview-only or not-yet-executed record; proves preview truth only |
|
||||
| `failed` | yes | Execution failed; no recovery claim can be made |
|
||||
| `partial` | yes | Execution reached terminal state but some items or assignments failed or only partially applied |
|
||||
| `completed_with_follow_up` | yes | Execution completed, but skipped or non-applied work still weakens confidence |
|
||||
| `completed` | no | No visible follow-up remains, but tenant-wide recovery is still not proven |
|
||||
|
||||
Relevant fields carried by the value object:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|------|------|---------|
|
||||
| `state` | string | One of the five result-attention states above |
|
||||
| `summary` | string | Operator-facing explanation of the outcome |
|
||||
| `followUpRequired` | boolean | Whether the result weakens confidence or needs action |
|
||||
| `primaryNextAction` | string | Recommended next action for the run |
|
||||
| `recoveryClaimBoundary` | string | Canonical claim-boundary identifier for the run outcome |
|
||||
| `tone` | string | Summary tone for UI presentation |
|
||||
|
||||
## Derived Surface Projection
|
||||
|
||||
### Recovery Evidence Summary
|
||||
|
||||
This spec does **not** add a new persisted entity. It adds a derived tenant-level dashboard projection that combines existing backup health and restore evidence at render time.
|
||||
|
||||
| Field | Type | Persisted | Meaning |
|
||||
|------|------|-----------|---------|
|
||||
| `tenantId` | integer | no | Tenant scope |
|
||||
| `backupPosture` | string | no | Current `TenantBackupHealthAssessment.posture` |
|
||||
| `relevantRestoreHistoryPresent` | boolean | no | Whether the tenant has any executed, non-preview restore history |
|
||||
| `latestRelevantRestoreRunId` | integer nullable | no | The most recent executed restore run relevant to overview continuity |
|
||||
| `latestRelevantAttentionState` | string nullable | no | Result-attention state for the latest relevant run |
|
||||
| `overviewState` | string | no | Canonical state key: `unvalidated`, `weakened`, or `no_recent_issues_visible` |
|
||||
| `headline` | string | no | Operator-facing dashboard headline for the recovery-evidence condition |
|
||||
| `summary` | string | no | Supporting summary text for the state |
|
||||
| `claimBoundary` | string | no | Text that prevents the summary from becoming a proof claim |
|
||||
| `action` | object nullable | no | Nested action payload with `label`, `url`, `disabled`, and `helperText`, matching the contract `ActionLink` shape |
|
||||
|
||||
### Overview State Rules
|
||||
|
||||
| Overview state | Entry rule | Required surface effect |
|
||||
|------|------------|-------------------------|
|
||||
| `unvalidated` | No executed, non-preview restore history exists for the tenant | Dashboard must show that recovery confidence is unvalidated and provide a next action into restore history |
|
||||
| `weakened` | Latest relevant restore history resolves to `failed`, `partial`, or `completed_with_follow_up` | Needs Attention must surface the weakened condition and link to the exact run or canonical restore-run list fallback |
|
||||
| `no_recent_issues_visible` | Relevant restore history exists and the latest relevant attention is `completed` | The dashboard may say no recent restore issues are visible, but must preserve the non-proof boundary |
|
||||
|
||||
Operator-facing copy may say that recovery evidence is not yet known, but the canonical derived state remains `unvalidated`.
|
||||
|
||||
### Non-qualifying restore records
|
||||
|
||||
The following records do **not** count as relevant restore history for overview confidence:
|
||||
|
||||
- `is_dry_run = true`
|
||||
- `status in [draft, scoped, checked, previewed]`
|
||||
- Any record whose `RestoreResultAttention.state` is `not_executed`
|
||||
|
||||
## Relationships
|
||||
|
||||
| Source | Relationship | Target | Use in this spec |
|
||||
|------|--------------|--------|------------------|
|
||||
| Tenant | has many | BackupSet | Existing backup-input truth via `TenantBackupHealthResolver` |
|
||||
| Tenant | has many | RestoreRun | Existing restore-history truth used for overview evidence |
|
||||
| RestoreRun | belongs to | BackupSet | Supports drillthrough context |
|
||||
| RestoreRun | optionally belongs to | OperationRun | Contributes operation outcome to `RestoreResultAttention` |
|
||||
|
||||
## Derived Continuity Context
|
||||
|
||||
The canonical restore-run list receives a **non-persisted page context** from the dashboard for fallback continuity.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|------|------|---------|
|
||||
| `recovery_posture_reason` | query string | Why the user arrived on the restore-run list from the dashboard |
|
||||
|
||||
Suggested reason values:
|
||||
|
||||
- `no_history`
|
||||
- `failed`
|
||||
- `partial`
|
||||
- `completed_with_follow_up`
|
||||
- `no_recent_issues_visible`
|
||||
|
||||
These are list-continuity reasons only. They are not new persisted domain states.
|
||||
272
specs/184-dashboard-recovery-honesty/plan.md
Normal file
272
specs/184-dashboard-recovery-honesty/plan.md
Normal file
@ -0,0 +1,272 @@
|
||||
# Implementation Plan: Dashboard Recovery Posture Honesty
|
||||
|
||||
**Branch**: `184-dashboard-recovery-honesty` | **Date**: 2026-04-08 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/184-dashboard-recovery-honesty/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/184-dashboard-recovery-honesty/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Harden the tenant dashboard so healthy backup inputs never read as validated recovery posture by default. The implementation will keep backup health and restore evidence as separate truths, reuse the existing backup positive-claim boundary and per-run `RestoreResultAttention` semantics, add an honest recovery-evidence summary to the tenant dashboard KPI and attention surfaces, and route no-history or weak-history signals into canonical restore-run list or detail drilldowns using the repo’s existing continuity-banner pattern. The slice stays read-only, introduces no new persistence, enum, provider, asset, or recovery-confidence engine, and limits code changes to existing dashboard widgets, existing restore-run list or detail surfaces, and focused Pest coverage.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers
|
||||
**Storage**: PostgreSQL with existing tenant-owned `backup_sets`, `restore_runs`, and linked `operation_runs`; no schema change planned
|
||||
**Testing**: Pest feature tests, Livewire widget or page tests, and narrow unit coverage for restore-history derivation, all run through Sail
|
||||
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
|
||||
**Project Type**: Laravel monolith web application rooted at `apps/platform`
|
||||
**Performance Goals**: Keep tenant dashboard rendering DB-only, avoid external calls at render time, cap recovery-evidence derivation to the most recent 10 tenant-scoped restore-run candidates with only the required relations eager-loaded, and preserve the existing restore-run list scanability without N+1 row lookups
|
||||
**Constraints**: No new persisted recovery-confidence field or table, no new recovery-proven signal, no new Graph calls, no new panel or provider registration, no new global-search behavior, no RBAC drift, no summary claim stronger than the evidence visible to the current user, and no new Filament asset registration
|
||||
**Scale/Scope**: One tenant dashboard page, two existing dashboard widgets, one restore-run list continuity seam, one restore-run detail reuse seam, one internal dashboard surface contract, and focused unit plus feature regression coverage
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Inventory-first | Pass | Existing backup and restore artifacts remain the only evidence sources; no ownership model changes |
|
||||
| Read/write separation | Pass | This slice is read-only summary hardening; no new mutation or remote work is introduced |
|
||||
| Graph contract path | Pass | No Graph calls, contract-registry changes, or provider changes are required |
|
||||
| Deterministic capabilities | Pass | Existing capability registry, policies, and `UiEnforcement` remain authoritative |
|
||||
| RBAC-UX planes and 404 vs 403 | Pass | Tenant dashboard and restore-run surfaces remain in `/admin/t/{tenant}/...`; non-members stay 404; in-scope members retain existing capability semantics |
|
||||
| Workspace isolation | Pass | No workspace-scope broadening is planned; workspace overview remains unchanged in this slice |
|
||||
| Tenant isolation | Pass | All evidence and drilldowns stay tenant-owned and tenant-scoped |
|
||||
| Destructive confirmation standard | Pass | No new destructive dashboard actions are added; existing restore-run destructive actions remain confirmed and server-authorized |
|
||||
| Global search safety | Pass | No new globally searchable resource or search behavior is introduced; existing resources retain their current view-page-backed search safety |
|
||||
| Run observability / Ops-UX | Pass | No new `OperationRun`, background work, notification surface, or lifecycle transition is added |
|
||||
| Data minimization | Pass | The slice reuses existing backup and restore metadata and keeps diagnostics on existing restore surfaces |
|
||||
| Proportionality (PROP-001) | Pass | The design extends existing widgets, list pages, and restore-safety seams instead of adding a new recovery-confidence subsystem |
|
||||
| No premature abstraction (ABSTR-001) | Pass | No new registry, interface, or orchestration layer is planned; at most a narrow extension of existing restore-safety seams or local widget derivation is needed |
|
||||
| Persisted truth (PERSIST-001) | Pass | Recovery posture remains derived at render time; no new stored truth is introduced |
|
||||
| Behavioral state (STATE-001) | Pass | `unvalidated`, `weakened`, and `no_recent_issues_visible` stay derived UI semantics, not new persisted domain state |
|
||||
| UI semantics (UI-SEM-001) | Pass | Recovery honesty is rendered directly on existing widgets and restore surfaces; no new presenter framework is planned |
|
||||
| Badge semantics (BADGE-001) | Pass | Existing badge catalogs remain authoritative; if restore attention appears on the list, it will reuse existing restore status and attention semantics rather than page-local mappings |
|
||||
| Filament-native UI (UI-FIL-001) | Pass | Existing `StatsOverviewWidget`, dashboard section view, Filament links, badges, and restore-run resource tables remain the primary seams |
|
||||
| UI Action Surface Contract | Pass | No new inspect model or destructive placement is introduced; restore-run list retains clickable-row inspect and grouped mutations |
|
||||
| UX-001 / HDR-001 | Pass | The dashboard keeps its existing widget layout; no new record headers or form layout changes are introduced |
|
||||
| Filament v5 / Livewire v4 compliance | Pass | The plan stays inside current Filament v5 and Livewire v4 surface patterns |
|
||||
| Provider registration location | Pass | No provider change is needed; existing Laravel 11+ registration remains in `bootstrap/providers.php` |
|
||||
| Global-search rule for changed resources | Pass | No change to searchable resource registration; `RestoreRunResource` already has detail pages and this slice touches no search-specific behavior |
|
||||
| Asset strategy | Pass | No new assets are planned; deployment continues to include `cd apps/platform && php artisan filament:assets` unchanged |
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/184-dashboard-recovery-honesty/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Keep the implementation scoped to tenant dashboard and restore-history confirmation surfaces; do not expand `WorkspaceOverviewBuilder` in this slice because the workspace overview does not currently restate backup or recovery posture.
|
||||
- Reuse `TenantBackupHealthAssessment::positiveClaimBoundary` as the canonical backup-health boundary on summary surfaces instead of inventing new copy.
|
||||
- Treat only non-dry-run, non-preview, executed restore runs as relevant restore history for overview confidence language.
|
||||
- Reuse `RestoreSafetyResolver::resultAttentionForRun(...)` as the sole authority for failed, partial, completed-with-follow-up, and completed-without-follow-up semantics.
|
||||
- Prefer canonical restore-run list drilldowns with a continuity reason banner when no specific run exists or when a direct detail link would be fragile; use restore-run detail only when a recent problematic run exists and is accessible.
|
||||
- Keep summary language cautious under RBAC restrictions: if deeper restore evidence cannot be opened, the summary may disable or fall back on the action but must not upgrade the claim.
|
||||
- Reuse the existing backup-health list continuity pattern (`backup_health_reason`) for restore-history continuity rather than adding a new UI shell or route family.
|
||||
- Extend existing dashboard, restore-run, and restore-attention tests instead of creating a new browser-level harness.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/184-dashboard-recovery-honesty/`:
|
||||
|
||||
- `research.md`: implementation decisions, constraints, and alternatives for recovery-posture honesty
|
||||
- `data-model.md`: existing evidence models and the derived overview recovery-posture projection
|
||||
- `contracts/dashboard-recovery-posture.openapi.yaml`: internal reference contract for tenant dashboard and restore-history drilldown surfaces
|
||||
- `quickstart.md`: focused validation workflow for honest dashboard recovery posture
|
||||
|
||||
Design decisions:
|
||||
|
||||
- Keep backup health and recovery evidence separate on the dashboard: backup posture remains the backup-input truth; a dedicated recovery-evidence summary or equivalent dashboard qualifier carries the restore-history truth.
|
||||
- Suppress all-clear semantics when relevant restore history is absent by making no-history its own derived overview condition rather than folding it into healthy backup posture.
|
||||
- Use existing per-run `RestoreResultAttention` output to decide whether recent restore history weakens confidence; do not duplicate that logic with raw status checks in widgets.
|
||||
- Add restore-history continuity to `ListRestoreRuns` via a query-string reason and subheading pattern analogous to `ListBackupSets` and `ListBackupSchedules`.
|
||||
- Make restore-run list rows more self-confirming by surfacing result-attention summary in a default-visible column or equivalent default-visible fact.
|
||||
- Reuse existing `ViewRestoreRun` result-attention presentation for detailed drilldown confirmation instead of building a new recovery detail page.
|
||||
- Leave workspace overview unchanged in this spec to avoid introducing a second summary surface with partially duplicated semantics; verify instead that no new contradiction is introduced.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/184-dashboard-recovery-honesty/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── dashboard-recovery-posture.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ └── TenantDashboard.php
|
||||
│ │ ├── Widgets/
|
||||
│ │ │ └── Dashboard/
|
||||
│ │ │ ├── DashboardKpis.php
|
||||
│ │ │ └── NeedsAttention.php
|
||||
│ │ └── Resources/
|
||||
│ │ ├── RestoreRunResource.php
|
||||
│ │ └── RestoreRunResource/
|
||||
│ │ └── Pages/
|
||||
│ │ ├── ListRestoreRuns.php
|
||||
│ │ └── ViewRestoreRun.php
|
||||
│ ├── Models/
|
||||
│ │ ├── BackupSet.php
|
||||
│ │ ├── RestoreRun.php
|
||||
│ │ └── OperationRun.php
|
||||
│ └── Support/
|
||||
│ ├── BackupHealth/
|
||||
│ │ ├── TenantBackupHealthAssessment.php
|
||||
│ │ └── TenantBackupHealthResolver.php
|
||||
│ ├── RestoreSafety/
|
||||
│ │ ├── RestoreResultAttention.php
|
||||
│ │ └── RestoreSafetyResolver.php
|
||||
│ └── OperationRunLinks.php
|
||||
├── resources/views/
|
||||
│ └── filament/widgets/dashboard/
|
||||
│ └── needs-attention.blade.php
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Filament/
|
||||
│ │ ├── DashboardKpisWidgetTest.php
|
||||
│ │ ├── NeedsAttentionWidgetTest.php
|
||||
│ │ ├── RestoreResultAttentionSurfaceTest.php
|
||||
│ │ ├── RestoreRunUiEnforcementTest.php
|
||||
│ │ ├── DashboardRecoveryPosturePerformanceTest.php
|
||||
│ │ └── RestoreRunListContinuityTest.php
|
||||
│ └── Rbac/
|
||||
│ └── DashboardRecoveryPostureVisibilityTest.php
|
||||
└── Unit/
|
||||
└── Support/
|
||||
└── RestoreSafety/
|
||||
└── RestoreResultAttentionTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Standard Laravel monolith inside `apps/platform`. The implementation stays inside existing dashboard widgets, existing restore-run resources or pages, existing restore-safety support classes, and existing test directories. No new root folders, no new panel providers, and no new cross-cutting framework are planned.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Derive Relevant Restore History Without A New Engine
|
||||
|
||||
**Goal**: Decide whether restore evidence is absent, weakened, or merely non-proving by reusing existing restore-safety truth and a minimal tenant-scoped history query.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php` or co-located dashboard widget methods | Add the minimal tenant-scoped query seam needed to determine relevant restore history, capped to the 10 most recent restore-run candidates and eager-loading only the relations required for the latest relevant visible executed run and its applicable `RestoreResultAttention`, without introducing a new persistent recovery-confidence model |
|
||||
| A.2 | Existing dashboard widgets only | Keep derived state local and lightweight: no new persisted field, no new enum, and no cross-domain recovery framework; if shared logic is unavoidable, keep it inside the existing restore-safety support namespace or a narrow local helper |
|
||||
| A.3 | `apps/platform/tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php` and a focused new unit test if needed | Cover preview-only exclusion, no-history detection, latest-run precedence, and weak-history precedence over older calmer history |
|
||||
|
||||
### Phase B — Surface Honest Recovery Language On The KPI Strip
|
||||
|
||||
**Goal**: Make the tenant dashboard scan distinguish backup-input health from recovery evidence in the first glance.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` | Keep the existing `Backup posture` stat as backup-input truth and append the visible backup claim boundary where the summary could otherwise sound stronger than the evidence |
|
||||
| B.2 | `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` | Add a recovery-evidence stat or equivalent adjacent summary signal that expresses `unvalidated`, `weakened`, or `no_recent_issues_visible` without ever claiming recovery proof |
|
||||
| B.3 | `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` | Route KPI drilldowns to canonical restore-run list or detail surfaces, with disabled or fallback behavior when a more specific drilldown is unavailable or inappropriate |
|
||||
|
||||
### Phase C — Integrate Recovery Honesty Into Needs Attention And Healthy Checks
|
||||
|
||||
**Goal**: Ensure healthy backup posture does not collapse into a quiet all-clear when restore evidence is missing or concerning.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add a recovery-family attention item for `no relevant restore history` that explains why confidence is unvalidated and points to restore history |
|
||||
| C.2 | `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add a recovery-family attention item for recent `failed`, `partial`, or `completed_with_follow_up` restore evidence using existing `RestoreResultAttention` semantics |
|
||||
| C.3 | `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add a healthy-check entry for `no recent restore issues visible` that still preserves the non-proof boundary, and prevent healthy backup messaging from becoming an unqualified all-clear |
|
||||
| C.4 | `apps/platform/resources/views/filament/widgets/dashboard/needs-attention.blade.php` only if needed | Reuse the existing attention and healthy-check rendering shape; only adjust the lead copy if the current calm sentence would still overclaim once recovery checks are present |
|
||||
|
||||
### Phase D — Restore History Drillthrough Continuity
|
||||
|
||||
**Goal**: Make every summary link land on a restore-history surface that confirms the same truth the dashboard just stated.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php` | Add `backup_health_reason`-style continuity support with a `recovery_posture_reason` subheading so `no history` and list-fallback links remain self-explanatory |
|
||||
| D.2 | `apps/platform/app/Filament/Resources/RestoreRunResource.php` | Add a default-visible result-attention summary column or equivalent row fact derived through `RestoreSafetyResolver::resultAttentionForRun(...)` so the restore-run list immediately confirms failed, partial, and follow-up states |
|
||||
| D.3 | `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` | Reuse the existing result-attention section as the canonical detail confirmation surface; add only lightweight context copy if a dashboard-linked reason is not already obvious |
|
||||
| D.4 | `apps/platform/app/Support/OperationRunLinks.php` and widget link builders only if needed | Keep canonical list and detail routing intact; prefer list fallback when a direct run link is brittle, unavailable, or semantically weaker than the restore-history list context |
|
||||
|
||||
### Phase E — Regression Protection And Focused Verification
|
||||
|
||||
**Goal**: Lock the honesty boundary into automated tests without widening the feature into a larger recovery-confidence program.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php` | Cover healthy backups with no relevant restore history, claim-boundary visibility on the KPI strip, and recovery-evidence summary behavior |
|
||||
| E.2 | `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php` | Cover no-history attention, failed or partial or follow-up escalation, healthy-check fallback, and non-overclaim behavior |
|
||||
| E.3 | `apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php` | Add continuity coverage for `recovery_posture_reason` list drilldowns, especially `no_history` fallback and weak-history fallback |
|
||||
| E.4 | `apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php` | Confirm dashboard drilldowns land on restore surfaces that show the same result-attention reason and claim boundary |
|
||||
| E.5 | `apps/platform/tests/Feature/Rbac/DashboardRecoveryPostureVisibilityTest.php` and `apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php` | Prove readonly members still see cautious summary truth, in-scope members lacking restore-history view receive 403 on drillthrough while the dashboard stays truthful, and non-members still receive 404 behavior |
|
||||
| E.6 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before implementation is considered complete |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D-001 — No Relevant Restore History Is Derived From Executed Runs Only
|
||||
|
||||
Preview-only, dry-run, draft, scoped, checked, and previewed restore records already carry a boundary that they do not prove execution. The overview should therefore treat them as insufficient evidence rather than as calming history.
|
||||
|
||||
### D-002 — Backup Health And Recovery Evidence Stay Orthogonal
|
||||
|
||||
`TenantBackupHealthAssessment` remains the source of backup-input truth. Recovery honesty is a second derived summary that references restore history without changing what backup health means.
|
||||
|
||||
### D-003 — Existing Claim Boundaries Stay Canonical
|
||||
|
||||
The backup positive-claim boundary already exists in `TenantBackupHealthAssessment`, and per-run restore claim boundaries already exist in `RestoreResultAttention`. The plan reuses those boundaries instead of inventing a second copy system.
|
||||
|
||||
### D-004 — Continuity Uses Existing List-Subheading Patterns
|
||||
|
||||
The repo already uses query-string continuity copy on list pages (`backup_health_reason`) to explain why a dashboard drillthrough landed on a list instead of a detail page. Restore history will use the same pattern rather than a new shell, modal, or route family.
|
||||
|
||||
### D-005 — Workspace Overview Stays Unchanged In This Slice
|
||||
|
||||
Current workspace overview logic surfaces governance, compare, findings, alerts, and operations, but not backup or recovery posture. Not changing it is the smallest way to avoid introducing contradictory portfolio-level semantics in this spec.
|
||||
|
||||
### D-006 — Readonly Users Can Still Validate The Claim Boundary
|
||||
|
||||
Existing tests show readonly tenant members can open restore-run history while mutations stay disabled. The plan uses that existing view-rights shape to preserve honesty for lower-capability operators without widening restore permissions.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Relevant restore history is misclassified because preview-only or dry-run records are counted as real evidence | High | Medium | Add explicit derivation tests for preview-only exclusion and executed-run selection |
|
||||
| Dashboard widgets drift semantically because backup health and restore evidence are derived in two different ways | High | Medium | Centralize the minimal history-selection logic in one existing seam or prove the local duplication is identical through tests |
|
||||
| Restore-run list drilldowns do not visibly confirm `completed_with_follow_up` or other weak-history reasons | High | Medium | Add a default-visible result-attention fact on list rows and a continuity subheading on fallback list views |
|
||||
| RBAC-restricted users see calmer summary language because a direct restore detail link is unavailable | High | Low | Keep summary claims independent from link availability and add readonly visibility regression coverage |
|
||||
| Dashboard render cost grows due to extra restore-history queries | Medium | Medium | Cap candidate selection to the latest 10 restore runs, eager-load only the required relations in that query, and keep workspace overview out of scope in this slice |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend the existing dashboard widget tests first instead of creating a new browser-driven harness.
|
||||
- Add unit coverage for the relevant restore-history selection and weak-history precedence if a shared restore-safety query seam is introduced.
|
||||
- Add feature coverage proving that healthy backup posture plus no relevant restore history never yields an all-clear on the dashboard.
|
||||
- Add feature coverage proving that failed, partial, and follow-up restore outcomes become overview-visible attention states.
|
||||
- Add continuity tests for dashboard-to-restore-history drilldowns, including list fallback and detail confirmation.
|
||||
- Add RBAC regression tests proving readonly members still see cautious truth while non-members remain 404 and restore mutations stay disabled.
|
||||
- Add query-shape regression coverage proving recovery-evidence derivation stays capped to the latest 10 candidate restore runs and does not introduce N+1 queries on the dashboard or restore-run list surfaces.
|
||||
- Keep all tests Livewire v4 compatible and run the smallest affected subset through Sail before asking for a broader suite run.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are currently justified. The plan deliberately avoids new persistence, new enums, new registries, and new page shells. Any shared derivation added during implementation must stay narrower than a new recovery-confidence subsystem and remain inside existing restore-safety or dashboard seams.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Operators can currently read healthy backup summaries as stronger recovery assurance than the product can actually prove when restore history is absent or weak.
|
||||
- **Existing structure is insufficient because**: The backup-health boundary and the per-run restore-result truth already exist, but the tenant dashboard does not currently connect them or preserve the same explanation on drilldown.
|
||||
- **Narrowest correct implementation**: Extend existing dashboard widgets, existing restore-run list/detail seams, and existing restore-safety truth rather than adding a new recovery-confidence model or page.
|
||||
- **Ownership cost created**: Additional widget copy, a narrow restore-history derivation path, one list continuity seam, and focused unit plus feature tests.
|
||||
- **Alternative intentionally rejected**: A full recovery-confidence engine, persisted posture state, new enum family, or standalone recovery page were rejected because they add broader truth and maintenance cost than this honesty slice needs.
|
||||
- **Release truth**: Current-release truth hardening.
|
||||
93
specs/184-dashboard-recovery-honesty/quickstart.md
Normal file
93
specs/184-dashboard-recovery-honesty/quickstart.md
Normal file
@ -0,0 +1,93 @@
|
||||
# Quickstart: Dashboard Recovery Posture Honesty
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start the application services if they are not already running:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
2. Use a tenant member account with at least readonly access for verification.
|
||||
|
||||
## Focused Automated Verification
|
||||
|
||||
Run the smallest relevant test set first:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreRunUiEnforcementTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/DashboardRecoveryPostureVisibilityTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php
|
||||
```
|
||||
|
||||
When implementation adds the new continuity and performance guard files, run them as well. The performance regression must exercise both the tenant dashboard and the restore-run list render paths:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreRunListContinuityTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardRecoveryPosturePerformanceTest.php
|
||||
```
|
||||
|
||||
Format after code changes:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual Validation Scenarios
|
||||
|
||||
### Scenario 1: Healthy backups, no restore history
|
||||
|
||||
1. Open the tenant dashboard.
|
||||
2. Verify `Backup posture` can still be healthy.
|
||||
3. Verify the dashboard also shows that recovery evidence is unvalidated.
|
||||
4. Verify the backup-health claim boundary is visible on the summary surface.
|
||||
5. Follow the recovery drillthrough and confirm the restore-run list explains that no relevant restore history exists.
|
||||
|
||||
### Scenario 2: Recent failed, partial, or follow-up restore
|
||||
|
||||
1. Open the tenant dashboard for a tenant with recent problematic restore history.
|
||||
2. Verify Needs Attention surfaces the recovery issue before any all-clear messaging.
|
||||
3. Follow the drillthrough.
|
||||
4. Confirm the destination surface shows the same reason: failed, partial, or follow-up required.
|
||||
|
||||
### Scenario 3: No recent restore issues visible
|
||||
|
||||
1. Open the tenant dashboard for a tenant with recent executed restore history and no current attention state.
|
||||
2. Verify the surface can say no recent restore issues are visible.
|
||||
3. Verify it still stops short of proving or guaranteeing recovery.
|
||||
|
||||
### Scenario 4: Readonly tenant member
|
||||
|
||||
1. Sign in as a readonly tenant member.
|
||||
2. Open the same tenant dashboard.
|
||||
3. Verify the summary remains cautious and truthful.
|
||||
4. Verify restore-run history remains inspectable for the readonly member.
|
||||
5. Verify restore mutations remain disabled.
|
||||
|
||||
### Scenario 5: In-scope member without restore-history view
|
||||
|
||||
1. Sign in as a tenant member who can open the dashboard but lacks restore-history viewing capability.
|
||||
2. Open the tenant dashboard.
|
||||
3. Verify the recovery summary stays unvalidated or weakened as appropriate and does not become calmer because drillthrough is unavailable.
|
||||
4. Follow the recovery action if one is offered.
|
||||
5. Confirm the restore-history surface denies access with 403 semantics while the dashboard summary remains truthful.
|
||||
|
||||
## Non-goals Check
|
||||
|
||||
Before considering the slice complete, verify that no surface introduces any of the following language:
|
||||
|
||||
- `recovery proven`
|
||||
- `recovery guaranteed`
|
||||
- `strong recovery confidence`
|
||||
- Any equivalent positive claim stronger than the current evidence supports
|
||||
|
||||
## Deployment Note
|
||||
|
||||
No new Filament assets are planned for this slice. Deployment keeps the existing asset step unchanged:
|
||||
|
||||
```bash
|
||||
cd apps/platform && php artisan filament:assets
|
||||
```
|
||||
91
specs/184-dashboard-recovery-honesty/research.md
Normal file
91
specs/184-dashboard-recovery-honesty/research.md
Normal file
@ -0,0 +1,91 @@
|
||||
# Research: Dashboard Recovery Posture Honesty
|
||||
|
||||
## Decision 1: Keep the slice on tenant dashboard and restore-history confirmation surfaces
|
||||
|
||||
**Decision**: Implement Spec 184 on the tenant dashboard (`DashboardKpis`, `NeedsAttention`) and the canonical restore-run list or detail drilldowns. Do not expand `WorkspaceOverviewBuilder` in this slice.
|
||||
|
||||
**Rationale**: Current workspace overview logic does not restate backup or recovery posture; it surfaces governance, compare, findings, alerts, and operations, then links into the tenant dashboard when appropriate. Not changing it keeps the blast radius small and avoids introducing a second partially overlapping recovery summary.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Extend workspace overview now with recovery posture. Rejected because it broadens the slice, increases query and wording risk, and is not needed to stop the tenant-dashboard overclaim.
|
||||
- Create a new recovery overview page. Rejected because the spec explicitly avoids a new recovery-confidence surface.
|
||||
|
||||
## Decision 2: Reuse the existing backup positive-claim boundary
|
||||
|
||||
**Decision**: Reuse `TenantBackupHealthAssessment::positiveClaimBoundary` on summary-level dashboard surfaces.
|
||||
|
||||
**Rationale**: The product already has canonical copy that says backup health reflects backup inputs only and does not prove restore success. Reusing it avoids page-local rewrites and keeps the claim boundary consistent across backup-detail and dashboard contexts.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Introduce new dashboard-only claim-boundary copy. Rejected because it creates semantic drift for the same truth.
|
||||
- Leave the boundary only on detail pages. Rejected because the spec specifically hardens summary surfaces.
|
||||
|
||||
## Decision 3: Define relevant restore history as executed, non-preview restore runs only
|
||||
|
||||
**Decision**: Treat only non-dry-run, non-preview, executed restore runs as relevant restore history for overview recovery language.
|
||||
|
||||
**Rationale**: `RestoreSafetyResolver::resultAttentionForRun(...)` already treats dry-runs and preview states as `not_executed` and explicitly says they do not prove execution. Counting those records as recovery evidence would overstate confidence.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Count every `RestoreRun` record, including preview-only runs. Rejected because preview truth is not execution truth.
|
||||
- Count only fully completed runs. Rejected because failed, partial, and follow-up runs are exactly the weak-history evidence the overview must surface.
|
||||
|
||||
## Decision 4: Use `RestoreResultAttention` as the sole authority for weak-history states
|
||||
|
||||
**Decision**: Reuse `RestoreSafetyResolver::resultAttentionForRun(...)` for failed, partial, completed-with-follow-up, and completed semantics instead of remapping raw statuses in widgets.
|
||||
|
||||
**Rationale**: The resolver already inspects status, operation outcome, item results, assignment failures, skips, and metadata such as `non_applied`. It is the narrowest existing source of truth for result quality and already carries recovery-claim boundaries.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Build dashboard-specific mapping from raw `RestoreRun.status`. Rejected because it would ignore existing deeper result logic and duplicate truth.
|
||||
- Introduce a new tenant-level recovery enum. Rejected because the spec is explicitly not a recovery-confidence engine.
|
||||
|
||||
## Decision 5: Use list-subheading continuity for no-history and fallback drilldowns
|
||||
|
||||
**Decision**: Reuse the `backup_health_reason` continuity pattern on `ListRestoreRuns` with a restore-history-specific reason query parameter for list fallbacks.
|
||||
|
||||
**Rationale**: The repo already uses list subheadings on backup-set and backup-schedule pages to explain why a dashboard drillthrough landed on a list. The same pattern fits `no history` and weak-history list fallbacks without new UI shells or modal layers.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Always deep-link to a restore-run detail. Rejected because no-history cases have no record, and weak-history detail links can become brittle when a record is deleted or inaccessible.
|
||||
- Use only existing table filters with no continuity copy. Rejected because current restore-run filters cannot explain `no history` and do not guarantee self-explanatory arrival states.
|
||||
|
||||
## Decision 6: Prefer canonical restore-run list fallback over stronger but fragile links
|
||||
|
||||
**Decision**: Link to restore-run detail only when a recent problematic run exists and the detail is the clearest confirmation. Otherwise fall back to the tenant restore-run list with continuity context.
|
||||
|
||||
**Rationale**: The list is the stable canonical collection route and is already accessible to readonly members. It is also the only truthful destination for `no history`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Always link to the most recent executed run. Rejected because that can create dead ends or misleading confirmations when the run is gone or no longer the right representative.
|
||||
- Link to admin operations pages instead of restore runs. Rejected because Spec 184 is about restore history and result attention, not generic operation monitoring.
|
||||
|
||||
## Decision 7: Keep summary language cautious under RBAC restrictions
|
||||
|
||||
**Decision**: Summary surfaces must stay cautious even if the current user cannot open the most specific restore evidence. Action links may disable or fall back, but the claim must never grow stronger.
|
||||
|
||||
**Rationale**: Existing tests show readonly tenant members can open restore-run history while mutations remain disabled. Even if a more specific deep link is unavailable, the summary must still express `unvalidated` or `weakened`, not `healthy`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hide recovery-honesty signals when drilldown is limited. Rejected because that would falsify the surface by omission.
|
||||
- Treat inaccessible detail as proof that no issues exist. Rejected because it directly violates the spec’s honesty boundary.
|
||||
|
||||
## Decision 8: Extend existing widget and restore-run tests instead of introducing a new harness
|
||||
|
||||
**Decision**: Build coverage on top of `DashboardKpisWidgetTest`, `NeedsAttentionWidgetTest`, `RestoreResultAttentionSurfaceTest`, `RestoreRunUiEnforcementTest`, and focused unit tests under `Support/RestoreSafety`.
|
||||
|
||||
**Rationale**: The repo already has targeted Livewire and Pest coverage for the exact seams being changed. Extending them is cheaper and keeps the tests aligned with business truth instead of test-only indirection.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create a new browser-only or end-to-end suite. Rejected because the feature is a UI-truth-hardening slice on existing server-rendered surfaces.
|
||||
- Skip unit coverage and rely only on widget tests. Rejected because executed-history selection and attention precedence are easier and cheaper to pin down at the narrow derivation seam.
|
||||
|
||||
## Decision 9: No new assets, panel registration, or global-search behavior
|
||||
|
||||
**Decision**: Keep the implementation entirely within existing Filament widgets, resource pages, and Blade views. Do not add assets, providers, or search changes.
|
||||
|
||||
**Rationale**: This feature changes honesty and drillthrough continuity, not panel infrastructure. The current deployment step for `filament:assets` remains unchanged because there are no new assets.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add custom JavaScript or CSS for richer attention states. Rejected because existing Filament primitives are sufficient.
|
||||
- Change panel or navigation registration. Rejected because it is unrelated to the feature goal.
|
||||
177
specs/184-dashboard-recovery-honesty/spec.md
Normal file
177
specs/184-dashboard-recovery-honesty/spec.md
Normal file
@ -0,0 +1,177 @@
|
||||
# Feature Specification: Dashboard Recovery Posture Honesty
|
||||
|
||||
**Feature Branch**: `[184-dashboard-recovery-honesty]`
|
||||
**Created**: 2026-04-08
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 184 — Dashboard Recovery Posture Honesty"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**: `/admin/t/{tenant}`, `/admin/t/{tenant}/restore-runs`, `/admin/t/{tenant}/restore-runs/{record}`, `/admin/t/{tenant}/backup-sets`, `/admin/t/{tenant}/backup-sets/{record}`
|
||||
- **Data Ownership**: Tenant-owned `BackupSet`, `RestoreRun`, and linked `OperationRun` outcome context are read within the active workspace and tenant scope to derive a more honest overview statement. No new persisted recovery-confidence state is introduced.
|
||||
- **RBAC**: Workspace plus tenant membership remains required on every affected surface. Members who can open the tenant dashboard must see honest summary boundaries even when they cannot start or manage restore runs. Existing restore-run creation and mutation actions remain under current restore permissions. Non-members continue to receive deny-as-not-found semantics.
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard KPI strip | Dashboard / stats overview | Explicit stat click per signal | forbidden | Supporting text inside the stat description | none | `/admin/t/{tenant}` | Signal-specific drill-through to `/admin/t/{tenant}/restore-runs` or `/admin/t/{tenant}/restore-runs/{record}` | Workspace context plus tenant context | Dashboard KPIs / Backup posture | Backup health is separate from restore evidence | existing widget pattern |
|
||||
| Needs Attention / Healthy Checks panel | Dashboard / attention summary | Explicit card CTA per attention item; healthy state is read-only | forbidden | Card CTA and helper copy only | none | `/admin/t/{tenant}` | `/admin/t/{tenant}/restore-runs`, `/admin/t/{tenant}/restore-runs/{record}` | Workspace context plus tenant context | Needs attention / Healthy checks | Unvalidated and weakened recovery confidence are visible before drilldown | existing widget pattern |
|
||||
| Restore runs page | CRUD / list-first resource | Full-row click to restore-run detail | required | Existing header action plus More menu | Existing More and bulk More groups | `/admin/t/{tenant}/restore-runs` | `/admin/t/{tenant}/restore-runs/{record}` | Tenant context plus restore-run identity | Restore runs / Restore run | Recent restore outcome and follow-up reason confirm the overview claim | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard KPI strip | Tenant operator | Dashboard summary | Do healthy backups also have supporting restore evidence, or is recovery posture still unvalidated? | Backup posture, recovery-confidence qualifier, visible claim boundary, next step | Per-run causes, raw backup metadata, deeper restore evidence | backup health, recovery evidence availability, recent restore attention | None; read-only summary | Open restore history, open supporting backup context when backup health itself needs follow-up | none |
|
||||
| Needs Attention / Healthy Checks panel | Tenant operator | Dashboard attention and healthy-boundary surface | What recovery-confidence issue needs action now, and why? | No restore history, weakened recent restore history, boundary copy, concrete next action | Full restore results, preview or check details, low-level run metadata | backup health, recovery evidence availability, restore result attention, recency | None; read-only summary | Open restore history, open latest problematic restore run | none |
|
||||
| Restore runs page | Tenant operator | List and detail | Which restore runs explain the dashboard signal? | Recent restore status, result-attention reason, completed timing, related backup context | Assignment-level failures, preview detail, low-level result payloads | execution lifecycle, result attention, follow-up state | Existing restore-run maintenance actions only | Inspect restore run, create restore run | Existing rerun, archive, restore archived, and force-delete actions |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: A tenant dashboard can currently look calm or healthy even when restore history is absent or recent restore results weaken confidence, so operators can overread backup health as recovery posture.
|
||||
- **Existing structure is insufficient because**: Backup health, restore history, and restore result attention already exist as separate truths, but the summary surfaces do not yet combine them with an honest claim boundary. Operators must manually cross-check multiple pages to avoid an overclaim.
|
||||
- **Narrowest correct implementation**: Derive a small set of overview honesty signals from existing backup health assessment, restore history presence, and per-run restore result attention, then show them on the existing dashboard widgets and existing restore-run drilldowns.
|
||||
- **Ownership cost**: Additional widget copy, narrow derived-summary logic, and focused feature plus RBAC regression tests that keep overview language and drilldown continuity aligned.
|
||||
- **Alternative intentionally rejected**: A new recovery-confidence score, enum, page, or persisted posture state was rejected because it would introduce new truth and new ownership cost before the current overview surfaces tell the existing truth accurately.
|
||||
- **Release truth**: current-release truth hardening
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - See Unvalidated Recovery Confidence Early (Priority: P1)
|
||||
|
||||
A tenant operator opens the tenant dashboard and needs to know within seconds whether healthy-looking backups are backed by any relevant restore evidence or whether recovery confidence is still unvalidated.
|
||||
|
||||
**Why this priority**: This is the highest-risk trust gap. If the first overview screen quietly converts healthy backups into a healthy recovery impression, later detail truth arrives too late.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering the tenant dashboard with healthy backup fixtures and no relevant restore history, then verifying that the overview shows an explicit unvalidated recovery-confidence signal instead of an all-clear.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has healthy backup posture and no relevant restore history, **When** the operator opens the tenant dashboard, **Then** the summary shows healthy backups plus an explicit unvalidated recovery-confidence message and a next action.
|
||||
2. **Given** the same tenant has no other attention items, **When** the healthy-check state renders, **Then** the widget does not show an unqualified all-good message and instead keeps the recovery-confidence boundary visible.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Escalate Weak Restore History on Overview (Priority: P2)
|
||||
|
||||
A tenant operator reviewing the dashboard needs recent failed, partial, or follow-up restore results to affect the overview immediately instead of hiding inside restore history details.
|
||||
|
||||
**Why this priority**: Weak restore history is evidence that directly changes how much trust the operator should place in recovery posture. It cannot remain a drilldown-only fact.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering overview surfaces with recent failed, partial, and follow-up restore fixtures and verifying that each case creates a visible confidence-related attention signal with matching drilldown behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has healthy backups but a recent failed or partial restore run, **When** the operator opens the dashboard, **Then** Needs Attention shows a recovery-confidence issue that links to restore history explaining the same failure state.
|
||||
2. **Given** a tenant has a recent restore run that completed with follow-up required, **When** the operator opens the dashboard, **Then** the overview shows weakened confidence rather than a neutral or healthy-only message.
|
||||
3. **Given** recent restore history exists without a current confidence-weakening attention state, **When** the operator opens the dashboard, **Then** the overview may say that no recent restore issues are visible but does not claim that recovery is proven.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Preserve Honest Drilldowns and RBAC Boundaries (Priority: P3)
|
||||
|
||||
A tenant operator or read-only member needs the dashboard signal and the destination surface to tell the same story, while RBAC limits must never make the summary look stronger than the accessible evidence.
|
||||
|
||||
**Why this priority**: Overview honesty fails if the next click contradicts the dashboard or if authorization gaps hide weakness by omission.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening overview signals as different tenant members, verifying that the linked restore-history surface confirms the same reason, and ensuring restricted users still see cautious summary language.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the dashboard says recovery confidence is unvalidated because no relevant restore history exists, **When** the operator follows the dashboard action, **Then** the destination surface confirms that the tenant lacks relevant restore history.
|
||||
2. **Given** the dashboard says recovery confidence is weakened by a recent problematic restore, **When** the operator follows the dashboard action, **Then** the destination surface confirms the same failed, partial, or follow-up reason.
|
||||
3. **Given** a tenant member can see the dashboard but cannot open deeper restore evidence, **When** the dashboard renders, **Then** the summary remains cautious and truthful and does not replace missing evidence with a stronger claim.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A tenant has only draft, preview-only, or dry-run restore history; the overview treats recovery confidence as unvalidated rather than positive.
|
||||
- A tenant has both an older successful restore and a more recent failed or follow-up restore; the weakened signal takes precedence on the summary surface.
|
||||
- A summary signal points to a restore run that is no longer directly openable; the drilldown falls back to tenant-scoped restore history rather than a dead end.
|
||||
- A user can see the dashboard but lacks permission to inspect restore runs; the summary still states unvalidated or weakened confidence without suggesting that everything is healthy.
|
||||
- Healthy backup posture and backup-automation follow-up can coexist with unvalidated recovery confidence; the overview must not let one healthy-sounding statement erase the other caution.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
This feature introduces no new Microsoft Graph calls, no new background work, no new `OperationRun`, and no new persistence. It is a read-first truth-hardening slice that makes existing backup and restore evidence visible more honestly on tenant overview surfaces.
|
||||
|
||||
Authorization remains in the tenant/admin plane under `/admin/t/{tenant}/...`. Non-members must continue to receive 404 responses. Established members missing deeper restore capabilities must continue to receive 403 on execution paths, but summary visibility must not depend on restore-mutation rights.
|
||||
|
||||
This slice reuses existing Filament dashboard widgets, stat descriptions, attention cards, and existing restore-run resource surfaces. No new local badge framework, page-local status language, or extra action surface is introduced. UI-FIL-001 is satisfied by continuing to use existing Filament widget primitives and shared status language. UX-001 create, edit, and detail-form rules are not materially changed; the dashboard keeps its existing layout, and the restore-run resource keeps its existing list-and-view contract.
|
||||
|
||||
The affected Filament surfaces keep exactly one primary inspect or open model, add no redundant View actions, and introduce no new destructive actions. Existing destructive restore-run actions continue to follow the current placement and confirmation rules. Action Surface Contract expectations therefore remain satisfied.
|
||||
|
||||
Existing per-run restore result attention remains the authoritative signal for restore outcome quality. This feature may summarize or elevate that truth, but it must not duplicate it with a second scoring or status system.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-184-001**: The system MUST present tenant backup health and tenant recovery-confidence evidence as separate truths on tenant dashboard summary surfaces.
|
||||
- **FR-184-002**: When backup health is healthy but no relevant restore history exists, the system MUST display an explicit unvalidated recovery-confidence state and MUST NOT present an all-clear summary.
|
||||
- **FR-184-003**: When the system cannot determine recovery confidence from the available restore history, the system MUST map that limitation to the canonical `unvalidated` overview state and say that limitation directly instead of inferring a positive recovery claim from backup health alone.
|
||||
- **FR-184-004**: Needs Attention or the healthy-boundary surface MUST surface absence of restore history as an overview-relevant condition with a clear next action.
|
||||
- **FR-184-005**: Recent restore history with `failed`, `partial`, `completed_with_follow_up`, or an equivalent confidence-weakening attention state MUST appear on overview surfaces as a recovery-confidence issue.
|
||||
- **FR-184-006**: Overview surfaces MUST distinguish unvalidated confidence from weakened confidence and MUST NOT collapse both states into one ambiguous bucket.
|
||||
- **FR-184-007**: Any positive backup-health summary on the dashboard MUST show a visible claim boundary that healthy backups reflect backup inputs only and do not prove restore success.
|
||||
- **FR-184-008**: Healthy checks MUST NOT render an unqualified healthy or all-clear state when recovery confidence is `unvalidated` or `weakened`, and any `no_recent_issues_visible` healthy check MUST preserve the non-proof boundary.
|
||||
- **FR-184-009**: When recovery confidence is unvalidated or weakened, overview copy MUST explain what is missing or concerning, why that affects confidence, and what the operator should do next.
|
||||
- **FR-184-010**: Overview signals about missing restore history MUST drill into a tenant-scoped restore-history surface that confirms the absence or insufficiency of relevant restore evidence.
|
||||
- **FR-184-011**: Overview signals about weakened restore history MUST drill into a tenant-scoped restore-history surface or restore-run detail that confirms the same failed, partial, or follow-up reason shown on the summary surface.
|
||||
- **FR-184-012**: The feature MUST reuse existing per-run restore result attention as the authoritative quality signal for restore outcomes and MUST NOT introduce a parallel positive-scoring or reason system.
|
||||
- **FR-184-013**: The feature MUST NOT introduce a new state or message that claims recovery is proven, guaranteed, or strongly confirmed beyond the evidence the current system already has.
|
||||
- **FR-184-014**: RBAC limits on restore history visibility MUST NOT cause summary surfaces to make stronger recovery claims than the visible evidence supports; when detailed restore evidence cannot be opened, the summary must remain cautious and truthful.
|
||||
- **FR-184-015**: This slice MUST NOT introduce or alter tenant-linked recovery posture summaries outside the tenant dashboard. Any future reuse of this posture signal on another surface MUST preserve the same distinction between backup posture and the canonical recovery-evidence states `unvalidated`, `weakened`, and `no_recent_issues_visible`.
|
||||
- **FR-184-016**: The feature MUST derive its summary state from existing tenant backup health, restore history, and restore result attention records and MUST NOT add a new persisted recovery-confidence field, table, or scoring artifact.
|
||||
- **FR-184-017**: When recent restore history exists without a current confidence-weakening attention state, overview surfaces MAY state that no recent restore issues are visible, but MUST stop short of claiming recovery proof.
|
||||
- **FR-184-018**: The feature MUST cap recovery-evidence derivation to the 10 most recent tenant-scoped restore-run candidates, eager-load only the relations required for summary and drillthrough selection, and MUST NOT introduce N+1 row lookups on dashboard or restore-run list surfaces.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Relevant restore history means tenant-scoped restore runs that have reached an executed result state or another existing result-attention state that the current system can classify. Draft-only, preview-only, or dry-run-only history does not count as proven recovery evidence.
|
||||
- Existing restore history surfaces already show enough result detail to confirm failed, partial, and follow-up reasons once the operator drills down from the overview.
|
||||
- Workspace-level surfaces that later reuse this posture language should consume the same tenant-level semantics rather than creating a separate recovery-confidence vocabulary.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing tenant dashboard surfaces remain the operator entry point for this slice.
|
||||
- Existing `TenantBackupHealthAssessment` and `TenantBackupHealthResolver` remain the source of backup-input truth.
|
||||
- Existing `RestoreRun` history surfaces and `RestoreSafetyResolver::resultAttentionForRun(...)` remain the source of restore-outcome truth.
|
||||
- Existing RBAC helper-text and disabled-link patterns remain the fallback behavior when the operator cannot open deeper restore evidence.
|
||||
|
||||
## Out of Scope and Follow-up
|
||||
|
||||
- No new recovery-confidence engine, score, enum, or dedicated posture page.
|
||||
- No automatic restore validation, scheduled restore probes, or restore execution changes.
|
||||
- No new backup-health rules, restore-result-attention taxonomy changes, or restore-safety model redesign.
|
||||
- No new claim that a tenant is recovery-proven.
|
||||
- Reasonable follow-up work includes broader workspace-level recovery rollups after tenant-level overview honesty is stable.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard summary widgets | `app/Filament/Pages/TenantDashboard.php`, `app/Filament/Widgets/Dashboard/DashboardKpis.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php` | none added | Explicit stat and card CTA only; no row click | none | n/a | none | n/a | n/a | no new audit event | Action Surface Contract stays satisfied because the dashboard remains read-only. UI-FIL-001 stays satisfied through existing Filament widget primitives. UX-001 create and edit form rules are not applicable to this dashboard slice. |
|
||||
| RestoreRunResource list and detail | `app/Filament/Resources/RestoreRunResource.php`, `app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php`, `app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` | Existing `New restore run` action remains | `recordUrl()` clickable row to restore-run detail | Existing More-menu maintenance actions remain unchanged | Existing grouped bulk actions remain unchanged | Existing `New restore run` empty-state CTA remains | none added | Existing restore-run create flow remains unchanged | existing restore-run mutation audit behavior only | This spec reuses restore-run list and detail as canonical drilldowns and adds no new destructive action or placement exception. |
|
||||
|
||||
## Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Backup health assessment**: Tenant-level summary of backup freshness and input health that is useful but not sufficient to prove recovery success.
|
||||
- **Restore history**: Tenant-scoped record of restore runs whose presence, absence, and recent outcomes affect how strongly the product can speak about recovery confidence.
|
||||
- **Restore result attention**: Per-run classification that distinguishes completed, failed, partial, follow-up, and not-executed outcome states that matter for operator trust.
|
||||
- **Recovery posture summary**: Non-persisted dashboard statement that combines backup health, restore history presence, and restore-result attention without becoming a new score or stored state.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In acceptance testing, operators can identify within 10 seconds whether a tenant has healthy backups plus unvalidated or weakened recovery evidence from `/admin/t/{tenant}` without opening raw details.
|
||||
- **SC-002**: In 100% of tested tenants with no relevant restore history, the dashboard or healthy-boundary surface shows an explicit unvalidated recovery-confidence signal and never shows a healthy-only all-clear.
|
||||
- **SC-003**: In 100% of tested tenants with recent failed, partial, or follow-up restore runs, the overview shows a confidence-related attention item with a drilldown that confirms the same reason.
|
||||
- **SC-004**: In 100% of tested positive backup-health scenarios, summary-level copy includes the claim boundary that healthy backups do not prove restore success.
|
||||
- **SC-005**: In 100% of tested RBAC-restricted scenarios, summary surfaces remain cautious and truthful even when the user cannot open deeper restore evidence pages.
|
||||
- **SC-006**: In targeted regression coverage, recovery-evidence derivation evaluates no more than the 10 most recent tenant-scoped restore-run candidates and introduces no N+1 row queries on the dashboard or restore-run list surfaces.
|
||||
204
specs/184-dashboard-recovery-honesty/tasks.md
Normal file
204
specs/184-dashboard-recovery-honesty/tasks.md
Normal file
@ -0,0 +1,204 @@
|
||||
# Tasks: Dashboard Recovery Posture Honesty
|
||||
|
||||
**Input**: Design documents from `/specs/184-dashboard-recovery-honesty/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/dashboard-recovery-posture.openapi.yaml`, `quickstart.md`
|
||||
|
||||
**Tests**: Required. This feature changes runtime behavior on existing Filament and Livewire surfaces, so Pest coverage must be added or extended before implementation is considered complete.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each slice stays independently testable, with the recommended delivery order `US1 -> US2 -> US3` because all three stories touch the same dashboard and restore-history seams.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare reusable test data states shared by all dashboard recovery-posture scenarios.
|
||||
|
||||
- [X] T001 [P] Add preview-only, failed, partial, completed-with-follow-up, and completed recovery-posture fixture states in `apps/platform/database/factories/RestoreRunFactory.php`
|
||||
- [X] T002 [P] Add recent and stale completed backup fixture states in `apps/platform/database/factories/BackupSetFactory.php`
|
||||
|
||||
**Checkpoint**: Shared test fixtures are ready for all stories.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Build the single restore-history derivation seam that all dashboard recovery-posture surfaces depend on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should start before this phase is complete.
|
||||
|
||||
- [X] T003 Add unit regressions for preview exclusion, latest-run precedence, weak-history precedence, and completed-history fallback in `apps/platform/tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php`
|
||||
- [X] T004 Implement tenant-scoped relevant restore-history selection and dashboard overview-state derivation in `apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php`
|
||||
|
||||
**Checkpoint**: Relevant restore history is derived from one authoritative seam and covered by unit tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - See Unvalidated Recovery Confidence Early (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make the tenant dashboard show that healthy backup inputs do not equal validated recovery posture when relevant restore history is absent.
|
||||
|
||||
**Independent Test**: Render the tenant dashboard with healthy backup fixtures and no executed, non-preview restore history, then verify the KPI strip and Needs Attention surfaces show an explicit unvalidated recovery signal instead of an all-clear.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T005 [P] [US1] Add healthy-backups-with-no-history KPI regression coverage in `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`
|
||||
- [X] T006 [P] [US1] Add no-history healthy-boundary regression coverage in `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T007 [P] [US1] Update `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` to keep `Backup posture` limited to backup-input truth, append the positive claim boundary, and add an `unvalidated` recovery-evidence KPI with a restore-history drillthrough
|
||||
- [X] T008 [P] [US1] Update `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` to surface missing relevant restore history and suppress an unqualified healthy fallback when recovery evidence is absent
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional and shows honest no-history recovery posture on the tenant dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Escalate Weak Restore History on Overview (Priority: P2)
|
||||
|
||||
**Goal**: Make recent failed, partial, or follow-up restore outcomes affect overview trust immediately instead of hiding only in restore-history details.
|
||||
|
||||
**Independent Test**: Render the tenant dashboard with recent failed, partial, and completed-with-follow-up restore fixtures and verify the overview surfaces show weakened confidence, while a calm `no recent issues visible` state appears only for recent completed restore history.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T009 [P] [US2] Add weak-history KPI regression coverage for failed, partial, follow-up, and calm completed restore outcomes in `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`
|
||||
- [X] T010 [P] [US2] Add weak-history attention and `no recent issues visible` coverage in `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`
|
||||
- [X] T011 [P] [US2] Add dashboard-linked restore-result confirmation coverage for problematic restore runs in `apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T012 [P] [US2] Update `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` to derive `weakened` and `no_recent_issues_visible` recovery-evidence states from `RestoreSafetyResolver` output
|
||||
- [X] T013 [P] [US2] Update `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` to surface failed, partial, and `completed_with_follow_up` restore attention using canonical summaries and next actions
|
||||
- [X] T014 [P] [US2] Update `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` to add a `no_recent_issues_visible` healthy-check that preserves the non-proof boundary for calm recent restore history
|
||||
- [X] T015 [P] [US2] Update `apps/platform/app/Filament/Resources/RestoreRunResource.php` to expose a default-visible result-attention summary fact on restore-run list rows
|
||||
- [X] T016 [P] [US2] Update `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` to keep restore-result attention copy aligned with dashboard-linked failure, partial, and follow-up reasons
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional and weak restore evidence is visible on overview and restore-run confirmation surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Preserve Honest Drilldowns and RBAC Boundaries (Priority: P3)
|
||||
|
||||
**Goal**: Keep dashboard recovery signals, restore-history drilldowns, and RBAC outcomes aligned so missing access never makes the overview read more confidently than the evidence allows.
|
||||
|
||||
**Independent Test**: Open dashboard recovery signals as an owner, a readonly tenant member, and a non-member; verify list and detail drilldowns preserve the same reason, readonly members keep inspect access with disabled mutations, and non-members still receive deny-as-not-found behavior.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T017 [P] [US3] Create no-history and list-fallback continuity coverage in `apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php`
|
||||
- [X] T018 [P] [US3] Create readonly, capability-denied, and non-member recovery-posture visibility coverage in `apps/platform/tests/Feature/Rbac/DashboardRecoveryPostureVisibilityTest.php`
|
||||
- [X] T019 [P] [US3] Extend restore-run enforcement coverage for readonly inspect access, capability-denied drillthrough 403 responses, disabled mutations, and deny-as-not-found semantics in `apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T020 [P] [US3] Update `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php` to accept `recovery_posture_reason` and render continuity subheadings for dashboard fallbacks
|
||||
- [X] T021 [P] [US3] Update `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php` to fall back from missing or brittle direct restore-run links to canonical restore-run list URLs with continuity context
|
||||
- [X] T022 [P] [US3] Update `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` to mirror the same fallback and disabled-link behavior for recovery-confidence actions
|
||||
- [X] T023 [P] [US3] Update `apps/platform/app/Filament/Resources/RestoreRunResource.php` and `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` to preserve truthful restore-history drillthroughs and 403 capability denials for in-scope members without changing existing mutation permissions
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional and dashboard-to-restore-history continuity stays honest across RBAC outcomes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final quality checks that span multiple stories.
|
||||
|
||||
- [X] T024 Verify `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` remains unchanged by this slice so no tenant-linked recovery posture summary is introduced outside the tenant dashboard
|
||||
- [X] T025 Review operator-facing recovery copy in `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php`, `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php`, `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php`, and `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` to remove any `recovery proven`, `recovery guaranteed`, or equivalent overclaim language
|
||||
- [X] T026 [P] Add query-shape regression coverage for the 10-candidate cap and no N+1 rendering across the tenant dashboard and restore-run list surfaces in `apps/platform/tests/Feature/Filament/DashboardRecoveryPosturePerformanceTest.php`
|
||||
- [X] T027 [P] Run formatting and the focused verification workflow from `specs/184-dashboard-recovery-honesty/quickstart.md` against `apps/platform/tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php`, `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`, `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php`, `apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php`, `apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php`, `apps/platform/tests/Feature/Filament/DashboardRecoveryPosturePerformanceTest.php`, and `apps/platform/tests/Feature/Rbac/DashboardRecoveryPostureVisibilityTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion; recommended MVP cut.
|
||||
- **User Story 2 (Phase 4)**: Depends on User Story 1 because it extends the same dashboard recovery-evidence seams in `DashboardKpis.php` and `NeedsAttention.php`.
|
||||
- **User Story 3 (Phase 5)**: Depends on User Story 2 because it aligns continuity and RBAC behavior for both no-history and weak-history overview signals.
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependencies beyond Foundational.
|
||||
- **US2**: Builds on the recovery-evidence KPI and attention seams introduced in US1.
|
||||
- **US3**: Builds on the drillthrough targets and recovery-evidence states introduced in US1 and US2.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the story tests first and confirm they fail before implementation.
|
||||
- Update dashboard derivation or resource behavior before adjusting cross-surface copy.
|
||||
- Keep each story shippable on its own before moving to the next priority.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T001` and `T002` can run in parallel.
|
||||
- Within US1, `T005` and `T006` can run in parallel, then `T007` and `T008` can run in parallel.
|
||||
- Within US2, `T009`, `T010`, and `T011` can run in parallel, then `T012`, `T013`, `T014`, `T015`, and `T016` can be split across contributors.
|
||||
- Within US3, `T017`, `T018`, and `T019` can run in parallel, then `T020`, `T021`, `T022`, and `T023` can be split across contributors.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US1
|
||||
T005 Add healthy-backups-with-no-history KPI regression coverage in apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||
T006 Add no-history healthy-boundary regression coverage in apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||
|
||||
# Parallel implementation pass for US1
|
||||
T007 Update apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||
T008 Update apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US2
|
||||
T009 Add weak-history KPI regression coverage in apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||
T010 Add weak-history attention coverage in apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||
T011 Add restore-result confirmation coverage in apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php
|
||||
|
||||
# Parallel implementation pass for US2
|
||||
T012 Update apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||
T013 Update apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
T014 Update apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
T015 Update apps/platform/app/Filament/Resources/RestoreRunResource.php
|
||||
T016 Update apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US3
|
||||
T017 Create apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php
|
||||
T018 Create apps/platform/tests/Feature/Rbac/DashboardRecoveryPostureVisibilityTest.php
|
||||
T019 Extend apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php
|
||||
|
||||
# Parallel implementation pass for US3
|
||||
T020 Update apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php
|
||||
T021 Update apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||
T022 Update apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
T023 Update apps/platform/app/Filament/Resources/RestoreRunResource.php and apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational derivation.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate the no-history dashboard truth using the focused tests in `quickstart.md`.
|
||||
5. Stop and review the wording before widening the slice.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Ship US1 to stop the highest-risk overclaim on the tenant dashboard.
|
||||
2. Add US2 to elevate weak restore history into the same overview surfaces.
|
||||
3. Add US3 to keep list or detail drilldowns and RBAC outcomes aligned with the dashboard summary.
|
||||
4. Finish with the focused formatting and verification pass from Phase 6.
|
||||
Loading…
Reference in New Issue
Block a user