From a25231683e185a7c832162cbccf7f41aea500490 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 8 Sep 2026 13:24:39 +0200
Subject: [PATCH] feat(analytics): add chart categories, loading states, and
apply action
Pass series bucket timestamps to sparkline payloads so charts align with the selected range. Show loading overlays on analytics filters and range tabs, restyle the analytics header, and replace Sentinel custom-image autosave with an Apply and restart button so the unsaved bar no longer flashes.
---
app/Livewire/Analytics.php | 1 +
app/Livewire/Dashboard/TrafficAnalytics.php | 1 +
.../Project/Application/Analytics.php | 1 +
.../livewire/analytics-placeholder.blade.php | 15 +--
resources/views/livewire/analytics.blade.php | 45 ++++---
.../traffic-analytics-placeholder.blade.php | 2 +-
.../dashboard/traffic-analytics.blade.php | 16 ++-
.../project/application/analytics.blade.php | 2 +-
.../views/livewire/server/sentinel.blade.php | 20 ++-
.../traffic/_breakdown-section.blade.php | 2 +-
.../livewire/traffic/_device-chart.blade.php | 5 +-
.../traffic/_requests-chart.blade.php | 56 +++++++--
.../livewire/traffic/_sparkline.blade.php | 69 +++++++++--
tests/Feature/SentinelUnsavedBarFlashTest.php | 11 ++
.../AnalyticsFormattingTest.php | 12 ++
.../TrafficAnalytics/ChartTokensTest.php | 115 ++++++++++++++++++
.../DashboardTrafficAnalyticsTest.php | 11 ++
.../TrafficAnalytics/GlobalAnalyticsTest.php | 15 +++
18 files changed, 336 insertions(+), 63 deletions(-)
diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php
index f569a4ef71..993d2aa4c1 100644
--- a/app/Livewire/Analytics.php
+++ b/app/Livewire/Analytics.php
@@ -440,6 +440,7 @@ class Analytics extends Component
's5xx' => array_column($this->series, 's5xx'),
],
'requestsSpark' => $this->requestsSpark(),
+ 'sparkCategories' => array_column($this->series, 'bucket'),
'errorsSpark' => $this->errorsSpark(),
'bandwidthSpark' => $this->bandwidthSpark(),
'uniquesSpark' => $this->uniquesSpark(),
diff --git a/app/Livewire/Dashboard/TrafficAnalytics.php b/app/Livewire/Dashboard/TrafficAnalytics.php
index 394bb0faa7..532460d454 100644
--- a/app/Livewire/Dashboard/TrafficAnalytics.php
+++ b/app/Livewire/Dashboard/TrafficAnalytics.php
@@ -120,6 +120,7 @@ class TrafficAnalytics extends Component
$this->dispatch("refreshChartData-{$this->chartId}-status", [
'requestsSpark' => $this->requestsSpark(),
+ 'sparkCategories' => array_column($this->series, 'bucket'),
'errorsSpark' => $this->errorsSpark(),
'bandwidthSpark' => $this->bandwidthSpark(),
'uniquesSpark' => $this->uniquesSpark(),
diff --git a/app/Livewire/Project/Application/Analytics.php b/app/Livewire/Project/Application/Analytics.php
index e495d93b4f..3db21ab400 100644
--- a/app/Livewire/Project/Application/Analytics.php
+++ b/app/Livewire/Project/Application/Analytics.php
@@ -159,6 +159,7 @@ class Analytics extends Component
's5xx' => array_column($this->series, 's5xx'),
],
'requestsSpark' => $this->requestsSpark(),
+ 'sparkCategories' => array_column($this->series, 'bucket'),
'errorsSpark' => $this->errorsSpark(),
'bandwidthSpark' => $this->bandwidthSpark(),
'uniquesSpark' => $this->uniquesSpark(),
diff --git a/resources/views/livewire/analytics-placeholder.blade.php b/resources/views/livewire/analytics-placeholder.blade.php
index d22a0c06f6..ca48f74145 100644
--- a/resources/views/livewire/analytics-placeholder.blade.php
+++ b/resources/views/livewire/analytics-placeholder.blade.php
@@ -1,16 +1,11 @@
{{-- Header (real chrome; only the data below is a skeleton) --}}
-
-
-
-
-
-
Analytics
-
- Request traffic across every application and server, reported by Sentinel.
-
-
+
+
Analytics
+
+ Request traffic across every application and server, reported by Sentinel.
+
{{-- Filter bar --}}
diff --git a/resources/views/livewire/analytics.blade.php b/resources/views/livewire/analytics.blade.php
index 98d505b110..fb6e935681 100644
--- a/resources/views/livewire/analytics.blade.php
+++ b/resources/views/livewire/analytics.blade.php
@@ -1,5 +1,5 @@
-
-
-
-
-
-
Analytics
-
- Request traffic across every application and server, reported by Sentinel.
-
-
+
+
Analytics
+
+ Request traffic across every application and server, reported by Sentinel.
+
@if ($servers->isNotEmpty() && $overview)
-
+
{{-- Re-key on the server filter so the application listbox re-initializes with the
newly-scoped options (and reset value) instead of showing stale Alpine state. --}}
-
+
@include('livewire.traffic._live-toggle')
- 24 hours
+ 24 hours
+
- 7 days
+ 7 days
+
- 30 days
+ 30 days
+
@@ -207,7 +218,7 @@ $appListboxOptions = array_merge(
{{-- Requests over time (single area series). --}}
-
@include('livewire.traffic._requests-chart')
diff --git a/resources/views/livewire/dashboard/traffic-analytics-placeholder.blade.php b/resources/views/livewire/dashboard/traffic-analytics-placeholder.blade.php
index eb01c43266..0e90662637 100644
--- a/resources/views/livewire/dashboard/traffic-analytics-placeholder.blade.php
+++ b/resources/views/livewire/dashboard/traffic-analytics-placeholder.blade.php
@@ -6,7 +6,7 @@
Traffic analytics
- Team-wide request volume across traffic-enabled servers
+ Team-wide request volume across servers with traffic analytics enabled
diff --git a/resources/views/livewire/dashboard/traffic-analytics.blade.php b/resources/views/livewire/dashboard/traffic-analytics.blade.php
index 62c49b67df..a87272008b 100644
--- a/resources/views/livewire/dashboard/traffic-analytics.blade.php
+++ b/resources/views/livewire/dashboard/traffic-analytics.blade.php
@@ -1,5 +1,5 @@
- Team-wide request volume across traffic-enabled servers
+ Team-wide request volume across servers with traffic analytics enabled
@@ -22,16 +22,22 @@ $spark = 'refreshChartData-'.$chartId.'-status';
@if ($servers->isNotEmpty() && $overview)
- 24 hours
+ 24 hours
+
- 7 days
+ 7 days
+
- 30 days
+ 30 days
+
@endif
diff --git a/resources/views/livewire/project/application/analytics.blade.php b/resources/views/livewire/project/application/analytics.blade.php
index edbc4ccc69..2b44fc395d 100644
--- a/resources/views/livewire/project/application/analytics.blade.php
+++ b/resources/views/livewire/project/application/analytics.blade.php
@@ -130,7 +130,7 @@ $analyticsServerUuid = $application->destination?->server?->uuid;
-
@include('livewire.traffic._requests-chart')
diff --git a/resources/views/livewire/server/sentinel.blade.php b/resources/views/livewire/server/sentinel.blade.php
index 0cf5b2f74d..068b9b835b 100644
--- a/resources/views/livewire/server/sentinel.blade.php
+++ b/resources/views/livewire/server/sentinel.blade.php
@@ -148,19 +148,27 @@
]" />
-
+
+
+
+
+
+ Apply and restart
+
+
diff --git a/resources/views/livewire/traffic/_breakdown-section.blade.php b/resources/views/livewire/traffic/_breakdown-section.blade.php
index a6d864425a..d854f4e924 100644
--- a/resources/views/livewire/traffic/_breakdown-section.blade.php
+++ b/resources/views/livewire/traffic/_breakdown-section.blade.php
@@ -18,7 +18,7 @@
@if (collect($rows)->isEmpty())
+ :description="'No '.strtolower($label).' data for the selected range.'" icon-name="network" />
@else
@foreach ($rows as $row)
diff --git a/resources/views/livewire/traffic/_device-chart.blade.php b/resources/views/livewire/traffic/_device-chart.blade.php
index 44ca9c092b..0f852e1a5f 100644
--- a/resources/views/livewire/traffic/_device-chart.blade.php
+++ b/resources/views/livewire/traffic/_device-chart.blade.php
@@ -11,8 +11,9 @@
$labels = $labels ?? [];
$series = $series ?? [];
$deviceChartId = $chartId.'-device';
+ $hasDeviceData = array_sum(array_map('intval', $series)) > 0;
@endphp
-@if (empty($series))
+@if (! $hasDeviceData)
@else
@@ -20,6 +21,7 @@
@script
@endscript
diff --git a/resources/views/livewire/traffic/_requests-chart.blade.php b/resources/views/livewire/traffic/_requests-chart.blade.php
index 486d0a1ae6..d8ded84f5b 100644
--- a/resources/views/livewire/traffic/_requests-chart.blade.php
+++ b/resources/views/livewire/traffic/_requests-chart.blade.php
@@ -4,14 +4,20 @@
token. Updated via the `refreshChartData-{chartId}-status` event (the `timeSeries.requests`
array, aligned with `timeSeries.categories`). Expects `$chartId` in scope.
--}}
+@php
+ $initialChartData = [
+ 'initialCategories' => array_column($series, 'bucket'),
+ 'initialRequests' => $this->requestsSpark(),
+ ];
+@endphp
-
+
{{-- No-data overlay: covers the empty chart frame when no requests fall in the range.
Uses the shared x-empty component so it matches the other analytics empty states
(e.g. Status codes). Toggled from the refresh listener below (kept mounted so the
chart's listener survives live/range re-renders). --}}
-
(() => {
+ requestAnimationFrame(() => {
checkTheme();
- const el = document.getElementById('{!! $chartId !!}-requests');
- const emptyEl = document.getElementById('{!! $chartId !!}-requests-empty');
+ const chartId = @js($chartId);
+ const initial = @js($initialChartData);
+ const initialPoints = initial.initialCategories.map((category, index) => ({
+ x: category,
+ y: initial.initialRequests[index] || 0,
+ }));
+ const el = document.getElementById(`${chartId}-requests`);
+ const emptyEl = document.getElementById(`${chartId}-requests-empty`);
+ if (!el) { return; }
const cssVar = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const accent = () => cssVar('--chart-status-3xx') || '#3b82f6';
const gridColor = () => cssVar('--chart-geo-empty') || 'rgba(128,128,128,0.15)';
// `24h` buckets are hourly, `7d`/`30d` daily — pick a matching axis/tooltip format.
const timeFormat = range => (range === '24h' ? 'HH:mm' : 'dd MMM');
+ const formatTimestamp = timestamp => `${new Date(timestamp).toLocaleString(undefined, {
+ timeZone: 'UTC',
+ hour12: false,
+ })} UTC`;
const chart = new ApexCharts(el, {
chart: {
@@ -42,7 +60,7 @@
animations: { enabled: false },
background: 'transparent',
},
- series: [{ name: 'Requests', data: [] }],
+ series: [{ name: 'Requests', data: initialPoints }],
colors: [accent()],
dataLabels: { enabled: false },
stroke: { width: 2, curve: 'smooth' },
@@ -58,25 +76,39 @@
},
yaxis: {
labels: {
+ minWidth: 28,
+ maxWidth: 28,
style: { colors: textColor },
formatter: value => Math.round(value).toLocaleString(),
},
},
- grid: { borderColor: gridColor() },
+ grid: {
+ borderColor: gridColor(),
+ padding: { left: 0, right: 12, top: 12, bottom: 0 },
+ },
legend: { show: false },
noData: {
text: 'Loading requests…',
style: { color: textColor },
},
tooltip: {
- y: {
- formatter: value => `${value.toLocaleString()} requests`,
+ shared: true,
+ intersect: false,
+ marker: { show: false },
+ custom: ({ series, seriesIndex, dataPointIndex, w }) => {
+ const requests = series[seriesIndex][dataPointIndex];
+ const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
+
+ return ``;
},
},
});
chart.render();
- Livewire.on('refreshChartData-{!! $chartId !!}-status', payload => {
+ Livewire.on(`refreshChartData-${chartId}-status`, payload => {
checkTheme();
const data = Array.isArray(payload) ? payload[0] : payload;
if (!data || !data.timeSeries) { return; }
@@ -90,7 +122,10 @@
chart.updateOptions({
colors: [accent()],
- grid: { borderColor: gridColor() },
+ grid: {
+ borderColor: gridColor(),
+ padding: { left: 0, right: 12, top: 12, bottom: 0 },
+ },
xaxis: {
type: 'datetime',
labels: { style: { colors: textColor }, datetimeUTC: false, format: timeFormat(data.range) },
@@ -99,6 +134,7 @@
});
chart.updateSeries([{ name: 'Requests', data: points }]);
});
+ });
})();
@endscript
diff --git a/resources/views/livewire/traffic/_sparkline.blade.php b/resources/views/livewire/traffic/_sparkline.blade.php
index f868c2b1f4..1e13f48214 100644
--- a/resources/views/livewire/traffic/_sparkline.blade.php
+++ b/resources/views/livewire/traffic/_sparkline.blade.php
@@ -22,53 +22,100 @@
$colorVar = $colorVar ?? '--chart-status-3xx';
$event = $event ?? '';
$key = $key ?? '';
+ $initialCategories = array_column($series ?? [], 'bucket');
+ $label = match ($key) {
+ 'requestsSpark' => 'Requests',
+ 'uniquesSpark' => 'Visitors',
+ 'bandwidthSpark' => 'Bandwidth',
+ 'errorsSpark' => 'Errors',
+ 'latencySpark' => 'p95 latency',
+ default => 'Value',
+ };
@endphp
-
diff --git a/tests/Feature/SentinelUnsavedBarFlashTest.php b/tests/Feature/SentinelUnsavedBarFlashTest.php
index 0c5d196c03..74aa844097 100644
--- a/tests/Feature/SentinelUnsavedBarFlashTest.php
+++ b/tests/Feature/SentinelUnsavedBarFlashTest.php
@@ -141,6 +141,17 @@ test('sentinel custom docker image x-init only sets wire when a value exists', f
->not->toContain("x-init=\"\$wire.set('sentinelCustomDockerImage', customImage)\"");
});
+test('sentinel custom docker image does not rerender while typing and has an explicit apply action', function () {
+ $contents = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php'));
+
+ expect($contents)
+ ->toContain('async applyCustomImage()')
+ ->toContain("await \$wire.set('sentinelCustomDockerImage', this.customImage || null)")
+ ->toContain('await $wire.restartSentinel()')
+ ->toContain('Apply and restart')
+ ->not->toContain('@input.debounce.500ms="saveCustomImage()"');
+});
+
/**
* Instant-save listboxes (e.g. MCP server) entangle + call instantSave. Until the
* round-trip finishes, the component is dirty — so an unscoped unsaved bar flashes.
diff --git a/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php b/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php
index f1c285f19f..9008b87380 100644
--- a/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php
+++ b/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php
@@ -32,3 +32,15 @@ it('builds a flagcdn image url for valid ISO codes only', function () {
expect(countryFlagUrl(''))->toBeNull();
expect(countryFlagUrl(null))->toBeNull();
});
+
+it('renders ampersands in breakdown empty-state descriptions', function () {
+ $html = view('livewire.traffic._breakdown-section', [
+ 'dimension' => 'agent',
+ 'label' => 'AI agents & bots',
+ 'rows' => [],
+ ])->render();
+
+ expect($html)
+ ->toContain('No ai agents & bots data for the selected range.')
+ ->not->toContain('No ai agents & bots data for the selected range.');
+});
diff --git a/tests/Feature/TrafficAnalytics/ChartTokensTest.php b/tests/Feature/TrafficAnalytics/ChartTokensTest.php
index 5d0bc265bb..c9e2467930 100644
--- a/tests/Feature/TrafficAnalytics/ChartTokensTest.php
+++ b/tests/Feature/TrafficAnalytics/ChartTokensTest.php
@@ -48,3 +48,118 @@ it('reads its accent color from a design token in the shared requests-chart part
expect($partial)->not->toContain('statusColorsDark');
expect($partial)->toContain('--chart-status-3xx');
});
+
+it('keeps request values in a compact y-axis outside the plot', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_requests-chart.blade.php'));
+
+ expect($partial)
+ ->toContain('minWidth: 28')
+ ->toContain('maxWidth: 28')
+ ->toContain('padding: { left: 0, right: 12, top: 12, bottom: 0 }')
+ ->not->toContain('floating: true')
+ ->not->toContain('offsetX: 32');
+});
+
+it('renders request charts full bleed inside their analytics sections', function () {
+ foreach ([
+ base_path('resources/views/livewire/analytics.blade.php'),
+ base_path('resources/views/livewire/project/application/analytics.blade.php'),
+ ] as $viewPath) {
+ $view = file_get_contents($viewPath);
+
+ expect($view)->toContain('id="analytics-requests-section" title="Requests" flush');
+ }
+});
+
+it('seeds the requests chart with the server-rendered series', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_requests-chart.blade.php'));
+
+ expect($partial)
+ ->toContain("'initialCategories' => array_column(\$series, 'bucket')")
+ ->toContain("'initialRequests' => \$this->requestsSpark()")
+ ->toContain('series: [{ name: \'Requests\', data: initialPoints }]');
+});
+
+it('waits for lazy Livewire chart elements before initializing ApexCharts', function () {
+ foreach ([
+ base_path('resources/views/livewire/traffic/_requests-chart.blade.php'),
+ base_path('resources/views/livewire/traffic/_device-chart.blade.php'),
+ ] as $partialPath) {
+ $partial = file_get_contents($partialPath);
+
+ expect($partial)
+ ->toContain('requestAnimationFrame(() => {')
+ ->toContain('if (!el) { return; }');
+ }
+});
+
+it('treats an all-zero device series as no data', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_device-chart.blade.php'));
+
+ expect($partial)
+ ->toContain("\$hasDeviceData = array_sum(array_map('intval', \$series)) > 0")
+ ->toContain('@if (! $hasDeviceData)');
+});
+
+it('keeps KPI sparklines axisless after live updates', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_sparkline.blade.php'));
+
+ expect($partial)
+ ->toContain('yaxis: { labels: { show: false }')
+ ->toContain("xaxis: { type: 'datetime', labels: { show: false }, axisBorder: { show: false }, axisTicks: { show: false } }")
+ ->toContain('grid: { padding: { left: 4, right: 4, top: 0, bottom: 0 } }');
+});
+
+it('shows values and UTC timestamps in analytics chart tooltips', function () {
+ $sparkline = file_get_contents(base_path('resources/views/livewire/traffic/_sparkline.blade.php'));
+ $requestsChart = file_get_contents(base_path('resources/views/livewire/traffic/_requests-chart.blade.php'));
+
+ expect($sparkline)
+ ->toContain('sparkCategories')
+ ->toContain('apexcharts-tooltip-custom-value')
+ ->toContain('formatTimestamp(timestamp)');
+
+ expect($requestsChart)
+ ->toContain('apexcharts-tooltip-custom-value')
+ ->toContain('formatTimestamp(timestamp)');
+
+ foreach ([
+ app_path('Livewire/Analytics.php'),
+ app_path('Livewire/Project/Application/Analytics.php'),
+ app_path('Livewire/Dashboard/TrafficAnalytics.php'),
+ ] as $component) {
+ expect(file_get_contents($component))->toContain("'sparkCategories' => array_column(\$this->series, 'bucket')");
+ }
+});
+
+it('registers sparkline refresh listeners without Blade control-flow inside Alpine data', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_sparkline.blade.php'));
+
+ expect($partial)
+ ->not->toContain('@if ($event && $key)')
+ ->toContain('if (event && key)')
+ ->toContain('this.refreshCleanup = Livewire.on(event')
+ ->toContain('destroy()');
+});
+
+it('lets KPI hover markers overflow without shrinking the chart scale', function () {
+ $partial = file_get_contents(base_path('resources/views/livewire/traffic/_sparkline.blade.php'));
+
+ expect($partial)
+ ->toContain('[&_.apexcharts-svg]:overflow-visible!')
+ ->not->toContain('headroom')
+ ->toContain('markers: { size: 0, hover: { size: 5, sizeOffset: 2 } }');
+});
+
+it('uses the standard page title styling in the analytics view and placeholder', function () {
+ foreach ([
+ base_path('resources/views/livewire/analytics.blade.php'),
+ base_path('resources/views/livewire/analytics-placeholder.blade.php'),
+ ] as $view) {
+ $contents = file_get_contents($view);
+
+ expect($contents)
+ ->toContain('Analytics ')
+ ->not->toContain('assertDontSee($otherTeamApplication->uuid);
});
+it('shows loading states while the dashboard range refreshes', function () {
+ $view = file_get_contents(resource_path('views/livewire/dashboard/traffic-analytics.blade.php'));
+
+ expect($view)
+ ->toContain('wire:loading.attr="disabled" wire:target="setRange"')
+ ->toContain('wire:loading.class="invisible" wire:target="setRange(\'24h\')"')
+ ->toContain('wire:loading wire:target="setRange(\'7d\')"')
+ ->toContain('wire:loading wire:target="setRange(\'30d\')"')
+ ->toContain('aria-label="Loading analytics"');
+});
+
it('shows a failure empty-state instead of an all-zero KPI panel when every server fetch fails', function () {
$serverOne = Server::factory()->create([
'team_id' => $this->team->id,
diff --git a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php
index dce4ebe9cf..3feb5ed668 100644
--- a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php
+++ b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php
@@ -152,6 +152,21 @@ it('renders a team-wide analytics summary across enabled servers', function () {
->assertSet('appOptions', [$application->uuid => 'Global Leaderboard App']);
});
+it('shows loading states while analytics filters refresh', function () {
+ $view = file_get_contents(resource_path('views/livewire/analytics.blade.php'));
+
+ expect($view)
+ ->toContain('wire:loading.class="pointer-events-none opacity-60" wire:target="serverUuid"')
+ ->toContain('wire:loading.flex wire:target="serverUuid"')
+ ->toContain('wire:loading.class="pointer-events-none opacity-60" wire:target="appUuid"')
+ ->toContain('wire:loading.flex wire:target="appUuid"')
+ ->toContain('wire:loading.attr="disabled" wire:target="setRange"')
+ ->toContain('wire:loading.class="invisible" wire:target="setRange(\'24h\')"')
+ ->toContain('wire:loading wire:target="setRange(\'7d\')"')
+ ->toContain('wire:loading wire:target="setRange(\'30d\')"')
+ ->toContain('aria-label="Loading analytics"');
+});
+
it('shows a no-data state for the requests chart when no traffic falls in the range', function () {
$server = bootEnabledGlobalServer();