Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
814f5b5
Add monitor history UI enhancements spec + implementation plan
rathorevaibhav Jun 24, 2026
e86e28d
Add Vitest tooling, config, and smoke test for pure JS utils
rathorevaibhav Jun 24, 2026
9c2fcac
Upgrade vitest to 3.x for pristine test output; add trailing newline
rathorevaibhav Jun 24, 2026
1a5f0c3
Add CHECK_TYPE_STATUSES and statusesForCheckType per check type
rathorevaibhav Jun 24, 2026
8c10d7f
Add UTC date/time/relative formatters in formatDate util
rathorevaibhav Jun 24, 2026
096281f
Harden formatDate toUTCDate against malformed input (NaN components)
rathorevaibhav Jun 24, 2026
d80a86f
Add heatmapCalendar grid, month-label, and cell-size utils
rathorevaibhav Jun 24, 2026
bb79924
Add reusable accessible Tooltip component (hover + focus)
rathorevaibhav Jun 24, 2026
653ae4e
Add graph check-type, available-years and year-resolution helpers
rathorevaibhav Jun 24, 2026
52990f5
Build per-type graph series (full-year daily_metrics + per-type summary)
rathorevaibhav Jun 25, 2026
bd3afd6
Drop brittle global assertInertia override; assert delivered success_…
rathorevaibhav Jun 25, 2026
07ef0d2
Implement buildTodayChecks for today-only newest-first per-type rows
rathorevaibhav Jun 25, 2026
1449ee2
Add filters and summary props with first_checked_at
rathorevaibhav Jun 25, 2026
233dcd4
Implement buildRecentChecks paginated by type and range
rathorevaibhav Jun 25, 2026
352c5f4
Remove legacy history prop and migrate tests to graph/filters/summary…
rathorevaibhav Jun 25, 2026
1285c49
Add buildHistoryParams history-param merge helper (Vitest TDD)
rathorevaibhav Jun 25, 2026
a7e326c
Rebuild MonitorHistoryHeatmap as responsive full-year grid with month…
rathorevaibhav Jun 25, 2026
fb7ebf7
Add MonitorTodayBar per-check today timeline with tooltips and legend
rathorevaibhav Jun 25, 2026
a30dc8e
Wire decoupled Graphs section (year nav, per-type headline, today bar…
rathorevaibhav Jun 25, 2026
78f013a
Add graph.today_iso (server-tz today) so heatmap today-highlight renders
rathorevaibhav Jun 25, 2026
5ca8151
Add MonitorHistoryFilters inline preset+range row
rathorevaibhav Jun 25, 2026
aca5710
Add SummaryStats reliability-led KPIs with unknown reconciliation and…
rathorevaibhav Jun 25, 2026
62dfaca
SummaryStats: use onViewAllTime callback prop instead of window Custo…
rathorevaibhav Jun 25, 2026
a65eb19
Wire MonitorHistoryFilters and SummaryStats into Show with timezone l…
rathorevaibhav Jun 25, 2026
036525d
Remove dead view-all-time window listener (SummaryStats uses onViewAl…
rathorevaibhav Jun 25, 2026
3f489c3
Guard legacy recent_checks block against removed history prop (interi…
rathorevaibhav Jun 25, 2026
852d309
Add RecentChecksPanel component with tabs and numbered pagination
rathorevaibhav Jun 25, 2026
9b118e7
Wire RecentChecksPanel into monitor Show page at position 4
rathorevaibhav Jun 25, 2026
3c425f6
Add MonitorLiveStatus live-status pill (E1)
rathorevaibhav Jun 25, 2026
424b79f
Move Back inline-left and mount MonitorLiveStatus in header (Change 8)
rathorevaibhav Jun 25, 2026
6a8e672
Make Monitor Snapshot a compact lower-emphasis strip (E9)
rathorevaibhav Jun 25, 2026
d219f32
Apply 2-tier headers and flatten History card with dividers (E9)
rathorevaibhav Jun 25, 2026
b3e4dab
Collapse disabled check types into one slim muted line (E9)
rathorevaibhav Jun 25, 2026
3f5dbe3
Final motion-reduce, contrast and focus-visible polish pass
rathorevaibhav Jun 25, 2026
5882124
Polish: drop dead import; mark today's heatmap cell as provisional in…
rathorevaibhav Jun 25, 2026
3d0331f
Add heatmapCell util: per-metric cell color + tooltip lines (TDD)
rathorevaibhav Jun 25, 2026
d9841dc
Tooltip: render via portal with fixed positioning so it isn't clipped…
rathorevaibhav Jun 25, 2026
f5e4229
Heatmap: per-metric color/legend/tooltip, solid-indigo today, hover-c…
rathorevaibhav Jun 25, 2026
68d7936
Show: make per-type headline a prominent stat
rathorevaibhav Jun 25, 2026
5e1c567
Spec: rewrite for recent-checks strip redesign + heatmap/tooltip poli…
rathorevaibhav Jun 25, 2026
ef1ce6e
Plan: recent-checks strip redesign (3 tasks, TDD)
rathorevaibhav Jun 25, 2026
338fff3
Graph: latest_checks = last 50 per type (newest-first, spans days)
rathorevaibhav Jun 25, 2026
7200f53
Add stripSlots: right-aligned, gray-padded recent-check slots
rathorevaibhav Jun 25, 2026
ce0b54d
Recent strip: rolling last-50 bars, gray-padded, single-color, no hov…
rathorevaibhav Jun 25, 2026
9304511
Recent strip: raise cap 50->150 so it fills the page width (backend l…
rathorevaibhav Jun 25, 2026
83889cf
Heatmap: unified 4-band uptime % scale (100/95+/90+/<90); tooltip 'Up…
rathorevaibhav Jun 25, 2026
df220bb
Address code review feedback on monitor history UI
rathorevaibhav Jun 25, 2026
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
281 changes: 208 additions & 73 deletions app/Http/Controllers/MonitorsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,93 +99,62 @@ public function store(MonitorRequest $request)
*/
public function show(Request $request, Monitor $monitor)
{
$history = null;
$graph = null;
$filters = null;
$summary = null;
$recentChecks = null;

if (config('monitor-history.enabled')) {
$range = $this->resolveHistoryRange($request, $monitor);
// The monitor's earliest check feeds the 'all' preset range, the
// available-years list and the summary's first_checked_at. Resolve it
// once here and thread it through, rather than re-running the same
// MIN(checked_at) lookup inside each consumer.
$firstCheckedAt = $monitor->checkLogs()->orderBy('checked_at')->value('checked_at');

$range = $this->resolveHistoryRange($request, $firstCheckedAt);
$fromUtc = $range['from']->copy()->startOfDay()->utc();
$toUtc = $range['to']->copy()->endOfDay()->utc();
$timezone = $range['timezone'];

$availableYears = $this->availableYears($timezone, $firstCheckedAt);
$graphYear = $this->resolveGraphYear($request, $availableYears);
$graph = $this->buildGraphPayload($monitor, $graphYear, $timezone, $availableYears);

$selectedRangeQuery = $monitor->checkLogs()
->whereBetween('checked_at', [$fromUtc, $toUtc]);

$allTimeSummary = $this->buildSummary($monitor->checkLogs());
$selectedRangeSummary = $this->buildSummary($selectedRangeQuery);

$dailyMetrics = $monitor->dailyCheckMetrics()
->forTimezone($timezone)
->betweenDates($range['from']->toDateString(), $range['to']->toDateString())
->orderBy('date')
->get()
->groupBy('check_type')
->map(function ($rows) {
return $rows->map(function ($row) {
return [
'date' => $row->date->toDateString(),
'total_checks' => $row->total_checks,
'successful_checks' => $row->successful_checks,
'warning_checks' => $row->warning_checks,
'failed_checks' => $row->failed_checks,
'success_ratio' => (float) $row->success_ratio,
'worst_status' => $row->worst_status,
'avg_response_time_ms' => $row->avg_response_time_ms,
'p95_response_time_ms' => $row->p95_response_time_ms,
];
})->values();
});

$recentChecks = $monitor->checkLogs()
->whereBetween('checked_at', [$fromUtc, $toUtc])
->latest('checked_at')
->limit((int) config('monitor-history.recent_checks_limit', 50))
->get()
->map(function (MonitorCheckLog $log) use ($timezone) {
return [
'id' => $log->id,
'check_type' => $log->check_type,
'status' => $log->status,
'checked_at' => $log->checked_at->timezone($timezone)->toDateTimeString(),
'message' => $log->message,
'failure_reason' => $log->failure_reason,
'response_time_ms' => $log->response_time_ms,
'metadata' => $log->metadata,
];
});

$history = [
'range' => [
'preset' => $range['preset'],
'from' => $range['from']->toDateString(),
'to' => $range['to']->toDateString(),
'timezone' => $timezone,
],
'check_types' => [
[
'type' => MonitorCheckLogService::CHECK_TYPE_UPTIME,
'enabled' => (bool) $monitor->uptime_check_enabled,
],
[
'type' => MonitorCheckLogService::CHECK_TYPE_DOMAIN,
'enabled' => (bool) $monitor->domain_check_enabled,
],
[
'type' => MonitorCheckLogService::CHECK_TYPE_CERTIFICATE,
'enabled' => (bool) $monitor->certificate_check_enabled,
],
],
'summary' => [
'all_time' => $allTimeSummary,
'selected_range' => $selectedRangeSummary,
],
'daily_metrics' => $dailyMetrics,
'recent_checks' => $recentChecks,
$filters = [
'preset' => $range['preset'],
'from' => $range['from']->toDateString(),
'to' => $range['to']->toDateString(),
'timezone' => $timezone,
];

$summary = [
'all_time' => $allTimeSummary,
'selected_range' => $selectedRangeSummary,
'first_checked_at' => $firstCheckedAt
? Carbon::parse($firstCheckedAt)->timezone($timezone)->toDateTimeString()
: null,
];

$recentType = $request->string('recent_type')->toString() ?: MonitorCheckLogService::CHECK_TYPE_UPTIME;
if (! in_array($recentType, [MonitorCheckLogService::CHECK_TYPE_UPTIME, MonitorCheckLogService::CHECK_TYPE_DOMAIN], true)) {
$recentType = MonitorCheckLogService::CHECK_TYPE_UPTIME;
}

$recentChecks = $this->buildRecentChecks($monitor, $recentType, $fromUtc, $toUtc, $timezone);
}

return Inertia::render('Monitors/Show', [
'monitor' => $monitor,
'history' => $history,
'graph' => $graph,
'filters' => $filters,
'summary' => $summary,
'recentChecks' => $recentChecks,
]);
}

Expand Down Expand Up @@ -242,7 +211,7 @@ public function destroy(Monitor $monitor)
return redirect()->route('monitors.index');
}

protected function resolveHistoryRange(Request $request, Monitor $monitor): array
protected function resolveHistoryRange(Request $request, $firstCheckedAt = null): array
{
// The daily metrics are aggregated server-side under this single timezone,
// so the detail page must read them back under the same one. We deliberately
Expand All @@ -256,8 +225,6 @@ protected function resolveHistoryRange(Request $request, Monitor $monitor): arra
$preset = $request->string('preset')->toString() ?: '30d';

if ($preset === 'all') {
$firstCheckedAt = $monitor->checkLogs()->orderBy('checked_at')->value('checked_at');

$from = $firstCheckedAt
? Carbon::parse($firstCheckedAt)->timezone($timezone)->startOfDay()
: Carbon::now($timezone)->subDays(30)->startOfDay();
Expand Down Expand Up @@ -376,4 +343,172 @@ protected function buildSummary($query): array

return $summary;
}

protected function graphCheckTypes(Monitor $monitor): array
{
return [
[
'type' => MonitorCheckLogService::CHECK_TYPE_UPTIME,
'enabled' => (bool) $monitor->uptime_check_enabled,
],
[
'type' => MonitorCheckLogService::CHECK_TYPE_DOMAIN,
'enabled' => (bool) $monitor->domain_check_enabled,
],
];
}

protected function availableYears(string $timezone, $firstCheckedAt = null): array
{
$currentYear = (int) Carbon::now($timezone)->format('Y');

if (! $firstCheckedAt) {
return [$currentYear];
}

$minYear = (int) Carbon::parse($firstCheckedAt)->timezone($timezone)->format('Y');

if ($minYear > $currentYear) {
$minYear = $currentYear;
}

return range($minYear, $currentYear);
}

protected function resolveGraphYear(Request $request, array $availableYears): int
{
$default = end($availableYears) ?: (int) Carbon::now('UTC')->format('Y');

$requested = $request->integer('year') ?: $default;

if (! in_array((int) $requested, $availableYears, true)) {
return (int) $default;
}

return (int) $requested;
}

protected function buildGraphPayload(Monitor $monitor, int $year, string $timezone, array $availableYears): array
{
$checkTypes = $this->graphCheckTypes($monitor);
$recentChecksLimit = (int) config('monitor-history.recent_checks_limit', 150);

$yearStartUtc = Carbon::create($year, 1, 1, 0, 0, 0, $timezone)->startOfDay()->utc();
$yearEndUtc = Carbon::create($year, 12, 31, 0, 0, 0, $timezone)->endOfDay()->utc();
$yearStartDate = Carbon::create($year, 1, 1, 0, 0, 0, $timezone)->toDateString();
$yearEndDate = Carbon::create($year, 12, 31, 0, 0, 0, $timezone)->toDateString();

$dailyMetricsByType = $monitor->dailyCheckMetrics()
->forTimezone($timezone)
->betweenDates($yearStartDate, $yearEndDate)
->orderBy('date')
->get()
->groupBy('check_type')
->map(function ($rows) {
return $rows->map(function ($row) {
return [
'date' => $row->date->toDateString(),
'total_checks' => $row->total_checks,
'successful_checks' => $row->successful_checks,
'warning_checks' => $row->warning_checks,
'failed_checks' => $row->failed_checks,
'success_ratio' => (float) $row->success_ratio,
'worst_status' => $row->worst_status,
'avg_response_time_ms' => $row->avg_response_time_ms,
'p95_response_time_ms' => $row->p95_response_time_ms,
];
})->values();
});

$series = [];

foreach ($checkTypes as $checkType) {
$type = $checkType['type'];

$typeSummary = $this->buildSummary(
$monitor->checkLogs()
->where('check_type', $type)
->whereBetween('checked_at', [$yearStartUtc, $yearEndUtc])
);

$series[$type] = [
'summary' => [
'total_checks' => $typeSummary['by_type'][$type]['total_checks'] ?? 0,
'success_ratio' => (float) ($typeSummary['by_type'][$type]['success_ratio'] ?? 0),
'status_totals' => $typeSummary['by_type'][$type]['status_totals'] ?? [
MonitorCheckLogService::STATUS_SUCCESS => 0,
MonitorCheckLogService::STATUS_WARNING => 0,
MonitorCheckLogService::STATUS_FAILED => 0,
MonitorCheckLogService::STATUS_UNKNOWN => 0,
],
],
'daily_metrics' => $dailyMetricsByType->get($type, collect())->values()->all(),
'latest_checks' => $this->buildLatestChecks($monitor, $type, $timezone, $recentChecksLimit),
];
}

return [
'year' => $year,
'available_years' => $availableYears,
'timezone' => $timezone,
'today_iso' => Carbon::now($timezone)->toDateString(),
'check_types' => $checkTypes,
'recent_checks_limit' => $recentChecksLimit,
'series' => $series,
];
}

protected function buildRecentChecks(Monitor $monitor, string $type, Carbon $fromUtc, Carbon $toUtc, string $timezone): array
{
$paginator = $monitor->checkLogs()
->where('check_type', $type)
->whereBetween('checked_at', [$fromUtc, $toUtc])
->latest('checked_at')
->paginate(25, ['*'], 'recent_page');

$data = collect($paginator->items())
->map(function (MonitorCheckLog $log) use ($timezone) {
return [
'id' => $log->id,
'check_type' => $log->check_type,
'status' => $log->status,
'checked_at' => $log->checked_at->timezone($timezone)->toDateTimeString(),
'message' => $log->message,
'failure_reason' => $log->failure_reason,
'response_time_ms' => $log->response_time_ms,
];
})
->all();

return [
'type' => $type,
'data' => $data,
'pagination' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
];
}

protected function buildLatestChecks(Monitor $monitor, string $checkType, string $timezone, int $limit): array
{
return $monitor->checkLogs()
->where('check_type', $checkType)
->latest('checked_at')
->limit($limit)
->get()
->map(function (MonitorCheckLog $log) use ($timezone) {
return [
'id' => $log->id,
'checked_at' => $log->checked_at->timezone($timezone)->toDateTimeString(),
'status' => $log->status,
'message' => $log->message,
'failure_reason' => $log->failure_reason,
'response_time_ms' => $log->response_time_ms,
];
})
->all();
}
}
8 changes: 6 additions & 2 deletions config/monitor-history.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@
],

/*
* Maximum recent check rows to return on monitor detail page.
* Maximum recent checks surfaced on the monitor detail page's recent strip.
* Single source of truth: the controller caps `latest_checks` to this value
* AND ships it in the graph payload, and the frontend strip uses the same
* number as its slot cap (MonitorRecentStrip maxSlots) — so the backend and
* frontend caps cannot drift apart.
*/
'recent_checks_limit' => 50,
'recent_checks_limit' => 150,

/*
* How many days of raw logs to keep before pruning.
Expand Down
Loading
Loading