feat(analytics): add server analytics page and split settings

Add a per-server Analytics page scoped to that host, move traffic
settings out of Sentinel, and put metrics settings on Charts.
Surface 4xx/5xx on path rows and restyle deployment logs.
This commit is contained in:
Andras Bacsai
2026-09-08 14:20:41 +02:00
parent bd7d0b5de1
commit f17cdc1c74
28 changed files with 970 additions and 246 deletions
+4
View File
@@ -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', ''),
+42 -20
View File
@@ -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));
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Server\Analytics;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\View\View;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public Server $server;
public function mount(string $server_uuid): void
{
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->authorize('view', $this->server);
}
public function render(): View
{
return view('livewire.server.analytics.show');
}
}
+33 -5
View File
@@ -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);
-74
View File
@@ -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 {
@@ -0,0 +1,109 @@
<?php
namespace App\Livewire\Server;
use App\Actions\Server\ConfigureTrafficAnalytics;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\View\View;
use Livewire\Attributes\Validate;
use Livewire\Component;
class TrafficAnalyticsSettings extends Component
{
use AuthorizesRequests;
public Server $server;
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 mount(): void
{
$this->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');
}
}
+222
View File
@@ -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;
}
@@ -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',
@@ -1,16 +1,20 @@
<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="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 (empty($scopedServerUuid ?? null))
<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>
@endif
{{-- Filter bar --}}
<div class="flex flex-wrap items-center gap-2">
<x-skeleton class="h-9 w-full rounded-lg sm:w-52" />
@if (empty($scopedServerUuid ?? null))
<x-skeleton class="h-9 w-full rounded-lg sm:w-52" />
@endif
<x-skeleton class="h-9 w-full rounded-lg sm:w-52" />
<x-skeleton class="h-9 w-44 rounded-lg sm:ml-auto" />
</div>
+32 -24
View File
@@ -21,29 +21,35 @@ $appListboxOptions = array_merge(
);
?>
<div class="flex w-full min-w-0 flex-col gap-6">
<x-slot:title>
Analytics | Coolify
</x-slot>
@if ($scopedServerUuid === null)
<x-slot:title>
Analytics | Coolify
</x-slot>
@endif
{{-- Header --}}
<div class="flex flex-col gap-4">
<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 ($scopedServerUuid === null)
<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>
@endif
@if ($servers->isNotEmpty() && $overview)
<div class="flex flex-wrap items-center gap-2">
<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" />
@if ($scopedServerUuid === null)
<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>
</div>
@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. --}}
<div class="relative w-full transition-opacity sm:w-52" wire:key="app-filter-{{ $serverUuid }}"
@@ -83,7 +89,7 @@ $appListboxOptions = array_merge(
</div>
{{-- Nudge: enabled-eligible servers that haven't turned traffic analytics on yet. --}}
@if (! empty($eligibleDisabledServers))
@if ($scopedServerUuid === null && ! empty($eligibleDisabledServers))
<div x-data="{ dismissed: localStorage.getItem('traffic-nudge-{{ $nudgeKey }}') === '1' }" x-show="!dismissed" x-cloak
class="flex items-start gap-3 rounded-xl border border-neutral-200 bg-white px-4 py-3 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
<div class="min-w-0 flex-1">
@@ -97,7 +103,7 @@ $appListboxOptions = array_merge(
</div>
<div class="flex shrink-0 items-center gap-2">
@if (count($eligibleDisabledServers) === 1)
<a class="button" href="{{ route('server.sentinel', ['server_uuid' => $eligibleDisabledServers[0]['uuid']]) }}" {{ wireNavigate() }}>
<a class="button" href="{{ route('server.analytics', ['server_uuid' => $eligibleDisabledServers[0]['uuid']]) }}" {{ wireNavigate() }}>
Enable on {{ \Illuminate\Support\Str::limit($eligibleDisabledServers[0]['name'], 16) }}
</a>
@else
@@ -118,13 +124,15 @@ $appListboxOptions = array_merge(
@if ($servers->isEmpty())
<x-empty size="sm" title="Traffic analytics is not enabled"
description="Enable Sentinel traffic analytics on a server to see request analytics here."
description="{{ $scopedServerUuid === null ? 'Enable traffic analytics on a server to see request analytics here.' : 'Enable traffic analytics in the settings below to begin collecting requests for this server.' }}"
icon-name="analytics">
<x-slot:contents>
<a class="button" href="{{ route('server.index') }}" {{ wireNavigate() }}>
View servers
</a>
</x-slot:contents>
@if ($scopedServerUuid === null)
<x-slot:contents>
<a class="button" href="{{ route('server.index') }}" {{ wireNavigate() }}>
View servers
</a>
</x-slot:contents>
@endif
</x-empty>
@elseif (! $overview)
<x-empty size="sm" title="No analytics data yet"
@@ -16,7 +16,7 @@ $analyticsServerUuid = $application->destination?->server?->uuid;
helper="Inspect traffic statistics reported by Sentinel.">
@if ($analyticsServerUuid)
<x-slot:actions>
<a class="button" href="{{ route('server.sentinel', ['server_uuid' => $analyticsServerUuid]) }}"
<a class="button" href="{{ route('server.analytics', ['server_uuid' => $analyticsServerUuid]) }}"
{{ wireNavigate() }}>
Server settings
<x-external-link />
@@ -18,7 +18,7 @@
geography for this application. Restarts the proxy + Sentinel.
</p>
</div>
<a class="button shrink-0" href="{{ route('server.sentinel', ['server_uuid' => $serverUuid]) }}" {{ wireNavigate() }}>
<a class="button shrink-0" href="{{ route('server.analytics', ['server_uuid' => $serverUuid]) }}" {{ wireNavigate() }}>
Server settings
<x-external-link />
</a>
@@ -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
<div id="logs" class="font-logs max-w-full cursor-default text-[11px] leading-relaxed sm:text-xs">
<div id="logs" @class([
'font-logs max-w-full cursor-default text-[11px] leading-relaxed sm:text-xs',
'runtime-log-without-time' => !$showTimeStamps,
])>
<div class="runtime-log-columns" aria-hidden="true">
@if ($showTimeStamps)
<span>Time</span>
@endif
<span>Type</span>
<span>Message</span>
</div>
<div x-show="searchQuery.trim() && matchCount === 0"
class="py-2 text-gray-500 dark:text-gray-400">
No matches found.
@@ -543,17 +570,38 @@
$timestamp = $carbonTs->format('Y-M-d H:i:s');
}
@endphp
<div wire:key="log-{{ $lineFingerprint }}-{{ $lineOccurrence }}" data-log-line data-log-content="{{ $line }}" class="log-line logs-viewer-line">
@php($lineKey = $lineFingerprint.'-'.$lineOccurrence)
<div wire:key="log-{{ $lineFingerprint }}-{{ $lineOccurrence }}" data-log-line data-log-content="{{ $line }}"
role="button" tabindex="0"
:aria-expanded="isLogExpanded(@js($lineKey))"
x-on:click="toggleLogDetails(@js($lineKey), $event)"
x-on:keydown="toggleLogDetails(@js($lineKey), $event)"
class="log-line logs-viewer-line">
@if ($timestamp && $showTimeStamps)
<span class="logs-viewer-timestamp text-gray-500">{{ $timestamp }}</span>
@endif
<span data-line-text="{{ $logContent }}" class="logs-viewer-line-text">{{ $logContent }}</span>
</div>
<pre x-cloak x-show="isLogExpanded(@js($lineKey))"
class="runtime-log-detail"
aria-label="Full log entry"
x-text="formatLogDetails(@js($logContent))"></pre>
@endforeach
</div>
@else
<pre id="logs"
class="font-logs max-w-full whitespace-pre-wrap break-all text-neutral-400">No logs yet.</pre>
<div class="runtime-log-loading" wire:loading.flex wire:target="getLogs" role="status">
<x-loading compact aria-label="Loading logs" />
<span>Loading logs</span>
</div>
<div id="logs" class="runtime-log-empty" wire:loading.remove wire:target="getLogs" role="status">
<span class="runtime-log-empty-icon" aria-hidden="true">
<x-reicon name="terminal" class="size-4" />
</span>
<div>
<p>No logs yet</p>
<span>Logs will appear here when the container produces output.</span>
</div>
</div>
@endif
</div>
</div>
@@ -0,0 +1,21 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Analytics | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-server.sidebar :server="$server" activeMenu="analytics" />
<div class="flex w-full min-w-0 flex-col gap-6">
@can('update', $server)
<livewire:server.traffic-analytics-settings :server="$server"
:key="'server-traffic-analytics-settings-'.$server->uuid" />
@endcan
<livewire:analytics :scoped-server-uuid="$server->uuid" :key="'server-analytics-'.$server->uuid" />
</div>
</div>
</div>
@@ -14,33 +14,50 @@
@if ($poll) wire:poll.5000ms="pollData" @endif
@endif>
@if ($server->isMetricsEnabled())
<x-application.settings-section id="server-metrics-overview-section" title="Metrics"
helper="Inspect recent CPU and memory usage reported by Sentinel.">
<x-slot:actions>
<div class="flex items-center gap-2">
<x-status-badge :status="$poll ? 'Live updates' : 'Historical range'"
:type="$poll ? 'success' : 'neutral'" />
<x-forms.button canGate="update" :canResource="$server" wire:click="toggleMetrics">
Disable metrics
</x-forms.button>
</div>
</x-slot:actions>
<form wire:submit.prevent="saveMetricsSettings" class="contents">
<x-unsaved-bar action="saveMetricsSettings"
targets="sentinelMetricsRefreshRateSeconds,sentinelMetricsHistoryDays,sentinelPushIntervalSeconds" />
<div class="max-w-xs">
<x-forms.listbox id="interval" label="Time range" onChange="setInterval" :options="[
['value' => 5, 'label' => 'Last 5 minutes · live'],
['value' => 10, 'label' => 'Last 10 minutes · live'],
['value' => 30, 'label' => 'Last 30 minutes'],
['value' => 60, 'label' => 'Last hour'],
['value' => 720, 'label' => 'Last 12 hours'],
['value' => 10080, 'label' => 'Last week'],
['value' => 43200, 'label' => 'Last 30 days'],
]" />
</div>
<p class="mt-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
Five and ten minute ranges refresh automatically every five seconds.
</p>
</x-application.settings-section>
<x-application.settings-section id="server-metrics-overview-section" title="Metrics"
helper="Inspect recent CPU and memory usage reported by Sentinel.">
<x-slot:actions>
<div class="flex items-center gap-2">
<x-status-badge :status="$poll ? 'Live updates' : 'Historical range'"
:type="$poll ? 'success' : 'neutral'" />
<x-forms.button canGate="update" :canResource="$server" wire:click="toggleMetrics">
Disable metrics
</x-forms.button>
</div>
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="sentinelMetricsRefreshRateSeconds" label="Collection rate" required
helper="Seconds between metric samples." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="sentinelMetricsHistoryDays" label="History retention" required
helper="Days of CPU and memory history to retain." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="10"
id="sentinelPushIntervalSeconds" label="Push interval" required
helper="Seconds between health reports sent to Coolify." />
</div>
<div class="mt-4 max-w-xs">
<x-forms.listbox id="interval" label="Time range" onChange="setInterval" :options="[
['value' => 5, 'label' => 'Last 5 minutes · live'],
['value' => 10, 'label' => 'Last 10 minutes · live'],
['value' => 30, 'label' => 'Last 30 minutes'],
['value' => 60, 'label' => 'Last hour'],
['value' => 720, 'label' => 'Last 12 hours'],
['value' => 10080, 'label' => 'Last week'],
['value' => 43200, 'label' => 'Last 30 days'],
]" />
</div>
<p class="mt-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
Five and ten minute ranges refresh automatically every five seconds.
</p>
</x-application.settings-section>
</form>
<x-application.settings-section id="server-cpu-metrics-section" title="CPU usage"
helper="Percentage of available CPU capacity used by this server.">
@@ -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 @@
</x-empty>
</x-application.settings-section>
@endif
</div>
</div>
</div>
@@ -6,7 +6,7 @@
`$wire.set('sentinelCustomDockerImage', …)` (and similar) briefly
flashes this bar on every page open. --}}
<x-unsaved-bar action="submit"
targets="sentinelCustomUrl,sentinelToken,sentinelMetricsRefreshRateSeconds,sentinelMetricsHistoryDays,sentinelPushIntervalSeconds,trafficTopn,trafficSampleThreshold,trafficRetention1hDays,trafficRetention1dDays,isGeoipEnabled,geoipRefreshDays,geoipMaxmindLicenseKey" />
targets="sentinelCustomUrl,sentinelToken" />
@endif
<x-application.settings-section id="server-sentinel-overview-section" title="Sentinel"
@@ -77,65 +77,6 @@
</div>
</x-application.settings-section>
<x-application.settings-section id="server-sentinel-metrics-section" title="Metrics collection"
helper="Control collection frequency, retention, and the push interval.">
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="sentinelMetricsRefreshRateSeconds" label="Collection rate" required
helper="Seconds between metric samples." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="sentinelMetricsHistoryDays" label="History retention" required
helper="Days of CPU and memory history to retain." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="10"
id="sentinelPushIntervalSeconds" label="Push interval" required
helper="Seconds between health reports sent to Coolify." />
</div>
</x-application.settings-section>
<x-application.settings-section id="server-sentinel-traffic-analytics-section" title="Traffic analytics"
helper="Collect proxy access logs and geolocate visitor traffic for applications on this server.">
<x-slot:actions>
<x-forms.button canGate="update" :canResource="$server" wire:click="toggleTrafficAnalytics"
wire:confirm="{{ $isTrafficAnalyticsEnabled ? 'Disable' : 'Enable' }} traffic analytics? The proxy and Sentinel will restart, causing a brief connectivity blip for all applications on this server.">
{{ $isTrafficAnalyticsEnabled ? 'Disable' : 'Enable' }} traffic analytics
</x-forms.button>
</x-slot:actions>
@if ($isTrafficAnalyticsEnabled)
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="trafficTopn" label="Top-N cap" required
helper="Maximum distinct values kept per dimension (paths, countries, browsers). Overflow folds into Other." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="0"
id="trafficSampleThreshold" label="Sample threshold" required
helper="Events per second above which Sentinel starts sampling. 0 disables sampling." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="trafficRetention1hDays" label="Hourly retention" required
helper="Days of hourly rollups to keep. This is the fine-grained history window." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="trafficRetention1dDays" label="Daily retention" required
helper="Days of daily rollups to keep before deletion." />
<x-forms.listbox canGate="update" :canResource="$server"
id="isGeoipEnabled" label="Geolocation"
:options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]"
helper="Country enrichment from visitor IPs. Disable to skip GeoIP lookups." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="geoipRefreshDays" label="GeoIP refresh interval" required
helper="Days between GeoIP database update checks." />
<x-forms.input canGate="update" :canResource="$server" type="password"
id="geoipMaxmindLicenseKey" label="MaxMind GeoIP license key" placeholder="Optional"
helper="Used to download the GeoLite2 database for visitor geolocation. Leave empty to use the default database source." />
</div>
@else
<x-empty size="sm" title="Traffic analytics is disabled"
description="Enable traffic analytics to collect proxy access logs and geolocate visitor traffic."
icon-name="dashboard" />
@endif
</x-application.settings-section>
@if (isDev())
<x-application.settings-section id="server-sentinel-development-section"
title="Development overrides"
@@ -0,0 +1,52 @@
<div class="application-settings-form flex w-full flex-col gap-6">
<form wire:submit.prevent="saveTrafficAnalyticsSettings" class="contents">
@if ($isTrafficAnalyticsEnabled)
<x-unsaved-bar action="saveTrafficAnalyticsSettings"
targets="trafficTopn,trafficSampleThreshold,trafficRetention1hDays,trafficRetention1dDays,isGeoipEnabled,geoipRefreshDays,geoipMaxmindLicenseKey" />
@endif
<x-application.settings-section id="server-traffic-analytics-settings-section" title="Traffic analytics"
helper="Control proxy traffic collection, retention, and visitor geolocation for this server.">
<x-slot:actions>
<x-forms.button canGate="update" :canResource="$server" wire:click="toggleTrafficAnalytics"
wire:confirm="{{ $isTrafficAnalyticsEnabled ? 'Disable' : 'Enable' }} traffic analytics? The proxy and Sentinel will restart, causing a brief connectivity blip for all applications on this server.">
{{ $isTrafficAnalyticsEnabled ? 'Disable' : 'Enable' }} traffic analytics
</x-forms.button>
</x-slot:actions>
@if ($isTrafficAnalyticsEnabled)
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="trafficTopn" label="Top-N cap" required
helper="Maximum distinct values kept per dimension (paths, countries, browsers). Overflow folds into Other." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="0"
id="trafficSampleThreshold" label="Sample threshold" required
helper="Events per second above which Sentinel starts sampling. 0 disables sampling." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="trafficRetention1hDays" label="Hourly retention" required
helper="Days of hourly rollups to keep. This is the fine-grained history window." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="trafficRetention1dDays" label="Daily retention" required
helper="Days of daily rollups to keep before deletion." />
<x-forms.listbox canGate="update" :canResource="$server"
id="isGeoipEnabled" label="Geolocation"
:options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]"
helper="Country enrichment from visitor IPs. Disable to skip GeoIP lookups." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="geoipRefreshDays" label="GeoIP refresh interval" required
helper="Days between GeoIP database update checks." />
<x-forms.input canGate="update" :canResource="$server" type="password"
id="geoipMaxmindLicenseKey" label="MaxMind GeoIP license key" placeholder="Optional"
helper="Used to download the GeoLite2 database for visitor geolocation. Leave empty to use the default database source." />
</div>
@else
<x-empty size="sm" title="Traffic analytics is disabled"
description="Enable traffic analytics to collect proxy access logs and geolocate visitor traffic."
icon-name="dashboard" />
@endif
</x-application.settings-section>
</form>
</div>
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#039;', '"': '&quot;',
})[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 `<div class="apexcharts-tooltip-custom">
<div class="apexcharts-tooltip-custom-value">${label}: <span class="apexcharts-tooltip-value-bold">${requests} requests</span></div>
</div>`;
},
},
noData: { text: 'Loading devices…', style: { color: textColor } },
});
chart.render();
@@ -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
<div wire:key="{{ $keyPrefix }}-{{ md5($pathStr) }}"
@@ -40,6 +43,12 @@
</div>
<span class="w-12 shrink-0 text-right text-[12px] font-medium tabular-nums text-black dark:text-fg"
title="{{ number_format($requests) }} requests">{{ compactNumber($requests) }}</span>
<span class="hidden w-14 shrink-0 text-right text-[11px] font-medium tabular-nums text-pink-600 sm:inline dark:text-pink-400"
title="{{ number_format($s4xx) }} client-error responses">{{ compactNumber($s4xx) }} 4xx</span>
<span class="w-14 shrink-0 text-right text-[11px] font-medium tabular-nums text-purple-600 dark:text-purple-400"
title="{{ number_format($s5xx) }} server-error responses">{{ compactNumber($s5xx) }} 5xx</span>
<span class="hidden w-12 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 lg:inline dark:text-fg-faint"
title="Combined 4xx and 5xx response rate">{{ $errorRate }}%</span>
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 sm:inline dark:text-fg-faint">{{ formatBytes((int) ($path['bytesOut'] ?? 0)) }}</span>
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 md:inline dark:text-fg-faint"
title="p95 latency">{{ number_format((float) ($path['p95'] ?? 0), 1) }} ms</span>
+2
View File
@@ -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');
@@ -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;")
+13 -1
View File
@@ -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);
@@ -0,0 +1,93 @@
<?php
use App\Livewire\Server\Charts;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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');
});
@@ -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'));
@@ -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);
@@ -0,0 +1,116 @@
<?php
use App\Livewire\Analytics;
use App\Livewire\Server\Analytics\Show;
use App\Livewire\Server\TrafficAnalyticsSettings;
use App\Models\Server;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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, '<livewire:server.traffic-analytics-settings'))
->toBeLessThan(strpos($view, '<livewire:analytics'));
});
it('matches other server pages without a visible page title', function () {
$view = file_get_contents(resource_path('views/livewire/server/analytics/show.blade.php'));
expect($view)
->not->toContain('>Analytics</h1>')
->toContain('<livewire:server.traffic-analytics-settings');
});
it('scopes the server analytics page to its route server', function () {
$server = Server::factory()->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);
});
@@ -1,7 +1,7 @@
<?php
use App\Actions\Server\ConfigureTrafficAnalytics;
use App\Livewire\Server\Sentinel;
use App\Livewire\Server\TrafficAnalyticsSettings;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -30,7 +30,7 @@ it('toggles traffic analytics via the sentinel settings component', function ()
expect($server->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();
@@ -1,6 +1,7 @@
<?php
use App\Data\Traffic\TrafficOverviewData;
use App\Data\Traffic\TrafficPathData;
it('maps a sentinel overview payload into a DTO', function () {
$json = [
@@ -19,3 +20,19 @@ it('maps a sentinel overview payload into a DTO', function () {
it('produces a zeroed overview', function () {
expect(TrafficOverviewData::zero()->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);
});