diff --git a/app/Data/Traffic/TrafficPathData.php b/app/Data/Traffic/TrafficPathData.php index 40549c9ebf..22fa8359da 100644 --- a/app/Data/Traffic/TrafficPathData.php +++ b/app/Data/Traffic/TrafficPathData.php @@ -10,6 +10,8 @@ class TrafficPathData extends Data public string $path, public int $requests, public int $bytesOut, + public int $s4xx, + public int $s5xx, public float $p50, public float $p95, // Owning app key (Sentinel returns this per path row so the UI can show the @@ -23,6 +25,8 @@ class TrafficPathData extends Data path: (string) data_get($row, 'path', ''), requests: (int) data_get($row, 'requests', 0), bytesOut: (int) data_get($row, 'bytes_out', 0), + s4xx: (int) data_get($row, 's4xx', 0), + s5xx: (int) data_get($row, 's5xx', 0), p50: (float) data_get($row, 'p50', 0.0), p95: (float) data_get($row, 'p95', 0.0), app: (string) data_get($row, 'app', ''), diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php index 993d2aa4c1..24ba69d2d4 100644 --- a/app/Livewire/Analytics.php +++ b/app/Livewire/Analytics.php @@ -21,6 +21,8 @@ class Analytics extends Component public string $chartId = 'global-analytics'; + public ?string $scopedServerUuid = null; + /** Traffic-enabled servers owned by the current team. */ public Collection $servers; @@ -109,32 +111,46 @@ class Analytics extends Component */ protected array $appMetaCache = []; - public function mount(): void + public function mount(?string $scopedServerUuid = null): void { $allServers = Server::ownedByCurrentTeamCached(); - $this->servers = $allServers - ->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled()) - ->values(); + $this->scopedServerUuid = $scopedServerUuid; - $this->serverOptions = $this->servers - ->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name]) - ->all(); + if ($this->scopedServerUuid !== null) { + $server = $allServers->firstWhere('uuid', $this->scopedServerUuid); + abort_if($server === null, 404); - $eligibleDisabled = $allServers - ->filter(fn (Server $server) => ! $server->isTrafficAnalyticsEnabled() - && ! $server->isSwarm() - && ! $server->isBuildServer()) - ->values(); + $this->serverUuid = $server->uuid; + $this->chartId = 'server-analytics-'.$server->uuid; + $this->servers = $server->isTrafficAnalyticsEnabled() ? collect([$server]) : collect(); + $this->serverOptions = [$server->uuid => $server->name]; + $this->eligibleDisabledServers = []; + $this->nudgeKey = ''; + } else { + $this->servers = $allServers + ->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled()) + ->values(); - $this->eligibleDisabledServers = $eligibleDisabled - ->map(fn (Server $server) => ['uuid' => $server->uuid, 'name' => $server->name]) - ->all(); - $this->nudgeKey = substr(md5($eligibleDisabled->pluck('uuid')->sort()->implode(',')), 0, 12); + $this->serverOptions = $this->servers + ->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name]) + ->all(); - // A bookmarked ?server= may point at a server that is no longer enabled. - if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) { - $this->serverUuid = ''; + $eligibleDisabled = $allServers + ->filter(fn (Server $server) => ! $server->isTrafficAnalyticsEnabled() + && ! $server->isSwarm() + && ! $server->isBuildServer()) + ->values(); + + $this->eligibleDisabledServers = $eligibleDisabled + ->map(fn (Server $server) => ['uuid' => $server->uuid, 'name' => $server->name]) + ->all(); + $this->nudgeKey = substr(md5($eligibleDisabled->pluck('uuid')->sort()->implode(',')), 0, 12); + + // A bookmarked ?server= may point at a server that is no longer enabled. + if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) { + $this->serverUuid = ''; + } } $this->refreshAppOptions(); @@ -226,6 +242,10 @@ class Analytics extends Component */ protected function targetServers(): Collection { + if ($this->scopedServerUuid !== null) { + return $this->servers; + } + if ($this->appUuid !== '') { $server = Application::ownedByCurrentTeam()->whereUuid($this->appUuid)->first() ?->destination?->server; @@ -311,9 +331,11 @@ class Analytics extends Component $key = $resolveId."\n".$pathStr; $domain = $resolveId !== '' ? ($this->appMeta($resolveId)['domain'] ?? null) : null; - $pathTotals[$key] ??= ['path' => $pathStr, 'domain' => $domain, 'requests' => 0, 'bytesOut' => 0, 'p95' => 0.0]; + $pathTotals[$key] ??= ['path' => $pathStr, 'domain' => $domain, 'requests' => 0, 'bytesOut' => 0, 's4xx' => 0, 's5xx' => 0, 'p95' => 0.0]; $pathTotals[$key]['requests'] += (int) ($data['requests'] ?? 0); $pathTotals[$key]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); + $pathTotals[$key]['s4xx'] += (int) ($data['s4xx'] ?? 0); + $pathTotals[$key]['s5xx'] += (int) ($data['s5xx'] ?? 0); $pathTotals[$key]['p95'] = max($pathTotals[$key]['p95'], (float) ($data['p95'] ?? 0)); } diff --git a/app/Livewire/Server/Analytics/Show.php b/app/Livewire/Server/Analytics/Show.php new file mode 100644 index 0000000000..abcf904e0c --- /dev/null +++ b/app/Livewire/Server/Analytics/Show.php @@ -0,0 +1,26 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('view', $this->server); + } + + public function render(): View + { + return view('livewire.server.analytics.show'); + } +} diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index 1cda771a7c..567034c801 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -5,6 +5,7 @@ namespace App\Livewire\Server; use App\Actions\Server\StartSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Validate; use Livewire\Component; class Charts extends Component @@ -23,15 +24,44 @@ class Charts extends Component public bool $poll = true; + #[Validate(['required', 'integer', 'min:1'])] + public int|string $sentinelMetricsRefreshRateSeconds; + + #[Validate(['required', 'integer', 'min:1'])] + public int|string $sentinelMetricsHistoryDays; + + #[Validate(['required', 'integer', 'min:10'])] + public int|string $sentinelPushIntervalSeconds; + public function mount(string $server_uuid) { try { $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds; + $this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days; + $this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds; } catch (\Throwable $e) { return handleError($e, $this); } } + public function saveMetricsSettings(): void + { + try { + $this->authorize('update', $this->server); + $this->validate(); + + $this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds; + $this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays; + $this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds; + $this->server->settings->save(); + + $this->dispatch('success', 'Metrics settings updated. Restarting Sentinel.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function toggleMetrics(): void { try { @@ -70,11 +100,9 @@ class Charts extends Component try { $cpuMetrics = $this->server->getCpuMetrics($this->interval); $memoryMetrics = $this->server->getMemoryMetrics($this->interval); - $this->dispatch("refreshChartData-{$this->chartId}-cpu", [ - 'seriesData' => $cpuMetrics, - ]); - $this->dispatch("refreshChartData-{$this->chartId}-memory", [ - 'seriesData' => $memoryMetrics, + $this->dispatch("refreshChartData-{$this->chartId}-metrics", [ + 'cpuSeries' => $cpuMetrics, + 'memorySeries' => $memoryMetrics, ]); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index d467380ba3..52010a91c4 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -2,7 +2,6 @@ namespace App\Livewire\Server; -use App\Actions\Server\ConfigureTrafficAnalytics; use App\Actions\Server\StartSentinel; use App\Actions\Server\StopSentinel; use App\Models\Server; @@ -23,15 +22,6 @@ class Sentinel extends Component public ?string $sentinelUpdatedAt = null; - #[Validate(['required', 'integer', 'min:1'])] - public int|string $sentinelMetricsRefreshRateSeconds; - - #[Validate(['required', 'integer', 'min:1'])] - public int|string $sentinelMetricsHistoryDays; - - #[Validate(['required', 'integer', 'min:10'])] - public int|string $sentinelPushIntervalSeconds; - #[Validate(['nullable', 'url'])] public ?string $sentinelCustomUrl = null; @@ -41,28 +31,6 @@ class Sentinel extends Component public ?string $sentinelCustomDockerImage = null; - public bool $isTrafficAnalyticsEnabled; - - #[Validate(['required', 'integer', 'min:1'])] - public int|string $trafficTopn; - - #[Validate(['required', 'integer', 'min:0'])] - public int|string $trafficSampleThreshold; - - #[Validate(['required', 'integer', 'min:1'])] - public int|string $trafficRetention1hDays; - - #[Validate(['required', 'integer', 'min:1'])] - public int|string $trafficRetention1dDays; - - public bool $isGeoipEnabled; - - #[Validate(['required', 'integer', 'min:1'])] - public int|string $geoipRefreshDays; - - #[Validate(['nullable', 'string', 'max:255'])] - public ?string $geoipMaxmindLicenseKey = null; - public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; @@ -83,38 +51,17 @@ class Sentinel extends Component $this->validate(); $this->server->settings->is_metrics_enabled = $this->isMetricsEnabled; $this->server->settings->sentinel_token = $this->sentinelToken; - $this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds; - $this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays; - $this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds; $this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl; $this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled; $this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled; - $this->server->settings->traffic_topn = $this->trafficTopn; - $this->server->settings->traffic_sample_threshold = $this->trafficSampleThreshold; - $this->server->settings->traffic_retention_1h_days = $this->trafficRetention1hDays; - $this->server->settings->traffic_retention_1d_days = $this->trafficRetention1dDays; - $this->server->settings->is_geoip_enabled = $this->isGeoipEnabled; - $this->server->settings->geoip_refresh_days = $this->geoipRefreshDays; - $this->server->settings->geoip_maxmind_license_key = $this->geoipMaxmindLicenseKey; $this->server->settings->save(); } else { $this->isMetricsEnabled = $this->server->settings->is_metrics_enabled; $this->sentinelToken = $this->server->settings->sentinel_token; - $this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds; - $this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days; - $this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds; $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; $this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled; $this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled; $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; - $this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled(); - $this->trafficTopn = $this->server->settings->traffic_topn; - $this->trafficSampleThreshold = $this->server->settings->traffic_sample_threshold; - $this->trafficRetention1hDays = $this->server->settings->traffic_retention_1h_days; - $this->trafficRetention1dDays = $this->server->settings->traffic_retention_1d_days; - $this->isGeoipEnabled = (bool) $this->server->settings->is_geoip_enabled; - $this->geoipRefreshDays = $this->server->settings->geoip_refresh_days; - $this->geoipMaxmindLicenseKey = $this->server->settings->geoip_maxmind_license_key; } } @@ -168,27 +115,6 @@ class Sentinel extends Component } } - public function toggleTrafficAnalytics(): void - { - try { - $this->authorize('update', $this->server); - if ($this->server->isSwarm() || $this->server->isBuildServer()) { - $this->dispatch('error', 'Traffic analytics is not supported on Swarm/Build servers.'); - - return; - } - $enable = ! $this->server->isTrafficAnalyticsEnabled(); - ConfigureTrafficAnalytics::run($this->server, $enable); - $this->server->refresh(); - $this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled(); - $this->dispatch('success', $enable - ? 'Traffic analytics enabled. Restarting proxy and Sentinel.' - : 'Traffic analytics disabled. Restarting proxy and Sentinel.'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - public function regenerateSentinelToken() { try { diff --git a/app/Livewire/Server/TrafficAnalyticsSettings.php b/app/Livewire/Server/TrafficAnalyticsSettings.php new file mode 100644 index 0000000000..48022d9aed --- /dev/null +++ b/app/Livewire/Server/TrafficAnalyticsSettings.php @@ -0,0 +1,109 @@ +authorize('update', $this->server); + $this->syncData(); + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->validate(); + $this->server->settings->traffic_topn = $this->trafficTopn; + $this->server->settings->traffic_sample_threshold = $this->trafficSampleThreshold; + $this->server->settings->traffic_retention_1h_days = $this->trafficRetention1hDays; + $this->server->settings->traffic_retention_1d_days = $this->trafficRetention1dDays; + $this->server->settings->is_geoip_enabled = $this->isGeoipEnabled; + $this->server->settings->geoip_refresh_days = $this->geoipRefreshDays; + $this->server->settings->geoip_maxmind_license_key = $this->geoipMaxmindLicenseKey; + $this->server->settings->save(); + + return; + } + + $this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled(); + $this->trafficTopn = $this->server->settings->traffic_topn; + $this->trafficSampleThreshold = $this->server->settings->traffic_sample_threshold; + $this->trafficRetention1hDays = $this->server->settings->traffic_retention_1h_days; + $this->trafficRetention1dDays = $this->server->settings->traffic_retention_1d_days; + $this->isGeoipEnabled = (bool) $this->server->settings->is_geoip_enabled; + $this->geoipRefreshDays = $this->server->settings->geoip_refresh_days; + $this->geoipMaxmindLicenseKey = $this->server->settings->geoip_maxmind_license_key; + } + + public function toggleTrafficAnalytics(): void + { + try { + $this->authorize('update', $this->server); + if ($this->server->isSwarm() || $this->server->isBuildServer()) { + $this->dispatch('error', 'Traffic analytics is not supported on Swarm/Build servers.'); + + return; + } + + $enable = ! $this->server->isTrafficAnalyticsEnabled(); + ConfigureTrafficAnalytics::run($this->server, $enable); + $this->server->refresh(); + $this->isTrafficAnalyticsEnabled = $this->server->isTrafficAnalyticsEnabled(); + $this->dispatch('success', $enable + ? 'Traffic analytics enabled. Restarting proxy and Sentinel.' + : 'Traffic analytics disabled. Restarting proxy and Sentinel.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function saveTrafficAnalyticsSettings(): void + { + try { + $this->authorize('update', $this->server); + $this->syncData(true); + $this->dispatch('success', 'Traffic analytics settings updated. Restarting Sentinel.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.server.traffic-analytics-settings'); + } +} diff --git a/resources/css/app.css b/resources/css/app.css index 3de7cc1907..c5f7b5166d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3624,6 +3624,228 @@ html[data-theme="custom"] .logs-viewer-timestamp { color: var(--color-fg-dim); } +.runtime-log-panel { + --runtime-log-line: rgba(0, 0, 0, 0.08); + --runtime-log-muted: #66666f; + --runtime-log-hover: rgba(0, 0, 0, 0.04); + --runtime-log-detail: rgba(0, 0, 0, 0.03); + --runtime-log-columns: 12.75rem 5.5rem minmax(0, 1fr); +} + +.dark .runtime-log-panel { + --runtime-log-line: var(--glass-line, rgba(255, 255, 255, 0.065)); + --runtime-log-muted: #a09da5; + --runtime-log-hover: rgba(255, 255, 255, 0.035); + --runtime-log-detail: rgba(0, 0, 0, 0.24); +} + +.runtime-log-viewport.logs-viewer-viewport { + container: runtime-log-explorer / inline-size; + padding: 0; +} + +.runtime-log-viewport.logs-viewer-viewport::after { + display: none; +} + +.runtime-log-columns, +.runtime-log-viewport [data-log-line]:not(.hidden) { + display: grid; + grid-template-columns: var(--runtime-log-columns); + gap: 0.625rem; + box-sizing: border-box; + width: 100%; + min-height: 2.75rem; + align-items: center; + padding: 0.6875rem 1.875rem 0.6875rem 1rem; +} + +.runtime-log-columns { + position: sticky; + top: 0; + z-index: 10; + border-bottom: 1px solid var(--runtime-log-line); + background: #f0eff2; + color: var(--runtime-log-muted); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; +} + +.dark .runtime-log-columns { + background: var(--coollabs-elevated); +} + +html[data-theme="custom"] .runtime-log-columns { + background: var(--color-log-toolbar); +} + +.runtime-log-viewport [data-log-line] { + position: relative; + border-bottom: 1px solid var(--runtime-log-line); + border-radius: 0; + cursor: pointer; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.8125rem; + line-height: 1.6; +} + +.runtime-log-viewport [data-log-line]:hover, +.runtime-log-viewport [data-log-line][aria-expanded="true"] { + background: var(--runtime-log-hover); +} + +.runtime-log-viewport [data-log-line]:focus-visible { + outline: 2px solid var(--color-accent, #b93642); + outline-offset: -2px; +} + +.runtime-log-viewport [data-log-line]::before { + content: "[" attr(data-log-level) "]"; + grid-column: 2; + grid-row: 1; + color: var(--runtime-log-muted); + text-transform: uppercase; +} + +.runtime-log-viewport [data-log-line]::after { + content: ""; + position: absolute; + top: 1rem; + right: 0.75rem; + width: 0.375rem; + height: 0.375rem; + border-right: 1.5px solid var(--runtime-log-muted); + border-bottom: 1.5px solid var(--runtime-log-muted); + transform: rotate(45deg); +} + +.runtime-log-viewport [data-log-line][aria-expanded="true"]::after { + top: 1.1875rem; + transform: rotate(225deg); +} + +.runtime-log-viewport .log-error::before { color: #e55e73; } +.runtime-log-viewport .log-warning::before { color: #d79945; } +.runtime-log-viewport .log-debug::before { color: #929099; } +.runtime-log-viewport .log-info::before { color: #8891f0; } + +.runtime-log-viewport .logs-viewer-timestamp { + grid-column: 1; + grid-row: 1; + color: var(--runtime-log-muted); + font-size: 0.8125rem; + line-height: 1.6; + white-space: nowrap; +} + +.runtime-log-viewport [data-line-text] { + grid-column: 3; + grid-row: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-log-detail { + margin: 0 0 0.25rem; + padding: 1.25rem; + border-bottom: 1px solid var(--runtime-log-line); + background: var(--runtime-log-detail); + color: inherit; + overflow-wrap: anywhere; + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.8125rem; + line-height: 1.8; +} + +.dark .runtime-log-detail { + color: #adbdc9; +} + +.runtime-log-viewport [data-log-line].hidden + .runtime-log-detail { + display: none !important; +} + +.runtime-log-without-time { + --runtime-log-columns: 5.5rem minmax(0, 1fr); +} + +.runtime-log-without-time [data-log-line]::before { + grid-column: 1; +} + +.runtime-log-without-time [data-line-text] { + grid-column: 2; +} + +@container runtime-log-explorer (max-width: 650px) { + .runtime-log-columns, + .runtime-log-viewport [data-log-line]:not(.hidden) { + grid-template-columns: minmax(0, 1fr) 5.5rem; + gap: 0.1875rem 0.5rem; + } + + .runtime-log-columns > :last-child, + .runtime-log-viewport [data-line-text] { + grid-column: 1 / -1; + grid-row: 2; + } + + .runtime-log-without-time [data-line-text] { + grid-column: 1 / -1; + } +} + +.runtime-log-empty { + display: flex; + min-height: 18rem; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem; + color: var(--runtime-log-muted); + text-align: left; +} + +.runtime-log-loading { + min-height: 18rem; + align-items: center; + justify-content: center; + gap: 0.625rem; + padding: 2rem; + color: var(--runtime-log-muted); + font-size: 0.8125rem; + font-weight: 500; +} + +.runtime-log-empty-icon { + display: inline-flex; + width: 2.25rem; + height: 2.25rem; + flex-shrink: 0; + align-items: center; + justify-content: center; + border: 1px solid var(--runtime-log-line); + border-radius: 0.5rem; + background: var(--runtime-log-detail); +} + +.runtime-log-empty p { + color: inherit; + font-size: 0.8125rem; + font-weight: 500; +} + +.runtime-log-empty div > span { + display: block; + margin-top: 0.125rem; + font-size: 0.75rem; + line-height: 1.25rem; +} + .env-table-detail { padding: 0.25rem 1rem 1.25rem; } diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 46e433e8a5..75fb43e2d7 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -132,6 +132,14 @@ 'group' => 'Operations', 'visible' => $server->isFunctional(), ], + [ + 'label' => 'Analytics', + 'route' => 'server.analytics', + 'active' => $activeMenu === 'analytics', + 'icon' => 'analytics', + 'group' => 'Operations', + 'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->isBuildServer(), + ], [ 'label' => 'Security', 'route' => 'server.security.patches', diff --git a/resources/views/livewire/analytics-placeholder.blade.php b/resources/views/livewire/analytics-placeholder.blade.php index ca48f74145..cb6007b95b 100644 --- a/resources/views/livewire/analytics-placeholder.blade.php +++ b/resources/views/livewire/analytics-placeholder.blade.php @@ -1,16 +1,20 @@
{{-- Header (real chrome; only the data below is a skeleton) --}}
-
-

Analytics

-

- Request traffic across every application and server, reported by Sentinel. -

-
+ @if (empty($scopedServerUuid ?? null)) +
+

Analytics

+

+ Request traffic across every application and server, reported by Sentinel. +

+
+ @endif {{-- Filter bar --}}
- + @if (empty($scopedServerUuid ?? null)) + + @endif
diff --git a/resources/views/livewire/analytics.blade.php b/resources/views/livewire/analytics.blade.php index fb6e935681..bb3b62ff39 100644 --- a/resources/views/livewire/analytics.blade.php +++ b/resources/views/livewire/analytics.blade.php @@ -21,29 +21,35 @@ $appListboxOptions = array_merge( ); ?>
- - Analytics | Coolify - + @if ($scopedServerUuid === null) + + Analytics | Coolify + + @endif {{-- Header --}}
-
-

Analytics

-

- Request traffic across every application and server, reported by Sentinel. -

-
+ @if ($scopedServerUuid === null) +
+

Analytics

+

+ Request traffic across every application and server, reported by Sentinel. +

+
+ @endif @if ($servers->isNotEmpty() && $overview)
-
- - + @endif {{-- 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. --}}
{{-- Nudge: enabled-eligible servers that haven't turned traffic analytics on yet. --}} - @if (! empty($eligibleDisabledServers)) + @if ($scopedServerUuid === null && ! empty($eligibleDisabledServers))
@@ -97,7 +103,7 @@ $appListboxOptions = array_merge(
- + Server settings diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index dc64666e2f..4c4e68a9f3 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -14,6 +14,7 @@ logFilters: JSON.parse(localStorage.getItem('coolify-log-filters')) || {error: true, warning: true, debug: true, info: true}, searchQuery: '', matchCount: 0, + expandedLogs: {}, containerName: '{{ $container ?? "logs" }}', makeFullscreen() { this.fullscreen = !this.fullscreen; @@ -119,6 +120,22 @@ if (/\b(debug|dbg|trace|verbose)\b/.test(content)) return 'debug'; return 'info'; }, + toggleLogDetails(key, event) { + if (window.getSelection()?.toString()) return; + if (event.type === 'keydown' && !['Enter', ' '].includes(event.key)) return; + if (event.type === 'keydown') event.preventDefault(); + this.expandedLogs[key] = !this.expandedLogs[key]; + }, + isLogExpanded(key) { + return this.expandedLogs[key] === true; + }, + formatLogDetails(content) { + try { + return JSON.stringify(JSON.parse(content), null, 2); + } catch { + return content; + } + }, toggleLogFilter(level) { this.logFilters[level] = !this.logFilters[level]; localStorage.setItem('coolify-log-filters', JSON.stringify(this.logFilters)); @@ -515,7 +532,17 @@ $displayLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== ''); $lineOccurrences = []; @endphp -
+
!$showTimeStamps, + ])> +
No matches found. @@ -543,17 +570,38 @@ $timestamp = $carbonTs->format('Y-M-d H:i:s'); } @endphp -
+ @php($lineKey = $lineFingerprint.'-'.$lineOccurrence) +
@if ($timestamp && $showTimeStamps) {{ $timestamp }} @endif {{ $logContent }}
+

                             @endforeach
                         
@else -
No logs yet.
+
+ + Loading logs +
+
+ +
+

No logs yet

+ Logs will appear here when the container produces output. +
+
@endif
diff --git a/resources/views/livewire/server/analytics/show.blade.php b/resources/views/livewire/server/analytics/show.blade.php new file mode 100644 index 0000000000..e25cc20382 --- /dev/null +++ b/resources/views/livewire/server/analytics/show.blade.php @@ -0,0 +1,21 @@ +
+ + {{ data_get_str($server, 'name')->limit(10) }} > Analytics | Coolify + + + + +
+ + +
+ @can('update', $server) + + @endcan + + +
+
+
diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 05d8b6f4b7..42ec30d1f4 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -14,33 +14,50 @@ @if ($poll) wire:poll.5000ms="pollData" @endif @endif> @if ($server->isMetricsEnabled()) - - -
- - - Disable metrics - -
-
+
+ -
- -
-

- Five and ten minute ranges refresh automatically every five seconds. -

- + + +
+ + + Disable metrics + +
+
+ +
+ + + +
+ +
+ +
+

+ Five and ten minute ranges refresh automatically every five seconds. +

+
+ @@ -119,7 +136,7 @@ xaxis: { type: 'datetime', labels: { - datetimeUTC: true, + datetimeUTC: false, style: { colors: textColor, }, @@ -183,18 +200,20 @@ cpuChart.render(); memoryChart.render(); - Livewire.on('refreshChartData-{!! $chartId !!}-cpu', chartData => { + Livewire.on('refreshChartData-{!! $chartId !!}-metrics', chartData => { checkTheme(); + const data = Array.isArray(chartData) ? chartData[0] : chartData; + cpuChart.updateOptions({ colors: [cpuColor], series: [{ name: 'CPU', - data: chartData[0].seriesData, + data: data.cpuSeries, }], xaxis: { type: 'datetime', labels: { - datetimeUTC: true, + datetimeUTC: false, style: { colors: textColor, }, @@ -219,20 +238,16 @@ }, }, }); - }); - - Livewire.on('refreshChartData-{!! $chartId !!}-memory', chartData => { - checkTheme(); memoryChart.updateOptions({ colors: [ramColor], series: [{ name: 'Memory', - data: chartData[0].seriesData, + data: data.memorySeries, }], xaxis: { type: 'datetime', labels: { - datetimeUTC: true, + datetimeUTC: false, style: { colors: textColor, }, @@ -296,6 +311,7 @@ @endif +
diff --git a/resources/views/livewire/server/sentinel.blade.php b/resources/views/livewire/server/sentinel.blade.php index 068b9b835b..a9f9c9c8fb 100644 --- a/resources/views/livewire/server/sentinel.blade.php +++ b/resources/views/livewire/server/sentinel.blade.php @@ -6,7 +6,7 @@ `$wire.set('sentinelCustomDockerImage', …)` (and similar) briefly flashes this bar on every page open. --}} + targets="sentinelCustomUrl,sentinelToken" /> @endif - -
- - - -
-
- - - - - {{ $isTrafficAnalyticsEnabled ? 'Disable' : 'Enable' }} traffic analytics - - - - @if ($isTrafficAnalyticsEnabled) -
- - - - - - - -
- @else - - @endif -
- @if (isDev()) +
+ @if ($isTrafficAnalyticsEnabled) + + @endif + + + + + {{ $isTrafficAnalyticsEnabled ? 'Disable' : 'Enable' }} traffic analytics + + + + @if ($isTrafficAnalyticsEnabled) +
+ + + + + + + +
+ @else + + @endif +
+ +
diff --git a/resources/views/livewire/traffic/_device-chart.blade.php b/resources/views/livewire/traffic/_device-chart.blade.php index 0f852e1a5f..049713db21 100644 --- a/resources/views/livewire/traffic/_device-chart.blade.php +++ b/resources/views/livewire/traffic/_device-chart.blade.php @@ -28,6 +28,9 @@ const palette = ['#3b82f6', '#f59e0b', '#ec4899', '#8b5cf6', '#10b981', '#14b8a6', '#6b7280']; const legend = () => ({ position: 'bottom', labels: { colors: textColor } }); + const escapeHtml = value => String(value).replace(/[&<>'"]/g, character => ({ + '&': '&', '<': '<', '>': '>', "'": ''', '"': '"', + })[character]); const chart = new ApexCharts(el, { chart: { type: 'donut', height: 240, background: 'transparent', animations: { enabled: false } }, @@ -38,10 +41,17 @@ dataLabels: { enabled: false }, legend: legend(), plotOptions: { pie: { donut: { size: '68%' } } }, - {{-- Donuts otherwise fill the whole tooltip with the slice color (white - text on light slices reads poorly); fillSeriesColor:false gives the - same neutral tooltip the other charts use. --}} - tooltip: { fillSeriesColor: false, y: { formatter: v => `${v.toLocaleString()} requests` } }, + tooltip: { + fillSeriesColor: false, + custom: ({ series, seriesIndex, w }) => { + const label = escapeHtml(w.globals.labels[seriesIndex] ?? ''); + const requests = Number(series[seriesIndex] ?? 0).toLocaleString(); + + return `
+
${label}: ${requests} requests
+
`; + }, + }, noData: { text: 'Loading devices…', style: { color: textColor } }, }); chart.render(); diff --git a/resources/views/livewire/traffic/_paths-list.blade.php b/resources/views/livewire/traffic/_paths-list.blade.php index be841a5bff..3ca1a97bdd 100644 --- a/resources/views/livewire/traffic/_paths-list.blade.php +++ b/resources/views/livewire/traffic/_paths-list.blade.php @@ -24,6 +24,9 @@ $pathStr = (string) ($path['path'] ?? ''); $href = $domain ? 'https://'.$domain.$pathStr : null; $requests = (int) ($path['requests'] ?? 0); + $s4xx = (int) ($path['s4xx'] ?? 0); + $s5xx = (int) ($path['s5xx'] ?? 0); + $errorRate = $requests > 0 ? round((($s4xx + $s5xx) / $requests) * 100, 1) : 0; $width = min(100, round(($requests / $maxRequests) * 100, 1)); @endphp
{{ compactNumber($requests) }} + + {{ compactNumber($s5xx) }} 5xx + diff --git a/routes/web.php b/routes/web.php index f561fd9ea6..95ceff53f1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -54,6 +54,7 @@ use App\Livewire\Security\IntegrationTokens; use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex; use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow; use App\Livewire\Server\Advanced as ServerAdvanced; +use App\Livewire\Server\Analytics\Show as ServerAnalytics; use App\Livewire\Server\CaCertificate\Show as CaCertificateShow; use App\Livewire\Server\Charts as ServerCharts; use App\Livewire\Server\CloudflareTunnel; @@ -378,6 +379,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/destinations', ServerDestinations::class)->name('server.destinations'); Route::get('/log-drains', LogDrains::class)->name('server.log-drains'); Route::get('/metrics', ServerCharts::class)->name('server.metrics'); + Route::get('/analytics', ServerAnalytics::class)->name('server.analytics'); Route::get('/danger', DeleteServer::class)->name('server.delete'); Route::get('/transfer', ServerTransfer::class)->name('server.transfer'); Route::get('/proxy', ProxyShow::class)->name('server.proxy'); diff --git a/tests/Feature/DeploymentLogsLayoutTest.php b/tests/Feature/DeploymentLogsLayoutTest.php index be825c663b..f340c98f5f 100644 --- a/tests/Feature/DeploymentLogsLayoutTest.php +++ b/tests/Feature/DeploymentLogsLayoutTest.php @@ -118,6 +118,17 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () { ->toContain('logs-viewer-lines') ->toContain('logs-viewer-actions') ->toContain('logs-viewer-line') + ->toContain('runtime-log-columns') + ->toContain('runtime-log-detail') + ->toContain('runtime-log-empty') + ->toContain('runtime-log-loading') + ->toContain('wire:loading.flex wire:target="getLogs"') + ->toContain('wire:loading.remove wire:target="getLogs"') + ->toContain('Loading logs') + ->toContain('Logs will appear here when the container produces output.') + ->toContain('toggleLogDetails') + ->toContain('formatLogDetails') + ->toContain('aria-expanded') ->toContain('pl-8!') ->toContain('z-10 size-3.5') ->and($deploymentView) @@ -131,6 +142,13 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () { ->toContain('.logs-viewer-actions') ->toContain('.logs-viewer-deployment-actions') ->toContain('.logs-settings-section') + ->toContain('.runtime-log-columns') + ->toContain('.runtime-log-detail') + ->toContain('.runtime-log-empty') + ->toContain('.runtime-log-loading') + ->toContain('grid-template-columns: var(--runtime-log-columns)') + ->toContain(".dark .runtime-log-columns {\n background: var(--coollabs-elevated);") + ->toContain("html[data-theme=\"custom\"] .runtime-log-columns {\n background: var(--color-log-toolbar);") ->toContain('padding: 0.5rem 0.75rem 0;') ->toContain('padding: 0.5rem 1rem 0;') ->toContain(".logs-viewer-viewport::after {\n content: \"\";\n flex: 0 0 2rem;") diff --git a/tests/Feature/SentinelUnsavedBarFlashTest.php b/tests/Feature/SentinelUnsavedBarFlashTest.php index 74aa844097..937d7cad5c 100644 --- a/tests/Feature/SentinelUnsavedBarFlashTest.php +++ b/tests/Feature/SentinelUnsavedBarFlashTest.php @@ -12,10 +12,22 @@ test('sentinel unsaved bar scopes dirty tracking to savable form fields', functi expect($contents) ->toContain('x-unsaved-bar') - ->toContain('targets="sentinelCustomUrl,sentinelToken,sentinelMetricsRefreshRateSeconds,sentinelMetricsHistoryDays,sentinelPushIntervalSeconds"') + ->toContain('targets="sentinelCustomUrl,sentinelToken"') + ->not->toContain('trafficTopn') + ->not->toContain('sentinelMetricsRefreshRateSeconds') + ->not->toContain('sentinelMetricsHistoryDays') + ->not->toContain('sentinelPushIntervalSeconds') ->not->toMatch('/x-unsaved-bar\s+action="submit"\s*\/>/'); }); +test('metrics unsaved bar scopes dirty tracking to metrics collection fields', function () { + $contents = file_get_contents(resource_path('views/livewire/server/charts.blade.php')); + + expect($contents) + ->toContain('x-unsaved-bar action="saveMetricsSettings"') + ->toContain('targets="sentinelMetricsRefreshRateSeconds,sentinelMetricsHistoryDays,sentinelPushIntervalSeconds"'); +}); + test('unsaved bar component accepts optional wire:target list', function () { $path = resource_path('views/components/unsaved-bar.blade.php'); $contents = file_get_contents($path); diff --git a/tests/Feature/Server/ServerMetricsSettingsTest.php b/tests/Feature/Server/ServerMetricsSettingsTest.php new file mode 100644 index 0000000000..16ac1d919c --- /dev/null +++ b/tests/Feature/Server/ServerMetricsSettingsTest.php @@ -0,0 +1,93 @@ +user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('shows metrics collection settings inside the main metrics section instead of a separate section', function () { + $metricsView = file_get_contents(resource_path('views/livewire/server/charts.blade.php')); + $sentinelView = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php')); + + expect($metricsView) + ->not->toContain('id="server-metrics-collection-section"') + ->toContain('id="sentinelMetricsRefreshRateSeconds"') + ->toContain('id="sentinelMetricsHistoryDays"') + ->toContain('id="sentinelPushIntervalSeconds"') + ->and($sentinelView) + ->not->toContain('id="server-sentinel-metrics-section"') + ->not->toContain('id="sentinelMetricsRefreshRateSeconds"') + ->not->toContain('id="sentinelMetricsHistoryDays"') + ->not->toContain('id="sentinelPushIntervalSeconds"'); +}); + +it('saves metrics collection settings from the server metrics page', function () { + Queue::fake(); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->is_sentinel_enabled = true; + $server->settings->is_metrics_enabled = true; + $server->settings->save(); + + Livewire::test(Charts::class, ['server_uuid' => $server->uuid]) + ->set('sentinelMetricsRefreshRateSeconds', 15) + ->set('sentinelMetricsHistoryDays', 14) + ->set('sentinelPushIntervalSeconds', 90) + ->call('saveMetricsSettings') + ->assertHasNoErrors(); + + $settings = $server->settings->fresh(); + + expect($settings->sentinel_metrics_refresh_rate_seconds)->toBe(15) + ->and($settings->sentinel_metrics_history_days)->toBe(14) + ->and($settings->sentinel_push_interval_seconds)->toBe(90); +}); + +it('validates metrics collection settings on the server metrics page', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + Livewire::test(Charts::class, ['server_uuid' => $server->uuid]) + ->set('sentinelMetricsRefreshRateSeconds', 0) + ->set('sentinelMetricsHistoryDays', 0) + ->set('sentinelPushIntervalSeconds', 9) + ->call('saveMetricsSettings') + ->assertHasErrors([ + 'sentinelMetricsRefreshRateSeconds', + 'sentinelMetricsHistoryDays', + 'sentinelPushIntervalSeconds', + ]); +}); + +it('uses the local datetime axis so server charts show day separators', function () { + $metricsView = file_get_contents(resource_path('views/livewire/server/charts.blade.php')); + + expect($metricsView) + ->not->toContain('datetimeUTC: true') + ->and(substr_count($metricsView, 'datetimeUTC: false')) + ->toBe(3); +}); + +it('updates CPU and memory charts together when the time range changes', function () { + $component = file_get_contents(app_path('Livewire/Server/Charts.php')); + $metricsView = file_get_contents(resource_path('views/livewire/server/charts.blade.php')); + + expect($component) + ->toContain('"refreshChartData-{$this->chartId}-metrics"') + ->toContain("'cpuSeries' => \$cpuMetrics") + ->toContain("'memorySeries' => \$memoryMetrics") + ->and($metricsView) + ->toContain("Livewire.on('refreshChartData-{!! \$chartId !!}-metrics'") + ->toContain('data.cpuSeries') + ->toContain('data.memorySeries'); +}); diff --git a/tests/Feature/TrafficAnalytics/ChartTokensTest.php b/tests/Feature/TrafficAnalytics/ChartTokensTest.php index c9cc84b764..ccbec59a1c 100644 --- a/tests/Feature/TrafficAnalytics/ChartTokensTest.php +++ b/tests/Feature/TrafficAnalytics/ChartTokensTest.php @@ -101,6 +101,14 @@ it('treats an all-zero device series as no data', function () { ->toContain('@if (! $hasDeviceData)'); }); +it('renders the device chart tooltip with the shared opaque background', function () { + $partial = file_get_contents(base_path('resources/views/livewire/traffic/_device-chart.blade.php')); + + expect($partial) + ->toContain('apexcharts-tooltip-custom') + ->toContain('apexcharts-tooltip-custom-value'); +}); + it('keeps KPI sparklines axisless after live updates', function () { $partial = file_get_contents(base_path('resources/views/livewire/traffic/_sparkline.blade.php')); diff --git a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php index 3feb5ed668..c0a13000f0 100644 --- a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php @@ -45,7 +45,7 @@ function fakeGlobalAnalyticsResponses(array $appUuids = []): array 'unique_visitors' => 320, ]), '/traffic/paths' => json_encode([ - ['path' => '/', 'app' => $appUuids[0] ?? '', 'requests' => 500, 'bytes_out' => 12000, 'p50' => 10.0, 'p95' => 30.0], + ['path' => '/', 'app' => $appUuids[0] ?? '', 'requests' => 500, 'bytes_out' => 12000, 's4xx' => 3, 's5xx' => 1, 'p50' => 10.0, 'p95' => 30.0], ]), '/traffic/breakdown/agent' => json_encode([ ['value' => 'GPTBot', 'requests' => 120, 'bytes_out' => 3000], @@ -138,6 +138,8 @@ it('renders a team-wide analytics summary across enabled servers', function () { ->assertSee('Global Leaderboard App') ->assertSee('Top hosts') ->assertSee('Top paths') + ->assertSee('3 4xx') + ->assertSee('1 5xx') ->assertSee('Status codes') ->assertSee('Countries') ->assertSee('United States') @@ -255,6 +257,8 @@ it('shows path domains, links top apps to analytics, groups by project, and surf // Path rows carry the resolved domain, top-app rows carry the domain + analytics link. expect($component->instance()->topPaths[0]['domain'])->toBe('shop.example.com'); + expect($component->instance()->topPaths[0]['s4xx'])->toBe(3); + expect($component->instance()->topPaths[0]['s5xx'])->toBe(1); expect($component->instance()->topApps[0]['domain'])->toBe('shop.example.com'); expect($component->instance()->topApps[0]['link'])->toBe($analyticsUrl); diff --git a/tests/Feature/TrafficAnalytics/ServerAnalyticsPageTest.php b/tests/Feature/TrafficAnalytics/ServerAnalyticsPageTest.php new file mode 100644 index 0000000000..1f74a6e59a --- /dev/null +++ b/tests/Feature/TrafficAnalytics/ServerAnalyticsPageTest.php @@ -0,0 +1,116 @@ +user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('registers a server analytics page in the server sidebar', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + expect(route('server.analytics', ['server_uuid' => $server->uuid])) + ->toEndWith("/server/{$server->uuid}/analytics"); + + $sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php')); + + expect($sidebar) + ->toContain("'label' => 'Analytics'") + ->toContain("'route' => 'server.analytics'"); +}); + +it('prevents access to another teams server analytics page', function () { + $otherUser = User::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherUser->teams()->first()->id]); + + expect(fn () => Livewire::test(Show::class, ['server_uuid' => $otherServer->uuid])) + ->toThrow(ModelNotFoundException::class); +}); + +it('moves traffic analytics configuration out of sentinel and onto analytics', function () { + $analyticsView = file_get_contents(resource_path('views/livewire/server/traffic-analytics-settings.blade.php')); + $sentinelView = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php')); + + expect($analyticsView) + ->toContain('id="server-traffic-analytics-settings-section"') + ->toContain('title="Traffic analytics"') + ->not->toContain('title="Traffic analytics settings"') + ->toContain('id="trafficTopn"') + ->toContain('id="trafficSampleThreshold"') + ->toContain('id="trafficRetention1hDays"') + ->toContain('id="trafficRetention1dDays"') + ->toContain('id="isGeoipEnabled"') + ->toContain('id="geoipRefreshDays"') + ->toContain('id="geoipMaxmindLicenseKey"') + ->and($sentinelView) + ->not->toContain('id="server-sentinel-traffic-analytics-section"') + ->not->toContain('id="trafficTopn"'); +}); + +it('renders traffic analytics settings above the server analytics dashboard', function () { + $view = file_get_contents(resource_path('views/livewire/server/analytics/show.blade.php')); + + expect(strpos($view, 'toBeLessThan(strpos($view, 'not->toContain('>Analytics') + ->toContain('create(['team_id' => $this->team->id]); + $otherServer = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->is_traffic_analytics_enabled = false; + $server->settings->save(); + $otherServer->settings->is_traffic_analytics_enabled = false; + $otherServer->settings->save(); + + Livewire::test(Analytics::class, ['scopedServerUuid' => $server->uuid]) + ->assertSet('scopedServerUuid', $server->uuid) + ->assertDontSee($otherServer->name); +}); + +it('saves traffic analytics settings from the server analytics page', function () { + Queue::fake(); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->is_traffic_analytics_enabled = false; + $server->settings->save(); + + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]) + ->set('trafficTopn', 100) + ->set('trafficSampleThreshold', 500) + ->set('trafficRetention1hDays', 14) + ->set('trafficRetention1dDays', 180) + ->set('isGeoipEnabled', false) + ->set('geoipRefreshDays', 7) + ->call('saveTrafficAnalyticsSettings') + ->assertHasNoErrors(); + + $settings = $server->settings->fresh(); + + expect($settings->traffic_topn)->toBe(100) + ->and($settings->traffic_sample_threshold)->toBe(500) + ->and($settings->traffic_retention_1h_days)->toBe(14) + ->and($settings->traffic_retention_1d_days)->toBe(180) + ->and($settings->is_geoip_enabled)->toBeFalse() + ->and($settings->geoip_refresh_days)->toBe(7); +}); diff --git a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php index 1ceb204fa4..d1a75ba0ff 100644 --- a/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/ToggleTrafficAnalyticsTest.php @@ -1,7 +1,7 @@ fresh()->isTrafficAnalyticsEnabled())->toBeFalse(); - Livewire::test(Sentinel::class, ['server' => $server]) + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]) ->call('toggleTrafficAnalytics') ->assertHasNoErrors(); @@ -47,7 +47,7 @@ it('does not enable traffic analytics on a swarm server', function () { expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse(); - Livewire::test(Sentinel::class, ['server' => $server]) + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]) ->call('toggleTrafficAnalytics') ->assertHasNoErrors(); @@ -61,14 +61,14 @@ it('saves traffic analytics settings from the sentinel form', function () { $server->settings->is_traffic_analytics_enabled = true; $server->settings->save(); - Livewire::test(Sentinel::class, ['server' => $server]) + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]) ->set('trafficTopn', 100) ->set('trafficSampleThreshold', 500) ->set('trafficRetention1hDays', 14) ->set('trafficRetention1dDays', 180) ->set('isGeoipEnabled', false) ->set('geoipRefreshDays', 7) - ->call('submit') + ->call('saveTrafficAnalyticsSettings') ->assertHasNoErrors(); $settings = $server->settings->fresh(); @@ -83,9 +83,9 @@ it('saves traffic analytics settings from the sentinel form', function () { it('rejects a zero top-n cap', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); - Livewire::test(Sentinel::class, ['server' => $server]) + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]) ->set('trafficTopn', 0) - ->call('submit') + ->call('saveTrafficAnalyticsSettings') ->assertHasErrors(['trafficTopn']); }); @@ -99,7 +99,7 @@ it('does not enable traffic analytics on a build server', function () { expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse(); - Livewire::test(Sentinel::class, ['server' => $server]) + Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]) ->call('toggleTrafficAnalytics') ->assertHasNoErrors(); diff --git a/tests/Feature/TrafficAnalytics/TrafficDataTest.php b/tests/Feature/TrafficAnalytics/TrafficDataTest.php index b5554a7888..b75fd0b550 100644 --- a/tests/Feature/TrafficAnalytics/TrafficDataTest.php +++ b/tests/Feature/TrafficAnalytics/TrafficDataTest.php @@ -1,6 +1,7 @@ requests)->toBe(0); }); + +it('maps per-path error counters from sentinel', function () { + $dto = TrafficPathData::fromSentinel([ + 'path' => '/api/checkout', + 'app' => 'app-1', + 'requests' => 20, + 'bytes_out' => 1000, + 's4xx' => 3, + 's5xx' => 2, + 'p50' => 10, + 'p95' => 30, + ]); + + expect($dto->s4xx)->toBe(3) + ->and($dto->s5xx)->toBe(2); +});