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')
@@ -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)
@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 `
+
Requests: ${requests.toLocaleString()}
+
${formatTimestamp(timestamp)}
+
`; }, }, }); 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 &amp; 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();