diff --git a/app/Domain/Activity/Queries/RecentActivityForUserQuery.php b/app/Domain/Activity/Queries/RecentActivityForUserQuery.php index 1f0ce78..77d801c 100644 --- a/app/Domain/Activity/Queries/RecentActivityForUserQuery.php +++ b/app/Domain/Activity/Queries/RecentActivityForUserQuery.php @@ -5,6 +5,7 @@ use App\Domain\Activity\ActivityEventPresenter; use App\Models\ActivityEvent; use App\Models\User; +use App\Models\Website; /** * Read-side query for the activity feed shown in the AppLayout right rail @@ -45,16 +46,35 @@ class RecentActivityForUserQuery */ public function handle(User $user, int $limit = self::RAIL_LIMIT): array { - // TODO(future): broaden the predicate when system-emitted events - // (deployments, websites, hosts) start landing without a repository. - // Today every spec-017 webhook event carries a repository_id, so - // the EXISTS subquery against repositories→projects is watertight - // and rows with repository_id IS NULL are filtered out for every - // user — they don't leak across users, but they also don't show. + // Two scoping paths land in the same feed: + // 1. Repository-scoped events (spec 017's webhook handlers + // and deployments — `repository_id` resolves through the + // project's owner). + // 2. Monitoring-scoped events (spec 024 — `source: monitoring`, + // `metadata.website_id` resolves through the website's + // project's owner). These rows have `repository_id` null. + // + // The user's website ids are pre-resolved into a list once so + // the JSON predicate stays cheap (no JSON join per row); cross- + // DB JSON-extract syntax is the same shape on MySQL and SQLite. + $userWebsiteIds = Website::query() + ->whereHas('project', fn ($q) => $q->where('owner_user_id', $user->id)) + ->pluck('id') + ->all(); + return ActivityEvent::query() ->with('repository:id,full_name') - ->whereHas('repository.project', function ($q) use ($user) { - $q->where('owner_user_id', $user->id); + ->where(function ($q) use ($user, $userWebsiteIds) { + $q->whereHas('repository.project', function ($inner) use ($user) { + $inner->where('owner_user_id', $user->id); + }); + + if (! empty($userWebsiteIds)) { + $q->orWhere(function ($inner) use ($userWebsiteIds) { + $inner->where('source', 'monitoring') + ->whereIn('metadata->website_id', $userWebsiteIds); + }); + } }) ->orderByDesc('occurred_at') ->orderByDesc('id') diff --git a/app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php b/app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php index 6b2fe57..3acfda5 100644 --- a/app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php +++ b/app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php @@ -2,11 +2,14 @@ namespace App\Domain\Monitoring\Actions; +use App\Domain\Activity\Actions\CreateActivityEventAction; use App\Domain\Monitoring\Probes\WebsiteProbeResult; +use App\Enums\ActivitySeverity; use App\Enums\WebsiteCheckStatus; use App\Enums\WebsiteStatus; use App\Models\Website; use App\Models\WebsiteCheck; +use Illuminate\Support\Carbon; /** * Persistence half of the probe pipeline. Given a `Website` + a @@ -19,18 +22,31 @@ * bad" without scanning the checks table. * * Returns the persisted `WebsiteCheck` so callers (manual probe - * controller, future scheduler job) can flash it back to the user - * without an extra round-trip. + * controller, scheduler-driven `RunWebsiteCheckJob`) can flash it + * back to the user without an extra round-trip. * - * Activity-event creation on status transitions deliberately lives - * in spec 024 — that's where status transitions are interesting, - * since manual probes are user-triggered and don't need a separate - * notification surface. + * Spec 024: extended to emit `ActivityEvent`s on healthy↔failed + * **category transitions only** — steady-state runs (Up→Up, Down→Down) + * stay silent so the activity feed isn't flooded. + * + * `CreateActivityEventAction` (spec 017) dispatches + * `ActivityEventCreated` (spec 019); spec 024 extended that broadcaster + * to resolve a recipient channel for monitoring-source events via + * `metadata.website_id → website → project → owner_user_id` (since + * monitoring rows have `repository_id = null` and would otherwise + * silently fail to broadcast). Realtime fan-out reaches the right rail. */ class RecordWebsiteCheckAction { + public function __construct( + private readonly CreateActivityEventAction $createActivity, + ) {} + public function execute(Website $website, WebsiteProbeResult $result): WebsiteCheck { + // Capture BEFORE the update so we can detect category swings. + $previousStatus = $website->status; + $checkedAt = now(); $check = WebsiteCheck::query()->create([ @@ -55,9 +71,130 @@ public function execute(Website $website, WebsiteProbeResult $result): WebsiteCh $website->forceFill($updates)->save(); + $this->maybeEmitTransitionActivity($website, $previousStatus, $result, $checkedAt); + return $check; } + /** + * Category transition detector. Three buckets: + * - Healthy = Up | Slow + * - Failed = Down | Error + * - Pending = first-ever probe state (initial seed) + * + * Emits an activity event on: + * - Healthy → Failed (incident — `website.down`, danger) + * - Pending → Failed (incident on first probe — same shape) + * - Failed → Healthy (recovery — `website.up`, success) + * + * Steady-state (Healthy → Healthy, Failed → Failed) and the silent + * Pending → Healthy first-probe-success path emit nothing — keeps + * the activity feed signal-dense. + */ + private function maybeEmitTransitionActivity( + Website $website, + ?WebsiteStatus $previousStatus, + WebsiteProbeResult $result, + Carbon $checkedAt, + ): void { + $previousCategory = $this->categoryFor($previousStatus); + $currentCategory = $this->categoryForCheckStatus($result->status); + + if ($previousCategory === $currentCategory) { + return; // Steady state — silent. + } + + if ($previousCategory === 'pending' && $currentCategory === 'healthy') { + return; // First probe + everything fine — uneventful. + } + + if ($currentCategory === 'failed') { + $this->createActivity->execute([ + 'event_type' => 'website.down', + 'severity' => ActivitySeverity::Danger, + 'title' => "{$website->name} went down", + 'description' => $this->failureDescription($result), + 'occurred_at' => $checkedAt, + 'source' => 'monitoring', + 'metadata' => [ + 'website_id' => $website->id, + 'url' => $website->url, + 'http_status_code' => $result->httpStatusCode, + 'error_message' => $result->errorMessage, + ], + ]); + + return; + } + + // Failed → Healthy (the only remaining transition). + $this->createActivity->execute([ + 'event_type' => 'website.up', + 'severity' => ActivitySeverity::Success, + 'title' => "{$website->name} recovered", + 'description' => $result->responseTimeMs !== null + ? "Up in {$result->responseTimeMs}ms" + : 'Up', + 'occurred_at' => $checkedAt, + 'source' => 'monitoring', + 'metadata' => [ + 'website_id' => $website->id, + 'url' => $website->url, + 'http_status_code' => $result->httpStatusCode, + 'response_time_ms' => $result->responseTimeMs, + ], + ]); + } + + /** + * Bucket a parent `WebsiteStatus` into healthy / failed / pending. + * Null is treated as `pending` — defensive against a brand-new + * row that bypassed the factory's default. + * + * @return 'healthy'|'failed'|'pending' + */ + private function categoryFor(?WebsiteStatus $status): string + { + return match ($status) { + WebsiteStatus::Up, WebsiteStatus::Slow => 'healthy', + WebsiteStatus::Down, WebsiteStatus::Error => 'failed', + null, WebsiteStatus::Pending => 'pending', + }; + } + + /** + * Bucket a `WebsiteCheckStatus` (the freshly-probed result) into + * the same healthy / failed buckets. There's no `pending` here — + * a recorded check always reflects an actual probe. + * + * @return 'healthy'|'failed' + */ + private function categoryForCheckStatus(WebsiteCheckStatus $status): string + { + return match ($status) { + WebsiteCheckStatus::Up, WebsiteCheckStatus::Slow => 'healthy', + WebsiteCheckStatus::Down, WebsiteCheckStatus::Error => 'failed', + }; + } + + /** + * Human-readable failure context for the activity event description. + * Prefer the captured error message (HTTP-layer body preview or + * transport error) over a bare HTTP status. + */ + private function failureDescription(WebsiteProbeResult $result): string + { + if ($result->errorMessage !== null && $result->errorMessage !== '') { + return $result->errorMessage; + } + + if ($result->httpStatusCode !== null) { + return "HTTP {$result->httpStatusCode}"; + } + + return 'Probe failed'; + } + /** * `WebsiteCheckStatus` and `WebsiteStatus` differ only by the * `pending` value (parent only). Map 1:1 by name. diff --git a/app/Domain/Monitoring/Jobs/DispatchDueWebsiteChecksJob.php b/app/Domain/Monitoring/Jobs/DispatchDueWebsiteChecksJob.php new file mode 100644 index 0000000..e25300e --- /dev/null +++ b/app/Domain/Monitoring/Jobs/DispatchDueWebsiteChecksJob.php @@ -0,0 +1,68 @@ +everyMinute()->withoutOverlapping()`. + * + * Loads every `Website` row, filters to "due now" in PHP (the + * predicate `last_checked_at + check_interval_seconds <= now()` is + * cross-DB awkward to express in raw SQL), and dispatches a per-website + * `RunWebsiteCheckJob` for each. The probe HTTP request happens in + * the per-website job — the dispatcher itself stays fast so a slow + * site doesn't block the every-minute tick. + * + * Soft cap of 500 websites per dispatcher run keeps a runaway + * configuration from amplifying into thousands of queued jobs in a + * single tick. Ordered by `last_checked_at` ascending with nulls + * first so the oldest-stale rows always land in the cap window — + * an `orderBy('id')` would silently strand the high-id tail when + * total > cap. Phase-1 expectation is well below the cap; revisit + * (cursor-based pagination, distributed locks) when a real account + * approaches it. + */ +class DispatchDueWebsiteChecksJob implements ShouldQueue +{ + use Dispatchable; + use InteractsWithQueue; + use Queueable; + use SerializesModels; + + /** Single attempt; the next every-minute tick is the retry path. */ + public int $tries = 1; + + /** Hard cap on websites picked up per tick. */ + private const SOFT_CAP = 500; + + public function handle(): void + { + $now = now(); + + Website::query() + // Never-checked rows always due → land at the head of the + // queue. After that, oldest-stale first. + ->orderByRaw('last_checked_at IS NULL DESC') + ->orderBy('last_checked_at') + ->limit(self::SOFT_CAP) + ->get() + ->filter(function (Website $website) use ($now) { + if ($website->last_checked_at === null) { + return true; + } + + return $website->last_checked_at + ->copy() + ->addSeconds($website->check_interval_seconds) + ->lessThanOrEqualTo($now); + }) + ->each(fn (Website $website) => RunWebsiteCheckJob::dispatch($website->id)); + } +} diff --git a/app/Domain/Monitoring/Jobs/RunWebsiteCheckJob.php b/app/Domain/Monitoring/Jobs/RunWebsiteCheckJob.php new file mode 100644 index 0000000..1b51716 --- /dev/null +++ b/app/Domain/Monitoring/Jobs/RunWebsiteCheckJob.php @@ -0,0 +1,53 @@ +find($this->websiteId); + + if ($website === null) { + return; + } + + $result = $probe->execute($website); + $record->execute($website, $result); + } +} diff --git a/app/Domain/Monitoring/Queries/GetWebsitePerformanceSummaryQuery.php b/app/Domain/Monitoring/Queries/GetWebsitePerformanceSummaryQuery.php new file mode 100644 index 0000000..e694379 --- /dev/null +++ b/app/Domain/Monitoring/Queries/GetWebsitePerformanceSummaryQuery.php @@ -0,0 +1,85 @@ + $this->uptimeFor($website, $now->copy()->subDay()), + 'uptime_7d' => $this->uptimeFor($website, $now->copy()->subDays(7)), + 'uptime_30d' => $this->uptimeFor($website, $now->copy()->subDays(30)), + 'last_incident_at' => $this->lastIncidentAt($website), + ]; + } + + /** + * Uptime % for a half-open window `[from, now)`. Null when the + * window is empty. + */ + private function uptimeFor(Website $website, Carbon $from): ?float + { + $total = WebsiteCheck::query() + ->where('website_id', $website->id) + ->where('checked_at', '>=', $from) + ->count(); + + if ($total === 0) { + return null; + } + + $successful = WebsiteCheck::query() + ->where('website_id', $website->id) + ->where('checked_at', '>=', $from) + ->whereIn('status', ['up', 'slow']) + ->count(); + + return round(($successful / $total) * 100, 2); + } + + /** + * `checked_at` of the most recent failed check (down or error), + * or null if the website has never failed. + */ + private function lastIncidentAt(Website $website): ?Carbon + { + return WebsiteCheck::query() + ->where('website_id', $website->id) + ->whereIn('status', ['down', 'error']) + ->orderByDesc('checked_at') + ->orderByDesc('id') + ->value('checked_at'); + } +} diff --git a/app/Events/ActivityEventCreated.php b/app/Events/ActivityEventCreated.php index 990c60f..ebe8625 100644 --- a/app/Events/ActivityEventCreated.php +++ b/app/Events/ActivityEventCreated.php @@ -4,6 +4,7 @@ use App\Domain\Activity\ActivityEventPresenter; use App\Models\ActivityEvent; +use App\Models\Website; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; @@ -34,28 +35,63 @@ public function __construct(public readonly ActivityEvent $activityEvent) {} * Broadcast on the project owner's private activity channel. * `users.{userId}.activity` is authorized in `routes/channels.php`. * + * Two scoping paths: + * 1. **Repo-scoped events** (spec 017's webhook handlers, + * spec 020's deployments) — channel resolves through + * `repository → project → owner_user_id`. + * 2. **Monitoring-scoped events** (spec 024 — `source: monitoring`, + * `repository_id` is null) — channel resolves through + * `metadata.website_id → website → project → owner_user_id`. + * + * If neither path resolves to an owner the broadcast no-ops; the + * row still exists in the DB, and `RecentActivityForUserQuery` + * picks it up on the next page-load refresh. + * * @return list */ public function broadcastOn(): array { - $repository = $this->activityEvent->repository; + $ownerUserId = $this->resolveOwnerUserId(); - // No repository → no project → no recipient. Silently drop the - // broadcast (the row still exists in the DB; spec 018's - // page-load query already filters these out for every user). - if ($repository === null) { + if ($ownerUserId === null) { return []; } - $project = $repository->project; + return [ + new PrivateChannel("users.{$ownerUserId}.activity"), + ]; + } - if ($project === null || $project->owner_user_id === null) { - return []; + /** + * Resolve the broadcast recipient user id. Returns null when the + * event isn't tied to any owner (orphan rows, system events + * without a website / repository). + */ + private function resolveOwnerUserId(): ?int + { + // Repo-scoped path (specs 017 / 020). + if ($this->activityEvent->repository_id !== null) { + $repository = $this->activityEvent->repository; + $project = $repository?->project; + + return $project?->owner_user_id; } - return [ - new PrivateChannel("users.{$project->owner_user_id}.activity"), - ]; + // Monitoring-scoped path (spec 024). + if ($this->activityEvent->source === 'monitoring') { + $metadata = $this->activityEvent->metadata ?? []; + $websiteId = $metadata['website_id'] ?? null; + + if ($websiteId === null) { + return null; + } + + $website = Website::query()->find($websiteId); + + return $website?->project?->owner_user_id; + } + + return null; } /** diff --git a/app/Http/Controllers/Monitoring/WebsiteController.php b/app/Http/Controllers/Monitoring/WebsiteController.php index 045e791..84d9928 100644 --- a/app/Http/Controllers/Monitoring/WebsiteController.php +++ b/app/Http/Controllers/Monitoring/WebsiteController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Monitoring; +use App\Domain\Monitoring\Queries\GetWebsitePerformanceSummaryQuery; use App\Http\Controllers\Controller; use App\Http\Requests\Monitoring\StoreWebsiteRequest; use App\Http\Requests\Monitoring\UpdateWebsiteRequest; @@ -81,8 +82,11 @@ public function store(StoreWebsiteRequest $request): RedirectResponse ->with('status', "Monitor created for {$website->name}."); } - public function show(Request $request, Website $website): Response - { + public function show( + Request $request, + Website $website, + GetWebsitePerformanceSummaryQuery $summaryQuery, + ): Response { $this->authorize('view', $website); $website->loadMissing('project:id,slug,name,color,icon,owner_user_id'); @@ -103,9 +107,21 @@ public function show(Request $request, Website $website): Response ]) ->all(); + // Spec 024 — uptime % over 24h / 7d / 30d windows + last + // incident timestamp. Three count queries; cheap at phase-1 + // scale, no caching layer. + $rawSummary = $summaryQuery->execute($website); + $summary = [ + 'uptime_24h' => $rawSummary['uptime_24h'], + 'uptime_7d' => $rawSummary['uptime_7d'], + 'uptime_30d' => $rawSummary['uptime_30d'], + 'last_incident_at' => $rawSummary['last_incident_at']?->diffForHumans(), + ]; + return Inertia::render('Monitoring/Websites/Show', [ 'website' => $this->transform($website), 'checks' => $checks, + 'summary' => $summary, 'canUpdate' => $request->user()?->can('update', $website) ?? false, 'canDelete' => $request->user()?->can('delete', $website) ?? false, 'canProbe' => $request->user()?->can('probe', $website) ?? false, diff --git a/resources/js/Pages/Monitoring/Websites/Show.vue b/resources/js/Pages/Monitoring/Websites/Show.vue index 018c85e..bc65bf7 100644 --- a/resources/js/Pages/Monitoring/Websites/Show.vue +++ b/resources/js/Pages/Monitoring/Websites/Show.vue @@ -45,14 +45,25 @@ interface CheckRow { checked_at_iso: string | null; } +interface SummaryShape { + uptime_24h: number | null; + uptime_7d: number | null; + uptime_30d: number | null; + last_incident_at: string | null; +} + const props = defineProps<{ website: WebsiteShape; checks: CheckRow[]; + summary: SummaryShape; canUpdate: boolean; canDelete: boolean; canProbe: boolean; }>(); +const formatUptime = (rate: number | null): string => + rate === null ? '—%' : `${rate}%`; + // `statusTone` re-exported from `@/lib/websiteStyles` above so the // four consumers stay in sync when the WebsiteStatus enum grows. @@ -213,6 +224,51 @@ const confirmDelete = () => { + +
+
+
+ Uptime · 24h +
+
+ {{ formatUptime(summary.uptime_24h) }} +
+
+
+
+ Uptime · 7d +
+
+ {{ formatUptime(summary.uptime_7d) }} +
+
+
+
+ Uptime · 30d +
+
+ {{ formatUptime(summary.uptime_30d) }} +
+
+
+
+ Last incident +
+
+ {{ summary.last_incident_at ?? 'Never' }} +
+
+
diff --git a/routes/console.php b/routes/console.php index 88473eb..927070f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ everyTenMinutes(); Schedule::command('inspire')->hourly(); + +// Spec 024 — every-minute dispatcher that picks website monitors whose +// configured `check_interval_seconds` has elapsed. The dispatcher itself +// stays fast (DB read + filter); per-website probes run async via +// `RunWebsiteCheckJob`. `withoutOverlapping()` guards against a slow +// dispatcher tick stacking onto the next minute. Production needs +// `php artisan schedule:work` (or cron) running. +Schedule::job(new DispatchDueWebsiteChecksJob) + ->everyMinute() + ->name('monitoring:dispatch-due-website-checks') + ->withoutOverlapping(); diff --git a/specs/README.md b/specs/README.md index 0e49ccc..33b0000 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 | 🟡 | 1/3 specs done (023). 024–025 next. | +| 5 | Website Monitoring | 🟡 | 2/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/024-scheduled-checks-and-uptime.md b/specs/phase-5-monitoring/024-scheduled-checks-and-uptime.md new file mode 100644 index 0000000..99196f0 --- /dev/null +++ b/specs/phase-5-monitoring/024-scheduled-checks-and-uptime.md @@ -0,0 +1,148 @@ +--- +spec: scheduled-checks-and-uptime +phase: 5-monitoring +status: done +owner: yoany +created: 2026-04-30 +updated: 2026-04-30 +issue: https://github.com/Copxer/nexus/issues/73 +branch: spec/024-scheduled-checks-and-uptime +--- + +# 024 — Scheduled checks + uptime calc + activity events + +## Goal +Automate the website probes shipped in spec 023, calculate count-based uptime % over 24h / 7d / 30d windows, and surface incident / recovery transitions on the activity feed. After this spec a website monitor created via the UI will probe itself every `check_interval_seconds` without user intervention, and a `down → up` flip will land on the right rail with `severity: success`. + +This is the **automation half** of phase 5. Spec 023 shipped CRUD + manual probes; spec 025 wires the Overview KPI + Reverb live updates on top of this data. + +Roadmap reference: §8.8 Website Performance Monitoring (scheduler example, performance summary query, status field), §19 Phase 5 acceptance criteria ("System checks URL every configured interval", "Uptime % is calculated", "Slow/down site triggers alert"). + +## Scope +**In scope:** + +- **`App\Domain\Monitoring\Jobs\DispatchDueWebsiteChecksJob`** — scheduler-bound dispatcher. + - Loads all websites (capped to 500 as a sanity bound; revisit when a real account approaches it). For each, computes "is due now" in PHP — `last_checked_at === null OR last_checked_at + check_interval_seconds <= now()`. + - **Why filter in PHP**, not SQL: the dynamic `last_checked_at + check_interval_seconds` predicate would need raw SQL that diverges across MySQL (`DATE_ADD`) and SQLite (`datetime(...)`). At phase-1 row counts (≤500 websites × the every-minute cadence) the in-PHP loop is sub-millisecond. + - For each due website, dispatches `RunWebsiteCheckJob::dispatch($website->id)`. + - `tries = 1` (mirrors the existing GitHub sync jobs). A failed dispatcher run just retries on the next minute tick. + - Doesn't itself perform HTTP probes — that's the per-website job's responsibility, so a slow probe doesn't block the dispatcher. + +- **`App\Domain\Monitoring\Jobs\RunWebsiteCheckJob`** — per-website async wrapper. + - Constructor: `int $websiteId`. Loads the row inside `handle()` (skip if deleted between dispatch and run). + - Resolves `RunWebsiteProbeAction` + `RecordWebsiteCheckAction` from the container, calls them in sequence. + - The probe's HTTP request is the slow part; running async means hundreds of monitors can probe in parallel without blocking the dispatcher. + - `tries = 1`. On failure the next every-minute dispatcher run will retry naturally. + +- **Laravel scheduler binding** in `routes/console.php`: + ```php + Schedule::job(new DispatchDueWebsiteChecksJob)->everyMinute() + ->name('monitoring:dispatch-due-website-checks') + ->withoutOverlapping(); + ``` + `withoutOverlapping()` guards against a slow dispatcher tick stacking onto the next minute's run. Production needs `php artisan schedule:work` (or cron) running. + +- **Activity event emission on status transitions.** Extend `RecordWebsiteCheckAction` to: + - Capture `$previousStatus = $website->status` before the update (it's an enum at this point — `Pending|Up|Down|Slow|Error`). + - Apply the existing persistence path. + - Compare *categories* (Healthy = `Up|Slow`, Failed = `Down|Error`, Pending = first-ever probe state) and emit an activity event on **category transitions only**: + - `Healthy → Failed` OR `Pending → Failed` → `event_type: website.down`, `severity: danger`, title `"{name} went down"`, description `"HTTP {code}"` or the captured error message. + - `Failed → Healthy` → `event_type: website.up`, `severity: success`, title `"{name} recovered"`, description `"Up in {response_time_ms}ms"`. + - Steady-state checks (Healthy → Healthy, Failed → Failed, Pending → Healthy) emit nothing — keeps the feed signal-dense. + - Reuses the existing `CreateActivityEventAction` (spec 017) so the event broadcasts via `ActivityEventCreated` (spec 019). Spec 019's broadcaster originally resolved the recipient channel through `repository → project → owner_user_id`, which would silently drop monitoring events (`repository_id` is null on those rows). **Extend `ActivityEventCreated::broadcastOn()`** to additionally resolve via `metadata.website_id → website → project → owner_user_id` for `source === 'monitoring'` rows so the right rail receives them in realtime. + - `source: 'monitoring'` (new value alongside the existing `'github'`). Stored as a free string per spec 017's schema; no migration needed. + - `metadata` carries `{ website_id, url, http_status_code, error_message }` for future drill-down. + - Activity events reference the website indirectly — `repository_id` is null because monitoring isn't repo-scoped. `RecentActivityForUserQuery` (spec 018) currently filters by `whereHas('repository.project')`, so monitoring events would be filtered OUT today. **Extend that query** to also include events whose source is `'monitoring'` AND whose `metadata->website_id` resolves to a website under one of the user's projects. + +- **`App\Domain\Monitoring\Queries\GetWebsitePerformanceSummaryQuery`** — count-based uptime aggregate. + - `execute(Website $website): array` returns `{ uptime_24h, uptime_7d, uptime_30d, last_incident_at }`. + - Each `uptime_*` is a float 0–100 (rounded to 2 decimals) or `null` when no checks landed in the window. + - **Definition**: `successful_checks / total_checks * 100`, where `successful = status IN ('up', 'slow')`. Slow counts as up — a successful response that was slow is still uptime. + - **Window**: `checked_at >= now() - 24h / 7d / 30d`. + - `last_incident_at` is the `checked_at` of the most recent `down` or `error` check, or `null` if the monitor has never failed. + - Three count queries per call (24h / 7d / 30d totals + a single "successful" lookup per window) plus the last-incident lookup. Cheap at phase-1 scale; cache later if needed. + +- **Show page integration.** + - `WebsiteController::show` injects `GetWebsitePerformanceSummaryQuery` and adds `summary` to the Inertia payload. + - `Pages/Monitoring/Websites/Show.vue` renders a stats strip in the header dl: 24h / 7d / 30d uptime % + "Last incident X ago". `null` rates render as `—%`. + +- **Tests** (Pest/PHPUnit): + - `DispatchDueWebsiteChecksJobTest` — Queue::fake'd; due websites dispatch a per-website job, undue ones don't. + - `RunWebsiteCheckJobTest` — Http::fake'd; persists a check + updates the website's `last_*` fields. + - `RecordWebsiteCheckActionTest` — extend with: emits incident event on healthy→failed, emits recovery event on failed→healthy, emits incident on pending→failed, emits NOTHING on steady-state transitions. + - `GetWebsitePerformanceSummaryQueryTest` — empty windows return null, mixed checks compute correctly per window, slow counts as success, last-incident pinpoints the most-recent failed check. + - `WebsiteControllerTest::test_show_returns_summary` — extends the existing show test with a `has('summary')` assertion. + - `RecentActivityForUserQueryTest` — extend with a monitoring-source event whose `metadata.website_id` resolves to the user's project; assert it surfaces alongside GitHub events. + +**Out of scope:** + +- Reverb broadcast for *check completion* → spec 025 covers the live dashboard updates. Activity events already broadcast via spec 019; that's the only realtime in this spec. +- Overview KPI integration (replacing `MOCK_KPIS['uptime']` with real website uptime data) → spec 025. +- Per-website "incident timeline" with correlated events → future polish. +- Configurable transition rules (e.g. "notify only on down lasting > 5 minutes") → debounce / SLA work for a future spec. +- Slow-as-incident classification — `slow` stays a soft signal, doesn't generate activity events. +- Per-region probes / scheduled probes from multiple geos → roadmap §8.8 "Later". +- DNS / TLS / TTFB timing fields on `WebsiteCheck` → roadmap §8.8 "Later". + +## Plan + +1. **`DispatchDueWebsiteChecksJob`** + tests — pure dispatcher, no HTTP. +2. **`RunWebsiteCheckJob`** + tests — async wrapper, reuses spec-023 actions. +3. **Schedule binding** — `routes/console.php`. +4. **Extend `RecordWebsiteCheckAction`** to detect category transitions and emit activity events via `CreateActivityEventAction`. Tests for each transition kind. +5. **`GetWebsitePerformanceSummaryQuery`** + tests (count-based; null on empty windows). +6. **Extend `WebsiteController::show`** payload with `summary`. Update controller test. +7. **Update `Pages/Monitoring/Websites/Show.vue`** — uptime stats + last-incident line in the header dl. +8. **Extend `RecentActivityForUserQuery`** to include monitoring-source events whose `metadata.website_id` resolves to a website under the user's projects. +9. **Self-review pass via `superpowers:code-reviewer`**. +10. **Open the PR**. + +## Acceptance criteria +- [ ] `DispatchDueWebsiteChecksJob` runs on the every-minute schedule via `Schedule::job(...)->everyMinute()->withoutOverlapping()`. +- [ ] The dispatcher only dispatches per-website jobs for websites whose `last_checked_at + check_interval_seconds <= now()` (or `last_checked_at` is null). +- [ ] `RunWebsiteCheckJob` persists a `WebsiteCheck` row and updates the parent `Website.{status,last_checked_at,last_success_at,last_failure_at}` per the spec-023 actions. +- [ ] `RecordWebsiteCheckAction` emits an `ActivityEvent` only on healthy↔failed category transitions; steady-state runs emit nothing. +- [ ] Incident events: `event_type: website.down`, `severity: danger`. Recovery events: `event_type: website.up`, `severity: success`. Both carry `source: monitoring` + `metadata.website_id`. +- [ ] `GetWebsitePerformanceSummaryQuery` returns `{uptime_24h, uptime_7d, uptime_30d, last_incident_at}`; rate is null when no checks in the window; slow counts as up. +- [ ] `WebsiteController::show` Inertia payload includes the summary; `Show.vue` renders 24h / 7d / 30d % + "Last incident" line. +- [ ] `RecentActivityForUserQuery` includes monitoring-source events for the authenticated user's websites alongside the existing GitHub events. +- [ ] 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 +- `app/Domain/Monitoring/Jobs/DispatchDueWebsiteChecksJob.php` — new. +- `app/Domain/Monitoring/Jobs/RunWebsiteCheckJob.php` — new. +- `app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php` — extend with transition-detection + activity event emission. +- `app/Domain/Monitoring/Queries/GetWebsitePerformanceSummaryQuery.php` — new. +- `app/Domain/Activity/Queries/RecentActivityForUserQuery.php` — extend to include monitoring-source events. +- `app/Http/Controllers/Monitoring/WebsiteController.php` — extend `show` payload with `summary`. +- `routes/console.php` — `Schedule::job(new DispatchDueWebsiteChecksJob)->everyMinute()->withoutOverlapping()`. +- `resources/js/Pages/Monitoring/Websites/Show.vue` — render uptime stats + last-incident line. +- `tests/Feature/Monitoring/DispatchDueWebsiteChecksJobTest.php` — new. +- `tests/Feature/Monitoring/RunWebsiteCheckJobTest.php` — new. +- `tests/Feature/Monitoring/RecordWebsiteCheckActionTest.php` — extend with transition-event assertions. +- `tests/Feature/Monitoring/GetWebsitePerformanceSummaryQueryTest.php` — new. +- `tests/Feature/Monitoring/WebsiteControllerTest.php` — extend `show` test. +- `tests/Feature/Activity/RecentActivityForUserQueryTest.php` — extend with monitoring-source event assertion. + +## Work log +Dated notes as work progresses. + +### 2026-04-30 +- Spec drafted. +- Opened issue [#73](https://github.com/Copxer/nexus/issues/73) and branch `spec/024-scheduled-checks-and-uptime` off `main`. +- Implementation complete. New `DispatchDueWebsiteChecksJob` (every-minute scheduler dispatcher, soft-cap 500, in-PHP filter ordered by `last_checked_at` so the high-id tail can't starve), new `RunWebsiteCheckJob` (per-website async wrapper around the spec-023 actions, `tries=1`), new `GetWebsitePerformanceSummaryQuery` (count-based uptime over 24h/7d/30d + last-incident timestamp; slow counts as up; null on empty windows), `RecordWebsiteCheckAction` extended to detect healthy/failed category transitions and emit `website.down` / `website.up` activity events via `CreateActivityEventAction` (steady-state runs stay silent). `ActivityEventCreated::broadcastOn()` extended to resolve the recipient channel via `metadata.website_id → website → project → owner_user_id` so monitoring rows broadcast in realtime instead of silently dropping. `RecentActivityForUserQuery` extended to surface monitoring events in the user's feed alongside repo events. +- 27 net new passing tests across 4 new test files + 2 extended; full suite 339 passed (was 312). +- Self-review pass via `superpowers:code-reviewer` flagged one material item (broadcast no-op for monitoring rows) — fixed by extending `ActivityEventCreated::broadcastOn()` plus updating spec + docblock to be honest about the path. Plus the recommended `orderBy('last_checked_at')` swap on the dispatcher to prevent starvation when website count exceeds the soft cap. + +## Decisions (locked 2026-04-30) +- **Count-based uptime (option A).** `successful / total` per window. Phase-1 simple; switches to duration-based if real users find the count-based number misleading on long check intervals. +- **Transition activity events for incident + recovery only (option A).** Slow is a soft signal — surfaced on the Show page directly but doesn't generate activity events. +- **Fresh on every Show page load (option A).** No caching; three count queries are cheap. Revisit when slow-query logs flag it. +- **Filter due websites in PHP, not SQL.** Dynamic `last_checked_at + check_interval_seconds` predicate is cross-DB awkward (MySQL `DATE_ADD` vs SQLite `datetime(...)`); the in-PHP loop is sub-millisecond at phase-1 scale. +- **Activity event source field `monitoring`.** Free-string column; no migration needed. +- **`RecentActivityForUserQuery` extended (not branched).** One query that handles both repo-scoped and monitoring-scoped events; cheaper than two queries + merge. + +## Open questions / blockers +- **`metadata->website_id` query syntax.** SQLite + MySQL both support `JSON_EXTRACT`; Laravel's query builder has `->where('metadata->website_id', ...)` shorthand. Confirm both DB drivers honor it during implementation; fall back to a raw clause if not. +- **Activity event title localisation.** Hard-coded English for phase-1; matches spec 019's existing pattern. i18n is a phase-9 polish. diff --git a/specs/phase-5-monitoring/README.md b/specs/phase-5-monitoring/README.md index 999ff19..157f1ea 100644 --- a/specs/phase-5-monitoring/README.md +++ b/specs/phase-5-monitoring/README.md @@ -10,7 +10,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) | 🟢 | -| 024 | Scheduled checks + uptime calc + activity events | ⬜ | +| 024 | Scheduled checks + uptime calc + activity events | 🟢 | | 025 | Overview integration + Reverb live updates + perf charts | ⬜ | ## Acceptance criteria (phase-level) diff --git a/tests/Feature/Activity/RecentActivityForUserQueryTest.php b/tests/Feature/Activity/RecentActivityForUserQueryTest.php index 659eb9a..e1b748e 100644 --- a/tests/Feature/Activity/RecentActivityForUserQueryTest.php +++ b/tests/Feature/Activity/RecentActivityForUserQueryTest.php @@ -7,6 +7,7 @@ use App\Models\Project; use App\Models\Repository; use App\Models\User; +use App\Models\Website; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -111,4 +112,103 @@ public function test_maps_event_to_ts_shape(): void $this->assertSame('octocat', $event['metadata']); $this->assertNotEmpty($event['occurred_at']); // relative-time string } + + // ──────────────────────────────────────────────────────────────── + // Spec 024 — monitoring-source events surface alongside repo + // events for websites under the user's projects. + // ──────────────────────────────────────────────────────────────── + + public function test_includes_monitoring_events_for_users_websites(): void + { + $owner = User::factory()->create(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $website = Website::factory()->create(['project_id' => $project->id]); + + ActivityEvent::factory()->create([ + 'repository_id' => null, + 'source' => 'monitoring', + 'event_type' => 'website.down', + 'severity' => 'danger', + 'title' => 'Marketing site went down', + 'occurred_at' => now()->subMinute(), + 'metadata' => ['website_id' => $website->id, 'url' => $website->url], + ]); + + $events = (new RecentActivityForUserQuery)->handle($owner); + + $this->assertCount(1, $events); + $this->assertSame('Marketing site went down', $events[0]['title']); + $this->assertSame('danger', $events[0]['severity']); + } + + public function test_does_not_leak_monitoring_events_for_other_users_websites(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + + // The OTHER user's website + monitoring event. + $othersProject = Project::factory()->create(['owner_user_id' => $other->id]); + $othersWebsite = Website::factory()->create(['project_id' => $othersProject->id]); + + ActivityEvent::factory()->create([ + 'repository_id' => null, + 'source' => 'monitoring', + 'event_type' => 'website.down', + 'title' => "Sibling's site", + 'occurred_at' => now()->subMinute(), + 'metadata' => ['website_id' => $othersWebsite->id], + ]); + + $events = (new RecentActivityForUserQuery)->handle($owner); + + $this->assertSame([], $events); + } + + public function test_orphaned_monitoring_event_does_not_appear(): void + { + $owner = User::factory()->create(); + // No projects/websites for this user, so no monitoring scope. + + // A monitoring-source event referencing some unknown website id. + ActivityEvent::factory()->create([ + 'repository_id' => null, + 'source' => 'monitoring', + 'event_type' => 'website.down', + 'title' => 'Orphan', + 'occurred_at' => now()->subMinute(), + 'metadata' => ['website_id' => 999_999], + ]); + + $events = (new RecentActivityForUserQuery)->handle($owner); + + $this->assertSame([], $events); + } + + public function test_combines_repo_and_monitoring_events_in_one_feed(): void + { + $owner = User::factory()->create(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $repo = Repository::factory()->create(['project_id' => $project->id]); + $website = Website::factory()->create(['project_id' => $project->id]); + + ActivityEvent::factory()->create([ + 'repository_id' => $repo->id, + 'title' => 'Repo event', + 'occurred_at' => now()->subMinutes(2), + ]); + ActivityEvent::factory()->create([ + 'repository_id' => null, + 'source' => 'monitoring', + 'title' => 'Monitor event', + 'occurred_at' => now()->subMinute(), + 'metadata' => ['website_id' => $website->id], + ]); + + $events = (new RecentActivityForUserQuery)->handle($owner); + + $this->assertCount(2, $events); + // Newest first. + $this->assertSame('Monitor event', $events[0]['title']); + $this->assertSame('Repo event', $events[1]['title']); + } } diff --git a/tests/Feature/Monitoring/DispatchDueWebsiteChecksJobTest.php b/tests/Feature/Monitoring/DispatchDueWebsiteChecksJobTest.php new file mode 100644 index 0000000..0ac9893 --- /dev/null +++ b/tests/Feature/Monitoring/DispatchDueWebsiteChecksJobTest.php @@ -0,0 +1,103 @@ +create(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + + return Website::factory()->create(array_merge([ + 'project_id' => $project->id, + 'check_interval_seconds' => 300, + ], $overrides)); + } + + public function test_dispatches_when_website_was_never_checked(): void + { + Queue::fake(); + $website = $this->makeWebsite(['last_checked_at' => null]); + + (new DispatchDueWebsiteChecksJob)->handle(); + + Queue::assertPushed( + RunWebsiteCheckJob::class, + fn (RunWebsiteCheckJob $job) => $job->websiteId === $website->id, + ); + } + + public function test_dispatches_when_interval_has_elapsed(): void + { + Queue::fake(); + $this->makeWebsite([ + 'last_checked_at' => now()->subSeconds(310), // > 300s interval + 'check_interval_seconds' => 300, + ]); + + (new DispatchDueWebsiteChecksJob)->handle(); + + Queue::assertPushed(RunWebsiteCheckJob::class); + } + + public function test_does_not_dispatch_when_check_is_recent(): void + { + Queue::fake(); + $this->makeWebsite([ + 'last_checked_at' => now()->subSeconds(60), // < 300s interval + 'check_interval_seconds' => 300, + ]); + + (new DispatchDueWebsiteChecksJob)->handle(); + + Queue::assertNotPushed(RunWebsiteCheckJob::class); + } + + public function test_filters_per_website_intervals_independently(): void + { + Queue::fake(); + + // Due — 60s interval, last checked 90s ago. + $due = $this->makeWebsite([ + 'last_checked_at' => now()->subSeconds(90), + 'check_interval_seconds' => 60, + ]); + // Not due — 3600s interval, last checked 600s ago. + $notDue = $this->makeWebsite([ + 'last_checked_at' => now()->subSeconds(600), + 'check_interval_seconds' => 3600, + ]); + + (new DispatchDueWebsiteChecksJob)->handle(); + + Queue::assertPushed( + RunWebsiteCheckJob::class, + fn (RunWebsiteCheckJob $job) => $job->websiteId === $due->id, + ); + Queue::assertNotPushed( + RunWebsiteCheckJob::class, + fn (RunWebsiteCheckJob $job) => $job->websiteId === $notDue->id, + ); + } + + public function test_does_nothing_with_no_websites(): void + { + Queue::fake(); + + (new DispatchDueWebsiteChecksJob)->handle(); + + Queue::assertNothingPushed(); + } +} diff --git a/tests/Feature/Monitoring/GetWebsitePerformanceSummaryQueryTest.php b/tests/Feature/Monitoring/GetWebsitePerformanceSummaryQueryTest.php new file mode 100644 index 0000000..5209143 --- /dev/null +++ b/tests/Feature/Monitoring/GetWebsitePerformanceSummaryQueryTest.php @@ -0,0 +1,177 @@ +create(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + + return Website::factory()->create(['project_id' => $project->id]); + } + + public function test_empty_window_returns_null_for_each_uptime(): void + { + $website = $this->makeWebsite(); + + $summary = (new GetWebsitePerformanceSummaryQuery)->execute($website); + + $this->assertNull($summary['uptime_24h']); + $this->assertNull($summary['uptime_7d']); + $this->assertNull($summary['uptime_30d']); + $this->assertNull($summary['last_incident_at']); + } + + public function test_all_up_returns_100_percent(): void + { + $website = $this->makeWebsite(); + + WebsiteCheck::factory()->count(3)->create([ + 'website_id' => $website->id, + 'status' => WebsiteCheckStatus::Up->value, + 'checked_at' => now()->subHours(2), + ]); + + $summary = (new GetWebsitePerformanceSummaryQuery)->execute($website); + + $this->assertSame(100.0, $summary['uptime_24h']); + $this->assertNull($summary['last_incident_at']); + } + + public function test_slow_counts_as_successful(): void + { + $website = $this->makeWebsite(); + + WebsiteCheck::factory()->count(2)->create([ + 'website_id' => $website->id, + 'status' => WebsiteCheckStatus::Up->value, + 'checked_at' => now()->subHours(2), + ]); + WebsiteCheck::factory()->count(2)->create([ + 'website_id' => $website->id, + 'status' => WebsiteCheckStatus::Slow->value, + 'checked_at' => now()->subHours(3), + ]); + + $summary = (new GetWebsitePerformanceSummaryQuery)->execute($website); + + $this->assertSame(100.0, $summary['uptime_24h']); + } + + public function test_mixed_checks_compute_correct_percentage(): void + { + $website = $this->makeWebsite(); + + // 4 successful (up), 1 failed (down) → 80%. + WebsiteCheck::factory()->count(4)->create([ + 'website_id' => $website->id, + 'status' => WebsiteCheckStatus::Up->value, + 'checked_at' => now()->subHours(2), + ]); + WebsiteCheck::factory()->create([ + 'website_id' => $website->id, + 'status' => WebsiteCheckStatus::Down->value, + 'checked_at' => now()->subHours(2), + ]); + + $summary = (new GetWebsitePerformanceSummaryQuery)->execute($website); + + $this->assertSame(80.0, $summary['uptime_24h']); + } + + public function test_window_excludes_old_checks(): void + { + $website = $this->makeWebsite(); + + // In the 7d window but not the 24h window. + WebsiteCheck::factory()->create([ + 'website_id' => $website->id, + 'status' => WebsiteCheckStatus::Up->value, + 'checked_at' => now()->subDays(3), + ]); + + $summary = (new GetWebsitePerformanceSummaryQuery)->execute($website); + + $this->assertNull($summary['uptime_24h']); // empty 24h window + $this->assertSame(100.0, $summary['uptime_7d']); + $this->assertSame(100.0, $summary['uptime_30d']); + } + + public function test_last_incident_returns_most_recent_failed_check(): void + { + $website = $this->makeWebsite(); + + $oldFailure = Carbon::parse('2026-04-01 10:00:00'); + $recentFailure = Carbon::parse('2026-04-15 14:00:00'); + + WebsiteCheck::factory()->create([ + 'website_id' => $website->id, + 'status' => WebsiteCheckStatus::Down->value, + 'checked_at' => $oldFailure, + ]); + WebsiteCheck::factory()->create([ + 'website_id' => $website->id, + 'status' => WebsiteCheckStatus::Error->value, + 'checked_at' => $recentFailure, + ]); + + $summary = (new GetWebsitePerformanceSummaryQuery)->execute($website); + + $this->assertNotNull($summary['last_incident_at']); + $this->assertSame( + $recentFailure->toIso8601String(), + $summary['last_incident_at']->toIso8601String(), + ); + } + + public function test_last_incident_ignores_successful_checks(): void + { + $website = $this->makeWebsite(); + + WebsiteCheck::factory()->create([ + 'website_id' => $website->id, + 'status' => WebsiteCheckStatus::Up->value, + 'checked_at' => now()->subHour(), + ]); + WebsiteCheck::factory()->create([ + 'website_id' => $website->id, + 'status' => WebsiteCheckStatus::Slow->value, + 'checked_at' => now()->subMinutes(30), + ]); + + $summary = (new GetWebsitePerformanceSummaryQuery)->execute($website); + + $this->assertNull($summary['last_incident_at']); + } + + public function test_query_scopes_to_one_website(): void + { + $website = $this->makeWebsite(); + $other = $this->makeWebsite(); + + WebsiteCheck::factory()->count(3)->create([ + 'website_id' => $other->id, + 'status' => WebsiteCheckStatus::Down->value, + 'checked_at' => now()->subHour(), + ]); + + $summary = (new GetWebsitePerformanceSummaryQuery)->execute($website); + + $this->assertNull($summary['uptime_24h']); + $this->assertNull($summary['last_incident_at']); + } +} diff --git a/tests/Feature/Monitoring/RecordWebsiteCheckActionTest.php b/tests/Feature/Monitoring/RecordWebsiteCheckActionTest.php index 673984f..7d2683b 100644 --- a/tests/Feature/Monitoring/RecordWebsiteCheckActionTest.php +++ b/tests/Feature/Monitoring/RecordWebsiteCheckActionTest.php @@ -2,29 +2,39 @@ namespace Tests\Feature\Monitoring; +use App\Domain\Activity\Actions\CreateActivityEventAction; use App\Domain\Monitoring\Actions\RecordWebsiteCheckAction; use App\Domain\Monitoring\Probes\WebsiteProbeResult; +use App\Enums\ActivitySeverity; use App\Enums\WebsiteCheckStatus; use App\Enums\WebsiteStatus; +use App\Events\ActivityEventCreated; +use App\Models\ActivityEvent; use App\Models\Project; use App\Models\User; use App\Models\Website; use App\Models\WebsiteCheck; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; use Tests\TestCase; class RecordWebsiteCheckActionTest extends TestCase { use RefreshDatabase; - private function makeWebsite(): Website + private function action(): RecordWebsiteCheckAction + { + return new RecordWebsiteCheckAction(new CreateActivityEventAction); + } + + private function makeWebsite(WebsiteStatus $status = WebsiteStatus::Pending): Website { $owner = User::factory()->create(); $project = Project::factory()->create(['owner_user_id' => $owner->id]); return Website::factory()->create([ 'project_id' => $project->id, - 'status' => WebsiteStatus::Pending->value, + 'status' => $status->value, ]); } @@ -38,7 +48,9 @@ public function test_persists_a_check_row_and_returns_it(): void errorMessage: null, ); - $check = (new RecordWebsiteCheckAction)->execute($website, $result); + Event::fake([ActivityEventCreated::class]); + + $check = $this->action()->execute($website, $result); $this->assertInstanceOf(WebsiteCheck::class, $check); $this->assertSame(1, WebsiteCheck::query()->count()); @@ -57,7 +69,7 @@ public function test_up_result_updates_status_and_last_success_at(): void errorMessage: null, ); - (new RecordWebsiteCheckAction)->execute($website, $result); + $this->action()->execute($website, $result); $website->refresh(); $this->assertSame(WebsiteStatus::Up, $website->status); @@ -76,7 +88,7 @@ public function test_slow_result_counts_as_success_for_last_success_at(): void errorMessage: null, ); - (new RecordWebsiteCheckAction)->execute($website, $result); + $this->action()->execute($website, $result); $website->refresh(); $this->assertSame(WebsiteStatus::Slow, $website->status); @@ -94,7 +106,7 @@ public function test_down_result_updates_status_and_last_failure_at(): void errorMessage: 'HTTP 503: Service Unavailable', ); - (new RecordWebsiteCheckAction)->execute($website, $result); + $this->action()->execute($website, $result); $website->refresh(); $this->assertSame(WebsiteStatus::Down, $website->status); @@ -113,7 +125,7 @@ public function test_error_result_updates_status_and_last_failure_at(): void errorMessage: 'Connection timed out after 10000ms', ); - (new RecordWebsiteCheckAction)->execute($website, $result); + $this->action()->execute($website, $result); $website->refresh(); $this->assertSame(WebsiteStatus::Error, $website->status); @@ -128,7 +140,7 @@ public function test_subsequent_check_does_not_clobber_prior_success_timestamp() // when status was Up"). The same rule applies in reverse. $website = $this->makeWebsite(); - (new RecordWebsiteCheckAction)->execute( + $this->action()->execute( $website, new WebsiteProbeResult(WebsiteCheckStatus::Up, 200, 100, null), ); @@ -137,7 +149,7 @@ public function test_subsequent_check_does_not_clobber_prior_success_timestamp() // Sleep so the timestamps differ enough to compare reliably. sleep(1); - (new RecordWebsiteCheckAction)->execute( + $this->action()->execute( $website, new WebsiteProbeResult(WebsiteCheckStatus::Down, 500, 150, 'HTTP 500'), ); @@ -147,4 +159,111 @@ public function test_subsequent_check_does_not_clobber_prior_success_timestamp() $this->assertNotNull($fresh->last_failure_at); $this->assertSame(WebsiteStatus::Down, $fresh->status); } + + // ──────────────────────────────────────────────────────────────── + // Spec 024 — activity events on category transitions. + // ──────────────────────────────────────────────────────────────── + + public function test_pending_to_healthy_does_not_emit_an_activity_event(): void + { + Event::fake([ActivityEventCreated::class]); + $website = $this->makeWebsite(WebsiteStatus::Pending); + + $this->action()->execute( + $website, + new WebsiteProbeResult(WebsiteCheckStatus::Up, 200, 100, null), + ); + + $this->assertSame(0, ActivityEvent::query()->count()); + Event::assertNotDispatched(ActivityEventCreated::class); + } + + public function test_pending_to_failed_emits_incident_event(): void + { + Event::fake([ActivityEventCreated::class]); + $website = $this->makeWebsite(WebsiteStatus::Pending); + + $this->action()->execute( + $website, + new WebsiteProbeResult(WebsiteCheckStatus::Down, 503, 220, 'HTTP 503: Service Unavailable'), + ); + + $this->assertSame(1, ActivityEvent::query()->count()); + $event = ActivityEvent::query()->first(); + $this->assertSame('website.down', $event->event_type); + $this->assertSame(ActivitySeverity::Danger, $event->severity); + $this->assertSame('monitoring', $event->source); + $this->assertSame($website->id, $event->metadata['website_id']); + } + + public function test_healthy_to_failed_emits_incident_event(): void + { + Event::fake([ActivityEventCreated::class]); + $website = $this->makeWebsite(WebsiteStatus::Up); + + $this->action()->execute( + $website, + new WebsiteProbeResult(WebsiteCheckStatus::Error, null, null, 'Connection timed out'), + ); + + $event = ActivityEvent::query()->firstOrFail(); + $this->assertSame('website.down', $event->event_type); + $this->assertSame(ActivitySeverity::Danger, $event->severity); + $this->assertStringContainsString($website->name, $event->title); + } + + public function test_failed_to_healthy_emits_recovery_event(): void + { + Event::fake([ActivityEventCreated::class]); + $website = $this->makeWebsite(WebsiteStatus::Down); + + $this->action()->execute( + $website, + new WebsiteProbeResult(WebsiteCheckStatus::Up, 200, 95, null), + ); + + $event = ActivityEvent::query()->firstOrFail(); + $this->assertSame('website.up', $event->event_type); + $this->assertSame(ActivitySeverity::Success, $event->severity); + $this->assertSame('monitoring', $event->source); + } + + public function test_steady_state_healthy_emits_nothing(): void + { + Event::fake([ActivityEventCreated::class]); + $website = $this->makeWebsite(WebsiteStatus::Up); + + $this->action()->execute( + $website, + new WebsiteProbeResult(WebsiteCheckStatus::Up, 200, 100, null), + ); + + $this->assertSame(0, ActivityEvent::query()->count()); + } + + public function test_steady_state_failed_emits_nothing(): void + { + Event::fake([ActivityEventCreated::class]); + $website = $this->makeWebsite(WebsiteStatus::Down); + + $this->action()->execute( + $website, + new WebsiteProbeResult(WebsiteCheckStatus::Down, 500, 200, 'HTTP 500'), + ); + + $this->assertSame(0, ActivityEvent::query()->count()); + } + + public function test_up_to_slow_emits_nothing_steady_state_within_healthy(): void + { + Event::fake([ActivityEventCreated::class]); + $website = $this->makeWebsite(WebsiteStatus::Up); + + $this->action()->execute( + $website, + new WebsiteProbeResult(WebsiteCheckStatus::Slow, 200, 4_200, null), + ); + + $this->assertSame(0, ActivityEvent::query()->count()); + } } diff --git a/tests/Feature/Monitoring/RunWebsiteCheckJobTest.php b/tests/Feature/Monitoring/RunWebsiteCheckJobTest.php new file mode 100644 index 0000000..7037f61 --- /dev/null +++ b/tests/Feature/Monitoring/RunWebsiteCheckJobTest.php @@ -0,0 +1,63 @@ + Http::response('OK', 200), + ]); + + $owner = User::factory()->create(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $website = Website::factory()->create([ + 'project_id' => $project->id, + 'url' => 'https://example.com/health', + 'expected_status_code' => 200, + 'status' => WebsiteStatus::Pending->value, + ]); + + (new RunWebsiteCheckJob($website->id))->handle( + app(RunWebsiteProbeAction::class), + app(RecordWebsiteCheckAction::class), + ); + + $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); + $this->assertNotNull($website->last_success_at); + } + + public function test_handle_is_a_noop_when_website_was_deleted(): void + { + // No exception, no DB writes — the job just returns early when + // the row was removed between dispatch and run. + (new RunWebsiteCheckJob(999_999))->handle( + app(RunWebsiteProbeAction::class), + app(RecordWebsiteCheckAction::class), + ); + + $this->assertSame(0, WebsiteCheck::query()->count()); + } +} diff --git a/tests/Feature/Monitoring/WebsiteControllerTest.php b/tests/Feature/Monitoring/WebsiteControllerTest.php index 2168a15..32eed7d 100644 --- a/tests/Feature/Monitoring/WebsiteControllerTest.php +++ b/tests/Feature/Monitoring/WebsiteControllerTest.php @@ -137,6 +137,12 @@ public function test_show_returns_website_with_recent_checks(): void ->component('Monitoring/Websites/Show') ->has('website') ->has('checks', 3) + ->has('summary', fn (AssertableInertia $summary) => $summary + ->has('uptime_24h') + ->has('uptime_7d') + ->has('uptime_30d') + ->has('last_incident_at') + ) ->where('canUpdate', true) ->where('canDelete', true) ->where('canProbe', true)