diff --git a/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php b/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php index 2864121..1b7cf44 100644 --- a/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php +++ b/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php @@ -2,6 +2,7 @@ namespace App\Domain\Dashboard\Queries; +use App\Domain\Monitoring\Queries\GetMonitoringUptimeKpiQuery; use App\Models\ActivityEvent; use App\Models\Project; use App\Models\Repository; @@ -34,9 +35,13 @@ * `activity_events.occurred_at` over the last 90 days. Bucketing * happens in PHP so the query stays cross-DB without `DAYOFWEEK()` / * `HOUR()` polyfills. + * - dashboard.uptime.{overall,change,sparkline,status} — Spec 025; + * `GetMonitoringUptimeKpiQuery` aggregates `website_checks` + * volume-weighted across all of the user's monitors over 24h + * (vs prior 24h) plus a 12-day daily sparkline. * * Still mock (extracted to MOCK_* constants — clearly marked): - * - dashboard.{services,alerts,uptime} → MOCK_KPIS + * - dashboard.{services,alerts} → MOCK_KPIS * * The right-rail activity feed is no longer surfaced from this query — * the AppLayout consumes the shared `activity.recent` Inertia prop @@ -64,6 +69,7 @@ public function handle(): array 'projects' => $this->projects(), 'hosts' => $this->hosts(), 'deployments' => $this->deploymentsKpi(), + 'uptime' => app(GetMonitoringUptimeKpiQuery::class)->execute(), 'topRepositories' => $this->topRepositories(), ]), 'activityHeatmap' => $this->activityHeatmap(), @@ -364,7 +370,7 @@ private function dailyCounts(string $modelClass, int $days): array // that ships the real source. // ────────────────────────────────────────────────────────────────── - /** Phase 5/6 (Services/Hosts), phase 7 (Alerts), phase 8 (Uptime). */ + /** Phase 5/6 (Services/Hosts), phase 7 (Alerts). */ private const MOCK_KPIS = [ 'services' => [ 'running' => 47, @@ -378,11 +384,5 @@ private function dailyCounts(string $modelClass, int $days): array 'sparkline' => [1, 0, 1, 2, 1, 1, 2, 2, 3, 2, 3, 3], 'status' => 'danger', ], - 'uptime' => [ - 'overall' => 99.98, - 'change' => 0.01, - 'sparkline' => [99.92, 99.93, 99.95, 99.94, 99.96, 99.97, 99.96, 99.97, 99.98, 99.98, 99.97, 99.98], - 'status' => 'success', - ], ]; } diff --git a/app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php b/app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php index 3acfda5..e4586da 100644 --- a/app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php +++ b/app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php @@ -7,6 +7,7 @@ use App\Enums\ActivitySeverity; use App\Enums\WebsiteCheckStatus; use App\Enums\WebsiteStatus; +use App\Events\WebsiteCheckRecorded; use App\Models\Website; use App\Models\WebsiteCheck; use Illuminate\Support\Carbon; @@ -35,6 +36,12 @@ * `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. + * + * Spec 025: dispatches `WebsiteCheckRecorded` after every persisted + * check (steady-state runs included, not just transitions) so the + * per-website Show page reflects every probe in realtime — the + * transition path above only fires on healthy↔failed swings, which + * leaves "still up, response time changed" updates invisible. */ class RecordWebsiteCheckAction { @@ -73,6 +80,17 @@ public function execute(Website $website, WebsiteProbeResult $result): WebsiteCh $this->maybeEmitTransitionActivity($website, $previousStatus, $result, $checkedAt); + // Spec 025 — broadcast every persisted check so the Show page + // reflects steady-state runs too. Pre-resolve the owner id + // here so the event's broadcaster doesn't lazy-load + // website→project relations during fan-out. + $website->loadMissing('project:id,owner_user_id'); + WebsiteCheckRecorded::dispatch( + $check->id, + $website->id, + $website->project?->owner_user_id, + ); + return $check; } diff --git a/app/Domain/Monitoring/Queries/GetMonitoringUptimeKpiQuery.php b/app/Domain/Monitoring/Queries/GetMonitoringUptimeKpiQuery.php new file mode 100644 index 0000000..fd465bb --- /dev/null +++ b/app/Domain/Monitoring/Queries/GetMonitoringUptimeKpiQuery.php @@ -0,0 +1,156 @@ +, + * status: 'success'|'warning'|'danger'|'muted', + * } + */ + public function execute(): array + { + $now = now(); + $currentStart = $now->copy()->subDay(); + $previousStart = $now->copy()->subDays(2); + + $overall = $this->uptimeFor($currentStart, $now); + $previous = $this->uptimeFor($previousStart, $currentStart); + + $change = ($overall === null || $previous === null) + ? 0.0 + : round($overall - $previous, 2); + + return [ + 'overall' => $overall, + 'change' => $change, + 'sparkline' => $this->sparkline(), + 'status' => $this->statusFor($overall), + ]; + } + + /** + * Volume-weighted uptime % over the half-open window `[from, to)`. + * Null when the window is empty. + */ + private function uptimeFor(Carbon $from, Carbon $to): ?float + { + $total = WebsiteCheck::query() + ->where('checked_at', '>=', $from) + ->where('checked_at', '<', $to) + ->count(); + + if ($total === 0) { + return null; + } + + $successful = WebsiteCheck::query() + ->where('checked_at', '>=', $from) + ->where('checked_at', '<', $to) + ->whereIn('status', ['up', 'slow']) + ->count(); + + return round(($successful / $total) * 100, 2); + } + + /** + * 12-entry daily uptime sparkline. Days with no checks default to + * 100.0 so a fresh account doesn't render as a 0-percent flatline. + * + * One DB query (grouped by date) — beats 12 round-trips for a + * cheap read. + * + * @return array + */ + private function sparkline(): array + { + $start = now()->startOfDay()->subDays(self::SPARKLINE_DAYS - 1); + + /** @var Collection $rows */ + $rows = WebsiteCheck::query() + ->where('checked_at', '>=', $start) + ->selectRaw( + 'DATE(checked_at) as date, COUNT(*) as total,' + .'SUM(CASE WHEN status IN (?, ?) THEN 1 ELSE 0 END) as successful', + ['up', 'slow'], + ) + ->groupBy('date') + ->get() + ->keyBy(fn ($row) => (string) $row->date); + + $series = []; + for ($i = 0; $i < self::SPARKLINE_DAYS; $i++) { + $day = $start->copy()->addDays($i)->toDateString(); + $row = $rows->get($day); + + if ($row === null || (int) $row->total === 0) { + // Empty day → "no failures observed" interpretation. + $series[] = 100.0; + + continue; + } + + $series[] = round(((int) $row->successful / (int) $row->total) * 100, 2); + } + + return $series; + } + + /** + * Map overall rate → status tone. + * + * @return 'success'|'warning'|'danger'|'muted' + */ + private function statusFor(?float $overall): string + { + if ($overall === null) { + return 'muted'; + } + if ($overall >= 99.0) { + return 'success'; + } + if ($overall >= 95.0) { + return 'warning'; + } + + return 'danger'; + } +} diff --git a/app/Events/WebsiteCheckRecorded.php b/app/Events/WebsiteCheckRecorded.php new file mode 100644 index 0000000..f75e893 --- /dev/null +++ b/app/Events/WebsiteCheckRecorded.php @@ -0,0 +1,96 @@ + + */ + public function broadcastOn(): array + { + if ($this->ownerUserId === null) { + return []; + } + + return [ + new PrivateChannel("users.{$this->ownerUserId}.monitoring"), + ]; + } + + /** + * Light-weight pulse — the client uses this as a trigger, not as + * the source of truth. The Show page partial-reloads the website + * + summary + checks props after receiving any pulse for its own + * website id. + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'check_id' => $this->checkId, + 'website_id' => $this->websiteId, + ]; + } + + /** + * Stable event name for Echo subscribers: + * `Echo.private('users.{id}.monitoring').listen('.WebsiteCheckRecorded', ...)`. + */ + public function broadcastAs(): string + { + return 'WebsiteCheckRecorded'; + } +} diff --git a/resources/js/Pages/Monitoring/Websites/Show.vue b/resources/js/Pages/Monitoring/Websites/Show.vue index bc65bf7..67013bd 100644 --- a/resources/js/Pages/Monitoring/Websites/Show.vue +++ b/resources/js/Pages/Monitoring/Websites/Show.vue @@ -1,8 +1,10 @@