Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand All @@ -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',
],
];
}
18 changes: 18 additions & 0 deletions app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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;
}

Expand Down
156 changes: 156 additions & 0 deletions app/Domain/Monitoring/Queries/GetMonitoringUptimeKpiQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

namespace App\Domain\Monitoring\Queries;

use App\Models\WebsiteCheck;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

/**
* Cross-website uptime aggregate for the Overview's Uptime KpiCard
* (spec 025). Replaces the long-standing `MOCK_KPIS['uptime']`
* placeholder with real data from `website_checks`.
*
* **Volume-weighted definition** (locked decision B): rate is
* `successful_checks / total_checks * 100` across **all** websites
* over the last 24h. A busy website with one failure contributes
* more to the system-wide rate than a quiet website with one success
* — the truest "are my services up" measure.
*
* Returned shape mirrors `MOCK_KPIS['uptime']` exactly so the
* `KpiCard` props in `Overview.vue` need no rename:
* overall → 24h volume-weighted % (or null when no checks anywhere)
* change → overall - previous-24h overall (or 0 when either side empty)
* sparkline → 12 daily uptime % values, oldest-first
* status → muted | success (≥99) | warning (≥95) | danger
*
* **Sparkline empty-day default of 100.0** — a fresh account with no
* checks before today shouldn't render as a 0-percent flatline (would
* read as "everything was down"). 100 reads as "no failures observed"
* which is the honest interpretation of zero data. Document the
* caveat; future polish can switch to null + `Sparkline` gap rendering.
*
* Phase-1 single-tenant scoping — handle takes no `User` arg, matches
* the rest of `GetOverviewDashboardQuery`'s slices.
*/
class GetMonitoringUptimeKpiQuery
{
/** Match the rest of the dashboard's sparkline cadence. */
private const SPARKLINE_DAYS = 12;

/**
* @return array{
* overall: float|null,
* change: float,
* sparkline: array<int, float>,
* 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<int, float>
*/
private function sparkline(): array
{
$start = now()->startOfDay()->subDays(self::SPARKLINE_DAYS - 1);

/** @var Collection<int, object{date: string, total: int, successful: int}> $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';
}
}
96 changes: 96 additions & 0 deletions app/Events/WebsiteCheckRecorded.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace App\Events;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

/**
* Real-time pulse emitted whenever a `website_checks` row is persisted
* (spec 025). The Vue `Pages/Monitoring/Websites/Show` page subscribes
* via Echo and triggers a partial Inertia reload of the website /
* summary / checks props on receipt — server-side query logic
* re-applies naturally, so we never replicate filter or aggregation
* logic in JS.
*
* Spec 024's transition events (`website.down` / `website.up`) ride
* the activity feed via `ActivityEventCreated` and surface on the
* AppLayout right rail. This event is **on top of that** — it fires
* for every check (steady-state runs included) so the per-website
* Show page reflects every probe in realtime, not just transitions.
*
* Pre-resolved owner id in the constructor (mirrors spec 021's
* `WorkflowRunUpserted` decision) — avoids the broadcaster lazy-
* loading the website / project relations during fan-out.
*
* `ShouldBroadcastNow` so the broadcast hits Reverb synchronously —
* matches the rest of the event family.
*/
class WebsiteCheckRecorded implements ShouldBroadcastNow
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;

public function __construct(
public readonly int $checkId,
public readonly int $websiteId,
public readonly ?int $ownerUserId,
) {}

/**
* Broadcast on the project owner's private monitoring channel.
* `users.{userId}.monitoring` is authorized in `routes/channels.php`.
*
* Returns no channels when the owner can't be resolved (orphan
* project, system-emitted check) — Laravel skips the publish.
*
* **Channel choice trade-off:** a per-user channel means the Show
* page receives pulses for ALL of the user's monitors and filters
* client-side by `website_id`. A per-website channel
* (`websites.{id}.checks`) would eliminate the filter but adds
* channel-auth proliferation and subscription churn on navigation.
* Phase-1 picks the per-user channel for simplicity. Revisit if
* monitor counts cross ~1k or check intervals drop below 30s.
*
* @return list<PrivateChannel>
*/
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<string, mixed>
*/
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';
}
}
Loading
Loading