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