mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-25 07:50:35 -05:00
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.
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
{{-- Header (real chrome; only the data below is a skeleton) --}}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
<x-reicon name="analytics" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-[16px]! leading-5! font-semibold! text-black dark:text-fg">Analytics</h1>
|
||||
<p class="mt-0.5 text-[12px] text-neutral-500 dark:text-fg-faint">
|
||||
Request traffic across every application and server, reported by Sentinel.
|
||||
</p>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Analytics</h1>
|
||||
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
Request traffic across every application and server, reported by Sentinel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Filter bar --}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
$tabButtonBase = 'h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40';
|
||||
$tabButtonBase = 'relative inline-flex h-7 items-center justify-center rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40';
|
||||
$tabButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]';
|
||||
$tabButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg';
|
||||
|
||||
@@ -27,43 +27,54 @@ $appListboxOptions = array_merge(
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
<x-reicon name="analytics" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-[16px]! leading-5! font-semibold! text-black dark:text-fg">Analytics</h1>
|
||||
<p class="mt-0.5 text-[12px] text-neutral-500 dark:text-fg-faint">
|
||||
Request traffic across every application and server, reported by Sentinel.
|
||||
</p>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Analytics</h1>
|
||||
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
Request traffic across every application and server, reported by Sentinel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if ($servers->isNotEmpty() && $overview)
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="w-full sm:w-52">
|
||||
<div class="relative w-full transition-opacity sm:w-52"
|
||||
wire:loading.class="pointer-events-none opacity-60" wire:target="serverUuid">
|
||||
<x-forms.listbox id="serverUuid" live :options="$serverListboxOptions" placeholder="All servers" />
|
||||
<div class="absolute inset-0 hidden items-center justify-center rounded-lg bg-white/70 dark:bg-base/70"
|
||||
wire:loading.flex wire:target="serverUuid">
|
||||
<x-loading compact aria-label="Loading analytics" />
|
||||
</div>
|
||||
</div>
|
||||
{{-- 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. --}}
|
||||
<div class="w-full sm:w-52" wire:key="app-filter-{{ $serverUuid }}">
|
||||
<div class="relative w-full transition-opacity sm:w-52" wire:key="app-filter-{{ $serverUuid }}"
|
||||
wire:loading.class="pointer-events-none opacity-60" wire:target="appUuid">
|
||||
<x-forms.listbox id="appUuid" live :options="$appListboxOptions" placeholder="All applications" />
|
||||
<div class="absolute inset-0 hidden items-center justify-center rounded-lg bg-white/70 dark:bg-base/70"
|
||||
wire:loading.flex wire:target="appUuid">
|
||||
<x-loading compact aria-label="Loading analytics" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 sm:ml-auto">
|
||||
@include('livewire.traffic._live-toggle')
|
||||
<div class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
|
||||
<button type="button" wire:click="setRange('24h')"
|
||||
wire:loading.attr="disabled" wire:target="setRange"
|
||||
@class([$tabButtonBase, $range === '24h' ? $tabButtonActive : $tabButtonInactive])>
|
||||
24 hours
|
||||
<span wire:loading.class="invisible" wire:target="setRange('24h')">24 hours</span>
|
||||
<x-loading compact class="absolute" wire:loading wire:target="setRange('24h')" aria-label="Loading analytics" />
|
||||
</button>
|
||||
<button type="button" wire:click="setRange('7d')"
|
||||
wire:loading.attr="disabled" wire:target="setRange"
|
||||
@class([$tabButtonBase, $range === '7d' ? $tabButtonActive : $tabButtonInactive])>
|
||||
7 days
|
||||
<span wire:loading.class="invisible" wire:target="setRange('7d')">7 days</span>
|
||||
<x-loading compact class="absolute" wire:loading wire:target="setRange('7d')" aria-label="Loading analytics" />
|
||||
</button>
|
||||
<button type="button" wire:click="setRange('30d')"
|
||||
wire:loading.attr="disabled" wire:target="setRange"
|
||||
@class([$tabButtonBase, $range === '30d' ? $tabButtonActive : $tabButtonInactive])>
|
||||
30 days
|
||||
<span wire:loading.class="invisible" wire:target="setRange('30d')">30 days</span>
|
||||
<x-loading compact class="absolute" wire:loading wire:target="setRange('30d')" aria-label="Loading analytics" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +218,7 @@ $appListboxOptions = array_merge(
|
||||
</x-application.settings-section>
|
||||
|
||||
{{-- Requests over time (single area series). --}}
|
||||
<x-application.settings-section id="analytics-requests-section" title="Requests"
|
||||
<x-application.settings-section id="analytics-requests-section" title="Requests" flush
|
||||
helper="Total request volume over time for the selected range.">
|
||||
@include('livewire.traffic._requests-chart')
|
||||
</x-application.settings-section>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
Traffic analytics
|
||||
</h2>
|
||||
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
Team-wide request volume across traffic-enabled servers
|
||||
Team-wide request volume across servers with traffic analytics enabled
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
$tabButtonBase = 'h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40';
|
||||
$tabButtonBase = 'relative inline-flex h-7 items-center justify-center rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40';
|
||||
$tabButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]';
|
||||
$tabButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg';
|
||||
|
||||
@@ -14,7 +14,7 @@ $spark = 'refreshChartData-'.$chartId.'-status';
|
||||
Traffic analytics
|
||||
</h2>
|
||||
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
Team-wide request volume across traffic-enabled servers
|
||||
Team-wide request volume across servers with traffic analytics enabled
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -22,16 +22,22 @@ $spark = 'refreshChartData-'.$chartId.'-status';
|
||||
@if ($servers->isNotEmpty() && $overview)
|
||||
<div class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
|
||||
<button type="button" wire:click="setRange('24h')"
|
||||
wire:loading.attr="disabled" wire:target="setRange"
|
||||
@class([$tabButtonBase, $range === '24h' ? $tabButtonActive : $tabButtonInactive])>
|
||||
24 hours
|
||||
<span wire:loading.class="invisible" wire:target="setRange('24h')">24 hours</span>
|
||||
<x-loading compact class="absolute" wire:loading wire:target="setRange('24h')" aria-label="Loading analytics" />
|
||||
</button>
|
||||
<button type="button" wire:click="setRange('7d')"
|
||||
wire:loading.attr="disabled" wire:target="setRange"
|
||||
@class([$tabButtonBase, $range === '7d' ? $tabButtonActive : $tabButtonInactive])>
|
||||
7 days
|
||||
<span wire:loading.class="invisible" wire:target="setRange('7d')">7 days</span>
|
||||
<x-loading compact class="absolute" wire:loading wire:target="setRange('7d')" aria-label="Loading analytics" />
|
||||
</button>
|
||||
<button type="button" wire:click="setRange('30d')"
|
||||
wire:loading.attr="disabled" wire:target="setRange"
|
||||
@class([$tabButtonBase, $range === '30d' ? $tabButtonActive : $tabButtonInactive])>
|
||||
30 days
|
||||
<span wire:loading.class="invisible" wire:target="setRange('30d')">30 days</span>
|
||||
<x-loading compact class="absolute" wire:loading wire:target="setRange('30d')" aria-label="Loading analytics" />
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -130,7 +130,7 @@ $analyticsServerUuid = $application->destination?->server?->uuid;
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="analytics-requests-section" title="Requests"
|
||||
<x-application.settings-section id="analytics-requests-section" title="Requests" flush
|
||||
helper="Total request volume over time for the selected range.">
|
||||
@include('livewire.traffic._requests-chart')
|
||||
</x-application.settings-section>
|
||||
|
||||
@@ -148,19 +148,27 @@
|
||||
]" />
|
||||
<div x-data="{
|
||||
customImage: localStorage.getItem('sentinel_custom_docker_image_{{ $server->uuid }}') || '',
|
||||
saveCustomImage() {
|
||||
async applyCustomImage() {
|
||||
localStorage.setItem('sentinel_custom_docker_image_{{ $server->uuid }}', this.customImage);
|
||||
$wire.set('sentinelCustomDockerImage', this.customImage || null);
|
||||
await $wire.set('sentinelCustomDockerImage', this.customImage || null);
|
||||
await $wire.restartSentinel();
|
||||
}
|
||||
}"
|
||||
{{-- Only hydrate Livewire when a real override exists. Unconditional
|
||||
$wire.set('', null→'') on every open marks the component dirty and
|
||||
flashes the unsaved bar until the round-trip completes. --}}
|
||||
x-init="if (customImage) { $wire.set('sentinelCustomDockerImage', customImage) }">
|
||||
<x-forms.input canGate="update" :canResource="$server" x-model="customImage"
|
||||
@input.debounce.500ms="saveCustomImage()"
|
||||
placeholder="sentinel:latest" label="Custom Docker image"
|
||||
helper="Leave empty to use the default Sentinel image." />
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<x-forms.input canGate="update" :canResource="$server" x-model="customImage"
|
||||
placeholder="sentinel:latest" label="Custom Docker image"
|
||||
helper="Leave empty to use the default Sentinel image." />
|
||||
</div>
|
||||
<x-forms.button canGate="update" :canResource="$server"
|
||||
x-on:click="applyCustomImage()">
|
||||
Apply and restart
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<x-application.settings-section id="analytics-{{ $dimension }}-section" :title="$label" :helper="$helper" flush>
|
||||
@if (collect($rows)->isEmpty())
|
||||
<x-empty size="sm" title="No data"
|
||||
description="No {{ strtolower($label) }} data for the selected range." icon-name="network" />
|
||||
:description="'No '.strtolower($label).' data for the selected range.'" icon-name="network" />
|
||||
@else
|
||||
<div x-data="{ page: 0, per: 10, total: {{ count($rows) }} }">
|
||||
@foreach ($rows as $row)
|
||||
|
||||
@@ -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)
|
||||
<x-empty size="sm" title="No device data" description="No device data for the selected range." icon-name="network" />
|
||||
@else
|
||||
<div wire:ignore id="{!! $deviceChartId !!}" class="min-h-[240px] w-full"></div>
|
||||
@@ -20,6 +21,7 @@
|
||||
@script
|
||||
<script>
|
||||
(() => {
|
||||
requestAnimationFrame(() => {
|
||||
checkTheme();
|
||||
const el = document.getElementById('{!! $deviceChartId !!}');
|
||||
if (!el) { return; }
|
||||
@@ -51,6 +53,7 @@
|
||||
chart.updateOptions({ labels: data.deviceLabels, legend: legend() });
|
||||
chart.updateSeries(data.deviceSeries);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endscript
|
||||
|
||||
@@ -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
|
||||
<div wire:ignore class="relative w-full">
|
||||
<div id="{!! $chartId !!}-requests" class="min-h-[220px] w-full"></div>
|
||||
<div id="{{ $chartId }}-requests" class="min-h-[220px] w-full"></div>
|
||||
|
||||
{{-- 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). --}}
|
||||
<div id="{!! $chartId !!}-requests-empty" style="display: {{ $this->hasRequestSeries() ? 'none' : 'flex' }}"
|
||||
<div id="{{ $chartId }}-requests-empty" style="display: {{ $this->hasRequestSeries() ? 'none' : 'flex' }}"
|
||||
class="absolute inset-0 items-center justify-center bg-white dark:bg-base">
|
||||
<x-empty size="sm" title="No requests in this range"
|
||||
description="No request traffic was recorded for the selected filters and range. Try a wider range or check back later."
|
||||
@@ -22,16 +28,28 @@
|
||||
@script
|
||||
<script>
|
||||
(() => {
|
||||
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 `<div class="apexcharts-tooltip-custom">
|
||||
<div class="apexcharts-tooltip-custom-value">Requests: <span class="apexcharts-tooltip-value-bold">${requests.toLocaleString()}</span></div>
|
||||
<div class="apexcharts-tooltip-custom-title">${formatTimestamp(timestamp)}</div>
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
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 }]);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endscript
|
||||
|
||||
@@ -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
|
||||
<div wire:ignore id="{!! $id !!}" class="h-9 w-full"
|
||||
<div wire:ignore id="{!! $id !!}" class="h-9 w-full [&_.apexcharts-svg]:overflow-visible!"
|
||||
x-data="{
|
||||
chart: null,
|
||||
refreshCleanup: null,
|
||||
accent() { return getComputedStyle(document.documentElement).getPropertyValue('{{ $colorVar }}').trim() || '#3b82f6'; },
|
||||
muted() { return getComputedStyle(document.documentElement).getPropertyValue('--chart-geo-empty').trim() || 'rgba(128,128,128,0.45)'; },
|
||||
isFlat(a) { return !Array.isArray(a) || a.length === 0 || a.every(v => !Number(v)); },
|
||||
opts(values) {
|
||||
points(values, categories) {
|
||||
return values.map((value, index) => categories[index] === undefined ? value : { x: categories[index], y: value });
|
||||
},
|
||||
opts(values, categories = []) {
|
||||
const flat = this.isFlat(values);
|
||||
const len = (Array.isArray(values) && values.length) ? values.length : 12;
|
||||
const data = flat ? Array(len).fill(0) : values;
|
||||
return {
|
||||
series: [{ data: flat ? Array(len).fill(0) : values }],
|
||||
series: [{ name: @js($label), data: this.points(data, categories) }],
|
||||
colors: [flat ? this.muted() : this.accent()],
|
||||
stroke: { width: flat ? 1 : 1.5, curve: 'smooth' },
|
||||
fill: { type: 'gradient', gradient: { opacityFrom: flat ? 0 : 0.35, opacityTo: 0 } },
|
||||
tooltip: { enabled: !flat },
|
||||
yaxis: flat ? { min: -1, max: 1 } : {},
|
||||
yaxis: { labels: { show: false }, ...(flat ? { min: -1, max: 1 } : {}) },
|
||||
};
|
||||
},
|
||||
init() {
|
||||
if (typeof ApexCharts === 'undefined') { return; }
|
||||
const o = this.opts(@js($initial));
|
||||
const o = this.opts(@js($initial), @js($initialCategories));
|
||||
const formatTimestamp = timestamp => `${new Date(timestamp).toLocaleString(undefined, {
|
||||
timeZone: 'UTC',
|
||||
hour12: false,
|
||||
})} UTC`;
|
||||
const formatValue = value => {
|
||||
if (@js($key) === 'bandwidthSpark') {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let amount = Number(value) || 0;
|
||||
let unit = 0;
|
||||
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit++; }
|
||||
return `${Number(amount.toFixed(unit === 0 ? 0 : 2))} ${units[unit]}`;
|
||||
}
|
||||
if (@js($key) === 'latencySpark') { return `${Number(value).toLocaleString()} ms`; }
|
||||
return Number(value).toLocaleString();
|
||||
};
|
||||
this.chart = new ApexCharts($el, {
|
||||
chart: { type: 'area', height: 36, sparkline: { enabled: true }, animations: { enabled: false }, background: 'transparent' },
|
||||
series: o.series,
|
||||
colors: o.colors,
|
||||
stroke: o.stroke,
|
||||
fill: o.fill,
|
||||
markers: { size: 0, hover: { size: 5, sizeOffset: 2 } },
|
||||
yaxis: o.yaxis,
|
||||
xaxis: { type: 'datetime', labels: { show: false }, axisBorder: { show: false }, axisTicks: { show: false } },
|
||||
grid: { padding: { left: 4, right: 4, top: 0, bottom: 0 } },
|
||||
tooltip: {
|
||||
enabled: o.tooltip.enabled,
|
||||
shared: true,
|
||||
intersect: false,
|
||||
fixed: { enabled: false },
|
||||
x: { show: false },
|
||||
y: { formatter: v => Math.round(v).toLocaleString(), title: { formatter: () => '' } },
|
||||
marker: { show: false },
|
||||
custom: ({ series, dataPointIndex, w }) => {
|
||||
const value = series[0][dataPointIndex];
|
||||
const timestamp = w.globals.seriesX[0][dataPointIndex];
|
||||
return `<div class='apexcharts-tooltip-custom'>
|
||||
<div class='apexcharts-tooltip-custom-value'>${@js($label)}: <span class='apexcharts-tooltip-value-bold'>${formatValue(value)}</span></div>
|
||||
<div class='apexcharts-tooltip-custom-title'>${formatTimestamp(timestamp)}</div>
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
this.chart.render();
|
||||
@if ($event && $key)
|
||||
Livewire.on('{{ $event }}', payload => {
|
||||
const event = @js($event);
|
||||
const key = @js($key);
|
||||
if (event && key) {
|
||||
this.refreshCleanup = Livewire.on(event, payload => {
|
||||
const data = Array.isArray(payload) ? payload[0] : payload;
|
||||
if (this.chart && data && '{{ $key }}' in data) {
|
||||
const u = this.opts(data['{{ $key }}']);
|
||||
if (this.chart && data && key in data) {
|
||||
const u = this.opts(data[key], data.sparkCategories || []);
|
||||
this.chart.updateOptions({ colors: u.colors, stroke: u.stroke, fill: u.fill, tooltip: { enabled: u.tooltip.enabled }, yaxis: u.yaxis }, false, false);
|
||||
this.chart.updateSeries(u.series);
|
||||
}
|
||||
});
|
||||
@endif
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
this.refreshCleanup?.();
|
||||
this.chart?.destroy();
|
||||
},
|
||||
}"></div>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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('<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Analytics</h1>')
|
||||
->not->toContain('<x-reicon name="analytics"');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -139,6 +139,17 @@ it('shows only sparkline KPI cards that link through to the full analytics page'
|
||||
->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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user