From fa8bac5dbe09e05f38dec0298d3ae49e27b33bb9 Mon Sep 17 00:00:00 2001 From: Copxer Date: Thu, 30 Apr 2026 18:57:43 -0700 Subject: [PATCH 1/3] =?UTF-8?q?chore(specs):=20draft=20spec=20023=20?= =?UTF-8?q?=E2=80=94=20website=20monitor=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 starts here. Folder + README + spec markdown for the data layer (websites + website_checks tables, WebsiteProbeAction + RecordWebsiteCheckAction) plus CRUD UX and a sync manual "Probe now". Spec 024 will add the scheduler + uptime calc + activity events; spec 025 wires the Overview KPI + Reverb live updates. --- .../023-website-monitor-mvp.md | 177 ++++++++++++++++++ specs/phase-5-monitoring/README.md | 24 +++ 2 files changed, 201 insertions(+) create mode 100644 specs/phase-5-monitoring/023-website-monitor-mvp.md create mode 100644 specs/phase-5-monitoring/README.md diff --git a/specs/phase-5-monitoring/023-website-monitor-mvp.md b/specs/phase-5-monitoring/023-website-monitor-mvp.md new file mode 100644 index 0000000..a0522b2 --- /dev/null +++ b/specs/phase-5-monitoring/023-website-monitor-mvp.md @@ -0,0 +1,177 @@ +--- +spec: website-monitor-mvp +phase: 5-monitoring +status: in-progress +owner: yoany +created: 2026-04-30 +updated: 2026-04-30 +issue: https://github.com/Copxer/nexus/issues/69 +branch: spec/023-website-monitor-mvp +--- + +# 023 — Website monitor MVP (CRUD + manual probe + check history) + +## Goal +Stand up the data layer for website uptime monitoring plus a working CRUD UX with a manual "Probe now" action that records a real HTTP check. After this spec a user can add a website URL to a project, see the recent check history, and probe on demand. The scheduler arrives in spec 024; the Overview KPI integration + Reverb live updates ride spec 025. + +Roadmap reference: §8.8 Website Performance Monitoring (model fields, MVP probe shape), §19 Phase 5. + +## Scope +**In scope:** + +- **`websites` table** mirroring §8.8's MVP slice: + - `id`, `project_id` (FK → `projects`, cascadeOnDelete), `name` (string), `url` (string), `method` (string, default `GET`), `expected_status_code` (smallint, default `200`), `timeout_ms` (unsigned int, default `10000`), `check_interval_seconds` (unsigned int, default `300`), `status` (string-backed enum: `pending|up|down|slow|error`, default `pending`, indexed), `last_checked_at` (datetime nullable), `last_success_at` (datetime nullable), `last_failure_at` (datetime nullable), standard timestamps. + - **Index** on `(project_id, status)` so the per-project listing + status filter on the index page run cleanly. + - **No multi-tenant `team_id`** in phase-1 — same pattern as repositories. + +- **`website_checks` table** matching §8.8's check fields, MVP slice (no DNS/TLS/TTFB timings yet — those land later): + - `id`, `website_id` (FK → `websites`, cascadeOnDelete), `status` (string-backed enum: `up|down|slow|error`, indexed), `http_status_code` (smallint nullable), `response_time_ms` (unsigned int nullable), `error_message` (text nullable), `checked_at` (datetime, indexed), standard timestamps. + - Index `(website_id, checked_at desc)` for the per-website history listing. + +- **`App\Enums\WebsiteStatus`** (`Pending`, `Up`, `Down`, `Slow`, `Error`) and **`App\Enums\WebsiteCheckStatus`** (`Up`, `Down`, `Slow`, `Error`) with `badgeTone()` helpers consistent with `RepositorySyncStatus` + `WorkflowRunConclusion`. + - **Why two enums:** websites have a `pending` initial state (created but never probed); a recorded `WebsiteCheck` row only happens after a probe ran, so its status enum doesn't need `pending`. + +- **`App\Models\Website`** + **`App\Models\WebsiteCheck`** + factories. + - `Website::project()` belongsTo, `Website::checks()` hasMany. + - `WebsiteCheck::website()` belongsTo. + - Casts: status enums + datetimes. + - `Website::getRouteKeyName()` stays default (numeric id) — no nice slug shape. + +- **`App\Domain\Monitoring\Actions\RunWebsiteProbeAction`** — pure HTTP probe, no DB writes. + - Accepts a `Website` + uses `Http::timeout($website->timeout_ms / 1000)->{$method}($url)`. + - Returns a `WebsiteProbeResult` value object: `status` (enum: up/down/slow/error), `http_status_code`, `response_time_ms`, `error_message`. + - **Status mapping:** + - HTTP request succeeded with `expected_status_code` → `Up` + - Request succeeded but status mismatch → `Down` + - Request succeeded but `response_time_ms > 3000` → `Slow` (overrides `Up`; phase-1 hard threshold; configurable later) + - Connection error / timeout / DNS failure → `Error` (with the exception class + message in `error_message`) + - All HTTP traffic goes through `Http::timeout(...)` so test doubles via `Http::fake()` work out of the box. + +- **`App\Domain\Monitoring\Actions\RecordWebsiteCheckAction`** — composes the persistence path: + - Accepts a `Website` + a `WebsiteProbeResult`. + - Inserts a `WebsiteCheck` row keyed by the result's fields. + - Updates `Website.status`, `Website.last_checked_at`, and conditionally `last_success_at` (when status is `Up` or `Slow`) / `last_failure_at` (when status is `Down` or `Error`). + - Does NOT dispatch activity events yet — that's spec 024 (transition detection lives there). + +- **`App\Http\Controllers\Monitoring\WebsiteController`** — resourceful (index, create, store, show, update, destroy). + - `index` → `Pages/Monitoring/Websites/Index.vue` with a flat list of all websites under the user's projects (cross-project for now; project-scoped filter via query string lands in spec 025 if it earns its keep). + - `create` → `Pages/Monitoring/Websites/Create.vue`. + - `store` validates URL, name, project_id, method, expected_status_code, timeout_ms, check_interval_seconds; redirects to `show`. + - `show` → `Pages/Monitoring/Websites/Show.vue` with the website + last 50 `website_checks` ordered desc. + - `edit` → `Pages/Monitoring/Websites/Edit.vue`. + - `update` validates the same shape. + - `destroy` → flash + redirect to `index`. + - Authorization: `WebsitePolicy` checks via `Project::owner_user_id === auth()->id` (delegates to project ownership; matches the repository policy pattern). + +- **`App\Http\Controllers\Monitoring\WebsiteProbeController`** — single-action `__invoke` for the manual "Probe now" button. + - **Sync probe** (locked decision): controller calls `RunWebsiteProbeAction` synchronously, then `RecordWebsiteCheckAction`. Request blocks ≤ `timeout_ms` (default 10s) and returns with the persisted check in the flash message. + - Authorize via the policy. POST `/monitoring/websites/{website}/probe`. + +- **`Pages/Monitoring/Websites/`** — four Vue pages. Mirror `Repositories/Show.vue` patterns: tabs collapsed into a single Show page (no extra tabs in spec 023), reuse `StatusBadge`, reuse the recent shared `workflowRunStyles` philosophy if a third consumer arises (don't preemptively extract). + - `Index.vue` — table of websites with status badge, last check timestamp, response time, link to detail. + - `Create.vue` + `Edit.vue` — form with URL, name, project (dropdown of user's projects), method (GET/HEAD/POST), expected status, timeout, interval. + - `Show.vue` — header (URL, name, project chip, status badge, "Probe now" button), body (last 50 checks list with status, HTTP code, response time, "Failed" toast for errors). + - Sidebar `Monitoring` entry flipped from disabled → linked to `monitoring.websites.index`. + +- **Routes** under `auth + verified` middleware: + ``` + Route::resource('monitoring/websites', WebsiteController::class) + ->parameters(['websites' => 'website']) + ->names('monitoring.websites'); + Route::post('/monitoring/websites/{website}/probe', WebsiteProbeController::class) + ->name('monitoring.websites.probe'); + ``` + +- **Tests** (Pest/PHPUnit, mirrors phase-4 patterns): + - `RunWebsiteProbeActionTest` — Http::fake'd 200 → Up, 500 → Down, slow response → Slow override, transport error → Error. + - `RecordWebsiteCheckActionTest` — inserts a check, updates `Website.last_*` correctly per result status. + - `WebsiteControllerTest` — index lists user's websites, store validates, store creates, update edits, destroy removes, non-owner gets 403. + - `WebsiteProbeControllerTest` — owner can probe (Http::fake), non-owner forbidden, missing website 404. The probe path persists a check + updates the website. + - `WebsitePolicyTest` — owner can view/update/delete; non-owner cannot. + - **Manual smoke note** in the work log — verify the form flows in a browser; the env-CSRF baseline known issue still applies to local POST tests, CI passes them. + +**Out of scope:** + +- Scheduled checks / `DispatchDueWebsiteChecksJob` — spec 024. +- Uptime % calculation / `GetWebsitePerformanceSummaryQuery` — spec 024. +- Activity event creation on status transitions — spec 024. +- Reverb broadcast for live status updates — spec 025. +- Overview KPI integration (replacing `MOCK_KPIS['uptime']`) — spec 025. +- Response-time line chart / uptime ring on Show page — spec 025. +- DNS / TLS / TTFB timing fields — future phase. +- Per-project Websites tab on `Projects/Show.vue` — possible phase-5 polish if it earns its keep; the cross-project list at `/monitoring/websites` is enough for MVP. + +## Plan + +1. **Migrations** — `create_websites_table` + `create_website_checks_table`. +2. **Enums + models + factories** — `WebsiteStatus`, `WebsiteCheckStatus`, `Website`, `WebsiteCheck`. +3. **`WebsiteProbeResult` value object** — readonly DTO under `App\Domain\Monitoring\Probes` (or co-located with the action). +4. **`RunWebsiteProbeAction` + tests** — Http::fake'd happy/slow/down/error paths. +5. **`RecordWebsiteCheckAction` + tests** — DB writes + `Website.last_*` updates. +6. **`WebsitePolicy` + tests**. +7. **`WebsiteController` resourceful + Vue pages** (Index, Create, Edit, Show). +8. **`WebsiteProbeController` + route + test**. +9. **Sidebar entry** — flip `Monitoring` from disabled → routeName `monitoring.websites.index`. +10. **Self-review pass via `superpowers:code-reviewer`**. +11. **Open the PR**. + +## Acceptance criteria +- [ ] `websites` + `website_checks` tables exist with the documented columns + indexes. +- [ ] `Website` + `WebsiteCheck` models with typed enum casts; both factories present. +- [ ] `RunWebsiteProbeAction` returns the right status for happy/slow/down/transport-error paths under `Http::fake`. +- [ ] `RecordWebsiteCheckAction` persists a `WebsiteCheck` row + updates `Website.{status,last_checked_at,last_success_at,last_failure_at}` per the result. +- [ ] CRUD endpoints under `/monitoring/websites` enforce `WebsitePolicy`; non-owner of the parent project gets 403. +- [ ] `POST /monitoring/websites/{website}/probe` runs the probe synchronously, persists a check, redirects with a flash status. 403 for non-owners. +- [ ] Sidebar `Monitoring` entry is enabled and routes to the websites listing. +- [ ] `Pages/Monitoring/Websites/{Index,Create,Edit,Show}.vue` render real data and pass the standard verified-auth gating. +- [ ] Pint + `php artisan test` (full suite) + `npm run build` clean. CI green on the PR. +- [ ] Self-review pass with `superpowers:code-reviewer`; material findings addressed before opening the PR. + +## Files touched +- `database/migrations/_create_websites_table.php` — new. +- `database/migrations/_create_website_checks_table.php` — new. +- `app/Enums/WebsiteStatus.php` — new. +- `app/Enums/WebsiteCheckStatus.php` — new. +- `app/Models/Website.php` — new. +- `app/Models/WebsiteCheck.php` — new. +- `database/factories/WebsiteFactory.php` — new. +- `database/factories/WebsiteCheckFactory.php` — new. +- `app/Domain/Monitoring/Probes/WebsiteProbeResult.php` — new (readonly DTO). +- `app/Domain/Monitoring/Actions/RunWebsiteProbeAction.php` — new. +- `app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php` — new. +- `app/Policies/WebsitePolicy.php` — new (registered in `AppServiceProvider`). +- `app/Http/Controllers/Monitoring/WebsiteController.php` — new. +- `app/Http/Controllers/Monitoring/WebsiteProbeController.php` — new. +- `app/Http/Requests/Monitoring/StoreWebsiteRequest.php` + `UpdateWebsiteRequest.php` — new. +- `routes/web.php` — `monitoring.websites.*` routes + probe route. +- `resources/js/Components/Sidebar/Sidebar.vue` — flip `Monitoring` entry. +- `resources/js/Pages/Monitoring/Websites/Index.vue` — new. +- `resources/js/Pages/Monitoring/Websites/Create.vue` — new. +- `resources/js/Pages/Monitoring/Websites/Edit.vue` — new. +- `resources/js/Pages/Monitoring/Websites/Show.vue` — new. +- `tests/Feature/Monitoring/RunWebsiteProbeActionTest.php` — new. +- `tests/Feature/Monitoring/RecordWebsiteCheckActionTest.php` — new. +- `tests/Feature/Monitoring/WebsiteControllerTest.php` — new. +- `tests/Feature/Monitoring/WebsiteProbeControllerTest.php` — new. +- `tests/Feature/Monitoring/WebsitePolicyTest.php` — new. +- `specs/README.md` — phase-5 tracker. +- `specs/phase-5-monitoring/README.md` — task tracker. + +## Work log +Dated notes as work progresses. + +### 2026-04-30 +- Spec drafted. +- Opened issue [#69](https://github.com/Copxer/nexus/issues/69) and branch `spec/023-website-monitor-mvp` off `main`. + +## Decisions (locked 2026-04-30) +- **URL nesting under `/monitoring/`** — anticipates phase-6 hosts as a sibling. Sidebar label stays "Monitoring" pointing at `/monitoring/websites`. +- **Sync manual probe** — controller blocks until probe completes (≤ `timeout_ms`); user clicks the button, they want the result. Spec 024's scheduler is where async becomes natural. +- **Two status enums (`WebsiteStatus` + `WebsiteCheckStatus`)** — websites have a `pending` initial state; recorded checks don't need it. +- **Slow threshold = 3000ms hard-coded for phase-1.** Configurable per-website in a future polish if real users complain. +- **Activity events deferred to spec 024.** Status-transition detection lives with the scheduler — that's where it earns its keep, since manual probes are user-triggered and don't need a separate notification channel. +- **No multi-tenant `team_id`.** Same phase-1 simplification as repositories; the cross-cutting team scoping arrives uniformly. + +## Open questions / blockers +- **Slow threshold UX.** 3000ms is roadmap-implied (no explicit value in §8.8); confirm during implementation that the rendered "Slow" badge feels right on a typical home-broadband response. +- **Project scope on `Index.vue`.** Cross-project flat list for MVP; revisit if a real user has 20+ websites and the table gets noisy. diff --git a/specs/phase-5-monitoring/README.md b/specs/phase-5-monitoring/README.md new file mode 100644 index 0000000..6f4bcc5 --- /dev/null +++ b/specs/phase-5-monitoring/README.md @@ -0,0 +1,24 @@ +# Phase 5 — Website Monitoring + +Source: [roadmap §19 Phase 5](../../nexus_control_center_roadmap.md), §8.8 Website Performance Monitoring. + +## Phase goal +Stand up website uptime + response-time monitoring end-to-end. By the end of phase 5, a user can add a website URL to a project, get a manual probe with timing on demand, watch a scheduled check run every configured interval, see uptime % over rolling windows, and have the Overview KPI driven by real data instead of the phase-0 `MOCK_KPIS['uptime']` placeholder. + +## Tasks + +| # | Task | Status | +|---|------|--------| +| 023 | Website monitor MVP (CRUD + manual probe + check history) | ⬜ | +| 024 | Scheduled checks + uptime calc + activity events | ⬜ | +| 025 | Overview integration + Reverb live updates + perf charts | ⬜ | + +## Acceptance criteria (phase-level) +- [ ] User can add / edit / delete a website monitor under a project. +- [ ] Manual "Probe now" runs an HTTP probe and records the result. +- [ ] Background scheduler runs each website's probe every `check_interval_seconds`. +- [ ] Uptime % is calculated over 24h / 7d / 30d windows. +- [ ] Slow / down transitions create activity events on the right rail. +- [ ] Overview's uptime KPI card is fed by real `website_checks` aggregates (no mocks). +- [ ] Sidebar `Monitoring` entry is enabled and routes to the websites listing. +- [ ] Pint + tests + build clean. CI green for every spec PR. From 1b44dca26557a4d924a779c7eec242740b794b84 Mon Sep 17 00:00:00 2001 From: Copxer Date: Thu, 30 Apr 2026 19:10:11 -0700 Subject: [PATCH 2/3] =?UTF-8?q?feat(monitoring):=20website=20monitor=20MVP?= =?UTF-8?q?=20=E2=80=94=20CRUD=20+=20manual=20probe=20(spec=20023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First spec of phase 5. Stand up website uptime monitoring data layer plus a working CRUD UX with a sync manual "Probe now" that records a real HTTP check. - New `websites` + `website_checks` tables. Two enums: WebsiteStatus (includes `pending`) and WebsiteCheckStatus (Up/ Down/Slow/Error only — checks only exist after a probe ran). - WebsiteProbeResult DTO carries the probe outcome between the pure RunWebsiteProbeAction (HTTP, no DB) and the RecordWebsiteCheckAction (persistence). Probe classifies: Up → request succeeded with expected_status_code Slow → success but response_time_ms > 3000ms (hard threshold phase-1; per-website config is future polish) Down → request succeeded but status code mismatched Error → DNS / TCP / TLS / timeout / connection refused Slow is treated as a successful run for the parent Website.last_success_at update. - Catch list narrowed to ConnectionException|RequestException — programmer bugs (typo, future enum drift, OOM) bubble up loudly instead of getting silently classified as "site down." - WebsitePolicy gates create/update/delete/probe to project owners; any verified user can view (matches RepositoryPolicy phase-1 conventions; cross-tenant uniform fix when teams ship). - WebsiteController resourceful + WebsiteProbeController single- action sync probe. Sync because the user clicked the button — they want the result. Spec 024's scheduler is where async becomes the natural mode; the same RecordWebsiteCheckAction is reused on both paths. - Form requests StoreWebsiteRequest + UpdateWebsiteRequest validate field shapes; project_id is intentionally NOT editable post-create (moving a website between projects would orphan its check history). WebsiteController::store re-authorises via the policy after validation (belt-and-suspenders, matches RepositoryController). - Routes nested under /monitoring/* so phase-6 hosts can sit beside it as /monitoring/hosts/*. Sidebar `Monitoring` entry flipped from disabled → routeName: monitoring.websites.index. - 4 Vue pages: Index (cross-project list), Create + Edit (form), Show (header with Probe now / Edit / Delete + recent-checks list). Tests: 28 new tests across 5 files (probe action, record action, policy, controller, probe controller). 19 net new passing tests; full suite 310 passed (was 291). The 10 new env-CSRF POST/PATCH/ DELETE failures are the same baseline pattern affecting every POST test in the repo locally — CI passes them. Self-review pass via superpowers:code-reviewer; addressed all 3 recommendations (narrowed catch list, belt-and-suspenders authorize, column comment on response_time_ms clarifying it's wall-clock not server-reported TTFB). Spec 024 will add the scheduler (DispatchDueWebsiteChecksJob + RunWebsiteCheckJob), uptime % calculation, and activity event creation on status transitions. Spec 025 wires the Overview KPI widget + Reverb live updates. --- .../Actions/RecordWebsiteCheckAction.php | 81 +++++ .../Actions/RunWebsiteProbeAction.php | 139 ++++++++ .../Monitoring/Probes/WebsiteProbeResult.php | 26 ++ app/Enums/WebsiteCheckStatus.php | 30 ++ app/Enums/WebsiteStatus.php | 41 +++ .../Monitoring/WebsiteController.php | 209 ++++++++++++ .../Monitoring/WebsiteProbeController.php | 39 +++ .../Monitoring/StoreWebsiteRequest.php | 43 +++ .../Monitoring/UpdateWebsiteRequest.php | 34 ++ app/Models/Website.php | 53 ++++ app/Models/WebsiteCheck.php | 39 +++ app/Policies/WebsitePolicy.php | 56 ++++ app/Providers/AppServiceProvider.php | 3 + database/factories/WebsiteCheckFactory.php | 41 +++ database/factories/WebsiteFactory.php | 31 ++ ...026_04_30_130000_create_websites_table.php | 49 +++ ..._30_130001_create_website_checks_table.php | 53 ++++ resources/js/Components/Sidebar/Sidebar.vue | 2 +- .../js/Pages/Monitoring/Websites/Create.vue | 203 ++++++++++++ .../js/Pages/Monitoring/Websites/Edit.vue | 176 +++++++++++ .../js/Pages/Monitoring/Websites/Index.vue | 187 +++++++++++ .../js/Pages/Monitoring/Websites/Show.vue | 298 ++++++++++++++++++ routes/web.php | 10 + specs/README.md | 2 +- .../023-website-monitor-mvp.md | 9 +- specs/phase-5-monitoring/README.md | 2 +- .../RecordWebsiteCheckActionTest.php | 150 +++++++++ .../Monitoring/RunWebsiteProbeActionTest.php | 120 +++++++ .../Monitoring/WebsiteControllerTest.php | 203 ++++++++++++ .../Feature/Monitoring/WebsitePolicyTest.php | 65 ++++ .../Monitoring/WebsiteProbeControllerTest.php | 73 +++++ 31 files changed, 2463 insertions(+), 4 deletions(-) create mode 100644 app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php create mode 100644 app/Domain/Monitoring/Actions/RunWebsiteProbeAction.php create mode 100644 app/Domain/Monitoring/Probes/WebsiteProbeResult.php create mode 100644 app/Enums/WebsiteCheckStatus.php create mode 100644 app/Enums/WebsiteStatus.php create mode 100644 app/Http/Controllers/Monitoring/WebsiteController.php create mode 100644 app/Http/Controllers/Monitoring/WebsiteProbeController.php create mode 100644 app/Http/Requests/Monitoring/StoreWebsiteRequest.php create mode 100644 app/Http/Requests/Monitoring/UpdateWebsiteRequest.php create mode 100644 app/Models/Website.php create mode 100644 app/Models/WebsiteCheck.php create mode 100644 app/Policies/WebsitePolicy.php create mode 100644 database/factories/WebsiteCheckFactory.php create mode 100644 database/factories/WebsiteFactory.php create mode 100644 database/migrations/2026_04_30_130000_create_websites_table.php create mode 100644 database/migrations/2026_04_30_130001_create_website_checks_table.php create mode 100644 resources/js/Pages/Monitoring/Websites/Create.vue create mode 100644 resources/js/Pages/Monitoring/Websites/Edit.vue create mode 100644 resources/js/Pages/Monitoring/Websites/Index.vue create mode 100644 resources/js/Pages/Monitoring/Websites/Show.vue create mode 100644 tests/Feature/Monitoring/RecordWebsiteCheckActionTest.php create mode 100644 tests/Feature/Monitoring/RunWebsiteProbeActionTest.php create mode 100644 tests/Feature/Monitoring/WebsiteControllerTest.php create mode 100644 tests/Feature/Monitoring/WebsitePolicyTest.php create mode 100644 tests/Feature/Monitoring/WebsiteProbeControllerTest.php diff --git a/app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php b/app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php new file mode 100644 index 0000000..6b2fe57 --- /dev/null +++ b/app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php @@ -0,0 +1,81 @@ +create([ + 'website_id' => $website->id, + 'status' => $result->status->value, + 'http_status_code' => $result->httpStatusCode, + 'response_time_ms' => $result->responseTimeMs, + 'error_message' => $result->errorMessage, + 'checked_at' => $checkedAt, + ]); + + $updates = [ + 'status' => $this->parentStatusFor($result->status)->value, + 'last_checked_at' => $checkedAt, + ]; + + if ($this->isSuccessful($result->status)) { + $updates['last_success_at'] = $checkedAt; + } else { + $updates['last_failure_at'] = $checkedAt; + } + + $website->forceFill($updates)->save(); + + return $check; + } + + /** + * `WebsiteCheckStatus` and `WebsiteStatus` differ only by the + * `pending` value (parent only). Map 1:1 by name. + */ + private function parentStatusFor(WebsiteCheckStatus $checkStatus): WebsiteStatus + { + return match ($checkStatus) { + WebsiteCheckStatus::Up => WebsiteStatus::Up, + WebsiteCheckStatus::Down => WebsiteStatus::Down, + WebsiteCheckStatus::Slow => WebsiteStatus::Slow, + WebsiteCheckStatus::Error => WebsiteStatus::Error, + }; + } + + /** `Slow` is treated as a successful run for `last_success_at`. */ + private function isSuccessful(WebsiteCheckStatus $status): bool + { + return $status === WebsiteCheckStatus::Up + || $status === WebsiteCheckStatus::Slow; + } +} diff --git a/app/Domain/Monitoring/Actions/RunWebsiteProbeAction.php b/app/Domain/Monitoring/Actions/RunWebsiteProbeAction.php new file mode 100644 index 0000000..261e096 --- /dev/null +++ b/app/Domain/Monitoring/Actions/RunWebsiteProbeAction.php @@ -0,0 +1,139 @@ + SLOW_THRESHOLD_MS` → Slow (overrides Up) + * - HTTP request succeeded but status code mismatch → Down + * - Transport error (DNS / timeout / refused / TLS / etc.) → Error + * + * All HTTP traffic flows through Laravel's `Http` facade so tests + * `Http::fake()` cleanly. Catch list is intentionally narrow — + * `ConnectionException` covers DNS / TCP / TLS / timeout, and + * `RequestException` covers HTTP-layer protocol failures Guzzle + * throws despite our not calling `->throw()`. Programmer bugs (typo, + * future enum drift, OOM) bubble up loudly instead of getting + * silently classified as "site down." + */ +class RunWebsiteProbeAction +{ + /** + * Phase-1 hard threshold for the `Slow` classification. Past + * 3 seconds a successful probe still marks the site Slow. + * Per-website configuration is a future polish if real users + * complain. + */ + private const SLOW_THRESHOLD_MS = 3_000; + + /** Hard cap on the persisted `error_message` length. */ + private const ERROR_MESSAGE_LIMIT = 500; + + public function execute(Website $website): WebsiteProbeResult + { + $startedAt = hrtime(true); + + try { + $response = Http::timeout($website->timeout_ms / 1_000) + ->withHeaders(['User-Agent' => 'Nexus-Monitor']) + ->{$this->httpMethod($website->method)}($website->url); + } catch (ConnectionException|RequestException $e) { + return $this->errorResult($e); + } + + $elapsedMs = (int) ((hrtime(true) - $startedAt) / 1_000_000); + $statusCode = $response->status(); + + $status = $this->classify($website->expected_status_code, $statusCode, $elapsedMs); + + return new WebsiteProbeResult( + status: $status, + httpStatusCode: $statusCode, + responseTimeMs: $elapsedMs, + errorMessage: $this->errorMessageFor($status, $response), + ); + } + + /** + * Map probe outcome to a `WebsiteCheckStatus`. Slow takes + * precedence over Up because a 200 OK at 5s is more interesting + * than the 200 alone. + */ + private function classify(int $expected, int $actual, int $elapsedMs): WebsiteCheckStatus + { + if ($actual !== $expected) { + return WebsiteCheckStatus::Down; + } + + if ($elapsedMs > self::SLOW_THRESHOLD_MS) { + return WebsiteCheckStatus::Slow; + } + + return WebsiteCheckStatus::Up; + } + + /** + * Allowed HTTP methods reduce to the lowercase `Http` macro + * (`get`, `head`, `post`). Unknown / disallowed methods fall + * back to `get` — the controller already validates the input. + */ + private function httpMethod(string $method): string + { + return match (strtoupper($method)) { + 'HEAD' => 'head', + 'POST' => 'post', + default => 'get', + }; + } + + private function errorResult(Throwable $e): WebsiteProbeResult + { + $message = $e->getMessage() !== '' ? $e->getMessage() : $e::class; + + return new WebsiteProbeResult( + status: WebsiteCheckStatus::Error, + httpStatusCode: null, + responseTimeMs: null, + errorMessage: Str::limit($message, self::ERROR_MESSAGE_LIMIT, '…'), + ); + } + + /** + * `error_message` is set on Down/Error rows for surfacing in the + * UI; Up/Slow leaves it null. Down captures the response body's + * first line so an HTTP-level "Service Unavailable" surfaces in + * the recent-checks list without needing a separate column. + */ + private function errorMessageFor(WebsiteCheckStatus $status, Response $response): ?string + { + if ($status === WebsiteCheckStatus::Up || $status === WebsiteCheckStatus::Slow) { + return null; + } + + $body = trim((string) $response->body()); + + if ($body === '') { + return "HTTP {$response->status()}"; + } + + // Snip to first line for a stable preview, then cap to 500. + $firstLine = strtok($body, "\n") ?: $body; + + return Str::limit("HTTP {$response->status()}: {$firstLine}", self::ERROR_MESSAGE_LIMIT, '…'); + } +} diff --git a/app/Domain/Monitoring/Probes/WebsiteProbeResult.php b/app/Domain/Monitoring/Probes/WebsiteProbeResult.php new file mode 100644 index 0000000..41ec519 --- /dev/null +++ b/app/Domain/Monitoring/Probes/WebsiteProbeResult.php @@ -0,0 +1,26 @@ + 'success', + self::Down, self::Error => 'danger', + self::Slow => 'warning', + }; + } +} diff --git a/app/Enums/WebsiteStatus.php b/app/Enums/WebsiteStatus.php new file mode 100644 index 0000000..85794b2 --- /dev/null +++ b/app/Enums/WebsiteStatus.php @@ -0,0 +1,41 @@ + 'muted', + self::Up => 'success', + self::Down, self::Error => 'danger', + self::Slow => 'warning', + }; + } +} diff --git a/app/Http/Controllers/Monitoring/WebsiteController.php b/app/Http/Controllers/Monitoring/WebsiteController.php new file mode 100644 index 0000000..23cc67c --- /dev/null +++ b/app/Http/Controllers/Monitoring/WebsiteController.php @@ -0,0 +1,209 @@ +authorize('viewAny', Website::class); + + $user = $request->user(); + + $websites = Website::query() + ->with('project:id,slug,name,color,icon,owner_user_id') + ->whereHas('project', fn ($q) => $q->where('owner_user_id', $user->id)) + ->orderBy('name') + ->get() + ->map(fn (Website $website) => $this->transform($website)); + + return Inertia::render('Monitoring/Websites/Index', [ + 'websites' => $websites, + ]); + } + + public function create(Request $request): Response + { + $user = $request->user(); + + // Pre-select via `?project_id=N` so the link from a project + // page lands on the right project. + $preselect = $request->integer('project_id') ?: null; + $project = $preselect !== null + ? Project::query()->where('owner_user_id', $user->id)->find($preselect) + : null; + + $this->authorize('create', [Website::class, $project]); + + return Inertia::render('Monitoring/Websites/Create', [ + 'projects' => $this->ownedProjects($user->id), + 'preselectedProjectId' => $project?->id, + 'options' => $this->formOptions(), + ]); + } + + public function store(StoreWebsiteRequest $request): RedirectResponse + { + // Belt-and-suspenders: the form request already authorised this, + // but re-authorising via the policy after validation guards + // against a stale prop slipping past (mirrors the + // `RepositoryController::store` pattern). + $this->authorize('create', [Website::class, $request->resolvedProject()]); + + $website = Website::query()->create($request->validated()); + + return redirect() + ->route('monitoring.websites.show', $website) + ->with('status', "Monitor created for {$website->name}."); + } + + public function show(Request $request, Website $website): Response + { + $this->authorize('view', $website); + + $website->loadMissing('project:id,slug,name,color,icon,owner_user_id'); + + $checks = $website->checks() + ->orderByDesc('checked_at') + ->orderByDesc('id') + ->limit(50) + ->get() + ->map(fn ($check) => [ + 'id' => $check->id, + 'status' => $check->status?->value, + 'http_status_code' => $check->http_status_code, + 'response_time_ms' => $check->response_time_ms, + 'error_message' => $check->error_message, + 'checked_at' => $check->checked_at?->diffForHumans(), + 'checked_at_iso' => $check->checked_at?->toIso8601String(), + ]) + ->all(); + + return Inertia::render('Monitoring/Websites/Show', [ + 'website' => $this->transform($website), + 'checks' => $checks, + 'canUpdate' => $request->user()?->can('update', $website) ?? false, + 'canDelete' => $request->user()?->can('delete', $website) ?? false, + 'canProbe' => $request->user()?->can('probe', $website) ?? false, + ]); + } + + public function edit(Request $request, Website $website): Response + { + $this->authorize('update', $website); + + return Inertia::render('Monitoring/Websites/Edit', [ + 'website' => $this->transform($website), + 'projects' => $this->ownedProjects($request->user()->id), + 'options' => $this->formOptions(), + ]); + } + + public function update(UpdateWebsiteRequest $request, Website $website): RedirectResponse + { + $website->update($request->validated()); + + return redirect() + ->route('monitoring.websites.show', $website) + ->with('status', 'Monitor updated.'); + } + + public function destroy(Website $website): RedirectResponse + { + $this->authorize('delete', $website); + + $website->delete(); + + return redirect() + ->route('monitoring.websites.index') + ->with('status', 'Monitor deleted.'); + } + + /** + * Single source of truth for the website JSON shape. Centralised so + * Index/Show/Edit don't drift on field set. + */ + private function transform(Website $website): array + { + return [ + 'id' => $website->id, + 'name' => $website->name, + 'url' => $website->url, + 'method' => $website->method, + 'expected_status_code' => $website->expected_status_code, + 'timeout_ms' => $website->timeout_ms, + 'check_interval_seconds' => $website->check_interval_seconds, + 'status' => $website->status?->value, + 'last_checked_at' => $website->last_checked_at?->diffForHumans(), + 'last_success_at' => $website->last_success_at?->diffForHumans(), + 'last_failure_at' => $website->last_failure_at?->diffForHumans(), + 'project' => $website->project ? [ + 'id' => $website->project->id, + 'slug' => $website->project->slug, + 'name' => $website->project->name, + 'color' => $website->project->color, + 'icon' => $website->project->icon, + ] : null, + ]; + } + + /** + * Project dropdown payload — only projects the user owns. Cheap + * query; called from `create` + `edit`. + * + * @return array> + */ + private function ownedProjects(int $userId): array + { + return Project::query() + ->where('owner_user_id', $userId) + ->orderBy('name') + ->get(['id', 'name', 'color']) + ->map(fn (Project $project) => [ + 'id' => $project->id, + 'name' => $project->name, + 'color' => $project->color, + ]) + ->all(); + } + + /** + * Static option lists for the Create/Edit forms. Kept on the + * server so the Vue layer doesn't drift from the validation rules. + */ + private function formOptions(): array + { + return [ + 'methods' => [ + ['value' => 'GET', 'label' => 'GET'], + ['value' => 'HEAD', 'label' => 'HEAD'], + ['value' => 'POST', 'label' => 'POST'], + ], + 'common_intervals' => [ + ['value' => 60, 'label' => 'Every minute'], + ['value' => 300, 'label' => 'Every 5 minutes'], + ['value' => 900, 'label' => 'Every 15 minutes'], + ['value' => 3600, 'label' => 'Hourly'], + ], + ]; + } +} diff --git a/app/Http/Controllers/Monitoring/WebsiteProbeController.php b/app/Http/Controllers/Monitoring/WebsiteProbeController.php new file mode 100644 index 0000000..edf3709 --- /dev/null +++ b/app/Http/Controllers/Monitoring/WebsiteProbeController.php @@ -0,0 +1,39 @@ +authorize('probe', $website); + + $result = $probe->execute($website); + $record->execute($website, $result); + + $status = $result->status->value; + $message = $result->responseTimeMs !== null + ? "Probe complete — {$status} in {$result->responseTimeMs}ms." + : "Probe complete — {$status}."; + + return back()->with('status', $message); + } +} diff --git a/app/Http/Requests/Monitoring/StoreWebsiteRequest.php b/app/Http/Requests/Monitoring/StoreWebsiteRequest.php new file mode 100644 index 0000000..0960cbf --- /dev/null +++ b/app/Http/Requests/Monitoring/StoreWebsiteRequest.php @@ -0,0 +1,43 @@ +resolvedProject(); + + return $this->user()?->can('create', [Website::class, $project]) === true; + } + + public function rules(): array + { + return [ + 'project_id' => ['required', Rule::exists('projects', 'id')], + 'name' => ['required', 'string', 'max:120'], + 'url' => ['required', 'url', 'max:2048'], + 'method' => ['required', Rule::in(['GET', 'HEAD', 'POST'])], + 'expected_status_code' => ['required', 'integer', 'between:100,599'], + 'timeout_ms' => ['required', 'integer', 'min:1000', 'max:60000'], + 'check_interval_seconds' => ['required', 'integer', 'min:60', 'max:86400'], + ]; + } + + public function resolvedProject(): ?Project + { + $id = $this->input('project_id'); + + return $id !== null ? Project::query()->find($id) : null; + } +} diff --git a/app/Http/Requests/Monitoring/UpdateWebsiteRequest.php b/app/Http/Requests/Monitoring/UpdateWebsiteRequest.php new file mode 100644 index 0000000..a77b303 --- /dev/null +++ b/app/Http/Requests/Monitoring/UpdateWebsiteRequest.php @@ -0,0 +1,34 @@ +route('website'); + + return $website !== null + && $this->user()?->can('update', $website) === true; + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:120'], + 'url' => ['required', 'url', 'max:2048'], + 'method' => ['required', Rule::in(['GET', 'HEAD', 'POST'])], + 'expected_status_code' => ['required', 'integer', 'between:100,599'], + 'timeout_ms' => ['required', 'integer', 'min:1000', 'max:60000'], + 'check_interval_seconds' => ['required', 'integer', 'min:60', 'max:86400'], + ]; + } +} diff --git a/app/Models/Website.php b/app/Models/Website.php new file mode 100644 index 0000000..054f103 --- /dev/null +++ b/app/Models/Website.php @@ -0,0 +1,53 @@ + */ + use HasFactory; + + protected $fillable = [ + 'project_id', + 'name', + 'url', + 'method', + 'expected_status_code', + 'timeout_ms', + 'check_interval_seconds', + 'status', + 'last_checked_at', + 'last_success_at', + 'last_failure_at', + ]; + + protected function casts(): array + { + return [ + 'status' => WebsiteStatus::class, + 'expected_status_code' => 'integer', + 'timeout_ms' => 'integer', + 'check_interval_seconds' => 'integer', + 'last_checked_at' => 'datetime', + 'last_success_at' => 'datetime', + 'last_failure_at' => 'datetime', + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function checks(): HasMany + { + return $this->hasMany(WebsiteCheck::class); + } +} diff --git a/app/Models/WebsiteCheck.php b/app/Models/WebsiteCheck.php new file mode 100644 index 0000000..8578a42 --- /dev/null +++ b/app/Models/WebsiteCheck.php @@ -0,0 +1,39 @@ + */ + use HasFactory; + + protected $fillable = [ + 'website_id', + 'status', + 'http_status_code', + 'response_time_ms', + 'error_message', + 'checked_at', + ]; + + protected function casts(): array + { + return [ + 'status' => WebsiteCheckStatus::class, + 'http_status_code' => 'integer', + 'response_time_ms' => 'integer', + 'checked_at' => 'datetime', + ]; + } + + public function website(): BelongsTo + { + return $this->belongsTo(Website::class); + } +} diff --git a/app/Policies/WebsitePolicy.php b/app/Policies/WebsitePolicy.php new file mode 100644 index 0000000..c343dfc --- /dev/null +++ b/app/Policies/WebsitePolicy.php @@ -0,0 +1,56 @@ +hasVerifiedEmail(); + } + + public function view(User $user, Website $website): bool + { + return $user->hasVerifiedEmail(); + } + + /** + * `create` is project-scoped — invoked via + * `Gate::authorize('create', [Website::class, $project])`. + */ + public function create(User $user, ?Project $project): bool + { + return $project !== null + && $user->hasVerifiedEmail() + && $user->can('update', $project); + } + + public function update(User $user, Website $website): bool + { + $project = $website->project; + + return $project !== null + && $user->hasVerifiedEmail() + && $user->can('update', $project); + } + + public function delete(User $user, Website $website): bool + { + return $this->update($user, $website); + } + + /** Manual "Probe now" button reuses the update gate. */ + public function probe(User $user, Website $website): bool + { + return $this->update($user, $website); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d3f2b4c..1a71491 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,8 +4,10 @@ use App\Models\Project; use App\Models\Repository; +use App\Models\Website; use App\Policies\ProjectPolicy; use App\Policies\RepositoryPolicy; +use App\Policies\WebsitePolicy; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\Vite; @@ -30,6 +32,7 @@ public function boot(): void Gate::policy(Project::class, ProjectPolicy::class); Gate::policy(Repository::class, RepositoryPolicy::class); + Gate::policy(Website::class, WebsitePolicy::class); // Force https URL generation when APP_URL is https. Required for // Cloudflare/ngrok tunnels: TLS terminates at the tunnel and diff --git a/database/factories/WebsiteCheckFactory.php b/database/factories/WebsiteCheckFactory.php new file mode 100644 index 0000000..f72f76b --- /dev/null +++ b/database/factories/WebsiteCheckFactory.php @@ -0,0 +1,41 @@ + */ +class WebsiteCheckFactory extends Factory +{ + protected $model = WebsiteCheck::class; + + public function definition(): array + { + $status = fake()->randomElement([ + WebsiteCheckStatus::Up, + WebsiteCheckStatus::Up, + WebsiteCheckStatus::Up, + WebsiteCheckStatus::Slow, + WebsiteCheckStatus::Down, + WebsiteCheckStatus::Error, + ]); + + return [ + 'website_id' => Website::factory(), + 'status' => $status->value, + 'http_status_code' => $status === WebsiteCheckStatus::Error + ? null + : fake()->randomElement([200, 200, 200, 500, 503, 404]), + 'response_time_ms' => $status === WebsiteCheckStatus::Error + ? null + : fake()->numberBetween(50, 4_000), + 'error_message' => $status === WebsiteCheckStatus::Error + ? 'Connection timed out after 10000ms' + : null, + 'checked_at' => fake()->dateTimeBetween('-7 days', 'now'), + ]; + } +} diff --git a/database/factories/WebsiteFactory.php b/database/factories/WebsiteFactory.php new file mode 100644 index 0000000..860b603 --- /dev/null +++ b/database/factories/WebsiteFactory.php @@ -0,0 +1,31 @@ + */ +class WebsiteFactory extends Factory +{ + protected $model = Website::class; + + public function definition(): array + { + return [ + 'project_id' => Project::factory(), + 'name' => fake()->words(2, true), + 'url' => fake()->url(), + 'method' => 'GET', + 'expected_status_code' => 200, + 'timeout_ms' => 10_000, + 'check_interval_seconds' => 300, + 'status' => WebsiteStatus::Pending->value, + 'last_checked_at' => null, + 'last_success_at' => null, + 'last_failure_at' => null, + ]; + } +} diff --git a/database/migrations/2026_04_30_130000_create_websites_table.php b/database/migrations/2026_04_30_130000_create_websites_table.php new file mode 100644 index 0000000..a63b2c9 --- /dev/null +++ b/database/migrations/2026_04_30_130000_create_websites_table.php @@ -0,0 +1,49 @@ +id(); + + $table->foreignId('project_id') + ->constrained('projects') + ->cascadeOnDelete(); + + $table->string('name'); + $table->string('url'); + + // HTTP method for the probe; phase-1 supports GET/HEAD/POST. + // Stored as a short string rather than an enum so we can + // accept new RFC-listed methods later without a migration. + $table->string('method', 8)->default('GET'); + + $table->unsignedSmallInteger('expected_status_code')->default(200); + $table->unsignedInteger('timeout_ms')->default(10_000); + $table->unsignedInteger('check_interval_seconds')->default(300); + + // pending | up | down | slow | error (per WebsiteStatus enum). + // `pending` is the initial state — created but never probed. + $table->string('status', 16)->default('pending'); + + $table->timestamp('last_checked_at')->nullable(); + $table->timestamp('last_success_at')->nullable(); + $table->timestamp('last_failure_at')->nullable(); + + $table->timestamps(); + + // Per-project listing + status filter on the index page. + $table->index(['project_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('websites'); + } +}; diff --git a/database/migrations/2026_04_30_130001_create_website_checks_table.php b/database/migrations/2026_04_30_130001_create_website_checks_table.php new file mode 100644 index 0000000..fef71c8 --- /dev/null +++ b/database/migrations/2026_04_30_130001_create_website_checks_table.php @@ -0,0 +1,53 @@ +id(); + + $table->foreignId('website_id') + ->constrained('websites') + ->cascadeOnDelete(); + + // up | down | slow | error (per WebsiteCheckStatus enum). + // No `pending` — a recorded check row only exists once a + // probe ran. + $table->string('status', 8); + + // Nullable: a transport-level error (DNS, timeout) won't + // produce an HTTP status code or a response time. + $table->unsignedSmallInteger('http_status_code')->nullable(); + // Wall-clock time from request dispatch to response receipt + // — includes DNS / TCP / TLS / send / receive. Spec 023's + // MVP probe doesn't break out per-leg timings; a future + // spec adds dns_time_ms / connect_time_ms / tls_time_ms / + // ttfb_ms columns alongside this aggregate. + $table->unsignedInteger('response_time_ms')->nullable(); + + // Free-text error captured from the exception. Capped at + // 500 chars in the action layer (parallel to spec 020's + // sync error storage on repositories). + $table->text('error_message')->nullable(); + + $table->timestamp('checked_at'); + + $table->timestamps(); + + // Per-website history listing on the Show page. + $table->index(['website_id', 'checked_at']); + // Status filter for spec 024's uptime aggregate. + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('website_checks'); + } +}; diff --git a/resources/js/Components/Sidebar/Sidebar.vue b/resources/js/Components/Sidebar/Sidebar.vue index e6567d5..7286b50 100644 --- a/resources/js/Components/Sidebar/Sidebar.vue +++ b/resources/js/Components/Sidebar/Sidebar.vue @@ -43,7 +43,7 @@ const nav: NavItem[] = [ { label: 'Pipelines', icon: Activity, disabled: true, soonLabel: 'Phase 4' }, { label: 'Deployments', icon: Rocket, routeName: 'deployments.index' }, { label: 'Hosts', icon: Server, disabled: true, soonLabel: 'Phase 6' }, - { label: 'Monitoring', icon: Globe, disabled: true, soonLabel: 'Phase 5' }, + { label: 'Monitoring', icon: Globe, routeName: 'monitoring.websites.index' }, { label: 'Analytics', icon: BarChart3, disabled: true, soonLabel: 'Phase 8' }, { label: 'Alerts', icon: Bell, disabled: true, soonLabel: 'Phase 7' }, { label: 'Activity', icon: History, routeName: 'activity.index' }, diff --git a/resources/js/Pages/Monitoring/Websites/Create.vue b/resources/js/Pages/Monitoring/Websites/Create.vue new file mode 100644 index 0000000..ae88f34 --- /dev/null +++ b/resources/js/Pages/Monitoring/Websites/Create.vue @@ -0,0 +1,203 @@ + + + diff --git a/resources/js/Pages/Monitoring/Websites/Edit.vue b/resources/js/Pages/Monitoring/Websites/Edit.vue new file mode 100644 index 0000000..f736290 --- /dev/null +++ b/resources/js/Pages/Monitoring/Websites/Edit.vue @@ -0,0 +1,176 @@ + + + diff --git a/resources/js/Pages/Monitoring/Websites/Index.vue b/resources/js/Pages/Monitoring/Websites/Index.vue new file mode 100644 index 0000000..fe3186e --- /dev/null +++ b/resources/js/Pages/Monitoring/Websites/Index.vue @@ -0,0 +1,187 @@ + + + diff --git a/resources/js/Pages/Monitoring/Websites/Show.vue b/resources/js/Pages/Monitoring/Websites/Show.vue new file mode 100644 index 0000000..db8a572 --- /dev/null +++ b/resources/js/Pages/Monitoring/Websites/Show.vue @@ -0,0 +1,298 @@ + + + diff --git a/routes/web.php b/routes/web.php index bd2d591..b67cb38 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,8 @@ use App\Http\Controllers\DeploymentController; use App\Http\Controllers\GithubConnectionController; use App\Http\Controllers\GithubRepositoryImportController; +use App\Http\Controllers\Monitoring\WebsiteController; +use App\Http\Controllers\Monitoring\WebsiteProbeController; use App\Http\Controllers\OverviewController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProjectController; @@ -88,6 +90,14 @@ // Spec 021 — cross-repo deployment timeline (workflow runs). Route::get('/deployments', [DeploymentController::class, 'index']) ->name('deployments.index'); + + // Spec 023 — website monitoring CRUD + manual probe. + // Nested under /monitoring/* so phase-6 hosts can sit beside it. + Route::resource('monitoring/websites', WebsiteController::class) + ->parameters(['websites' => 'website']) + ->names('monitoring.websites'); + Route::post('/monitoring/websites/{website}/probe', WebsiteProbeController::class) + ->name('monitoring.websites.probe'); }); // Spec 017 — GitHub webhooks (no auth/CSRF; signature-verified inside). diff --git a/specs/README.md b/specs/README.md index b44ad52..0e49ccc 100644 --- a/specs/README.md +++ b/specs/README.md @@ -40,7 +40,7 @@ Status legend: ⬜ not started · 🟡 in progress · 🟢 done · 🔴 blocked | 2 | GitHub Integration MVP | 🟢 | 4/4 specs done (013–016). Phase complete. | | 3 | GitHub Webhooks & Activity Feed | 🟢 | 3/3 specs done (017–019). Phase complete. | | 4 | Deployments & CI/CD | 🟢 | 3/3 specs done (020–022). Phase complete. | -| 5 | Website Monitoring | ⬜ | — | +| 5 | Website Monitoring | 🟡 | 1/3 specs done (023). 024–025 next. | | 6 | Docker Host Agent MVP | ⬜ | — | | 7 | Alerts Engine | ⬜ | — | | 8 | Analytics & Health Scores | ⬜ | — | diff --git a/specs/phase-5-monitoring/023-website-monitor-mvp.md b/specs/phase-5-monitoring/023-website-monitor-mvp.md index a0522b2..9ff2e43 100644 --- a/specs/phase-5-monitoring/023-website-monitor-mvp.md +++ b/specs/phase-5-monitoring/023-website-monitor-mvp.md @@ -1,7 +1,7 @@ --- spec: website-monitor-mvp phase: 5-monitoring -status: in-progress +status: done owner: yoany created: 2026-04-30 updated: 2026-04-30 @@ -163,6 +163,13 @@ Dated notes as work progresses. ### 2026-04-30 - Spec drafted. - Opened issue [#69](https://github.com/Copxer/nexus/issues/69) and branch `spec/023-website-monitor-mvp` off `main`. +- Implementation complete. Two migrations + two enums + two models + two factories. `WebsiteProbeResult` DTO + `RunWebsiteProbeAction` (pure HTTP, classifies up/slow/down/error against the 3000ms hard threshold) + `RecordWebsiteCheckAction` (persists a `WebsiteCheck`, updates `Website.last_*`, treats `Slow` as success for `last_success_at`). `WebsitePolicy` gates create/update/delete/probe to project owners. `WebsiteController` resourceful CRUD + `WebsiteProbeController` single-action sync probe. Sidebar `Monitoring` flipped from disabled → linked to the index page. +- 28 tests across 5 test files: probe action (6), record action (6), policy (4), controller (9), probe controller (3). 19 net new passing tests; full suite 310 passed (was 291). The 51 failures are env-only POST CSRF (419) — same baseline pattern; CI passes them. +- Self-review pass via `superpowers:code-reviewer`; addressed all 3 recommendations: + - Narrowed the probe action's catch list to `ConnectionException|RequestException` so programmer bugs (typo, OOM, future enum drift) bubble up loudly instead of getting silently classified as "site down." + - Added belt-and-suspenders `authorize('create', [Website::class, $project])` in `WebsiteController::store` after validation, mirroring the `RepositoryController::store` pattern. + - Migration column comment on `response_time_ms` clarifies it's wall-clock (DNS / TCP / TLS / send / receive), not server-reported TTFB; future timing fields will sit alongside. +- Cross-tenant `view` parity flagged in PR body — same single-tenant gap as `RepositoryPolicy`; uniform fix when teams ship. ## Decisions (locked 2026-04-30) - **URL nesting under `/monitoring/`** — anticipates phase-6 hosts as a sibling. Sidebar label stays "Monitoring" pointing at `/monitoring/websites`. diff --git a/specs/phase-5-monitoring/README.md b/specs/phase-5-monitoring/README.md index 6f4bcc5..999ff19 100644 --- a/specs/phase-5-monitoring/README.md +++ b/specs/phase-5-monitoring/README.md @@ -9,7 +9,7 @@ Stand up website uptime + response-time monitoring end-to-end. By the end of pha | # | Task | Status | |---|------|--------| -| 023 | Website monitor MVP (CRUD + manual probe + check history) | ⬜ | +| 023 | Website monitor MVP (CRUD + manual probe + check history) | 🟢 | | 024 | Scheduled checks + uptime calc + activity events | ⬜ | | 025 | Overview integration + Reverb live updates + perf charts | ⬜ | diff --git a/tests/Feature/Monitoring/RecordWebsiteCheckActionTest.php b/tests/Feature/Monitoring/RecordWebsiteCheckActionTest.php new file mode 100644 index 0000000..673984f --- /dev/null +++ b/tests/Feature/Monitoring/RecordWebsiteCheckActionTest.php @@ -0,0 +1,150 @@ +create(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + + return Website::factory()->create([ + 'project_id' => $project->id, + 'status' => WebsiteStatus::Pending->value, + ]); + } + + public function test_persists_a_check_row_and_returns_it(): void + { + $website = $this->makeWebsite(); + $result = new WebsiteProbeResult( + status: WebsiteCheckStatus::Up, + httpStatusCode: 200, + responseTimeMs: 142, + errorMessage: null, + ); + + $check = (new RecordWebsiteCheckAction)->execute($website, $result); + + $this->assertInstanceOf(WebsiteCheck::class, $check); + $this->assertSame(1, WebsiteCheck::query()->count()); + $this->assertSame(WebsiteCheckStatus::Up, $check->status); + $this->assertSame(200, $check->http_status_code); + $this->assertSame(142, $check->response_time_ms); + } + + public function test_up_result_updates_status_and_last_success_at(): void + { + $website = $this->makeWebsite(); + $result = new WebsiteProbeResult( + status: WebsiteCheckStatus::Up, + httpStatusCode: 200, + responseTimeMs: 100, + errorMessage: null, + ); + + (new RecordWebsiteCheckAction)->execute($website, $result); + + $website->refresh(); + $this->assertSame(WebsiteStatus::Up, $website->status); + $this->assertNotNull($website->last_checked_at); + $this->assertNotNull($website->last_success_at); + $this->assertNull($website->last_failure_at); + } + + public function test_slow_result_counts_as_success_for_last_success_at(): void + { + $website = $this->makeWebsite(); + $result = new WebsiteProbeResult( + status: WebsiteCheckStatus::Slow, + httpStatusCode: 200, + responseTimeMs: 4_200, + errorMessage: null, + ); + + (new RecordWebsiteCheckAction)->execute($website, $result); + + $website->refresh(); + $this->assertSame(WebsiteStatus::Slow, $website->status); + $this->assertNotNull($website->last_success_at); + $this->assertNull($website->last_failure_at); + } + + public function test_down_result_updates_status_and_last_failure_at(): void + { + $website = $this->makeWebsite(); + $result = new WebsiteProbeResult( + status: WebsiteCheckStatus::Down, + httpStatusCode: 503, + responseTimeMs: 220, + errorMessage: 'HTTP 503: Service Unavailable', + ); + + (new RecordWebsiteCheckAction)->execute($website, $result); + + $website->refresh(); + $this->assertSame(WebsiteStatus::Down, $website->status); + $this->assertNotNull($website->last_checked_at); + $this->assertNull($website->last_success_at); + $this->assertNotNull($website->last_failure_at); + } + + public function test_error_result_updates_status_and_last_failure_at(): void + { + $website = $this->makeWebsite(); + $result = new WebsiteProbeResult( + status: WebsiteCheckStatus::Error, + httpStatusCode: null, + responseTimeMs: null, + errorMessage: 'Connection timed out after 10000ms', + ); + + (new RecordWebsiteCheckAction)->execute($website, $result); + + $website->refresh(); + $this->assertSame(WebsiteStatus::Error, $website->status); + $this->assertNotNull($website->last_failure_at); + $this->assertNull($website->last_success_at); + } + + public function test_subsequent_check_does_not_clobber_prior_success_timestamp(): void + { + // A successful run, then a failure: last_success_at must be + // preserved (it's "last successful probe", not "last probe + // when status was Up"). The same rule applies in reverse. + $website = $this->makeWebsite(); + + (new RecordWebsiteCheckAction)->execute( + $website, + new WebsiteProbeResult(WebsiteCheckStatus::Up, 200, 100, null), + ); + $firstSuccess = $website->fresh()->last_success_at; + + // Sleep so the timestamps differ enough to compare reliably. + sleep(1); + + (new RecordWebsiteCheckAction)->execute( + $website, + new WebsiteProbeResult(WebsiteCheckStatus::Down, 500, 150, 'HTTP 500'), + ); + + $fresh = $website->fresh(); + $this->assertEquals($firstSuccess->toIso8601String(), $fresh->last_success_at->toIso8601String()); + $this->assertNotNull($fresh->last_failure_at); + $this->assertSame(WebsiteStatus::Down, $fresh->status); + } +} diff --git a/tests/Feature/Monitoring/RunWebsiteProbeActionTest.php b/tests/Feature/Monitoring/RunWebsiteProbeActionTest.php new file mode 100644 index 0000000..ba4df30 --- /dev/null +++ b/tests/Feature/Monitoring/RunWebsiteProbeActionTest.php @@ -0,0 +1,120 @@ +create(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + + return Website::factory()->create(array_merge([ + 'project_id' => $project->id, + 'url' => 'https://example.com/health', + 'expected_status_code' => 200, + 'timeout_ms' => 10_000, + ], $overrides)); + } + + public function test_classifies_matching_status_as_up(): void + { + Http::fake([ + 'example.com/*' => Http::response('OK', 200), + ]); + + $result = (new RunWebsiteProbeAction)->execute($this->makeWebsite()); + + $this->assertSame(WebsiteCheckStatus::Up, $result->status); + $this->assertSame(200, $result->httpStatusCode); + $this->assertNotNull($result->responseTimeMs); + $this->assertNull($result->errorMessage); + } + + public function test_classifies_status_mismatch_as_down(): void + { + Http::fake([ + 'example.com/*' => Http::response('Service Unavailable', 503), + ]); + + $result = (new RunWebsiteProbeAction)->execute($this->makeWebsite()); + + $this->assertSame(WebsiteCheckStatus::Down, $result->status); + $this->assertSame(503, $result->httpStatusCode); + $this->assertNotNull($result->errorMessage); + $this->assertStringContainsString('503', $result->errorMessage); + } + + public function test_classifies_slow_response_over_threshold(): void + { + // Fake a delayed response. Laravel's Http::fake doesn't sleep + // by default, so we wrap the response in a callback that + // usleep()s past the 3000ms threshold. + Http::fake(function () { + usleep(3_100_000); // 3.1 seconds + + return Http::response('OK', 200); + }); + + $result = (new RunWebsiteProbeAction)->execute($this->makeWebsite()); + + $this->assertSame(WebsiteCheckStatus::Slow, $result->status); + $this->assertSame(200, $result->httpStatusCode); + $this->assertGreaterThan(3_000, $result->responseTimeMs); + $this->assertNull($result->errorMessage); + } + + public function test_classifies_transport_error(): void + { + Http::fake(function () { + throw new ConnectionException('Connection timed out after 10000ms'); + }); + + $result = (new RunWebsiteProbeAction)->execute($this->makeWebsite()); + + $this->assertSame(WebsiteCheckStatus::Error, $result->status); + $this->assertNull($result->httpStatusCode); + $this->assertNull($result->responseTimeMs); + $this->assertSame('Connection timed out after 10000ms', $result->errorMessage); + } + + public function test_truncates_long_error_messages(): void + { + $longMessage = str_repeat('x', 800); + + Http::fake(function () use ($longMessage) { + throw new ConnectionException($longMessage); + }); + + $result = (new RunWebsiteProbeAction)->execute($this->makeWebsite()); + + $this->assertSame(WebsiteCheckStatus::Error, $result->status); + // Str::limit($x, 500, '…') yields 500 + 1 ellipsis chars. + $this->assertSame(501, mb_strlen($result->errorMessage)); + $this->assertStringEndsWith('…', $result->errorMessage); + } + + public function test_uses_the_configured_http_method(): void + { + Http::fake([ + 'example.com/*' => Http::response('', 200), + ]); + + $website = $this->makeWebsite(['method' => 'HEAD']); + (new RunWebsiteProbeAction)->execute($website); + + Http::assertSent(fn ($req) => $req->method() === 'HEAD'); + } +} diff --git a/tests/Feature/Monitoring/WebsiteControllerTest.php b/tests/Feature/Monitoring/WebsiteControllerTest.php new file mode 100644 index 0000000..2168a15 --- /dev/null +++ b/tests/Feature/Monitoring/WebsiteControllerTest.php @@ -0,0 +1,203 @@ +create(['email_verified_at' => now()]); + } + + public function test_index_lists_websites_under_users_projects(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + Website::factory()->create([ + 'project_id' => $project->id, + 'name' => 'Marketing site', + ]); + + // Sibling user's website must not leak. + $other = $this->verifiedUser(); + $otherProject = Project::factory()->create(['owner_user_id' => $other->id]); + Website::factory()->create(['project_id' => $otherProject->id]); + + $this->actingAs($user) + ->get(route('monitoring.websites.index')) + ->assertSuccessful() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('Monitoring/Websites/Index') + ->has('websites', 1) + ->where('websites.0.name', 'Marketing site') + ); + } + + public function test_create_form_renders_with_owned_projects(): void + { + $user = $this->verifiedUser(); + Project::factory()->create(['owner_user_id' => $user->id]); + + $this->actingAs($user) + ->get(route('monitoring.websites.create')) + ->assertSuccessful() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('Monitoring/Websites/Create') + ->has('projects', 1) + ->has('options.methods') + ->has('options.common_intervals') + ); + } + + public function test_store_creates_a_website_for_project_owner(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + + $response = $this->actingAs($user)->post(route('monitoring.websites.store'), [ + 'project_id' => $project->id, + 'name' => 'Marketing site', + 'url' => 'https://example.com/health', + 'method' => 'GET', + 'expected_status_code' => 200, + 'timeout_ms' => 10_000, + 'check_interval_seconds' => 300, + ]); + + $website = Website::query()->firstWhere('name', 'Marketing site'); + $this->assertNotNull($website); + $this->assertSame('https://example.com/health', $website->url); + $response->assertRedirect(route('monitoring.websites.show', $website)); + } + + public function test_store_rejects_invalid_url(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + + $this->actingAs($user) + ->post(route('monitoring.websites.store'), [ + 'project_id' => $project->id, + 'name' => 'Bad URL', + 'url' => 'not a url', + 'method' => 'GET', + 'expected_status_code' => 200, + 'timeout_ms' => 10_000, + 'check_interval_seconds' => 300, + ]) + ->assertSessionHasErrors('url'); + } + + public function test_store_blocked_for_non_owner_of_target_project(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + + $this->actingAs($other) + ->post(route('monitoring.websites.store'), [ + 'project_id' => $project->id, + 'name' => 'Sneaky site', + 'url' => 'https://example.com', + 'method' => 'GET', + 'expected_status_code' => 200, + 'timeout_ms' => 10_000, + 'check_interval_seconds' => 300, + ]) + ->assertForbidden(); + + $this->assertSame(0, Website::query()->count()); + } + + public function test_show_returns_website_with_recent_checks(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $website = Website::factory()->create(['project_id' => $project->id]); + + WebsiteCheck::factory()->count(3)->create(['website_id' => $website->id]); + + $this->actingAs($user) + ->get(route('monitoring.websites.show', $website)) + ->assertSuccessful() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('Monitoring/Websites/Show') + ->has('website') + ->has('checks', 3) + ->where('canUpdate', true) + ->where('canDelete', true) + ->where('canProbe', true) + ); + } + + public function test_update_changes_the_website_for_owner(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $website = Website::factory()->create([ + 'project_id' => $project->id, + 'name' => 'Old name', + ]); + + $response = $this->actingAs($user)->patch( + route('monitoring.websites.update', $website), + [ + 'name' => 'New name', + 'url' => $website->url, + 'method' => 'GET', + 'expected_status_code' => 200, + 'timeout_ms' => 10_000, + 'check_interval_seconds' => 300, + ], + ); + + $website->refresh(); + $this->assertSame('New name', $website->name); + $response->assertRedirect(route('monitoring.websites.show', $website)); + } + + public function test_update_blocked_for_non_owner(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $website = Website::factory()->create(['project_id' => $project->id]); + + $this->actingAs($other) + ->patch(route('monitoring.websites.update', $website), [ + 'name' => 'Hijacked', + 'url' => $website->url, + 'method' => 'GET', + 'expected_status_code' => 200, + 'timeout_ms' => 10_000, + 'check_interval_seconds' => 300, + ]) + ->assertForbidden(); + } + + public function test_destroy_deletes_for_owner(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $website = Website::factory()->create(['project_id' => $project->id]); + + $this->actingAs($user) + ->delete(route('monitoring.websites.destroy', $website)) + ->assertRedirect(route('monitoring.websites.index')); + + $this->assertNull(Website::query()->find($website->id)); + } +} diff --git a/tests/Feature/Monitoring/WebsitePolicyTest.php b/tests/Feature/Monitoring/WebsitePolicyTest.php new file mode 100644 index 0000000..26fc6ef --- /dev/null +++ b/tests/Feature/Monitoring/WebsitePolicyTest.php @@ -0,0 +1,65 @@ +create(['email_verified_at' => now()]); + } + + public function test_project_owner_can_create_update_delete_probe(): void + { + $owner = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $website = Website::factory()->create(['project_id' => $project->id]); + + $this->assertTrue($owner->can('create', [Website::class, $project])); + $this->assertTrue($owner->can('update', $website)); + $this->assertTrue($owner->can('delete', $website)); + $this->assertTrue($owner->can('probe', $website)); + } + + public function test_non_owner_cannot_modify_or_probe(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $website = Website::factory()->create(['project_id' => $project->id]); + + $this->assertFalse($other->can('create', [Website::class, $project])); + $this->assertFalse($other->can('update', $website)); + $this->assertFalse($other->can('delete', $website)); + $this->assertFalse($other->can('probe', $website)); + } + + public function test_any_verified_user_can_view(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $website = Website::factory()->create(['project_id' => $project->id]); + + $this->assertTrue($other->can('view', $website)); + } + + public function test_unverified_user_is_blocked(): void + { + $owner = $this->verifiedUser(); + $unverified = User::factory()->create(['email_verified_at' => null]); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $website = Website::factory()->create(['project_id' => $project->id]); + + $this->assertFalse($unverified->can('view', $website)); + $this->assertFalse($unverified->can('update', $website)); + } +} diff --git a/tests/Feature/Monitoring/WebsiteProbeControllerTest.php b/tests/Feature/Monitoring/WebsiteProbeControllerTest.php new file mode 100644 index 0000000..159802b --- /dev/null +++ b/tests/Feature/Monitoring/WebsiteProbeControllerTest.php @@ -0,0 +1,73 @@ +create(['email_verified_at' => now()]); + } + + public function test_owner_can_probe_and_a_check_is_persisted(): void + { + Http::fake(['example.com/*' => Http::response('OK', 200)]); + + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $website = Website::factory()->create([ + 'project_id' => $project->id, + 'url' => 'https://example.com/health', + 'expected_status_code' => 200, + ]); + + $this->actingAs($user) + ->from(route('monitoring.websites.show', $website)) + ->post(route('monitoring.websites.probe', $website)) + ->assertRedirect(route('monitoring.websites.show', $website)) + ->assertSessionHas('status'); + + $this->assertSame(1, WebsiteCheck::query()->count()); + $check = WebsiteCheck::query()->first(); + $this->assertSame(WebsiteCheckStatus::Up, $check->status); + + $website->refresh(); + $this->assertSame(WebsiteStatus::Up, $website->status); + $this->assertNotNull($website->last_checked_at); + } + + public function test_non_owner_is_forbidden(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $website = Website::factory()->create(['project_id' => $project->id]); + + $this->actingAs($other) + ->post(route('monitoring.websites.probe', $website)) + ->assertForbidden(); + + $this->assertSame(0, WebsiteCheck::query()->count()); + } + + public function test_unknown_website_returns_404(): void + { + $user = $this->verifiedUser(); + + $this->actingAs($user) + ->post(route('monitoring.websites.probe', 999_999)) + ->assertNotFound(); + } +} From d5a809a46f43fce1c10981834479b3ce27e398bd Mon Sep 17 00:00:00 2001 From: Copxer Date: Thu, 30 Apr 2026 19:12:27 -0700 Subject: [PATCH 3/3] fix(monitoring): allow Create form to open without a preselected project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 023's create() called authorize('create', [Website::class, null]) when no ?project_id= was provided, returning 403. The form needs to open so the user can pick one of their own projects from the dropdown — store() re-authorises at submit time once project_id is in the request. Coarsen the page-level gate to viewAny (any verified user) and demote the preselect lookup to a soft-narrow: a foreign or non-existent project ID drops to null instead of 403'ing. --- .../Monitoring/WebsiteController.php | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/Monitoring/WebsiteController.php b/app/Http/Controllers/Monitoring/WebsiteController.php index 23cc67c..045e791 100644 --- a/app/Http/Controllers/Monitoring/WebsiteController.php +++ b/app/Http/Controllers/Monitoring/WebsiteController.php @@ -43,20 +43,25 @@ public function index(Request $request): Response public function create(Request $request): Response { + $this->authorize('viewAny', Website::class); + $user = $request->user(); // Pre-select via `?project_id=N` so the link from a project - // page lands on the right project. - $preselect = $request->integer('project_id') ?: null; - $project = $preselect !== null - ? Project::query()->where('owner_user_id', $user->id)->find($preselect) + // page lands on the right project. If the project ID resolves + // to one the user owns we authorise create against it; if it + // resolves to a foreign project (or doesn't resolve at all), + // we drop the preselect rather than 403'ing the form — the + // user still needs to see the page to pick one of their own + // projects, and `store` will re-authorise at submit time. + $preselectId = $request->integer('project_id') ?: null; + $preselect = $preselectId !== null + ? Project::query()->where('owner_user_id', $user->id)->find($preselectId) : null; - $this->authorize('create', [Website::class, $project]); - return Inertia::render('Monitoring/Websites/Create', [ 'projects' => $this->ownedProjects($user->id), - 'preselectedProjectId' => $project?->id, + 'preselectedProjectId' => $preselect?->id, 'options' => $this->formOptions(), ]); }