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
81 changes: 81 additions & 0 deletions app/Domain/Monitoring/Actions/RecordWebsiteCheckAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

namespace App\Domain\Monitoring\Actions;

use App\Domain\Monitoring\Probes\WebsiteProbeResult;
use App\Enums\WebsiteCheckStatus;
use App\Enums\WebsiteStatus;
use App\Models\Website;
use App\Models\WebsiteCheck;

/**
* Persistence half of the probe pipeline. Given a `Website` + a
* `WebsiteProbeResult` from `RunWebsiteProbeAction`, this:
*
* 1. Inserts a `WebsiteCheck` row carrying the probe's outcome.
* 2. Mirrors the result onto `Website.{status,last_checked_at}`.
* 3. Bumps `last_success_at` (Up/Slow) or `last_failure_at`
* (Down/Error) so the index page can show "last good" / "last
* 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.
*
* 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.
*/
class RecordWebsiteCheckAction
{
public function execute(Website $website, WebsiteProbeResult $result): WebsiteCheck
{
$checkedAt = now();

$check = WebsiteCheck::query()->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;
}
}
139 changes: 139 additions & 0 deletions app/Domain/Monitoring/Actions/RunWebsiteProbeAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

namespace App\Domain\Monitoring\Actions;

use App\Domain\Monitoring\Probes\WebsiteProbeResult;
use App\Enums\WebsiteCheckStatus;
use App\Models\Website;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;

/**
* Pure HTTP probe — no DB writes. Returns a `WebsiteProbeResult` the
* caller (controller / spec 024's job) hands to
* `RecordWebsiteCheckAction` for persistence.
*
* Status mapping:
* - HTTP request succeeded with `expected_status_code` → Up
* - if `response_time_ms > 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, '…');
}
}
26 changes: 26 additions & 0 deletions app/Domain/Monitoring/Probes/WebsiteProbeResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace App\Domain\Monitoring\Probes;

use App\Enums\WebsiteCheckStatus;

/**
* Immutable result of a single `RunWebsiteProbeAction` invocation.
*
* Carries everything `RecordWebsiteCheckAction` needs to persist a
* `WebsiteCheck` row + update the parent `Website.last_*` fields.
* No DB access here — pure data.
*
* `httpStatusCode` and `responseTimeMs` are nullable because a
* transport-level failure (DNS / timeout / connection refused / TLS)
* never produces them.
*/
final class WebsiteProbeResult
{
public function __construct(
public readonly WebsiteCheckStatus $status,
public readonly ?int $httpStatusCode,
public readonly ?int $responseTimeMs,
public readonly ?string $errorMessage,
) {}
}
30 changes: 30 additions & 0 deletions app/Enums/WebsiteCheckStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Enums;

/**
* Outcome of a single recorded `WebsiteCheck` row (spec 023).
*
* Same value set as `WebsiteStatus` minus `pending` — a check only
* exists after a probe ran, so there's no pending state to model.
*
* Tones match the parent `WebsiteStatus` for visual consistency
* across the index list (parent status), the show page header
* (parent status), and the recent-checks list (per-row status).
*/
enum WebsiteCheckStatus: string
{
case Up = 'up';
case Down = 'down';
case Slow = 'slow';
case Error = 'error';

public function badgeTone(): string
{
return match ($this) {
self::Up => 'success',
self::Down, self::Error => 'danger',
self::Slow => 'warning',
};
}
}
41 changes: 41 additions & 0 deletions app/Enums/WebsiteStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace App\Enums;

/**
* Lifecycle of a website monitor (spec 023).
*
* - `pending` — created, never probed.
* - `up` — last probe matched the expected status code.
* - `down` — last probe responded but did not match the expected
* status code.
* - `slow` — last probe matched the expected status code but
* exceeded the slow threshold (3000ms phase-1).
* - `error` — last probe could not complete (DNS / timeout /
* connection refused / TLS failure).
*
* Tones map to `StatusBadge`:
* pending → muted (no signal yet)
* up → success
* down → danger
* slow → warning
* error → danger
*/
enum WebsiteStatus: string
{
case Pending = 'pending';
case Up = 'up';
case Down = 'down';
case Slow = 'slow';
case Error = 'error';

public function badgeTone(): string
{
return match ($this) {
self::Pending => 'muted',
self::Up => 'success',
self::Down, self::Error => 'danger',
self::Slow => 'warning',
};
}
}
Loading
Loading