mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-24 07:25:13 -05:00
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:
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"',
|
||||
})[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>
|
||||
|
||||
@@ -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;")
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user