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 & 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();