From dbcef807aebe92fca511172033a0ccdf198d06cb Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:54:34 +0000 Subject: [PATCH 001/119] feat(metrics): add reachable /metrics fleet page skeleton --- app/Livewire/Fleet/Metrics.php | 57 +++++++++++++++++++ resources/views/components/navbar.blade.php | 8 +++ .../fleet/metrics-placeholder.blade.php | 3 + .../views/livewire/fleet/metrics.blade.php | 14 +++++ routes/web.php | 2 + .../Livewire/Fleet/MetricsPageTest.php | 33 +++++++++++ 6 files changed, 117 insertions(+) create mode 100644 app/Livewire/Fleet/Metrics.php create mode 100644 resources/views/livewire/fleet/metrics-placeholder.blade.php create mode 100644 resources/views/livewire/fleet/metrics.blade.php create mode 100644 tests/Feature/Livewire/Fleet/MetricsPageTest.php diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php new file mode 100644 index 0000000000..8cbefffd00 --- /dev/null +++ b/app/Livewire/Fleet/Metrics.php @@ -0,0 +1,57 @@ + uuid => name */ + public array $serverOptions = []; + + #[Url(as: 'range')] + public string $range = '24h'; + + #[Url(as: 'server')] + public string $serverUuid = ''; + + public bool $live = false; + + public function mount(): void + { + $allServers = Server::ownedByCurrentTeamCached(); + + $this->servers = $allServers + ->filter(fn (Server $server) => $server->isMetricsEnabled()) + ->values(); + + $this->serverOptions = $this->servers + ->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name]) + ->all(); + + if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) { + $this->serverUuid = ''; + } + } + + public function placeholder(): View + { + return view('livewire.fleet.metrics-placeholder'); + } + + public function render(): View + { + return view('livewire.fleet.metrics'); + } +} diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 251628c496..0722bdd076 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -95,6 +95,14 @@ Analytics +
  • + + + Metrics + +
  • @can('canAccessTerminal')
  • + + diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php new file mode 100644 index 0000000000..69255b779a --- /dev/null +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -0,0 +1,14 @@ +
    +
    +

    Metrics

    +

    Resource usage across your servers.

    +
    + + @if ($servers->isEmpty()) + + + + + + @endif +
    diff --git a/routes/web.php b/routes/web.php index 2a0b66a2ac..0a42e7386d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use App\Livewire\Destination\Index as DestinationIndex; use App\Livewire\Destination\Resources as DestinationResources; use App\Livewire\Destination\Show as DestinationShow; use App\Livewire\Dev\LivewireRequestFailurePreview; +use App\Livewire\Fleet\Metrics as FleetMetrics; use App\Livewire\ForcePasswordReset; use App\Livewire\Notifications\Discord as NotificationDiscord; use App\Livewire\Notifications\Email as NotificationEmail; @@ -161,6 +162,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/', Dashboard::class)->name('dashboard'); Route::get('/analytics', Analytics::class)->name('analytics'); + Route::get('/metrics', FleetMetrics::class)->name('metrics'); Route::get('/admin', AdminIndex::class)->name('admin.index'); Route::get('/onboarding', BoardingIndex::class)->name('onboarding'); diff --git a/tests/Feature/Livewire/Fleet/MetricsPageTest.php b/tests/Feature/Livewire/Fleet/MetricsPageTest.php new file mode 100644 index 0000000000..ad44bf6d72 --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsPageTest.php @@ -0,0 +1,33 @@ + InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->team = $team; +}); + +it('renders the metrics page with an empty state when no server has metrics enabled', function () { + $component = loadLazy(Livewire::test(Metrics::class)); + + $component + ->assertOk() + ->assertSee('Metrics') + ->assertSee('Enable metrics on a server'); +}); + +it('registers the metrics route', function () { + expect(route('metrics'))->toEndWith('/metrics'); +}); From f950f9058bbefea79017f145ccc6533655728c02 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:54:57 +0000 Subject: [PATCH 002/119] feat(metrics): add pressure threshold helper --- app/Support/Metrics/PressureLevel.php | 39 ++++++++++++++++++++++++ tests/Unit/Metrics/PressureLevelTest.php | 22 +++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 app/Support/Metrics/PressureLevel.php create mode 100644 tests/Unit/Metrics/PressureLevelTest.php diff --git a/app/Support/Metrics/PressureLevel.php b/app/Support/Metrics/PressureLevel.php new file mode 100644 index 0000000000..2904c56504 --- /dev/null +++ b/app/Support/Metrics/PressureLevel.php @@ -0,0 +1,39 @@ += $error => 'error', + $percent >= $warn => 'warning', + default => 'ok', + }; + } + + public static function textClass(string $level): string + { + return match ($level) { + 'error' => 'text-red-500 dark:text-red-400', + 'warning' => 'text-orange-500 dark:text-warning', + default => 'text-black dark:text-fg', + }; + } + + public static function barClass(string $level): string + { + return match ($level) { + 'error' => 'bg-red-500 dark:bg-red-400', + 'warning' => 'bg-orange-500 dark:bg-warning', + default => 'bg-neutral-400 dark:bg-white/30', + }; + } +} diff --git a/tests/Unit/Metrics/PressureLevelTest.php b/tests/Unit/Metrics/PressureLevelTest.php new file mode 100644 index 0000000000..e2c922a216 --- /dev/null +++ b/tests/Unit/Metrics/PressureLevelTest.php @@ -0,0 +1,22 @@ +toBe('ok'); + expect(PressureLevel::for(70, 'cpu'))->toBe('warning'); + expect(PressureLevel::for(89.9, 'memory'))->toBe('warning'); + expect(PressureLevel::for(90, 'memory'))->toBe('error'); +}); + +it('classifies disk pressure at 80/90 cutoffs', function () { + expect(PressureLevel::for(75, 'disk'))->toBe('ok'); + expect(PressureLevel::for(80, 'disk'))->toBe('warning'); + expect(PressureLevel::for(95, 'disk'))->toBe('error'); +}); + +it('treats a null percent as ok and maps levels to classes', function () { + expect(PressureLevel::for(null, 'cpu'))->toBe('ok'); + expect(PressureLevel::textClass('error'))->toContain('text-red-500'); + expect(PressureLevel::barClass('warning'))->toContain('bg-orange-500'); +}); From 30aadf03672bdf945cee47d610b33db6c9e5518a Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:55:29 +0000 Subject: [PATCH 003/119] feat(metrics): aggregate fleet KPIs with weighted math --- app/Services/FleetMetricsAggregator.php | 92 +++++++++++++++++++ .../Services/FleetMetricsAggregatorTest.php | 69 ++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 app/Services/FleetMetricsAggregator.php create mode 100644 tests/Unit/Services/FleetMetricsAggregatorTest.php diff --git a/app/Services/FleetMetricsAggregator.php b/app/Services/FleetMetricsAggregator.php new file mode 100644 index 0000000000..72f6ddb10b --- /dev/null +++ b/app/Services/FleetMetricsAggregator.php @@ -0,0 +1,92 @@ +> $rows + * @return array + */ + public static function fleetKpis(array $rows): array + { + $online = array_values(array_filter($rows, fn ($r) => $r['online'] === true)); + + $cpuValues = self::present($online, 'cpu'); + $cpuAvg = $cpuValues === [] ? 0.0 : round(array_sum($cpuValues) / count($cpuValues), 1); + + $busiest = null; + foreach ($online as $r) { + if ($r['cpu'] !== null && ($busiest === null || $r['cpu'] > $busiest['percent'])) { + $busiest = ['name' => $r['name'], 'percent' => (float) $r['cpu']]; + } + } + + $memUsed = (int) self::sum($online, 'memUsed'); + $memTotal = (int) self::sum($online, 'memTotal'); + $diskUsed = (int) self::sum($online, 'diskUsed'); + $diskTotal = (int) self::sum($online, 'diskTotal'); + $load1 = self::present($online, 'load1'); + + $needsAttention = 0; + foreach ($online as $r) { + $hot = PressureLevel::for($r['cpu'], 'cpu') !== 'ok' + || PressureLevel::for(self::percent($r['memUsed'], $r['memTotal']), 'memory') !== 'ok' + || PressureLevel::for($r['diskPercent'], 'disk') !== 'ok'; + if ($hot) { + $needsAttention++; + } + } + + return [ + 'serversOnline' => count($online), + 'serversTotal' => count($rows), + 'needsAttention' => $needsAttention, + 'cpuAvg' => $cpuAvg, + 'cpuApproximate' => count($cpuValues) > 1, + 'cpuBusiest' => $busiest, + 'memUsed' => $memUsed, + 'memTotal' => $memTotal, + 'memPercent' => self::percent($memUsed, $memTotal), + 'diskUsed' => $diskUsed, + 'diskTotal' => $diskTotal, + 'diskPercent' => self::percent($diskUsed, $diskTotal), + 'netRx' => (float) self::sum($online, 'netRx'), + 'netTx' => (float) self::sum($online, 'netTx'), + 'loadBusiest' => $load1 === [] ? 0.0 : max($load1), + 'loadAvg' => $load1 === [] ? 0.0 : round(array_sum($load1) / count($load1), 2), + 'containers' => (int) self::sum($online, 'containers'), + ]; + } + + /** + * @param array> $rows + * @return array + */ + private static function present(array $rows, string $key): array + { + return array_values(array_map( + fn ($r) => (float) $r[$key], + array_filter($rows, fn ($r) => $r[$key] !== null) + )); + } + + /** + * @param array> $rows + */ + private static function sum(array $rows, string $key): float + { + return array_sum(array_map(fn ($r) => $r[$key] ?? 0, $rows)); + } + + private static function percent(int|float|null $used, int|float|null $total): float + { + if (! $total) { + return 0.0; + } + + return round(($used / $total) * 100, 1); + } +} diff --git a/tests/Unit/Services/FleetMetricsAggregatorTest.php b/tests/Unit/Services/FleetMetricsAggregatorTest.php new file mode 100644 index 0000000000..2de55616f7 --- /dev/null +++ b/tests/Unit/Services/FleetMetricsAggregatorTest.php @@ -0,0 +1,69 @@ + 'u', 'name' => 'srv', 'online' => true, + 'cpu' => 10.0, 'memUsed' => 1_000, 'memTotal' => 10_000, + 'diskUsed' => 1_000, 'diskTotal' => 10_000, 'diskPercent' => 10.0, + 'load1' => 0.5, 'netRx' => 100.0, 'netTx' => 50.0, 'containers' => 2, + ], $overrides); +} + +it('weights memory and disk by totals, not mean of percents', function () { + $rows = [ + row(['memUsed' => 100, 'memTotal' => 100, 'diskUsed' => 100, 'diskTotal' => 100, 'diskPercent' => 100.0]), + row(['memUsed' => 10, 'memTotal' => 1_000, 'diskUsed' => 10, 'diskTotal' => 1_000, 'diskPercent' => 1.0]), + ]; + + $kpis = FleetMetricsAggregator::fleetKpis($rows); + + // Σused/Σtotal = 110/1100 = 10%, not the (100+1)/2 = 50.5% a naive mean gives. + expect(round($kpis['memPercent'], 1))->toBe(10.0); + expect(round($kpis['diskPercent'], 1))->toBe(10.0); +}); + +it('means cpu, flags approximate for multiple servers, and finds the busiest', function () { + $kpis = FleetMetricsAggregator::fleetKpis([ + row(['name' => 'a', 'cpu' => 20.0]), + row(['name' => 'b', 'cpu' => 80.0]), + ]); + + expect($kpis['cpuAvg'])->toBe(50.0); + expect($kpis['cpuApproximate'])->toBeTrue(); + expect($kpis['cpuBusiest'])->toBe(['name' => 'b', 'percent' => 80.0]); +}); + +it('sums network rates and reports busiest and average load', function () { + $kpis = FleetMetricsAggregator::fleetKpis([ + row(['netRx' => 100.0, 'netTx' => 10.0, 'load1' => 0.5]), + row(['netRx' => 200.0, 'netTx' => 20.0, 'load1' => 2.5]), + ]); + + expect($kpis['netRx'])->toBe(300.0); + expect($kpis['netTx'])->toBe(30.0); + expect($kpis['loadBusiest'])->toBe(2.5); + expect($kpis['loadAvg'])->toBe(1.5); +}); + +it('ignores offline rows and counts servers needing attention', function () { + $kpis = FleetMetricsAggregator::fleetKpis([ + row(['cpu' => 95.0]), // over cpu error cutoff + row(['online' => false, 'cpu' => 99.0]), + ]); + + expect($kpis['serversOnline'])->toBe(1); + expect($kpis['serversTotal'])->toBe(2); + expect($kpis['needsAttention'])->toBe(1); + expect($kpis['cpuAvg'])->toBe(95.0); // offline row excluded from the mean +}); + +it('returns zeroed kpis for no rows', function () { + $kpis = FleetMetricsAggregator::fleetKpis([]); + + expect($kpis['serversOnline'])->toBe(0); + expect($kpis['cpuAvg'])->toBe(0.0); + expect($kpis['cpuBusiest'])->toBeNull(); +}); From bc3ec590d01d94b106928ba4977173913c55e512 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:55:50 +0000 Subject: [PATCH 004/119] feat(metrics): add bucket series merge and container ranking --- app/Services/FleetMetricsAggregator.php | 44 +++++++++++++++++++ .../FleetMetricsAggregatorRankingTest.php | 33 ++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/Unit/Services/FleetMetricsAggregatorRankingTest.php diff --git a/app/Services/FleetMetricsAggregator.php b/app/Services/FleetMetricsAggregator.php index 72f6ddb10b..29e0459d3b 100644 --- a/app/Services/FleetMetricsAggregator.php +++ b/app/Services/FleetMetricsAggregator.php @@ -61,6 +61,50 @@ class FleetMetricsAggregator ]; } + /** + * @param array> $seriesPerServer + * @return array + */ + public static function sumSeriesByBucket(array $seriesPerServer, string $mode): array + { + $totals = []; + $counts = []; + + foreach ($seriesPerServer as $series) { + foreach ($series as [$ts, $value]) { + $totals[$ts] = ($totals[$ts] ?? 0.0) + (float) $value; + $counts[$ts] = ($counts[$ts] ?? 0) + 1; + } + } + + ksort($totals); + + $out = []; + foreach ($totals as $ts => $sum) { + $out[] = [(int) $ts, $mode === 'avg' ? round($sum / $counts[$ts], 2) : round($sum, 2)]; + } + + return $out; + } + + /** + * @param array> $containers + * @return array> + */ + public static function rankContainers(array $containers, string $metric, int $limit = 20): array + { + $key = match ($metric) { + 'memory' => 'memUsed', + 'disk' => 'diskBytes', + 'network' => 'net', + default => 'cpu', + }; + + usort($containers, fn ($a, $b) => ($b[$key] ?? 0) <=> ($a[$key] ?? 0)); + + return array_slice($containers, 0, $limit); + } + /** * @param array> $rows * @return array diff --git a/tests/Unit/Services/FleetMetricsAggregatorRankingTest.php b/tests/Unit/Services/FleetMetricsAggregatorRankingTest.php new file mode 100644 index 0000000000..6b6fe2ee1e --- /dev/null +++ b/tests/Unit/Services/FleetMetricsAggregatorRankingTest.php @@ -0,0 +1,33 @@ +toBe([[1000, 3.0], [2000, 5.0], [3000, 9.0]]); +}); + +it('averages series across servers per bucket', function () { + $merged = FleetMetricsAggregator::sumSeriesByBucket([ + [[1000, 20.0]], + [[1000, 40.0]], + ], 'avg'); + + expect($merged)->toBe([[1000, 30.0]]); +}); + +it('ranks containers by the selected metric and caps the list', function () { + $containers = [ + ['id' => 'a', 'cpu' => 10.0, 'memUsed' => 500, 'diskBytes' => 5, 'net' => 1.0], + ['id' => 'b', 'cpu' => 90.0, 'memUsed' => 100, 'diskBytes' => 9, 'net' => 2.0], + ['id' => 'c', 'cpu' => 50.0, 'memUsed' => 900, 'diskBytes' => 1, 'net' => 3.0], + ]; + + expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'cpu', 2), 'id'))->toBe(['b', 'c']); + expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'memory'), 'id'))->toBe(['c', 'a', 'b']); + expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'network'), 'id'))->toBe(['c', 'b', 'a']); +}); From ba8ed4e8fb2d80415ef434ca6e7d05e67dac0da6 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:56:44 +0000 Subject: [PATCH 005/119] feat(metrics): add SentinelMetricsClient with graceful degradation --- app/Services/SentinelMetricsClient.php | 199 ++++++++++++++++++ .../Services/SentinelMetricsClientTest.php | 106 ++++++++++ 2 files changed, 305 insertions(+) create mode 100644 app/Services/SentinelMetricsClient.php create mode 100644 tests/Unit/Services/SentinelMetricsClientTest.php diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php new file mode 100644 index 0000000000..5f27f1e564 --- /dev/null +++ b/app/Services/SentinelMetricsClient.php @@ -0,0 +1,199 @@ + now()->subDays(7), + '30d' => now()->subDays(30), + default => now()->subDay(), + }; + + return $from->toIso8601ZuluString(); + } + + /** + * @return array|null + */ + public function summary(): ?array + { + try { + $json = json_decode($this->raw($this->url('/summary')), true); + } catch (\Throwable) { + return null; + } + + if (! is_array($json)) { + return null; + } + + $disk = $this->rootMount($json['disk'] ?? []); + + return [ + 'online' => true, + 'cpu' => isset($json['cpu']['percent']) ? (float) $json['cpu']['percent'] : null, + 'memUsed' => isset($json['memory']['used']) ? (int) $json['memory']['used'] : null, + 'memTotal' => isset($json['memory']['total']) ? (int) $json['memory']['total'] : null, + 'diskUsed' => $disk['used'], + 'diskTotal' => $disk['total'], + 'diskPercent' => $disk['percent'], + 'load1' => isset($json['load']['load1']) ? (float) $json['load']['load1'] : null, + 'netRx' => isset($json['network']['rxBytesPerSec']) ? (float) $json['network']['rxBytesPerSec'] : null, + 'netTx' => isset($json['network']['txBytesPerSec']) ? (float) $json['network']['txBytesPerSec'] : null, + ]; + } + + /** + * @return array> + */ + public function containersCurrent(): array + { + try { + $rows = json_decode($this->raw($this->url('/containers/current')), true); + } catch (\Throwable) { + return []; + } + + if (! is_array($rows)) { + return []; + } + + return array_values(array_map(function ($c) { + $disk = ($c['disk']['writableLayer'] ?? 0) + ($c['disk']['volumesTotal'] ?? 0); + $net = ($c['network']['rxBytesPerSec'] ?? 0) + ($c['network']['txBytesPerSec'] ?? 0); + + return [ + 'id' => (string) ($c['id'] ?? ''), + 'cpu' => isset($c['cpu']['percent']) ? (float) $c['cpu']['percent'] : null, + 'memUsed' => isset($c['memory']['used']) ? (int) $c['memory']['used'] : null, + 'memPercent' => isset($c['memory']['usedPercent']) ? (float) $c['memory']['usedPercent'] : null, + 'diskBytes' => isset($c['disk']) ? (int) $disk : null, + 'net' => isset($c['network']) ? (float) $net : null, + ]; + }, $rows)); + } + + /** + * @return array + */ + public function history(string $metric, string $from): array + { + [$path, $field] = match ($metric) { + 'memory' => ['/memory/history', 'used'], + 'disk' => ['/disk/history', 'usedPercent'], + 'load' => ['/load/history', 'load1'], + default => ['/cpu/history', 'percent'], + }; + + return $this->seriesRows($this->url($path, ['from' => $from]), $field); + } + + /** + * @return array{rx: array, tx: array} + */ + public function networkHistory(string $from): array + { + $url = $this->url('/network/history', ['from' => $from]); + + return [ + 'rx' => $this->seriesRows($url, 'rxBytesPerSec'), + 'tx' => $this->seriesRows($url, 'txBytesPerSec'), + ]; + } + + /** + * @return array + */ + private function seriesRows(string $url, string $field): array + { + try { + $rows = json_decode($this->raw($url), true); + } catch (\Throwable) { + return []; + } + + if (! is_array($rows)) { + return []; + } + + return array_values(array_map( + fn ($r) => [(int) ($r['time'] ?? 0), (float) ($r[$field] ?? 0)], + $rows + )); + } + + /** + * @param array> $mounts + * @return array{used: ?int, total: ?int, percent: ?float} + */ + private function rootMount(array $mounts): array + { + if ($mounts === []) { + return ['used' => null, 'total' => null, 'percent' => null]; + } + + $root = collect($mounts)->firstWhere('mount', '/') ?? $mounts[0]; + + return [ + 'used' => (int) ($root['used'] ?? 0), + 'total' => (int) ($root['total'] ?? 0), + 'percent' => (float) ($root['usedPercent'] ?? 0), + ]; + } + + /** + * @param array $query + */ + private function url(string $path, array $query = []): string + { + $qs = empty($query) ? '' : '?'.str_replace('%3A', ':', http_build_query($query)); + + return $this->base.$path.$qs; + } + + private function cacheKey(string $url): string + { + return 'metrics:'.$this->server->uuid.':'.md5($url); + } + + protected function raw(string $url): string + { + return Cache::remember($this->cacheKey($url), 60, fn () => $this->guard($this->remoteFetch($url))); + } + + protected function remoteFetch(string $url): string + { + $token = $this->server->settings->ensureValidSentinelToken(); + + return instant_remote_process( + ["docker exec coolify-sentinel sh -c 'curl -s -H \"Authorization: Bearer {$token}\" \"{$url}\"'"], + $this->server, + false + ); + } + + private function guard(string $response): string + { + $payload = json_decode($response, true); + + if ($payload === null) { + throw new \RuntimeException('Metrics returned an invalid response.'); + } + + if (is_array($payload) && array_key_exists('error', $payload)) { + throw new \RuntimeException('Metrics request failed.'); + } + + return $response; + } +} diff --git a/tests/Unit/Services/SentinelMetricsClientTest.php b/tests/Unit/Services/SentinelMetricsClientTest.php new file mode 100644 index 0000000000..1b1088169e --- /dev/null +++ b/tests/Unit/Services/SentinelMetricsClientTest.php @@ -0,0 +1,106 @@ + substring => body */ + public array $bodies = []; + + /** @var array */ + public array $failing = []; + + protected function raw(string $url): string + { + foreach ($this->failing as $needle) { + if (str_contains($url, $needle)) { + throw new RuntimeException('boom'); + } + } + foreach ($this->bodies as $needle => $body) { + if (str_contains($url, $needle)) { + return $body; + } + } + + return '[]'; + } +} + +function fakeMetricsClient(): FakeMetricsClient +{ + $server = Server::factory()->make(['uuid' => 'srv-uuid']); + + return new FakeMetricsClient($server); +} + +it('normalizes the host summary, choosing the root disk mount', function () { + $client = fakeMetricsClient(); + $client->bodies = [ + '/summary' => json_encode([ + 'cpu' => ['time' => 't', 'percent' => 42.5], + 'memory' => ['time' => 't', 'total' => 1000, 'used' => 250, 'usedPercent' => 25.0], + 'disk' => [ + ['time' => 't', 'mount' => '/boot', 'total' => 10, 'used' => 9, 'usedPercent' => 90.0], + ['time' => 't', 'mount' => '/', 'total' => 100, 'used' => 40, 'usedPercent' => 40.0], + ], + 'network' => ['time' => 't', 'rxBytesPerSec' => 100.0, 'txBytesPerSec' => 20.0], + 'load' => ['time' => 't', 'load1' => 1.5, 'load5' => 1.0, 'load15' => 0.5], + ]), + ]; + + $summary = $client->summary(); + + expect($summary['cpu'])->toBe(42.5); + expect($summary['memUsed'])->toBe(250); + expect($summary['diskPercent'])->toBe(40.0); // root mount, not /boot + expect($summary['netRx'])->toBe(100.0); + expect($summary['load1'])->toBe(1.5); +}); + +it('returns null summary when the endpoint fails', function () { + $client = fakeMetricsClient(); + $client->failing = ['/summary']; + + expect($client->summary())->toBeNull(); +}); + +it('normalizes containers, parsing the stringified cpu percent', function () { + $client = fakeMetricsClient(); + $client->bodies = [ + '/containers/current' => json_encode([ + ['id' => 'app-a', 'cpu' => ['time' => 't', 'percent' => '12.5'], + 'memory' => ['used' => 500, 'usedPercent' => 5.0], + 'disk' => ['writableLayer' => 3, 'volumesTotal' => 7], + 'network' => ['rxBytesPerSec' => 4.0, 'txBytesPerSec' => 1.0]], + ]), + ]; + + $rows = $client->containersCurrent(); + + expect($rows[0]['id'])->toBe('app-a'); + expect($rows[0]['cpu'])->toBe(12.5); + expect($rows[0]['memUsed'])->toBe(500); + expect($rows[0]['diskBytes'])->toBe(10); // writableLayer + volumesTotal + expect($rows[0]['net'])->toBe(5.0); // rx + tx +}); + +it('returns an empty container list when the endpoint 404s', function () { + $client = fakeMetricsClient(); + $client->failing = ['/containers/current']; + + expect($client->containersCurrent())->toBe([]); +}); + +it('maps cpu history to [time, value] pairs', function () { + $client = fakeMetricsClient(); + $client->bodies = [ + '/cpu/history' => json_encode([ + ['time' => 1000, 'percent' => '10'], + ['time' => 2000, 'percent' => '20'], + ]), + ]; + + expect($client->history('cpu', 'from'))->toBe([[1000, 10.0], [2000, 20.0]]); +}); From fb021c3d9d80d5ace1cd555dabfc686861e12242 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:58:55 +0000 Subject: [PATCH 006/119] feat(metrics): populate fleet KPIs, server rows, and container ranking --- app/Livewire/Fleet/Metrics.php | 149 ++++++++++++++++++ .../Livewire/Fleet/MetricsDataTest.php | 128 +++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 tests/Feature/Livewire/Fleet/MetricsDataTest.php diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 8cbefffd00..63dfd5605a 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -2,7 +2,10 @@ namespace App\Livewire\Fleet; +use App\Models\Application; use App\Models\Server; +use App\Services\FleetMetricsAggregator; +use App\Services\SentinelMetricsClient; use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; use Livewire\Attributes\Lazy; @@ -26,8 +29,23 @@ class Metrics extends Component #[Url(as: 'server')] public string $serverUuid = ''; + #[Url(as: 'cmetric')] + public string $containerMetric = 'cpu'; + public bool $live = false; + /** @var array */ + public array $kpis = []; + + /** @var array> */ + public array $serverRows = []; + + /** @var array> */ + public array $topContainers = []; + + /** @var array */ + protected array $containerMetaCache = []; + public function mount(): void { $allServers = Server::ownedByCurrentTeamCached(); @@ -43,6 +61,137 @@ class Metrics extends Component if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) { $this->serverUuid = ''; } + + if ($this->servers->isNotEmpty()) { + $this->loadData(); + } + } + + public function updatedServerUuid(): void + { + $this->loadData(); + } + + public function updatedContainerMetric(): void + { + $this->loadData(); + } + + public function setRange(string $range): void + { + $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $this->loadData(); + } + + /** + * @return Collection + */ + protected function targetServers(): Collection + { + if ($this->serverUuid !== '') { + return $this->servers->filter(fn (Server $s) => $s->uuid === $this->serverUuid)->values(); + } + + return $this->servers; + } + + public function loadData(): void + { + if ($this->servers->isEmpty()) { + return; + } + + $rows = []; + $containers = []; + + foreach ($this->targetServers() as $server) { + $client = $this->metricsClient($server); + + try { + $summary = $client->summary(); + } catch (\Throwable) { + $summary = null; + } + + if ($summary === null) { + $rows[] = $this->offlineRow($server); + + continue; + } + + $current = $client->containersCurrent(); + $rows[] = array_merge( + ['uuid' => $server->uuid, 'name' => $server->name, 'online' => true, 'containers' => count($current)], + $summary + ); + + foreach ($current as $c) { + $meta = $this->resolveContainer($server, $c['id']); + $containers[] = array_merge($c, ['server' => $server->name, 'name' => $meta['name'], 'link' => $meta['link']]); + } + } + + $this->serverRows = $rows; + $this->kpis = FleetMetricsAggregator::fleetKpis($rows); + $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); + + $this->dispatch("refreshChartData-{$this->chartId}", $this->chartPayload()); + } + + /** + * @return array + */ + protected function offlineRow(Server $server): array + { + return [ + 'uuid' => $server->uuid, 'name' => $server->name, 'online' => false, + 'cpu' => null, 'memUsed' => null, 'memTotal' => null, + 'diskUsed' => null, 'diskTotal' => null, 'diskPercent' => null, + 'load1' => null, 'netRx' => null, 'netTx' => null, 'containers' => null, + ]; + } + + protected function metricsClient(Server $server): SentinelMetricsClient + { + return app(SentinelMetricsClient::class, ['server' => $server]); + } + + /** + * Resolve a Sentinel container id (the resource uuid Sentinel keys by) to a + * team-owned resource name + metrics link, memoized. Shows the raw id when no + * team-owned resource matches, so a Sentinel id never discloses another team's name. + * + * @return array{name: string, link: ?string} + */ + protected function resolveContainer(Server $server, string $id): array + { + if (isset($this->containerMetaCache[$id])) { + return $this->containerMetaCache[$id]; + } + + $app = Application::ownedByCurrentTeam()->with('environment.project')->whereUuid($id)->first(); + + $link = null; + if ($app && data_get($app, 'environment.project.uuid')) { + $link = route('project.application.metrics', [ + 'project_uuid' => $app->environment->project->uuid, + 'environment_uuid' => $app->environment->uuid, + 'application_uuid' => $app->uuid, + ]); + } + + return $this->containerMetaCache[$id] = [ + 'name' => $app?->name ?? $id, + 'link' => $link, + ]; + } + + /** + * @return array + */ + protected function chartPayload(): array + { + return ['range' => $this->range]; } public function placeholder(): View diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php new file mode 100644 index 0000000000..1ef9cd5b32 --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -0,0 +1,128 @@ + serverUuid => summary */ + public static array $summaries = []; + + /** @var array serverUuid => containers */ + public static array $containers = []; + + public function summary(): ?array + { + return self::$summaries[$this->server->uuid] ?? null; + } + + public function containersCurrent(): array + { + return self::$containers[$this->server->uuid] ?? []; + } + + public function history(string $metric, string $from): array + { + return []; + } + + public function networkHistory(string $from): array + { + return ['rx' => [], 'tx' => []]; + } +} + +class TestableMetrics extends Metrics +{ + protected function metricsClient(Server $server): SentinelMetricsClient + { + return new StubMetricsClient($server); + } +} + +beforeEach(function () { + StubMetricsClient::$summaries = []; + StubMetricsClient::$containers = []; + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->team = $team; +}); + +function metricsServer($team, $privateKey): Server +{ + $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $server->settings->update(['is_metrics_enabled' => true]); + + return $server; +} + +function onlineSummary(array $overrides = []): array +{ + return array_merge([ + 'online' => true, 'cpu' => 30.0, 'memUsed' => 100, 'memTotal' => 200, + 'diskUsed' => 10, 'diskTotal' => 100, 'diskPercent' => 10.0, + 'load1' => 0.4, 'netRx' => 5.0, 'netTx' => 1.0, + ], $overrides); +} + +it('keeps an unreachable server in the table but excludes it from KPIs', function () { + $ok = metricsServer($this->team, $this->privateKey); + $dead = metricsServer($this->team, $this->privateKey); + + StubMetricsClient::$summaries = [ + $ok->uuid => onlineSummary(['cpu' => 30.0]), + $dead->uuid => null, + ]; + + $component = Livewire::test(TestableMetrics::class); + + expect($component->get('serverRows'))->toHaveCount(2); + + $deadRow = collect($component->get('serverRows'))->firstWhere('uuid', $dead->uuid); + expect($deadRow['online'])->toBeFalse(); + expect($deadRow['cpu'])->toBeNull(); + + expect($component->get('kpis')['serversOnline'])->toBe(1); + expect($component->get('kpis')['cpuAvg'])->toBe(30.0); +}); + +it('ranks containers across servers by the selected metric', function () { + $s = metricsServer($this->team, $this->privateKey); + + StubMetricsClient::$summaries = [$s->uuid => onlineSummary(['diskPercent' => 50.0])]; + StubMetricsClient::$containers = [$s->uuid => [ + ['id' => 'low', 'cpu' => 5.0, 'memUsed' => 10, 'memPercent' => 1.0, 'diskBytes' => 1, 'net' => 0.0], + ['id' => 'high', 'cpu' => 88.0, 'memUsed' => 20, 'memPercent' => 2.0, 'diskBytes' => 2, 'net' => 0.0], + ]]; + + $component = Livewire::test(TestableMetrics::class); + + expect(array_column($component->get('topContainers'), 'id')[0])->toBe('high'); +}); + +it('cannot see another team’s servers', function () { + $otherTeam = Team::factory()->create(); + // Reuse the existing key id: the factory's private key is hardcoded, so a second + // PrivateKey::create collides on fingerprint. Only the server's team_id matters here. + $server = Server::factory()->create(['team_id' => $otherTeam->id, 'private_key_id' => $this->privateKey->id]); + $server->settings->update(['is_metrics_enabled' => true]); + + $component = Livewire::test(TestableMetrics::class); + + expect($component->get('serverRows'))->toHaveCount(0); +}); From 5273a4c035bb1ce38d214c3092bac933bdb59e2a Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:01:11 +0000 Subject: [PATCH 007/119] feat(metrics): build and dispatch fleet trend chart payloads --- .../Concerns/BuildsMetricsChartPayload.php | 25 +++++++ app/Livewire/Fleet/Metrics.php | 23 +++--- .../Fleet/MetricsChartPayloadTest.php | 73 +++++++++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 app/Livewire/Concerns/BuildsMetricsChartPayload.php create mode 100644 tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php diff --git a/app/Livewire/Concerns/BuildsMetricsChartPayload.php b/app/Livewire/Concerns/BuildsMetricsChartPayload.php new file mode 100644 index 0000000000..c660ed49d3 --- /dev/null +++ b/app/Livewire/Concerns/BuildsMetricsChartPayload.php @@ -0,0 +1,25 @@ + + */ + protected function buildChartPayload(array $seriesByServer): array + { + return [ + 'range' => $this->range, + 'cpu' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['cpu'], 'avg'), + 'memory' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['memory'], 'sum'), + 'disk' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['disk'], 'avg'), + 'load' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['load'], 'avg'), + 'networkRx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkRx'], 'sum'), + 'networkTx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkTx'], 'sum'), + ]; + } +} diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 63dfd5605a..1de503492f 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -2,6 +2,7 @@ namespace App\Livewire\Fleet; +use App\Livewire\Concerns\BuildsMetricsChartPayload; use App\Models\Application; use App\Models\Server; use App\Services\FleetMetricsAggregator; @@ -15,6 +16,8 @@ use Livewire\Component; #[Lazy] class Metrics extends Component { + use BuildsMetricsChartPayload; + public string $chartId = 'fleet-metrics'; /** Metrics-enabled servers owned by the current team. */ @@ -103,6 +106,8 @@ class Metrics extends Component $rows = []; $containers = []; + $series = ['cpu' => [], 'memory' => [], 'disk' => [], 'load' => [], 'networkRx' => [], 'networkTx' => []]; + $from = SentinelMetricsClient::rangeFrom($this->range); foreach ($this->targetServers() as $server) { $client = $this->metricsClient($server); @@ -129,13 +134,21 @@ class Metrics extends Component $meta = $this->resolveContainer($server, $c['id']); $containers[] = array_merge($c, ['server' => $server->name, 'name' => $meta['name'], 'link' => $meta['link']]); } + + $series['cpu'][] = $client->history('cpu', $from); + $series['memory'][] = $client->history('memory', $from); + $series['disk'][] = $client->history('disk', $from); + $series['load'][] = $client->history('load', $from); + $net = $client->networkHistory($from); + $series['networkRx'][] = $net['rx']; + $series['networkTx'][] = $net['tx']; } $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); - $this->dispatch("refreshChartData-{$this->chartId}", $this->chartPayload()); + $this->dispatch("refreshChartData-{$this->chartId}", $this->buildChartPayload($series)); } /** @@ -186,14 +199,6 @@ class Metrics extends Component ]; } - /** - * @return array - */ - protected function chartPayload(): array - { - return ['range' => $this->range]; - } - public function placeholder(): View { return view('livewire.fleet.metrics-placeholder'); diff --git a/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php b/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php new file mode 100644 index 0000000000..0dc92d383d --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php @@ -0,0 +1,73 @@ + true, 'cpu' => 10.0, 'memUsed' => 1, 'memTotal' => 2, + 'diskUsed' => 1, 'diskTotal' => 2, 'diskPercent' => 50.0, 'load1' => 0.1, 'netRx' => 0.0, 'netTx' => 0.0]; + } + + public function containersCurrent(): array + { + return []; + } + + public function history(string $metric, string $from): array + { + return [[1000, 10.0], [2000, 20.0]]; + } + + public function networkHistory(string $from): array + { + return ['rx' => [[1000, 5.0]], 'tx' => [[1000, 1.0]]]; + } +} + +class ChartTestableMetrics extends Metrics +{ + protected function metricsClient(Server $server): SentinelMetricsClient + { + return new ChartStubClient($server); + } +} + +beforeEach(function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->team = $team; +}); + +it('dispatches a chart payload with every trend series', function () { + $s = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + $s->settings->update(['is_metrics_enabled' => true]); + + Livewire::test(ChartTestableMetrics::class) + ->call('loadData') + ->assertDispatched('refreshChartData-fleet-metrics', function ($event, $params) { + $payload = $params[0]; + + // Livewire JSON-serializes event params, so whole floats arrive as ints; + // compare loosely (10 == 10.0). + return $payload['cpu'] == [[1000, 10], [2000, 20]] + && $payload['networkRx'] == [[1000, 5]] + && array_key_exists('load', $payload); + }); +}); From 6b9086795f07107a0d846c9a2a7a3e0df2a63ed1 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:05:41 +0000 Subject: [PATCH 008/119] feat(metrics): render fleet KPIs, tables, and trend charts --- app/Livewire/Fleet/Metrics.php | 18 ++ .../livewire/fleet/_usage-cell.blade.php | 12 + .../views/livewire/fleet/metrics.blade.php | 210 +++++++++++++++++- 3 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 resources/views/livewire/fleet/_usage-cell.blade.php diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 1de503492f..c47d282033 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -46,6 +46,8 @@ class Metrics extends Component /** @var array> */ public array $topContainers = []; + public ?string $lastUpdatedAt = null; + /** @var array */ protected array $containerMetaCache = []; @@ -86,6 +88,21 @@ class Metrics extends Component $this->loadData(); } + public function isLivePollable(): bool + { + return $this->live && $this->range === '24h'; + } + + /** + * Re-pull within the 60s cache TTL. The TTL bounds staleness, so a refresh is a + * cheap cache-hit reload rather than a global cache flush (matches the Analytics + * live toggle, which also relies on the TTL). + */ + public function refresh(): void + { + $this->loadData(); + } + /** * @return Collection */ @@ -147,6 +164,7 @@ class Metrics extends Component $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); + $this->lastUpdatedAt = now()->toIso8601String(); $this->dispatch("refreshChartData-{$this->chartId}", $this->buildChartPayload($series)); } diff --git a/resources/views/livewire/fleet/_usage-cell.blade.php b/resources/views/livewire/fleet/_usage-cell.blade.php new file mode 100644 index 0000000000..55c233996a --- /dev/null +++ b/resources/views/livewire/fleet/_usage-cell.blade.php @@ -0,0 +1,12 @@ +@php + $level = \App\Support\Metrics\PressureLevel::for($percent, $kind); + $width = $percent === null ? 0 : min(100, round($percent, 1)); +@endphp +
    + + {{ $percent === null ? '-' : round($percent).'%' }} + + +
    diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 69255b779a..f3a5857126 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -1,7 +1,33 @@
    -
    -

    Metrics

    -

    Resource usage across your servers.

    + {{-- Header + controls --}} +
    +
    +

    Metrics

    +

    Resource usage across your servers.

    +
    + @if ($servers->isNotEmpty()) +
    + @if ($lastUpdatedAt) + + @endif + +
    + @foreach (['24h', '7d', '30d'] as $r) + + @endforeach +
    + +
    + @endif
    @if ($servers->isEmpty()) @@ -10,5 +36,183 @@ + @else + {{-- KPI tiles --}} +
    +
    + Servers online + {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} + @if (($kpis['needsAttention'] ?? 0) > 0) + {{ $kpis['needsAttention'] }} needs attention + @endif +
    +
    + Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif + {{ round($kpis['cpuAvg'] ?? 0) }}% + @if ($kpis['cpuBusiest'] ?? null){{ $kpis['cpuBusiest']['name'] }}: {{ round($kpis['cpuBusiest']['percent']) }}%@endif +
    +
    + Fleet memory + {{ round($kpis['memPercent'] ?? 0) }}% + {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} +
    +
    + Fleet disk + {{ round($kpis['diskPercent'] ?? 0) }}% + {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} +
    +
    + Fleet network + {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s + rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s +
    +
    + Containers + {{ compactNumber($kpis['containers'] ?? 0) }} +
    +
    + + {{-- Trend charts --}} +
    + @foreach (['cpu' => 'CPU %', 'memory' => 'Memory used', 'network' => 'Network', 'load' => 'Load average', 'disk' => 'Disk %'] as $key => $label) + +
    +
    +
    +
    + @endforeach +
    + + {{-- Servers table --}} + + + + + + + + + @foreach (collect($serverRows)->sortByDesc('cpu') as $row) + + + + + + + + + + @endforeach + +
    ServerCPUMemoryDiskLoadNetworkContainers
    + {{ $row['name'] }} + @unless ($row['online']) + + @endunless + @include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])@include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])@include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk']){{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}{{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}{{ $row['containers'] ?? '-' }}
    +
    + + {{-- Hottest containers --}} + + +
    + @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + + @endforeach +
    +
    + @if (empty($topContainers)) + + @else + + + + @foreach ($topContainers as $c) + + + + + + @endforeach + +
    ContainerServer{{ ucfirst($containerMetric) }}
    @if ($c['link']){{ $c['name'] }}@else{{ $c['name'] }}@endif{{ $c['server'] }} + @switch($containerMetric) + @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break + @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break + @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break + @default{{ round($c['cpu'] ?? 0) }}% + @endswitch +
    + @endif +
    @endif + + @script + + @endscript
    From c2310a74659741eb6c1b2d99ebda71e1d489e747 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:06:08 +0000 Subject: [PATCH 009/119] docs(metrics): refresh DESIGN for disk/network/load history and fleet page --- DESIGN.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 53b058ddef..5a6d914860 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -746,10 +746,14 @@ Charts follow the application metrics implementation: - downsample long time ranges before rendering. Only add a metric if Sentinel exposes historical data for it. Current Sentinel -history endpoints store CPU and memory. Root filesystem usage is included in -the periodic push payload for threshold notifications, but it is not stored as -a historical Sentinel metric and has no history endpoint, so it cannot power a -disk-usage graph yet. +history endpoints store CPU, memory, disk, network throughput, and load average +(Sentinel PR #47 added disk, network, and load). Bulk snapshot endpoints +(`/api/summary`, `/api/containers/current`) return the latest host and +per-container values in one call for fleet-wide views. + +The fleet Metrics page (`/metrics`, top-level sidebar) aggregates these across +every team server: KPI tiles, per-metric trend cards, a threshold-colored +servers table, and a fleet-wide hottest-containers list. --- @@ -769,6 +773,7 @@ Use these as implementation references: | Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` | | Standard table toolbar controls | `resources/views/components/table/*` | | Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` | +| Fleet metrics page | `resources/views/livewire/fleet/metrics.blade.php` | | Browser terminal workspace | `resources/views/livewire/terminal/index.blade.php` | | Layer card | `resources/views/components/application/settings-section.blade.php` | | Custom dropdown | `resources/views/components/forms/listbox.blade.php` | From fbce977dbfb855d03c50b19f0a5e2974eec06689 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:07:01 +0000 Subject: [PATCH 010/119] test(metrics): assert KPI tiles, stale badge, and container toggle render --- .../Livewire/Fleet/MetricsDataTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php index 1ef9cd5b32..ee10acade2 100644 --- a/tests/Feature/Livewire/Fleet/MetricsDataTest.php +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -115,6 +115,25 @@ it('ranks containers across servers by the selected metric', function () { expect(array_column($component->get('topContainers'), 'id')[0])->toBe('high'); }); +it('shows the KPI tiles, a stale badge for offline servers, and the container toggle', function () { + $ok = metricsServer($this->team, $this->privateKey); + $ok->update(['name' => 'web-1']); + $dead = metricsServer($this->team, $this->privateKey); + $dead->update(['name' => 'web-2']); + + StubMetricsClient::$summaries = [ + $ok->uuid => onlineSummary(['cpu' => 95.0, 'diskPercent' => 95.0]), + $dead->uuid => null, + ]; + + Livewire::test(TestableMetrics::class) + ->assertSee('web-1') + ->assertSee('web-2') + ->assertSee('Fleet CPU') + ->assertSee('needs attention') + ->assertSee('Offline'); +}); + it('cannot see another team’s servers', function () { $otherTeam = Team::factory()->create(); // Reuse the existing key id: the factory's private key is hardcoded, so a second From 9fced09bd15ec85304d74cbb399962dbf44fae58 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:39:14 +0000 Subject: [PATCH 011/119] fix(metrics): div-grid tables, chart initial render, metrics skeleton, graph icon --- app/Livewire/Fleet/Metrics.php | 6 +- resources/css/app.css | 8 + resources/views/components/navbar.blade.php | 2 +- .../fleet/metrics-placeholder.blade.php | 17 +- .../views/livewire/fleet/metrics.blade.php | 170 ++++++++++-------- .../Livewire/Fleet/MetricsDataTest.php | 2 +- 6 files changed, 123 insertions(+), 82 deletions(-) diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index c47d282033..e29f8314ae 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -46,6 +46,9 @@ class Metrics extends Component /** @var array> */ public array $topContainers = []; + /** @var array Trend series for the charts, also embedded for first paint. */ + public array $chartData = []; + public ?string $lastUpdatedAt = null; /** @var array */ @@ -164,9 +167,10 @@ class Metrics extends Component $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); + $this->chartData = $this->buildChartPayload($series); $this->lastUpdatedAt = now()->toIso8601String(); - $this->dispatch("refreshChartData-{$this->chartId}", $this->buildChartPayload($series)); + $this->dispatch("refreshChartData-{$this->chartId}", $this->chartData); } /** diff --git a/resources/css/app.css b/resources/css/app.css index a8eef8e2d7..a1d5a7fde1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3183,6 +3183,14 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 5.5rem; } +.fleet-servers-table-grid { + grid-template-columns: minmax(9rem, 1.4fr) 8rem 8rem 8rem 5rem 8rem 5.5rem; +} + +.fleet-containers-table-grid { + grid-template-columns: minmax(10rem, 1.6fr) minmax(8rem, 1fr) 7rem; +} + .scheduled-executions-table-grid { grid-template-columns: 5.5rem minmax(9rem, 1fr) minmax(7rem, 0.7fr) 8.5rem 5rem minmax(12rem, 1.5fr); } diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 0722bdd076..2861a4650d 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -99,7 +99,7 @@
    - + Metrics
  • diff --git a/resources/views/livewire/fleet/metrics-placeholder.blade.php b/resources/views/livewire/fleet/metrics-placeholder.blade.php index aee0488f17..f67be9c225 100644 --- a/resources/views/livewire/fleet/metrics-placeholder.blade.php +++ b/resources/views/livewire/fleet/metrics-placeholder.blade.php @@ -1,3 +1,18 @@
    - +
    +

    Metrics

    +

    Resource usage across your servers.

    +
    + + + +
    + @foreach (['CPU', 'Memory used'] as $label) +
    + {{ $label }} + +
    + @endforeach +
    diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index f3a5857126..514ff66d4e 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -19,12 +19,12 @@
    @foreach (['24h', '7d', '30d'] as $r) + class="{{ $range === $r ? 'bg-[var(--coollabs-base)] text-black shadow-sm dark:text-fg' : 'text-neutral-500 hover:text-black dark:hover:text-fg' }} rounded px-2.5 py-1 text-[12px] font-medium transition-colors">{{ $r }} @endforeach
    @endif @@ -33,7 +33,7 @@ @if ($servers->isEmpty()) - + @else @@ -43,13 +43,15 @@ Servers online {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} @if (($kpis['needsAttention'] ?? 0) > 0) - {{ $kpis['needsAttention'] }} needs attention + {{ $kpis['needsAttention'] }} need attention + @else + All healthy @endif
    - Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif + Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif {{ round($kpis['cpuAvg'] ?? 0) }}% - @if ($kpis['cpuBusiest'] ?? null){{ $kpis['cpuBusiest']['name'] }}: {{ round($kpis['cpuBusiest']['percent']) }}%@endif + @if ($kpis['cpuBusiest'] ?? null)Busiest: {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}%@endif
    Fleet memory @@ -64,20 +66,21 @@
    Fleet network {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s - rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s + rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s · tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s
    Containers {{ compactNumber($kpis['containers'] ?? 0) }} + running
    {{-- Trend charts --}}
    - @foreach (['cpu' => 'CPU %', 'memory' => 'Memory used', 'network' => 'Network', 'load' => 'Load average', 'disk' => 'Disk %'] as $key => $label) + @foreach (['cpu' => 'CPU', 'memory' => 'Memory used', 'network' => 'Network throughput', 'load' => 'Load average', 'disk' => 'Disk usage'] as $key => $label)
    -
    +
    @endforeach @@ -85,31 +88,34 @@ {{-- Servers table --}} - - - - - - - - @foreach (collect($serverRows)->sortByDesc('cpu') as $row) - - - - - - - - - - @endforeach - -
    ServerCPUMemoryDiskLoadNetworkContainers
    - {{ $row['name'] }} - @unless ($row['online']) - - @endunless - @include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])@include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])@include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk']){{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}{{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}{{ $row['containers'] ?? '-' }}
    +
    +
    + Server + CPU + Memory + Disk + Load + Network + Containers +
    + @foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row) +
    +
    + {{ $row['name'] }} + @unless ($row['online']) + Offline + @endunless +
    +
    @include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])
    +
    @include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])
    +
    @include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk'])
    +
    {{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}
    +
    {{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}
    +
    {{ $row['containers'] ?? '-' }}
    +
    + @endforeach +
    {{-- Hottest containers --}} @@ -118,32 +124,42 @@
    @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + class="{{ $containerMetric === $m ? 'bg-[var(--coollabs-base)] text-black shadow-sm dark:text-fg' : 'text-neutral-500 hover:text-black dark:hover:text-fg' }} rounded px-2.5 py-1 text-[12px] font-medium transition-colors">{{ $mlabel }} @endforeach
    @if (empty($topContainers)) - +
    + +
    @else - - - - @foreach ($topContainers as $c) - - - - - - @endforeach - -
    ContainerServer{{ ucfirst($containerMetric) }}
    @if ($c['link']){{ $c['name'] }}@else{{ $c['name'] }}@endif{{ $c['server'] }} - @switch($containerMetric) - @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break - @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break - @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break - @default{{ round($c['cpu'] ?? 0) }}% - @endswitch -
    +
    +
    + Container + Server + {{ ucfirst($containerMetric) }} +
    + @foreach ($topContainers as $c) +
    +
    + @if ($c['link']) + {{ $c['name'] }} + @else + {{ $c['name'] }} + @endif +
    +
    {{ $c['server'] }}
    +
    + @switch($containerMetric) + @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break + @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break + @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break + @default{{ round($c['cpu'] ?? 0) }}% + @endswitch +
    +
    + @endforeach +
    @endif @endif @@ -156,7 +172,7 @@ const axisColor = (typeof textColor !== 'undefined') ? textColor : 'rgba(128,128,128,0.7)'; const fmtPercent = v => `${Number(Number(v).toFixed(1))}%`; - const fmtNumber = v => Number(v).toLocaleString(); + const fmtNumber = v => Number(Number(v).toFixed(2)).toLocaleString(); const fmtBytes = v => { const units = ['B', 'KB', 'MB', 'GB', 'TB']; let amount = Number(v) || 0; @@ -166,34 +182,37 @@ }; const fmtRate = v => `${fmtBytes(v)}/s`; - const baseOptions = (colors, formatter) => ({ - chart: { height: 240, type: 'area', toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent' }, - series: [], + const baseOptions = (series, colors, formatter) => ({ + chart: { height: 240, type: 'area', toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent', animations: { enabled: true } }, + series, colors, stroke: { curve: 'smooth', width: 2 }, fill: { type: 'gradient', gradient: { opacityFrom: 0.28, opacityTo: 0.02, stops: [0, 90, 100] } }, dataLabels: { enabled: false }, grid: { borderColor: 'rgba(128, 128, 128, 0.14)', strokeDashArray: 4 }, - legend: { show: false }, + legend: { show: series.length > 1, labels: { colors: axisColor } }, xaxis: { type: 'datetime', labels: { datetimeUTC: true, style: { colors: axisColor } } }, yaxis: { min: 0, max: max => max > 0 ? max * 1.2 : 1, forceNiceScale: true, tickAmount: 4, labels: { style: { colors: axisColor }, formatter } }, noData: { text: 'No data for this range', style: { color: axisColor } }, tooltip: { x: { format: 'yyyy-MM-dd HH:mm' } }, }); - const specs = { - cpu: { colors: ['#3b82f6'], formatter: fmtPercent }, - memory: { colors: ['#8b5cf6'], formatter: fmtBytes }, - network: { colors: ['#10b981', '#f59e0b'], formatter: fmtRate }, - load: { colors: ['#14b8a6'], formatter: fmtNumber }, - disk: { colors: ['#ef4444'], formatter: fmtPercent }, - }; + const initial = @js($chartData); + + const build = data => ({ + cpu: { series: [{ name: 'CPU %', data: data.cpu || [] }], colors: ['#3b82f6'], formatter: fmtPercent }, + memory: { series: [{ name: 'Memory', data: data.memory || [] }], colors: ['#8b5cf6'], formatter: fmtBytes }, + network: { series: [{ name: 'RX', data: data.networkRx || [] }, { name: 'TX', data: data.networkTx || [] }], colors: ['#10b981', '#f59e0b'], formatter: fmtRate }, + load: { series: [{ name: 'Load', data: data.load || [] }], colors: ['#14b8a6'], formatter: fmtNumber }, + disk: { series: [{ name: 'Disk %', data: data.disk || [] }], colors: ['#ef4444'], formatter: fmtPercent }, + }); const charts = {}; + const specs = build(initial || {}); for (const [key, spec] of Object.entries(specs)) { const el = document.getElementById(`fleet-chart-${key}`); if (! el) { continue; } - const chart = new ApexCharts(el, baseOptions(spec.colors, spec.formatter)); + const chart = new ApexCharts(el, baseOptions(spec.series, spec.colors, spec.formatter)); chart.render(); charts[key] = chart; } @@ -202,15 +221,10 @@ if (typeof checkTheme === 'function') { checkTheme(); } const data = Array.isArray(payload) ? payload[0] : payload; if (! data) { return; } - - charts.cpu?.updateSeries([{ name: 'CPU', data: data.cpu || [] }]); - charts.memory?.updateSeries([{ name: 'Memory', data: data.memory || [] }]); - charts.disk?.updateSeries([{ name: 'Disk', data: data.disk || [] }]); - charts.load?.updateSeries([{ name: 'Load', data: data.load || [] }]); - charts.network?.updateSeries([ - { name: 'RX', data: data.networkRx || [] }, - { name: 'TX', data: data.networkTx || [] }, - ]); + const next = build(data); + for (const [key, chart] of Object.entries(charts)) { + chart.updateSeries(next[key].series); + } }); })(); diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php index ee10acade2..789694fb12 100644 --- a/tests/Feature/Livewire/Fleet/MetricsDataTest.php +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -130,7 +130,7 @@ it('shows the KPI tiles, a stale badge for offline servers, and the container to ->assertSee('web-1') ->assertSee('web-2') ->assertSee('Fleet CPU') - ->assertSee('needs attention') + ->assertSee('need attention') ->assertSee('Offline'); }); From f3fdee90da04454cb3c9ec9d886c4806359793ef Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:43:37 +0000 Subject: [PATCH 012/119] feat(metrics): add disk/network/load charts to the per-server metrics page --- app/Livewire/Server/Charts.php | 22 ++++++ app/Traits/HasMetrics.php | 70 ++++++++++++++++++- .../views/livewire/server/charts.blade.php | 57 ++++++++++++++- tests/Unit/Metrics/HasMetricsMappingTest.php | 61 ++++++++++++++++ 4 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Metrics/HasMetricsMappingTest.php diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index 567034c801..654f0b9686 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -100,15 +100,37 @@ class Charts extends Component try { $cpuMetrics = $this->server->getCpuMetrics($this->interval); $memoryMetrics = $this->server->getMemoryMetrics($this->interval); + // Disk/network/load arrived in a later Sentinel; fetch each optionally so an + // older Sentinel (which 404s them) still shows CPU and memory. + $diskMetrics = $this->optionalMetric(fn () => $this->server->getDiskMetrics($this->interval)); + $loadMetrics = $this->optionalMetric(fn () => $this->server->getLoadMetrics($this->interval)); + $networkMetrics = $this->optionalMetric(fn () => $this->server->getNetworkMetrics($this->interval)) ?? []; $this->dispatch("refreshChartData-{$this->chartId}-metrics", [ 'cpuSeries' => $cpuMetrics, 'memorySeries' => $memoryMetrics, + 'diskSeries' => $diskMetrics, + 'loadSeries' => $loadMetrics, + 'networkRxSeries' => $networkMetrics['rx'] ?? [], + 'networkTxSeries' => $networkMetrics['tx'] ?? [], ]); } catch (\Throwable $e) { return handleError($e, $this); } } + /** + * Fetch a metric that may not exist on an older Sentinel; a failure yields null + * rather than breaking the whole chart refresh. + */ + private function optionalMetric(callable $fetch): mixed + { + try { + return $fetch(); + } catch (\Throwable) { + return null; + } + } + public function setInterval() { if ($this->interval <= 10) { diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 712f09a48b..0cba5af8ab 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -27,7 +27,61 @@ trait HasMetrics return convertContainerMemoryBytesToMegabytes($metrics); } + /** + * Root filesystem usage percentage over time. Disk history interleaves every + * mount, so we keep only the root mount before mapping. + */ + public function getDiskMetrics(int $mins = 5): ?array + { + $rows = $this->fetchMetricRows('disk', $mins); + if ($rows === null) { + return null; + } + + $root = collect($rows)->filter(fn ($r) => ($r['mount'] ?? null) === '/')->values()->all(); + + return $this->mapRows($root ?: $rows, 'usedPercent', $mins); + } + + /** + * Host load average (1 minute) over time. + */ + public function getLoadMetrics(int $mins = 5): ?array + { + return $this->getMetrics('load', $mins, 'load1'); + } + + /** + * Network throughput over time, as separate receive/transmit byte-rate series. + * + * @return array{rx: ?array, tx: ?array}|null + */ + public function getNetworkMetrics(int $mins = 5): ?array + { + $rows = $this->fetchMetricRows('network', $mins); + if ($rows === null) { + return null; + } + + return [ + 'rx' => $this->mapRows($rows, 'rxBytesPerSec', $mins), + 'tx' => $this->mapRows($rows, 'txBytesPerSec', $mins), + ]; + } + private function getMetrics(string $type, int $mins, string $valueField): ?array + { + return $this->mapRows($this->fetchMetricRows($type, $mins), $valueField, $mins); + } + + /** + * Fetch and decode the raw Sentinel history rows for a metric type, or null when + * metrics are disabled. Throws on a Sentinel error response (caller decides whether + * to treat that as fatal or optional). + * + * @return array>|null + */ + protected function fetchMetricRows(string $type, int $mins): ?array { $server = $this->getMetricsServer(); if (! $server->isMetricsEnabled()) { @@ -63,7 +117,21 @@ trait HasMetrics throw new \Exception($error); } - $metrics = collect(json_decode($response, true))->map(function ($metric) use ($valueField) { + return json_decode($response, true) ?: []; + } + + /** + * Map raw rows to [timestampMs, value] pairs, downsampling long ranges. + * + * @param array>|null $rows + */ + private function mapRows(?array $rows, string $valueField, int $mins): ?array + { + if ($rows === null) { + return null; + } + + $metrics = collect($rows)->map(function ($metric) use ($valueField) { return [(int) $metric['time'], (float) ($metric[$valueField] ?? 0.0)]; })->toArray(); diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 442fc1979f..3592a1528d 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -69,6 +69,21 @@
    + +
    +
    + + +
    +
    + + +
    +
    + @script diff --git a/tests/Unit/Metrics/HasMetricsMappingTest.php b/tests/Unit/Metrics/HasMetricsMappingTest.php new file mode 100644 index 0000000000..aff3dd557c --- /dev/null +++ b/tests/Unit/Metrics/HasMetricsMappingTest.php @@ -0,0 +1,61 @@ + type => rows */ + public array $rowsByType = []; + + protected function fetchMetricRows(string $type, int $mins): ?array + { + return $this->rowsByType[$type] ?? null; + } +} + +it('keeps only the root mount for disk metrics', function () { + $d = new MetricsMappingDouble; + $d->rowsByType['disk'] = [ + ['time' => 1000, 'mount' => '/boot', 'usedPercent' => 90.0], + ['time' => 1000, 'mount' => '/', 'usedPercent' => 40.0], + ['time' => 2000, 'mount' => '/', 'usedPercent' => 42.0], + ]; + + expect($d->getDiskMetrics(5))->toBe([[1000, 40.0], [2000, 42.0]]); +}); + +it('splits network metrics into rx and tx series', function () { + $d = new MetricsMappingDouble; + $d->rowsByType['network'] = [ + ['time' => 1000, 'rxBytesPerSec' => 100.0, 'txBytesPerSec' => 10.0], + ['time' => 2000, 'rxBytesPerSec' => 200.0, 'txBytesPerSec' => 20.0], + ]; + + expect($d->getNetworkMetrics(5))->toBe([ + 'rx' => [[1000, 100.0], [2000, 200.0]], + 'tx' => [[1000, 10.0], [2000, 20.0]], + ]); +}); + +it('maps the one minute load average', function () { + $d = new MetricsMappingDouble; + $d->rowsByType['load'] = [ + ['time' => 1000, 'load1' => 0.5, 'load5' => 1.0, 'load15' => 1.5], + ]; + + expect($d->getLoadMetrics(5))->toBe([[1000, 0.5]]); +}); + +it('returns null for a metric when metrics are unavailable', function () { + $d = new MetricsMappingDouble; // rowsByType empty -> fetch returns null + + expect($d->getDiskMetrics(5))->toBeNull(); + expect($d->getNetworkMetrics(5))->toBeNull(); + expect($d->getLoadMetrics(5))->toBeNull(); +}); From 2aa656becef918c55a041e29d66dfdda971a1745 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:49:20 +0000 Subject: [PATCH 013/119] refactor(metrics): mirror analytics header/overview card, declutter KPI tiles --- app/Livewire/Fleet/Metrics.php | 20 -- .../views/livewire/fleet/metrics.blade.php | 172 ++++++++++-------- 2 files changed, 101 insertions(+), 91 deletions(-) diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index e29f8314ae..0d44d1d470 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -35,8 +35,6 @@ class Metrics extends Component #[Url(as: 'cmetric')] public string $containerMetric = 'cpu'; - public bool $live = false; - /** @var array */ public array $kpis = []; @@ -49,8 +47,6 @@ class Metrics extends Component /** @var array Trend series for the charts, also embedded for first paint. */ public array $chartData = []; - public ?string $lastUpdatedAt = null; - /** @var array */ protected array $containerMetaCache = []; @@ -91,21 +87,6 @@ class Metrics extends Component $this->loadData(); } - public function isLivePollable(): bool - { - return $this->live && $this->range === '24h'; - } - - /** - * Re-pull within the 60s cache TTL. The TTL bounds staleness, so a refresh is a - * cheap cache-hit reload rather than a global cache flush (matches the Analytics - * live toggle, which also relies on the TTL). - */ - public function refresh(): void - { - $this->loadData(); - } - /** * @return Collection */ @@ -168,7 +149,6 @@ class Metrics extends Component $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); $this->chartData = $this->buildChartPayload($series); - $this->lastUpdatedAt = now()->toIso8601String(); $this->dispatch("refreshChartData-{$this->chartId}", $this->chartData); } diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 514ff66d4e..db28a47285 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -1,79 +1,104 @@ -
    - {{-- Header + controls --}} -
    -
    -

    Metrics

    -

    Resource usage across your servers.

    + '', 'label' => 'All servers']], + collect($serverOptions)->map(fn ($name, $uuid) => ['value' => $uuid, 'label' => $name])->values()->all(), +); +?> +
    + + Metrics | Coolify + + + {{-- Header --}} +
    +
    +

    Metrics

    +

    + Resource usage across every server, reported by Sentinel. +

    + @if ($servers->isNotEmpty()) -
    - @if ($lastUpdatedAt) - - @endif - -
    - @foreach (['24h', '7d', '30d'] as $r) - - @endforeach +
    +
    + +
    + +
    +
    + @foreach (['24h' => '24 hours', '7d' => '7 days', '30d' => '30 days'] as $value => $label) + + @endforeach +
    -
    @endif
    @if ($servers->isEmpty()) - - - - + + + + View servers + + @else - {{-- KPI tiles --}} -
    -
    - Servers online - {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} - @if (($kpis['needsAttention'] ?? 0) > 0) - {{ $kpis['needsAttention'] }} need attention - @else - All healthy - @endif + {{-- Overview KPIs --}} + +
    +
    + Servers online + {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} + @if (($kpis['needsAttention'] ?? 0) > 0) + {{ $kpis['needsAttention'] }} need attention + @endif +
    +
    + + Fleet CPU + @if ($kpis['cpuApproximate'] ?? false) + ~ + @endif + + {{ round($kpis['cpuAvg'] ?? 0) }}% + @if ($kpis['cpuBusiest'] ?? null) + Busiest {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}% + @endif +
    +
    + Fleet memory + {{ round($kpis['memPercent'] ?? 0) }}% + {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} +
    +
    + Fleet disk + {{ round($kpis['diskPercent'] ?? 0) }}% + {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} +
    +
    + Fleet network + {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s + ↓{{ formatBytes($kpis['netRx'] ?? 0) }} ↑{{ formatBytes($kpis['netTx'] ?? 0) }} +
    +
    + Containers + {{ compactNumber($kpis['containers'] ?? 0) }} +
    -
    - Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif - {{ round($kpis['cpuAvg'] ?? 0) }}% - @if ($kpis['cpuBusiest'] ?? null)Busiest: {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}%@endif -
    -
    - Fleet memory - {{ round($kpis['memPercent'] ?? 0) }}% - {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} -
    -
    - Fleet disk - {{ round($kpis['diskPercent'] ?? 0) }}% - {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} -
    -
    - Fleet network - {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s - rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s · tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s -
    -
    - Containers - {{ compactNumber($kpis['containers'] ?? 0) }} - running -
    -
    + {{-- Trend charts --}}
    @@ -87,7 +112,8 @@
    {{-- Servers table --}} - +
    Server @@ -119,25 +145,29 @@ {{-- Hottest containers --}} - + -
    +
    @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + @class([$tabButtonBase, $containerMetric === $m ? $tabButtonActive : $tabButtonInactive])> + {{ $mlabel }} + @endforeach
    @if (empty($topContainers))
    - +
    @else
    Container Server - {{ ucfirst($containerMetric) }} + {{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }}
    @foreach ($topContainers as $c)
    From 6b0c97211a57aafba4e98fa65b4113ab59a94210 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 09:18:05 +0000 Subject: [PATCH 014/119] fix(metrics): downsample history, re-rank containers without refetch, add live refresh --- app/Livewire/Fleet/Metrics.php | 14 +++++++++- app/Services/SentinelMetricsClient.php | 13 +++++++++- .../views/livewire/fleet/metrics.blade.php | 26 +++++++++++++++++++ .../Livewire/Fleet/MetricsDataTest.php | 19 ++++++++++++++ .../Services/SentinelMetricsClientTest.php | 12 +++++++++ 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 0d44d1d470..679fdf5593 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -35,12 +35,17 @@ class Metrics extends Component #[Url(as: 'cmetric')] public string $containerMetric = 'cpu'; + public bool $live = false; + /** @var array */ public array $kpis = []; /** @var array> */ public array $serverRows = []; + /** @var array> All fetched containers, kept so the metric toggle re-ranks without re-fetching. */ + public array $containersRaw = []; + /** @var array> */ public array $topContainers = []; @@ -78,7 +83,8 @@ class Metrics extends Component public function updatedContainerMetric(): void { - $this->loadData(); + // Re-rank the already-fetched containers; no need to re-hit Sentinel. + $this->topContainers = FleetMetricsAggregator::rankContainers($this->containersRaw, $this->containerMetric); } public function setRange(string $range): void @@ -87,6 +93,11 @@ class Metrics extends Component $this->loadData(); } + public function isLivePollable(): bool + { + return $this->live && $this->range === '24h'; + } + /** * @return Collection */ @@ -147,6 +158,7 @@ class Metrics extends Component $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); + $this->containersRaw = $containers; $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); $this->chartData = $this->buildChartPayload($series); diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php index 5f27f1e564..820fd9c0f8 100644 --- a/app/Services/SentinelMetricsClient.php +++ b/app/Services/SentinelMetricsClient.php @@ -9,6 +9,9 @@ class SentinelMetricsClient { private string $base = 'http://localhost:8888/api'; + /** Cap on points per trend series after downsampling raw Sentinel samples. */ + private const MAX_SERIES_POINTS = 300; + public function __construct(protected Server $server) {} public static function rangeFrom(string $range): string @@ -126,10 +129,18 @@ class SentinelMetricsClient return []; } - return array_values(array_map( + $series = array_values(array_map( fn ($r) => [(int) ($r['time'] ?? 0), (float) ($r[$field] ?? 0)], $rows )); + + // Sentinel returns raw samples (thousands of points over 24h). Cap the series so + // the chart payload stays small enough to embed and morph cheaply. + if (count($series) > self::MAX_SERIES_POINTS) { + $series = downsampleLTTB($series, self::MAX_SERIES_POINTS); + } + + return $series; } /** diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index db28a47285..07bd0d9e0d 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -30,6 +30,28 @@ $serverListboxOptions = array_merge(
    + @if ($range === '24h') +
    + +
    + @endif
    @foreach (['24h' => '24 hours', '7d' => '7 days', '30d' => '30 days'] as $value => $label) - @endforeach +
    + +
    + @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + + @endforeach +
    @if (empty($topContainers)) @@ -189,23 +194,28 @@ $serverListboxOptions = array_merge( description="Container metrics need a Sentinel build with the bulk endpoints." />
    @else -
    +
    Container Server {{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }}
    @foreach ($topContainers as $c) + @php $meta = $this->containerMeta($c['id']); @endphp
    -
    - @if ($c['link']) - {{ $c['name'] }} +
    + @if ($meta['link']) + {{ $meta['name'] }} @else - {{ $c['name'] }} + {{ $meta['name'] }} + @endif + @if ($meta['image']) + {{ $meta['image'] }} @endif
    -
    {{ $c['server'] }}
    -
    +
    {{ $c['server'] }}
    +
    @switch($containerMetric) @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break @@ -223,6 +233,9 @@ $serverListboxOptions = array_merge( @script @endscript From 701c81b28f6eeb2a8477ebd94eb80c1aec61f7b0 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 09:30:56 +0000 Subject: [PATCH 016/119] fix(metrics): custom chart tooltip so it has a background (default is stripped) --- .../views/livewire/fleet/metrics.blade.php | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 0687779d0c..6db9550bc5 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -251,6 +251,25 @@ $serverListboxOptions = array_merge( }; const fmtRate = v => `${fmtBytes(v)}/s`; + const fmtLocalTime = ts => new Date(ts).toLocaleString(undefined, { hour12: false, timeZoneName: 'short' }); + const fmtUtcTime = ts => new Date(ts).toLocaleString(undefined, { hour12: false, timeZone: 'UTC', timeZoneName: 'short' }); + + // The app strips the default ApexCharts tooltip background (utilities.css), so + // every chart must render its own `apexcharts-tooltip-custom` markup. + const customTooltip = formatter => ({ series, dataPointIndex, w }) => { + const timestamp = w.globals.seriesX[0]?.[dataPointIndex]; + let rows = ''; + series.forEach((s, i) => { + const value = s[dataPointIndex]; + if (value === null || value === undefined) { return; } + rows += `
    ${w.globals.seriesNames[i]}: ${formatter(value)}
    `; + }); + return `
    ${rows} +
    Your time: ${fmtLocalTime(timestamp)}
    +
    UTC: ${fmtUtcTime(timestamp)}
    +
    `; + }; + const baseOptions = (series, colors, formatter) => ({ chart: { height: 240, type: 'area', toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent', animations: { enabled: true } }, series, @@ -263,7 +282,7 @@ $serverListboxOptions = array_merge( xaxis: { type: 'datetime', labels: { datetimeUTC: true, style: { colors: axisColor } } }, yaxis: { min: 0, max: max => max > 0 ? max * 1.2 : 1, forceNiceScale: true, tickAmount: 4, labels: { style: { colors: axisColor }, formatter } }, noData: { text: 'No data for this range', style: { color: axisColor } }, - tooltip: { x: { format: 'yyyy-MM-dd HH:mm' } }, + tooltip: { shared: true, intersect: false, marker: { show: false }, custom: customTooltip(formatter) }, }); const initial = @js($chartData); From ab436942c94ad78f5191526e17c1ba4fbfd8936b Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 17 Sep 2026 06:30:59 +0000 Subject: [PATCH 017/119] feat(metrics): polish fleet + server metrics UI/UX Refine the /metrics fleet page and align the per-server metrics page with it. Fleet overview: - add live sparklines to the CPU/Memory/Disk/Network KPI tiles (shared axis, capped to ~40 points per sparkline) - fill the Servers online + Containers tiles (fleet-health bar, hottest container) so all six tiles read as one system - scope-aware labels: drop the "Fleet" prefix and helper copy when a single server is selected Trend charts: - hero layout: CPU spans full width, the other four form a 2x2 grid - apply the same hero layout + skeleton to the per-server page for consistency Hottest containers table: - add a rank column and a per-row magnitude bar scaled to the busiest container - fix a leaked Blade @break directive that rendered literally and forced horizontal scroll on the Network tab - keep the value column a fixed width so bars align across rows Live refresh: - add analytics-style loading feedback to the range tabs and server filter - stable wire:key on table rows + width transitions on bars so data glides on live refresh instead of snapping Generalize the shared _sparkline partial (optional color/label/format) so it serves non-traffic metrics without changing analytics behaviour. --- .../Concerns/BuildsMetricsChartPayload.php | 124 +++++++++++++- resources/css/app.css | 2 +- .../livewire/fleet/_usage-cell.blade.php | 2 +- .../fleet/metrics-placeholder.blade.php | 2 +- .../views/livewire/fleet/metrics.blade.php | 159 +++++++++++++++--- .../views/livewire/server/charts.blade.php | 58 ++++--- .../livewire/traffic/_sparkline.blade.php | 16 +- .../Fleet/MetricsChartPayloadTest.php | 55 ++++++ .../Livewire/Fleet/MetricsDataTest.php | 27 ++- 9 files changed, 378 insertions(+), 67 deletions(-) diff --git a/app/Livewire/Concerns/BuildsMetricsChartPayload.php b/app/Livewire/Concerns/BuildsMetricsChartPayload.php index c660ed49d3..8ec43ee2b8 100644 --- a/app/Livewire/Concerns/BuildsMetricsChartPayload.php +++ b/app/Livewire/Concerns/BuildsMetricsChartPayload.php @@ -6,20 +6,130 @@ use App\Services\FleetMetricsAggregator; trait BuildsMetricsChartPayload { + /** + * Sparklines read at a glance, not on close inspection, so they carry far fewer + * points than the full trend charts (industry guidance is ~10-50 per spark). All + * KPI-tile sparks share one downsampled axis so their shapes stay comparable. + */ + private const SPARK_MAX_POINTS = 40; + /** * @param array{cpu: array, memory: array, disk: array, load: array, networkRx: array, networkTx: array} $seriesByServer * @return array */ protected function buildChartPayload(array $seriesByServer): array { + $cpu = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['cpu'], 'avg'); + $memory = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['memory'], 'sum'); + $disk = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['disk'], 'avg'); + $load = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['load'], 'avg'); + $networkRx = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkRx'], 'sum'); + $networkTx = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkTx'], 'sum'); + + // Throughput spark = rx + tx per bucket. + $network = $this->combineSeries($networkRx, $networkTx); + $categories = $this->sparkCategories($cpu, $memory, $disk, $network); + + [$sparkCategories, $sparks] = $this->capSparks($categories, [ + 'cpuSpark' => $this->alignSpark($cpu, $categories), + 'memSpark' => $this->alignSpark($memory, $categories), + 'diskSpark' => $this->alignSpark($disk, $categories), + 'netSpark' => $this->alignSpark($network, $categories), + ], self::SPARK_MAX_POINTS); + return [ 'range' => $this->range, - 'cpu' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['cpu'], 'avg'), - 'memory' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['memory'], 'sum'), - 'disk' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['disk'], 'avg'), - 'load' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['load'], 'avg'), - 'networkRx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkRx'], 'sum'), - 'networkTx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkTx'], 'sum'), - ]; + 'cpu' => $cpu, + 'memory' => $memory, + 'disk' => $disk, + 'load' => $load, + 'networkRx' => $networkRx, + 'networkTx' => $networkTx, + // KPI-tile sparklines: values-only series on one shared, capped time axis. + 'sparkCategories' => $sparkCategories, + ] + $sparks; + } + + /** + * Thin every spark and the shared axis to at most $max points using the same + * evenly-spaced indices (first and last always kept), so the series stay aligned. + * + * @param array $categories + * @param array> $sparks + * @return array{0: array, 1: array>} + */ + private function capSparks(array $categories, array $sparks, int $max): array + { + $count = count($categories); + if ($count <= $max || $max < 2) { + return [$categories, $sparks]; + } + + $indices = []; + for ($i = 0; $i < $max; $i++) { + $indices[] = (int) round($i * ($count - 1) / ($max - 1)); + } + $indices = array_values(array_unique($indices)); + + $pick = fn (array $values): array => array_values(array_map(fn (int $i) => $values[$i], $indices)); + + return [$pick($categories), array_map($pick, $sparks)]; + } + + /** + * Sum two [ts, value] series bucket-by-bucket into one sorted [ts, value] series. + * + * @param array $a + * @param array $b + * @return array + */ + private function combineSeries(array $a, array $b): array + { + $map = []; + foreach ([$a, $b] as $series) { + foreach ($series as [$ts, $value]) { + $map[$ts] = ($map[$ts] ?? 0.0) + (float) $value; + } + } + ksort($map); + + return array_map(fn ($ts, $value) => [(int) $ts, $value], array_keys($map), array_values($map)); + } + + /** + * Sorted union of bucket timestamps across every series (the shared spark x-axis). + * + * @param array ...$seriesList + * @return array + */ + private function sparkCategories(array ...$seriesList): array + { + $set = []; + foreach ($seriesList as $series) { + foreach ($series as [$ts]) { + $set[(int) $ts] = true; + } + } + $categories = array_keys($set); + sort($categories); + + return $categories; + } + + /** + * Project a [ts, value] series onto the shared category axis, filling gaps with 0. + * + * @param array $series + * @param array $categories + * @return array + */ + private function alignSpark(array $series, array $categories): array + { + $map = []; + foreach ($series as [$ts, $value]) { + $map[(int) $ts] = $value; + } + + return array_map(fn ($ts) => $map[$ts] ?? 0, $categories); } } diff --git a/resources/css/app.css b/resources/css/app.css index a1d5a7fde1..a6b7fa4b17 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3188,7 +3188,7 @@ input[type="search"]::-webkit-search-results-decoration { } .fleet-containers-table-grid { - grid-template-columns: minmax(10rem, 1.6fr) minmax(8rem, 1fr) 7rem; + grid-template-columns: 2.25rem minmax(9rem, 1.7fr) minmax(6rem, 0.9fr) minmax(8.5rem, 1fr); } .scheduled-executions-table-grid { diff --git a/resources/views/livewire/fleet/_usage-cell.blade.php b/resources/views/livewire/fleet/_usage-cell.blade.php index 55c233996a..4ef75c08be 100644 --- a/resources/views/livewire/fleet/_usage-cell.blade.php +++ b/resources/views/livewire/fleet/_usage-cell.blade.php @@ -7,6 +7,6 @@ {{ $percent === null ? '-' : round($percent).'%' }}
    diff --git a/resources/views/livewire/fleet/metrics-placeholder.blade.php b/resources/views/livewire/fleet/metrics-placeholder.blade.php index f65f4e4319..0ff339a199 100644 --- a/resources/views/livewire/fleet/metrics-placeholder.blade.php +++ b/resources/views/livewire/fleet/metrics-placeholder.blade.php @@ -22,7 +22,7 @@
    @foreach (['CPU', 'Memory used', 'Network throughput', 'Load average', 'Disk usage'] as $label) - + $label === 'CPU'])> @endforeach diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 6db9550bc5..b5b0856811 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -3,6 +3,9 @@ $tabButtonBase = 'relative inline-flex h-7 items-center justify-center rounded-m $tabButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'; $tabButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'; +// Tiles read "Fleet …" across all servers, but just "CPU / Memory / …" when scoped to one. +$scoped = $serverUuid !== ''; + $serverListboxOptions = array_merge( [['value' => '', 'label' => 'All servers']], collect($serverOptions)->map(fn ($name, $uuid) => ['value' => $uuid, 'label' => $name])->values()->all(), @@ -27,6 +30,10 @@ $serverListboxOptions = array_merge(
    +
    @@ -57,7 +64,8 @@ $serverListboxOptions = array_merge( @endforeach
    @@ -83,53 +91,128 @@ $serverListboxOptions = array_merge( @else {{-- Overview KPIs --}} + helper="{{ $scoped ? 'Current resource usage for the selected server.' : 'Current resource usage across the selected servers.' }}">
    Servers online {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} - @if (($kpis['needsAttention'] ?? 0) > 0) - {{ $kpis['needsAttention'] }} need attention - @endif + @php + $sTotal = max(1, (int) ($kpis['serversTotal'] ?? 0)); + $sOnline = (int) ($kpis['serversOnline'] ?? 0); + $sAttention = min($sOnline, (int) ($kpis['needsAttention'] ?? 0)); + $sHealthy = max(0, $sOnline - $sAttention); + $sOffline = max(0, $sTotal - $sOnline); + @endphp +
    +
    +
    + @if ($sHealthy > 0)
    @endif + @if ($sAttention > 0)
    @endif + @if ($sOffline > 0)
    @endif +
    +
    + {{ $sHealthy }} healthy + @if ($sAttention > 0){{ $sAttention }} attention@endif + @if ($sOffline > 0){{ $sOffline }} offline@endif +
    +
    +
    - Fleet CPU + {{ $scoped ? 'CPU' : 'Fleet CPU' }} @if ($kpis['cpuApproximate'] ?? false) ~ @endif {{ round($kpis['cpuAvg'] ?? 0) }}% - @if ($kpis['cpuBusiest'] ?? null) - Busiest {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}% - @endif +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-cpu', + 'initial' => $chartData['cpuSpark'] ?? [], + 'color' => '#3b82f6', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'cpuSpark', + 'label' => $scoped ? 'CPU' : 'Fleet CPU', + 'format' => 'percent', + ]) +
    - Fleet memory + {{ $scoped ? 'Memory' : 'Fleet memory' }} {{ round($kpis['memPercent'] ?? 0) }}% - {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-memory', + 'initial' => $chartData['memSpark'] ?? [], + 'color' => '#8b5cf6', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'memSpark', + 'label' => 'Memory used', + 'format' => 'bytes', + ]) +
    - Fleet disk + {{ $scoped ? 'Disk' : 'Fleet disk' }} {{ round($kpis['diskPercent'] ?? 0) }}% - {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-disk', + 'initial' => $chartData['diskSpark'] ?? [], + 'color' => '#ef4444', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'diskSpark', + 'label' => $scoped ? 'Disk' : 'Fleet disk', + 'format' => 'percent', + ]) +
    - Fleet network + {{ $scoped ? 'Network' : 'Fleet network' }} {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s - ↓{{ formatBytes($kpis['netRx'] ?? 0) }} ↑{{ formatBytes($kpis['netTx'] ?? 0) }} +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-network', + 'initial' => $chartData['netSpark'] ?? [], + 'color' => '#10b981', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'netSpark', + 'label' => 'Network', + 'format' => 'bytes', + ]) +
    Containers {{ compactNumber($kpis['containers'] ?? 0) }} +
    +
    + @if (! empty($topContainers)) + @php + $hottest = $this->containerMeta($topContainers[0]['id']); + $hottestValue = match ($containerMetric) { + 'memory' => formatBytes($topContainers[0]['memUsed'] ?? 0), + 'disk' => formatBytes($topContainers[0]['diskBytes'] ?? 0), + 'network' => formatBytes($topContainers[0]['net'] ?? 0).'/s', + default => round($topContainers[0]['cpu'] ?? 0).'%', + }; + @endphp + {{ $hottest['name'] }} + hottest, {{ $hottestValue }} + @endif +
    +
    {{-- Trend charts --}} + {{-- CPU is the primary triage signal, so it spans full width; the other four + fleet series fall into a clean 2x2 below (no orphaned cell). --}}
    @foreach (['cpu' => 'CPU', 'memory' => 'Memory used', 'network' => 'Network throughput', 'load' => 'Load average', 'disk' => 'Disk usage'] as $key => $label) - + $key === 'cpu'])>
    @@ -151,7 +234,8 @@ $serverListboxOptions = array_merge( Containers
    @foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row) -
    +
    {{ $row['name'] }} @@ -172,7 +256,7 @@ $serverListboxOptions = array_merge( {{-- Hottest containers --}} + helper="{{ $scoped ? 'Busiest containers on this server, ranked by the selected metric.' : 'Busiest containers across the fleet, ranked by the selected metric.' }}">
    @else + @php + $metricOf = fn ($c) => match ($containerMetric) { + 'memory' => $c['memUsed'] ?? 0, + 'disk' => $c['diskBytes'] ?? 0, + 'network' => $c['net'] ?? 0, + default => $c['cpu'] ?? 0, + }; + // Bars are scaled to the leader so the row length reads as "share of the hottest". + $maxMetric = max(1, collect($topContainers)->map($metricOf)->max()); + @endphp
    + # Container Server {{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }}
    @foreach ($topContainers as $c) - @php $meta = $this->containerMeta($c['id']); @endphp -
    + @php + $meta = $this->containerMeta($c['id']); + $pct = min(100, round($metricOf($c) / $maxMetric * 100, 1)); + $display = match ($containerMetric) { + 'memory' => formatBytes($c['memUsed'] ?? 0), + 'disk' => formatBytes($c['diskBytes'] ?? 0), + 'network' => formatBytes($c['net'] ?? 0).'/s', + default => round($c['cpu'] ?? 0).'%', + }; + @endphp +
    +
    {{ $loop->iteration }}
    @if ($meta['link']) {{ $meta['name'] }} @@ -215,13 +321,12 @@ $serverListboxOptions = array_merge( @endif
    {{ $c['server'] }}
    -
    - @switch($containerMetric) - @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break - @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break - @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break - @default{{ round($c['cpu'] ?? 0) }}% - @endswitch +
    + + {{ $display }}
    @endforeach diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 3592a1528d..edf970ffae 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -59,30 +59,34 @@ - -
    -
    + {{-- CPU is the primary signal, so it spans full width; the other four + charts fall into a 2x2 below (mirrors the fleet /metrics layout). --}} +
    + +
    +
    - -
    -
    + +
    +
    - -
    -
    + +
    +
    - -
    -
    + +
    +
    - -
    -
    + +
    +
    +
    @script + +
    + +
    +
    + {#if cv.target} + + + {/if} + Deploy canvas + Beta +
    +
    + +
    + + + + +
    + + +
    +
    + + {#if noticeOpen} +
    + +

    The canvas is not optimized for mobile yet. It works best on a larger screen.{#if cv.target}{' On a phone, use the '}New resource page.{/if}

    + +
    + {/if} + + + +
    +
    zoomLabel}> +
    + + {#each cv.flow.nodes as n (n.id)} + + {/each} + {#if resultNode} + +
    + + + {#if cv.result.urls.length} + {#each cv.result.urls.slice(0, 3) as url} + {url.replace(/^https?:\/\//, '')} + {/each} + {:else} + Open in Coolify + {/if} + +
    + {/if} +
    +
    + + {#if cv.menu} +
    +
    Add the next step
    + {#each cv.menu.valid as type} + pickNext(type)} /> + {/each} +
    + {/if} + {#if cv.toast} +
    {cv.toast.text}
    + {/if} +
    + + {#if cv.dropdown} + {#key cv.dropdown?.trigger} + + {/key} + {/if} +
    diff --git a/resources/js/canvas/deploy/Dropdown.svelte b/resources/js/canvas/deploy/Dropdown.svelte new file mode 100644 index 0000000000..8806ded1a5 --- /dev/null +++ b/resources/js/canvas/deploy/Dropdown.svelte @@ -0,0 +1,137 @@ + + +
    + {#if items.length > 8} + (active = 0)} /> + {/if} +
    + {#each shown as [value, text, badge], i} + + {:else} +

    {q ? 'No match' : 'None yet'}

    + {/each} +
    + {#if add} +
    + {#if adding} +
    + + { if (e.key !== 'Escape') e.stopPropagation(); }} + /> + +
    +

    {problem}

    + {:else} + + {/if} +
    + {/if} +
    diff --git a/resources/js/canvas/deploy/Edges.svelte b/resources/js/canvas/deploy/Edges.svelte new file mode 100644 index 0000000000..8eac07e5f6 --- /dev/null +++ b/resources/js/canvas/deploy/Edges.svelte @@ -0,0 +1,48 @@ + + + + {#each lines as line} + {#if line.edge} + + + removeEdge(line.edge)}>Click to remove this connection + {/if} + + {/each} + {#if dragged} + + {/if} + diff --git a/resources/js/canvas/deploy/Field.svelte b/resources/js/canvas/deploy/Field.svelte new file mode 100644 index 0000000000..293457c860 --- /dev/null +++ b/resources/js/canvas/deploy/Field.svelte @@ -0,0 +1,105 @@ + + +{#if f.kind === 'note'} +

    {f.text}

    +{:else} +
    + + + {#if f.kind === 'segmented'} +
    + {#each f.options as [option, text]} + + {/each} +
    + {:else if f.kind === 'select'} + + {#if error}{error}{/if} + {:else if f.kind === 'textarea'} + + {:else} + set(e.currentTarget.value)} + onpointerdown={stop} + onkeydown={stop} + /> + {/if} +
    +{/if} diff --git a/resources/js/canvas/deploy/Icon.svelte b/resources/js/canvas/deploy/Icon.svelte new file mode 100644 index 0000000000..f81085b6da --- /dev/null +++ b/resources/js/canvas/deploy/Icon.svelte @@ -0,0 +1,25 @@ + + +{#if glyph} + {@html glyph} +{:else} + +{/if} diff --git a/resources/js/canvas/deploy/Node.svelte b/resources/js/canvas/deploy/Node.svelte new file mode 100644 index 0000000000..b6f36e6965 --- /dev/null +++ b/resources/js/canvas/deploy/Node.svelte @@ -0,0 +1,104 @@ + + +
    + +
    dragNode(n, e)}> + + {#if logoSrc && !broken[logoSrc]} + (broken[logoSrc] = true)} /> + {#if logo.dark}{/if} + {:else} + + {/if} + +
    {t.title}{t.sub}
    + {#if removable} + + {/if} +
    +
    + {#each fields as f (f)} + + {/each} + {#if n.type === 'deploy'} + {#if !plan.lines.length} +

    Every resource you connect to a Server shows here.

    + {/if} + {#each plan.lines as { source } (source.id)} + +
    + {/each} + +
    + + +
    + {/if} + {#if t.stage === 'config' && !sourceAbove(n.id, cv.flow)} +

    Connect it after a source step.

    + {/if} +
    + {#if n.type !== 'start'}{/if} + {#if st?.message}
    {st.message}
    {/if} + {#if n.type !== 'deploy'} + + startConnect(n, e)}> + {/if} + {#if blank} +
    Drag and drop to add a step
    + {/if} +
    diff --git a/resources/js/canvas/deploy/PaletteItem.svelte b/resources/js/canvas/deploy/PaletteItem.svelte new file mode 100644 index 0000000000..f7928cfb04 --- /dev/null +++ b/resources/js/canvas/deploy/PaletteItem.svelte @@ -0,0 +1,21 @@ + + + diff --git a/resources/js/canvas/deploy/RunPanel.svelte b/resources/js/canvas/deploy/RunPanel.svelte new file mode 100644 index 0000000000..77d5fc249e --- /dev/null +++ b/resources/js/canvas/deploy/RunPanel.svelte @@ -0,0 +1,48 @@ + + + diff --git a/resources/js/canvas/deploy/index.js b/resources/js/canvas/deploy/index.js new file mode 100644 index 0000000000..876d4487fd --- /dev/null +++ b/resources/js/canvas/deploy/index.js @@ -0,0 +1,18 @@ +// Deploy canvas (Svelte): mounted on the /canvas page into [data-deploy-canvas], +// on the first load and after Livewire SPA navigation. + +import { mount, unmount } from 'svelte'; +import { mountOnPage } from '../../canvas-core.js'; +import DeployCanvas from './DeployCanvas.svelte'; +import { init } from './state.svelte.js'; +import { injectStyles } from './styles.js'; + +export function initializeDeployCanvas() { + mountOnPage('[data-deploy-canvas]', '.cv-root', container => { + injectStyles(); + init(container); + container.replaceChildren(); + const app = mount(DeployCanvas, { target: container }); + return { root: container.querySelector('.cv-root'), unmount: () => unmount(app) }; + }); +} diff --git a/resources/js/canvas/deploy/state.svelte.js b/resources/js/canvas/deploy/state.svelte.js new file mode 100644 index 0000000000..666955915a --- /dev/null +++ b/resources/js/canvas/deploy/state.svelte.js @@ -0,0 +1,661 @@ +// Deploy canvas runtime: the state of the open canvas, what the steps do to +// it (add, connect, run…), and the Svelte actions around the imperative +// helpers of canvas-core.js (pan/zoom, top bar placement, card sizes). + +import { flushSync, tick } from 'svelte'; +import { api, freeSpot, MIN_ZOOM, panZoom } from '../../canvas-core.js'; +import { + canFollow, defaults, domainList, fresh, LOG_POLL_MS, lists, load, multiOut, nextOf, nodeById, outsOf, planOf, + resourceHref, save, sleep, targetFrom, TYPES, uid, uniqueLabels, withDeploy, withoutSecrets, withTarget, +} from '../../deploy-canvas.js'; + +export const PORT_Y = 26; // header centre (3.25rem / 2) +const CARD_W = 296; + +const emptyRun = () => ({ open: false, title: 'Deploy', sub: '', steps: [], panes: [], active: null, defaultHref: '', openHref: '', showOpen: false }); + +export const cv = $state({ + canAdd: false, // may create projects and environments (from the page) + target: null, // the environment that opened the canvas + flow: fresh(), + view: { x: 40, y: 40, z: 1 }, + runState: {}, // node id -> { state: running|done|failed, message } + invalid: {}, // node id -> true: a required field is empty + // The last successful run's addresses, shown as a pill after Deploy. + result: null, // { after: node id, urls: [], href } + running: false, // false, 'save' or 'deploy' + sizes: {}, // node id -> { w, h } of its card + listsVersion: 0, // bumped when an option list loads + menu: null, // the "Add the next step" menu: { prev, valid, p, left, top } + pendingLine: null, // the dragged line, kept while its menu is open + temp: null, // the line being dragged from a port + armed: null, // node id of the port being dragged + dropTarget: null, // node id the dragged line would connect to + toast: null, // { text, bad } + dropdown: null, // { trigger, items, current, onPick, add } + run: emptyRun(), // the run panel +}); + +// DOM of the open canvas (set by the actions below); mount counts the +// canvases, so a run of an earlier one stops writing here. +export const els = { root: null, stage: null, viewport: null, pz: null, nodeEls: new Map(), mount: 0, fitOnOpen: false }; + +lists.onChange = () => { cv.listsVersion++; }; + +export function init(container) { + els.mount++; + els.nodeEls.clear(); + cv.canAdd = container.dataset.canAdd === '1'; + if (load('deployed', false)) { + save('flow', null); + save('result', null); + save('deployed', false); + } + cv.target = targetFrom(location.search); + cv.flow = withTarget(withDeploy(load('flow', null) || fresh()), cv.target); + save('flow', withoutSecrets(cv.flow)); // drops secrets that older versions stored + // Small screens: the default view is made for desktop, so the first visit fits the steps instead. + els.fitOnOpen = !load('view', null) && !matchMedia('(min-width: 768px)').matches; + cv.view = load('view', { x: 40, y: 40, z: 1 }); + cv.result = load('result', null); + Object.assign(cv, { runState: {}, invalid: {}, running: false, sizes: {}, menu: null, pendingLine: null, temp: null, armed: null, dropTarget: null, toast: null, dropdown: null, run: emptyRun() }); +} + +/** The canvas left the page: its runs stop. */ +export function leave() { + els.mount++; +} + +// ── Actions ──────────────────────────────────────────────────────────── + +/** use:pan on the viewport: wheel and pinch zoom, drag to pan. zoomLabel() is the zoom button. */ +export function pan(viewport, zoomLabel) { + const world = viewport.querySelector('.cv-world'); + const edges = viewport.querySelector('.cv-edges'); + els.viewport = viewport; + els.stage = viewport.parentElement; + els.root = viewport.closest('.cv-root'); + els.pz = panZoom({ + root: els.root, + viewport, + world, + // The button can sit in the app top bar (see dock), so it is looked up on each write. + zoomLabel: { set textContent(text) { const label = zoomLabel(); if (label) label.textContent = text; } }, + getView: () => cv.view, + onChange: closeDropdown, + canPan: e => [viewport, world, edges].includes(e.target), + onPanStart: closeMenu, + }); +} + +const DESKTOP = '(min-width: 1024px)'; +/** + * use:dock on the header. Desktop: it docks into the app top bar, or with the + * floating nav it is a separate panel next to the top bar (as on the + * environment canvas) when it fits there. Small screens: a box above the canvas. + */ +export function dock(top) { + const root = top.closest('.cv-root'); + const stage = root.querySelector('.cv-stage'); + function place() { + const desktop = matchMedia(DESKTOP).matches; + const slot = desktop && document.getElementById('page-toolbar-slot'); + const topbar = document.querySelector('.app-topbar'); + top.removeAttribute('style'); + if (desktop && topbar && document.documentElement.dataset.navStyle === 'floating') { + top.className = 'fixed z-40 flex items-center gap-3 rounded-xl border border-neutral-200 bg-white px-2 py-1.5 dark:border-white/[0.08] dark:bg-panel'; + root.append(top); + const gap = 8; + const head = topbar.getBoundingClientRect(); + const width = top.offsetWidth; + if (head.right + gap + width <= innerWidth - gap) { + // Centered over the canvas, but never over the top bar. + const box = stage.getBoundingClientRect(); + const left = Math.max(box.left + (box.width - width) / 2, head.right + gap); + Object.assign(top.style, { top: `${head.top}px`, left: `${left}px`, minHeight: `${head.height}px` }); + root.classList.add('cv-docked'); + return; + } + top.removeAttribute('style'); + } + top.className = slot ? 'flex items-center gap-3 pl-3' : 'cv-island cv-top'; + root.classList.toggle('cv-docked', !!slot); + if (slot) slot.replaceChildren(top); + else root.prepend(top); + } + place(); + const replace = () => root.isConnected && place(); + // The floating header is centered over the canvas next to the app top bar, so follow their sizes. + const sizes = new ResizeObserver(replace); + for (const target of [stage, document.querySelector('.app-topbar')].filter(Boolean)) sizes.observe(target); + const media = matchMedia(DESKTOP); + media.addEventListener('change', replace); + addEventListener('resize', replace); + const navStyle = new MutationObserver(replace); + navStyle.observe(document.documentElement, { attributes: true, attributeFilter: ['data-nav-style'] }); + return { + destroy() { + sizes.disconnect(); + media.removeEventListener('change', replace); + removeEventListener('resize', replace); + navStyle.disconnect(); + top.remove(); // it may sit in the app top bar + }, + }; +} + +/** use:track on a card: its element (for measuring) and its size (for the lines). */ +export function track(node, id) { + els.nodeEls.set(id, node); + const measure = () => { cv.sizes[id] = { w: node.offsetWidth, h: node.offsetHeight }; }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(node); + return { + destroy() { + observer.disconnect(); + if (els.nodeEls.get(id) === node) els.nodeEls.delete(id); + delete cv.sizes[id]; + }, + }; +} + +// ── Canvas: geometry, fit ────────────────────────────────────────────── +// Port positions in world coordinates. +export const outPoint = n => ({ x: n.x + (cv.sizes[n.id]?.w || CARD_W), y: n.y + PORT_Y }); +export const inPoint = n => ({ x: n.x, y: n.y + PORT_Y }); +export const curve = (a, b) => { + const dx = Math.max(40, Math.abs(b.x - a.x) / 2); + return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`; +}; +const toWorld = (cx, cy) => els.pz.toWorld(cx, cy); +const width = n => els.nodeEls.get(n.id)?.offsetWidth || CARD_W; +const height = n => els.nodeEls.get(n.id)?.offsetHeight || 160; +// A card's box in world units (its rendered size, or a usual card size). +const boxOf = n => ({ x: n.x, y: n.y, w: width(n), h: height(n) }); + +export function fit() { + if (!els.nodeEls.size) return; + let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; + for (const n of cv.flow.nodes) { + const node = els.nodeEls.get(n.id); + minX = Math.min(minX, n.x); + minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x + (node?.offsetWidth || 280)); + maxY = Math.max(maxY, n.y + (node?.offsetHeight || 120)); + } + const pill = els.root.querySelector('.cv-result'); + if (pill) { + maxX = Math.max(maxX, pill.offsetLeft + pill.offsetWidth); + maxY = Math.max(maxY, pill.offsetTop + pill.offsetHeight); + } + const r = els.viewport.getBoundingClientRect(); + const pad = 60; + const view = cv.view; + view.z = Math.min(1.2, Math.max(MIN_ZOOM, Math.min((r.width - pad * 2) / (maxX - minX), (r.height - pad * 2) / (maxY - minY)))); + view.x = (r.width - (maxX - minX) * view.z) / 2 - minX * view.z; + view.y = (r.height - (maxY - minY) * view.z) / 2 - minY * view.z; + els.pz.apply(); +} + +// ── Toast, menus, dropdown ───────────────────────────────────────────── +let toastTimer = 0; +export function toast(text, bad = false) { + cv.toast = { text, bad }; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { cv.toast = null; }, bad ? 4200 : 2600); +} + +export function closeMenu() { + cv.menu = null; + cv.pendingLine = null; +} +// Close an older menu but keep the line being dropped. +function openNextMenu(prev, cx, cy, p) { + cv.menu = null; + const valid = Object.keys(TYPES).filter(type => canFollow(prev, type, null, cv.flow)); + if (!valid.length) return toast('Nothing can follow this step.', true); + const r = els.stage.getBoundingClientRect(); + cv.menu = { prev: prev.id, valid, p, left: Math.min(cx - r.left, r.width - 220), top: Math.min(cy - r.top, r.height - 40 * valid.length - 40) }; +} +export function pickNext(type) { + const { prev, p } = cv.menu; + closeMenu(); + const n = addNode(type, p.x + 8, p.y - PORT_Y, true); + connect(prev, n.id); +} + +// Our dropdown panel (fixed in the canvas root, so zoom never shrinks it). +export function closeDropdown() { + cv.dropdown = null; +} +export function openDropdown(trigger, items, current, onPick, add = null) { + if (cv.dropdown?.trigger === trigger) return closeDropdown(); + cv.dropdown = { trigger, items, current, onPick, add }; +} + +// ── Flow edits ───────────────────────────────────────────────────────── +// A structural change redraws the cards, which clears their invalid marks. +const redraw = () => { cv.invalid = {}; }; + +export function connect(from, to) { + const a = nodeById(from, cv.flow); + cv.flow.edges = cv.flow.edges.filter(e => !(e.from === from && e.to === to) && (multiOut(a) || e.from !== from) && e.to !== to); + cv.flow.edges.push({ from, to }); + redraw(); +} +export function removeEdge(edge) { + if (cv.running) return; + cv.flow.edges = cv.flow.edges.filter(e => e !== edge); + redraw(); + toast('Connection removed.'); +} +export function addNode(type, x, y, quiet) { + cv.flow.nodes.push({ id: uid(), type, x: Math.round(x), y: Math.round(y), data: defaults(type) }); + const n = cv.flow.nodes.at(-1); + redraw(); + flushSync(); // render the card, to measure it + settle(n); + if (!quiet) toast(`${TYPES[type].title} added. Connect it from the dot of the step before.`); + return n; +} +export function removeNode(id) { + if (cv.running) return; + cv.flow.nodes = cv.flow.nodes.filter(n => n.id !== id); + cv.flow.edges = cv.flow.edges.filter(e => e.from !== id && e.to !== id); + delete cv.runState[id]; + redraw(); +} +export function resetCanvas() { + if (cv.running) return; + cv.flow = withTarget(fresh(), cv.target); + cv.runState = {}; + cv.result = null; + save('result', null); + save('deployed', false); + redraw(); +} +export function hideResult() { + cv.result = null; + save('result', null); +} +// A field change. Fields loaded from the one changed (`rerender`) start empty again. +export function setField(n, f, value) { + const fields = TYPES[n.type].fields; + n.data[f.key] = value; + f.after?.(n.data); + if (f.rerender) { + for (const other of fields) if (other.load && other !== f && other.key !== f.key && fields.indexOf(other) > fields.indexOf(f)) delete n.data[other.key]; + } + delete cv.invalid[n.id]; +} + +// Move a card to the nearest spot where it does not cover another card. +function settle(n) { + const spot = freeSpot(boxOf(n), cv.flow.nodes.filter(m => m !== n).map(boxOf)); + if (!spot || (spot.x === n.x && spot.y === n.y)) return; + n.x = Math.round(spot.x); + n.y = Math.round(spot.y); +} +// Click on a step in the list: add it after the step it most likely follows. +function appendToChain(type) { + const flow = cv.flow; + const plan = planOf(flow); + // The newest step in the tree this one can follow, with room for it. + const inTree = [plan.start, ...plan.servers, ...plan.lines.flatMap(l => [l.source, ...l.settings])]; + const parent = inTree.reverse().find(n => canFollow(n, type, null, flow) && (multiOut(n) || !nextOf(n.id, flow))); + if (!parent) return addNode(type, ...centre()); + const siblings = multiOut(parent) ? outsOf(parent.id, flow).map(id => nodeById(id, flow)).filter(Boolean) : []; + const y = siblings.length ? Math.max(...siblings.map(n => n.y + height(n))) + 40 : parent.y; + const n = addNode(type, parent.x + width(parent) + 80, y, true); + connect(parent.id, n.id); +} +function centre() { + const r = els.viewport.getBoundingClientRect(); + const p = toWorld(r.left + r.width / 2, r.top + r.height / 2); + return [p.x - 140, p.y - 60]; +} + +// ── Pointer drags ────────────────────────────────────────────────────── + +/** A palette step: drag it to the canvas, or click to append it to the chain. */ +export function dragFromPalette(type, event) { + if (event.button !== 0) return; + const item = event.currentTarget; + const startX = event.clientX; + const startY = event.clientY; + let ghost = null; + const move = e => { + if (!ghost && Math.hypot(e.clientX - startX, e.clientY - startY) < 5) return; + if (!ghost) { + ghost = item.cloneNode(true); + Object.assign(ghost.style, { position: 'fixed', zIndex: 2147483001, width: `${item.offsetWidth}px`, pointerEvents: 'none', opacity: 0.9, background: 'var(--cv-ghost)', borderRadius: '8px', boxShadow: '0 8px 24px -8px rgb(0 0 0 / 0.35)' }); + ghost.style.setProperty('--cv-ghost', 'light-dark(#fff, #222)'); + els.root.append(ghost); + } + ghost.style.left = `${e.clientX - 20}px`; + ghost.style.top = `${e.clientY - 18}px`; + }; + const stop = () => { + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + removeEventListener('pointercancel', cancel); + }; + // Touch: a sideways swipe scrolls the step strip (small screens) and cancels the pointer. + const cancel = () => { + stop(); + ghost?.remove(); + }; + const up = e => { + stop(); + if (!ghost) return appendToChain(type); + ghost.remove(); + const r = els.viewport.getBoundingClientRect(); + if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return; + const p = toWorld(e.clientX, e.clientY); + addNode(type, p.x - 30, p.y - 16); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); + addEventListener('pointercancel', cancel); +} + +/** + * Drag a card by its header. Pointer capture keeps the drag even when the + * pointer outruns the card. + */ +export function dragNode(n, event) { + if (event.button !== 0 || event.target.closest('button, input, select, textarea')) return; + const handle = event.currentTarget; + event.stopPropagation(); + event.preventDefault(); + closeMenu(); + try { handle.setPointerCapture(event.pointerId); } catch { /* not capturable */ } + const ox = event.clientX / cv.view.z - n.x; + const oy = event.clientY / cv.view.z - n.y; + // The other cards stay put during the drag, so measure them once. + const others = cv.flow.nodes.filter(m => m !== n).map(boxOf); + const move = e => { + if (e.pointerId !== event.pointerId) return; + const wanted = { x: e.clientX / cv.view.z - ox, y: e.clientY / cv.view.z - oy, w: width(n), h: height(n) }; + // Cards cannot cover each other: stop at the edge of a card in the way. + const spot = freeSpot(wanted, others); + if (!spot) return; + n.x = Math.round(spot.x); + n.y = Math.round(spot.y); + }; + const up = e => { + if (e.pointerId !== event.pointerId) return; + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + removeEventListener('pointercancel', up); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); + addEventListener('pointercancel', up); +} + +// Drag from an output port: drop on a node to connect, on empty space to pick. +export function startConnect(n, event) { + event.stopPropagation(); + event.preventDefault(); + if (cv.running) return; + cv.armed = n.id; + const a = outPoint(n); + let target = null; + const move = e => { + cv.temp = { a, b: toWorld(e.clientX, e.clientY) }; + const hit = document.elementFromPoint(e.clientX, e.clientY)?.closest?.('.cv-node'); + const id = hit?.dataset.id; + const candidate = id && id !== n.id ? nodeById(id, cv.flow) : null; + target = candidate && canFollow(n, candidate.type, candidate, cv.flow) ? candidate : null; + cv.dropTarget = target?.id ?? null; + }; + const up = e => { + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + cv.armed = null; + cv.temp = null; + cv.dropTarget = null; + if (target) return connect(n.id, target.id); + const over = document.elementFromPoint(e.clientX, e.clientY); + if (over?.closest?.('.cv-node')) return toast('That step cannot follow this one.', true); + const p = toWorld(e.clientX, e.clientY); + if (Math.hypot(p.x - a.x, p.y - a.y) < 12) return; + cv.pendingLine = { a, b: p }; + openNextMenu(n, e.clientX, e.clientY, p); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); +} + +// ── Run ──────────────────────────────────────────────────────────────── +// Log panes: "all" holds the steps; each deployment gets its own tab and pane. +const paneOf = key => cv.run.panes.find(pane => pane.key === key); +function openPane(key, label) { + if (!paneOf(key)) cv.run.panes.push({ key, label, href: '', state: '', status: '', lines: [] }); + return paneOf(key); +} +export function showPane(key) { + const r = cv.run; + r.active = key; + const href = paneOf(key)?.href || r.defaultHref; + if (href) r.openHref = href; +} +function setPaneState(key, state, status) { + const pane = paneOf(key); + if (!pane) return; + pane.state = state; + pane.status = status; +} +function logLine(text, cls = '', key = 'all') { + const pane = paneOf(key) || paneOf('all'); + const box = els.root?.querySelector(`.cv-logs[data-key="${pane.key}"]`); + const atEnd = !box || box.scrollTop + box.clientHeight >= box.scrollHeight - 30; + pane.lines.push({ text, cls }); + if (box && atEnd) tick().then(() => { box.scrollTop = box.scrollHeight; }); +} +function setStep(n, state, message) { + cv.runState[n.id] = { state, message }; + delete cv.invalid[n.id]; + const row = cv.run.steps.find(s => s.id === n.id); + if (row) { + row.state = state; + row.message = message || ''; + } +} + +function validate(plan) { + const problems = []; + if (!plan.servers.length) problems.push('Connect a Server right after Start.'); + else if (!plan.lines.length) problems.push('Connect a source step (Git repository, Docker image, Dockerfile, Service or Database) to a Server.'); + for (const n of [plan.start, ...new Set(plan.lines.map(l => l.server)), ...plan.lines.flatMap(l => [l.source, ...l.settings])]) { + const missing = TYPES[n.type].fields.filter(f => f.required && (!f.show || f.show(n.data)) && !String(n.data[f.key] ?? '').trim()).map(f => f.label); + if (missing.length) { + problems.push(`${TYPES[n.type].title}: fill in ${missing.join(', ')}.`); + cv.invalid[n.id] = true; + } + } + return problems; +} + +export async function runFlow(action = 'deploy') { + if (cv.running) return; + closeMenu(); + const plan = planOf(cv.flow); + const problems = validate(plan); + if (problems.length) { + toast(problems[0], true); + return; + } + cv.running = action; + cv.runState = {}; + cv.result = null; + save('result', null); + save('deployed', false); + const servers = [...new Set(plan.lines.map(l => l.server))]; + const list = [plan.start, ...servers, ...plan.lines.flatMap(l => [l.source, ...l.settings]), plan.deploy]; + const names = uniqueLabels(plan.lines.map(l => l.source)); + const labelOf = n => names.get(n.id) || TYPES[n.type].title; + const r = cv.run; + r.open = true; + r.title = action === 'save' ? 'Saving' : 'Deploying'; + r.sub = `${plan.lines.length} resource${plan.lines.length === 1 ? '' : 's'}`; + r.panes = []; + r.active = null; + r.defaultHref = ''; + openPane('all', 'Steps'); + showPane('all'); + r.showOpen = false; + r.steps = list.map(n => ({ id: n.id, label: labelOf(n), state: '', message: 'Waiting' })); + redraw(); + // The page can be left (and the canvas mounted again) during a run. + const owner = els.mount; + const live = () => els.mount === owner; + // Run one step; false when it failed. + const step = async (n, work) => { + setStep(n, 'running', 'Running…'); + try { + const message = await work(); + if (!live()) return false; + setStep(n, 'done', message); + logLine(`✓ ${labelOf(n)}: ${message}`, 'dim'); + return true; + } catch (error) { + if (!live()) return false; + setStep(n, 'failed', error.message); + logLine(`✗ ${labelOf(n)}: ${error.message}`, 'err'); + return false; + } + }; + const base = {}; + let ok = await step(plan.start, () => TYPES.start.run(base, plan.start.data)); + for (const server of servers) { + if (!ok) break; + ok = await step(server, async () => 'Server picked.'); + } + const shipped = []; // { line, ctx, label } per created resource + for (const line of plan.lines) { + if (!ok) break; + const ctx = { ...base }; + await TYPES.server.run(ctx, line.server.data); + const domainNode = line.settings.find(n => n.type === 'domain'); + if (domainNode) ctx.domains = domainList(domainNode.data.domains); + for (const n of [line.source, ...line.settings]) { + ok = await step(n, () => TYPES[n.type].run(ctx, n.data)); + if (!ok) break; + } + if (ctx.resource) shipped.push({ line, ctx, label: names.get(line.source.id) }); + } + if (!live()) return; + const href = () => shipped.length === 1 ? resourceHref(shipped[0].ctx) : `/project/${base.project}/environment/${base.environment}`; + if (shipped.length) { + r.showOpen = true; + r.openHref = r.defaultHref = href(); + } + if (ok) { + ok = await step(plan.deploy, async () => action === 'save' + ? `Saved ${shipped.length} resource${shipped.length === 1 ? '' : 's'}. Not deployed.` + : deployAll(shipped, live)); + } + if (!live()) return; + if (shipped.length) r.defaultHref = href(); + showPane(r.active); + if (ok) { + const urls = []; + for (const { ctx } of action === 'save' ? [] : shipped) { + let found = String(ctx.domains || ctx.createdDomains || '').split(',').map(v => v.trim()).filter(Boolean); + if (!found.length && ctx.resource.kind === 'application') { + try { + const app = await api('GET', `/applications/${ctx.resource.uuid}`); + found = String(app?.fqdn || '').split(',').map(v => v.trim()).filter(Boolean); + } catch { /* keep none */ } + } + urls.push(...found); + } + cv.result = { after: plan.deploy.id, urls, href: href() }; + save('result', cv.result); + // The next visit starts with an empty canvas. + if (action === 'deploy') save('deployed', true); + } + r.title = ok ? 'Done' : 'Stopped'; + r.sub = ok ? (action === 'save' ? 'Saved in Coolify. Deploy them from there or from here.' : 'Every step finished.') : 'A step failed. Fix it and try again.'; + cv.running = false; +} + +// Deploy every resource at once and follow them. +async function deployAll(shipped, live) { + const many = shipped.length > 1; + // A tab per resource, in order; the first one opens. + for (const { line, ctx, label } of shipped) openPane(line.source.id, label).href = resourceHref(ctx); + if (shipped.length) showPane(shipped[0].line.source.id); + const results = await Promise.allSettled(shipped.map(async ({ line, ctx, label }) => { + const key = line.source.id; + setPaneState(key, 'running', 'Starting'); + try { + const res = await api('POST', '/deploy', { uuid: ctx.resource.uuid }); + if (!live()) return 'Left the canvas.'; + const item = res?.deployments?.[0]; + let message = item?.message || 'Started.'; + if (item?.deployment_uuid) { + ctx.deployment = item.deployment_uuid; + paneOf(key).href = resourceHref(ctx); + if (cv.run.active === key) showPane(key); + message = await followDeployment(label, item.deployment_uuid, key, live); + if (!live()) return message; + } else { + logLine(message, '', key); + } + setPaneState(key, 'done', message); + logLine(`✓ ${label}: ${message}`, 'dim'); + return `${label}: ${message}`; + } catch (error) { + if (live()) { + setPaneState(key, 'failed', error.message.replace(`${label}: `, '')); + logLine(error.message, 'err', key); + } + throw error; + } + })); + const failed = results.filter(res => res.status === 'rejected').map(res => res.reason.message); + if (failed.length) throw new Error(failed.join(' · ')); + return many ? `${results.length} resources deployed.` : results[0].value; +} + +// Poll the deployment (Coolify's own log view also polls every 2 s). +// Logs go to the resource's own pane (key). +async function followDeployment(label, deploymentUuid, key, live) { + logLine(`Deployment ${deploymentUuid}`, 'dim', key); + let shown = 0; + let warned = false; + for (;;) { + if (!live()) return 'Left the canvas.'; + let dep; + try { + dep = await api('GET', `/deployments/${deploymentUuid}`); + } catch (error) { + if (!live()) return 'Left the canvas.'; + logLine(`(could not read the deployment: ${error.message})`, 'err', key); + await sleep(LOG_POLL_MS * 2); + continue; + } + if (!live()) return 'Left the canvas.'; + let entries = []; + if (typeof dep.logs === 'string') { + try { entries = JSON.parse(dep.logs) || []; } catch { entries = []; } + } else if (dep.logs === undefined && !warned) { + warned = true; + logLine('Logs are hidden: only team admins can see deployment logs.', 'err', key); + } + const visible = entries.filter(entry => !entry.hidden); + for (const entry of visible.slice(shown)) { + for (const line of String(entry.output ?? '').split('\n')) if (line.trim()) logLine(line, entry.type === 'stderr' ? 'err' : '', key); + } + shown = visible.length; + setPaneState(key, 'running', String(dep.status || '').replace(/_/g, ' ')); + if (dep.status === 'finished') return 'Deployed.'; + if (dep.status === 'failed') throw new Error(`${label}: deployment failed. See the logs.`); + if (dep.status === 'cancelled-by-user') throw new Error(`${label}: deployment cancelled.`); + await sleep(LOG_POLL_MS); + } +} diff --git a/resources/js/canvas/deploy/styles.js b/resources/js/canvas/deploy/styles.js new file mode 100644 index 0000000000..e04d9951cd --- /dev/null +++ b/resources/js/canvas/deploy/styles.js @@ -0,0 +1,319 @@ +// The deploy canvas look: islands on a backdrop. The colors are CSS variables +// on .cv-root, so a custom color scheme can replace them. Injected once as a +// diff --git a/resources/js/canvas/environment/Icon.svelte b/resources/js/canvas/environment/Icon.svelte new file mode 100644 index 0000000000..8a715697fa --- /dev/null +++ b/resources/js/canvas/environment/Icon.svelte @@ -0,0 +1,10 @@ + + + diff --git a/resources/js/canvas/environment/NodeIcon.svelte b/resources/js/canvas/environment/NodeIcon.svelte new file mode 100644 index 0000000000..920d30b42a --- /dev/null +++ b/resources/js/canvas/environment/NodeIcon.svelte @@ -0,0 +1,37 @@ + + +{#if faviconShown} + +{:else} + {#if !logo?.src || broken} + + {:else} + + + {#if logo.dark} + + {/if} + + {/if} + {#if logo?.favicon && !failed[logo.favicon]} + (loadedFavicon = logo.favicon)} onerror={() => (failed[logo.favicon] = true)} /> + {/if} +{/if} diff --git a/resources/js/canvas/environment/Panel.svelte b/resources/js/canvas/environment/Panel.svelte new file mode 100644 index 0000000000..8086ac4bcd --- /dev/null +++ b/resources/js/canvas/environment/Panel.svelte @@ -0,0 +1,324 @@ + + +{#snippet section(title, content)} +
    +

    {title}

    + {@render content()} +
    +{/snippet} + +{#snippet note(content)} +

    {content}

    +{/snippet} + +{#snippet shell(title, iconSource, body, footer)} + +{/snippet} + +{#snippet meta(n)} +
    + {TYPE_LABELS[n.type] || n.type} + {statusLabel(n.status)} +
    +{/snippet} + +{#snippet openButton(n, cls, label)} + {label} +{/snippet} + +{#snippet removeButton(l)} + {#if canEdit} + + {/if} +{/snippet} + +{#if connectView && node && isSource(node)} + {#snippet body()} +
    + {#if node.reference}{node.reference}{/if} + {#if node.missing}Deleted{/if} +
    + {#if node.missing} + {@render note(`This shared variable was deleted. The resources below still reference it and get the text ${node.reference} instead of a value. Add the variable again, or remove the reference.`)} + {:else if node.subtitle} + {#snippet description()}

    {node.subtitle}

    {/snippet} + {@render section('Description', description)} + {/if} + {#snippet used()} +
      + {#if !usedBy.length}
    • {text.noTargets}
    • {/if} + {#each usedBy as l (l.id)} + {@const resource = nodeById(l.target)} +
    • + + {@render removeButton(l)} +
    • + {/each} +
    + {/snippet} + {@render section(`${text.usedBy} (${usedBy.length})`, used)} + {#if canEdit && !node.missing} + {@const linked = new Set(usedBy.map(l => l.target))} + !isSource(n) && !linked.has(n.id)), + resource => ({ title: `Add ${node.name} to ${resource.name}`, sub: resource.context || '' }), + resource => oncreate(node.id, resource.id))} /> + {/if} + {#if !node.missing}{@render note(canEdit ? text.note(node) : text.viewOnly)}{/if} + {/snippet} + {#snippet footer()}{@render openButton(node, '', `Open ${TYPE_LABELS[node.type].toLowerCase()}`)}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if connectView && node} + {#snippet body()} + {@render meta(node)} + {#if node.context} + {#snippet context()}{node.context}{/snippet} + {@render section('Project / environment', context)} + {/if} + {#snippet list()} +
      + {#if !sources.length}
    • {text.noSources}
    • {/if} + {#each sources as l (l.id)} + {@const source = nodeById(l.source)} +
    • + + {@render removeButton(l)} +
    • + {/each} +
    + {/snippet} + {@render section(`${text.sources} (${sources.length})`, list)} + {#if canEdit} + {@const linked = new Set(sources.map(l => l.source))} + isSource(n) && !n.missing && !linked.has(n.id)), + source => ({ title: `Add ${source.name} to ${node.name}`, sub: source.type === 'variable' ? source.subtitle || '' : '' }), + source => oncreate(source.id, node.id))} /> + {/if} + {/snippet} + {#snippet footer()}{@render openButton(node, 'button-highlighted', 'Open')}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if connectView && link} + {@const variable = nodeById(link.source)} + {@const resource = nodeById(link.target)} + {#snippet body()} + {#snippet ends()} +
    + {#each [[text.source, variable], ['Resource', resource]] as [endLabel, end] (endLabel)} + + {/each} +
    + {/snippet} + {@render section('Ends', ends)} + {#if link.keys?.length} + {#snippet keys()} +
    + {#each link.keys as key (key)}{key}{/each} +
    + {/snippet} + {@render section(`Variables on ${resource?.name || 'the resource'}`, keys)} + {/if} + {#if link.missing}{@render note(`The shared variable was deleted. ${resource?.name} gets the text ${variable?.reference} instead of a value.`)}{/if} + {#if canEdit && !link.removable}{@render note(removeHint(link))}{/if} + {/snippet} + {#snippet footer()} + + {/snippet} + {@render shell(`${variable?.name || text.source} → ${resource?.name || 'Resource'}`, 'link', body, canEdit ? footer : null)} +{:else if node} + {#snippet body()} + {@render meta(node)} + {#if node.subtitle} + {#snippet details()}

    {node.subtitle}

    {/snippet} + {@render section('Details', details)} + {/if} + {#if node.urls?.length} + {#snippet urls()} + + {/snippet} + {@render section(node.urls.length === 1 ? 'URL' : 'URLs', urls)} + {/if} + {#snippet serverList()}

    {servers.length ? servers.join(', ') : NO_SERVER}

    {/snippet} + {@render section(servers.length > 1 ? 'Servers' : 'Server', serverList)} + {#if node.children?.length} + {#snippet containers()} +
      + {#each node.children as child, i (i)} +
    • + + {child.name} + {TYPE_LABELS[child.type] || child.type || ''} +
    • + {/each} +
    + {/snippet} + {@render section('Containers', containers)} + {/if} + {#if node.volumes?.length} + {#snippet volumes()} +
    + {#each node.volumes as v, i (i)}{v}{/each} +
    + {/snippet} + {@render section('Volumes', volumes)} + {/if} + {#snippet linkList()} +
      + {#if !nodeLinks.length}
    • {canEdit ? 'No links. Drag the dot on the right edge of a card to another card.' : 'No links.'}
    • {/if} + {#each nodeLinks as l (l.id)} +
    • + +
    • + {/each} +
    + {/snippet} + {@render section('Links', linkList)} + {/snippet} + {#snippet footer()}{@render openButton(node, 'button-highlighted', 'Open')}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if link} + {#snippet body()} + {#snippet ends()} +
    + {#each [['From', link.source], ['To', link.target]] as [endLabel, id] (endLabel)} + {@const end = nodeById(id)} + + {/each} +
    + {/snippet} + {@render section('Resources', ends)} + {#snippet labelField()} + {#if canEdit} + { + if (e.key === 'Enter') { + e.preventDefault(); + labelInput.blur(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + label = saved; + labelInput.blur(); + } + }} + onblur={saveLabel} + /> + {:else} +

    {link.label || 'No label'}

    + {/if} + {/snippet} + {@render section('Label', labelField)} + {@render note('Links are for the diagram only. They do not change your resources.')} + {/snippet} + {#snippet footer()} + + {/snippet} + {@render shell(link.label || 'Link', 'link', body, canEdit ? footer : null)} +{/if} diff --git a/resources/js/canvas/environment/StatusDot.svelte b/resources/js/canvas/environment/StatusDot.svelte new file mode 100644 index 0000000000..26bed75873 --- /dev/null +++ b/resources/js/canvas/environment/StatusDot.svelte @@ -0,0 +1,13 @@ + + + + {#if tone === 'busy'} + + {/if} + + diff --git a/resources/js/canvas/environment/Toolbar.svelte b/resources/js/canvas/environment/Toolbar.svelte new file mode 100644 index 0000000000..f286c791a0 --- /dev/null +++ b/resources/js/canvas/environment/Toolbar.svelte @@ -0,0 +1,51 @@ + + +
    +
    + {#if title}{title}{/if} + + Beta + + View only +
    + +
    + + + + +
    + + + +
    + +
    +
    diff --git a/resources/js/canvas/environment/Zone.svelte b/resources/js/canvas/environment/Zone.svelte new file mode 100644 index 0000000000..2bbc915609 --- /dev/null +++ b/resources/js/canvas/environment/Zone.svelte @@ -0,0 +1,32 @@ + + +
    + +
    diff --git a/resources/js/canvas/environment/index.js b/resources/js/canvas/environment/index.js new file mode 100644 index 0000000000..5cb79c380a --- /dev/null +++ b/resources/js/canvas/environment/index.js @@ -0,0 +1,11 @@ +// Environment canvas page mount (initial load and Livewire SPA navigation). +import { mount, unmount } from 'svelte'; +import { mountOnPage } from '../../canvas-core.js'; +import EnvironmentCanvas from './EnvironmentCanvas.svelte'; + +export function initializeEnvironmentCanvas() { + mountOnPage('[data-environment-canvas]', '[data-ec-root]', container => { + const app = mount(EnvironmentCanvas, { target: container, props: { container } }); + return { root: container.querySelector('[data-ec-root]'), unmount: () => unmount(app) }; + }); +} diff --git a/resources/js/deploy-canvas.js b/resources/js/deploy-canvas.js new file mode 100644 index 0000000000..82d28ccd52 --- /dev/null +++ b/resources/js/deploy-canvas.js @@ -0,0 +1,510 @@ +/** + * Deploy canvas: build a deploy workflow (project → server → source → + * settings → deploy) from nodes, run it through Coolify's API controllers + * (at /canvas/api, with the browser session) and follow the deployment logs. + * + * Mounted on the /canvas page into [data-deploy-canvas] by + * canvas/deploy/index.js (Svelte components). This file keeps the DOM-free + * parts: step types, flow rules and storage; `node --test` imports it. + */ + +import { api } from './canvas-core.js'; + +// ── Settings ─────────────────────────────────────────────────────────── +export const LOG_POLL_MS = 2000; // Deployment log refresh (Coolify's own log view uses 2 s too). +export const STORE = 'coolify-canvas'; // localStorage prefix (flow, view). + +// ── Small helpers ────────────────────────────────────────────────────── +// Icons: the app's reicon glyphs (rendered by the page into +// [data-deploy-canvas-icons]), or a few local paths in the same style. +export const LOCAL_ICONS = { + minus: ['M5 12h14'], + fit: ['M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5'], + back: ['M19 12H5', 'M11 6l-6 6 6 6'], // the environment canvas back arrow + fold: ['M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z', 'M9 4v16'], // the sidebar toggle in the navbar +}; +export const uid = () => Math.random().toString(36).slice(2, 10); +const b64 = text => btoa(unescape(encodeURIComponent(text))); +export const load = (key, fallback) => { + try { return JSON.parse(localStorage.getItem(`${STORE}:${key}`)) ?? fallback; } catch { return fallback; } +}; +export const save = (key, value) => localStorage.setItem(`${STORE}:${key}`, JSON.stringify(value)); +export const sleep = ms => new Promise(r => setTimeout(r, ms)); + +export const ICONS = { + start: 'projects', + server: 'servers', + git: 'sources', + image: 'cloud', + file: 'file-content', + layers: 'layers', + db: 'database', + sliders: 'sliders', + variables: 'variables', + globe: 'globe', + deploy: 'play-circle', + x: 'x', + plus: 'plus', + minus: 'minus', + fit: 'fit', + out: 'external-link', + fold: 'fold', + check: 'check', + chevron: 'chevron-down', + info: 'info-circle', +}; + +// Cached option lists for the node fields (key → array; loading state). +// onChange runs when a list loads or fails (the canvas redraws its fields). +export const lists = { cache: {}, pending: {}, errors: {}, onChange: () => {} }; +function options(key, fetcher) { + const { cache, pending, errors } = lists; + if (cache[key]) return cache[key]; + if (!pending[key]) { + pending[key] = fetcher().then(list => { + cache[key] = list; + delete errors[key]; + }).catch(error => { + errors[key] = error.message; + }).finally(() => { + delete pending[key]; + lists.onChange(); + }); + } + return null; +} +// The cache key a select loads from (to show its error). +export function loaderKey(f, d) { + const map = { project: 'projects', environment: `envs:${d.project}`, server: 'servers', destination: `dest:${d.server}`, ghApp: 'gh', ghRepo: `repos:${d.ghApp}`, ghBranch: `br:${d.ghApp}:${d.ghRepo}`, key: 'keys' }; + return map[f.key]; +} +const LOADERS = { + projects: () => options('projects', async () => (await api('GET', '/projects')).map(p => [p.uuid, p.name])), + envs: project => project && options(`envs:${project}`, async () => (await api('GET', `/projects/${project}/environments`)).map(e => [e.uuid, e.name])), + // Build-only servers cannot run resources (as on the New resource page). + servers: () => options('servers', async () => (await api('GET', '/servers')).filter(s => s.settings?.server_role !== 'build').map(s => [s.uuid, `${s.name}${s.is_reachable === false ? ' (unreachable)' : ''}`])), + destinations: server => server && options(`dest:${server}`, async () => (await api('GET', `/servers/${server}/destinations`)).map(d => [d.uuid, `${d.name} · ${d.network}`])), + ghApps: () => options('gh', async () => (await api('GET', '/github-apps')).filter(a => !a.is_public && a.app_id).map(a => [`${a.id}|${a.uuid}`, a.name])), + repos: app => app && options(`repos:${app}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories`)).repositories.map(r => [r.full_name, r.full_name])), + branches: (app, repo) => app && repo && options(`br:${app}:${repo}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories/${repo}/branches`)).branches.map(b => [b.name, b.name])), + keys: () => options('keys', async () => (await api('GET', '/security/keys')).map(k => [k.uuid, k.name])), +}; + +// ── Node types ───────────────────────────────────────────────────────── +// stage: start → infra → source → config → ship. kind: what a source creates. +// Select options are [value, label, badge?]; the badge shows next to the label. +const BUILD_PACKS = [['railpack', 'Railpack'], ['nixpacks', 'Nixpacks', 'Deprecated'], ['static', 'Static'], ['dockerfile', 'Dockerfile'], ['dockercompose', 'Docker Compose']]; +// The PostgreSQL images of the New resource page (project/new/select.blade.php). +const POSTGRES_IMAGES = [ + ['postgres:18-alpine', 'PostgreSQL 18'], + ['postgres:17-alpine', 'PostgreSQL 17'], + ['postgres:16-alpine', 'PostgreSQL 16'], + ['supabase/postgres:17.4.1.032', 'Supabase PostgreSQL'], + ['postgis/postgis:17-3.5-alpine', 'PostGIS'], + ['pgvector/pgvector:pg18', 'PGVector 18'], + ['pgvector/pgvector:pg17', 'PGVector 17'], +]; +const DATABASES = [['postgresql', 'PostgreSQL'], ['mysql', 'MySQL'], ['mariadb', 'MariaDB'], ['mongodb', 'MongoDB'], ['redis', 'Redis'], ['keydb', 'KeyDB'], ['dragonfly', 'Dragonfly'], ['clickhouse', 'ClickHouse']]; +// One-click service templates ([key, name]), from the page (deploy-canvas.blade.php). +let serviceTemplates = null; +const templateOptions = () => { + if (!serviceTemplates) { + try { serviceTemplates = JSON.parse(document.querySelector('[data-deploy-canvas-services]')?.textContent || '[]'); } catch { serviceTemplates = []; } + } + return serviceTemplates; +}; + +// The logo of a source step: the one resource_logo() (bootstrap/helpers/services.php) and the +// New resource page show for what it creates. Service logos come from the page +// (deploy-canvas.blade.php). Null keeps the step's glyph. +// The page gives versioned URLs (resource_logo_url()), so a changed logo is not loaded from the cache. +let resourceLogos = null; +const resourceLogoUrl = name => { + if (!resourceLogos) { + try { resourceLogos = JSON.parse(globalThis.document?.querySelector('[data-deploy-canvas-resource-logos]')?.textContent || '{}'); } catch { resourceLogos = {}; } + } + return resourceLogos[name] ?? `/svgs/resources/${name}.svg`; +}; +const resourceLogo = (name, hasDark = false) => ({ + src: resourceLogoUrl(name), dark: hasDark ? resourceLogoUrl(`${name}-dark`) : null, fallback: null, padded: false, +}); +const DATABASE_LOGOS = { postgresql: 'postgres' }; +let serviceLogos = null; +const serviceLogo = key => { + if (!serviceLogos) { + try { serviceLogos = JSON.parse(document.querySelector('[data-deploy-canvas-service-logos]')?.textContent || '{}'); } catch { serviceLogos = {}; } + } + const logo = serviceLogos[key]; + return logo ? { src: logo[0], dark: null, fallback: logo[1], padded: true } : null; +}; +export function logoOf(n) { + const d = n.data || {}; + switch (n.type) { + case 'git': + if (d.buildPack === 'dockerfile') return resourceLogo('dockerfile'); + if (d.buildPack === 'dockercompose') return resourceLogo('docker-compose'); + if (d.mode === 'github') return resourceLogo('github-app', true); + return resourceLogo(d.mode === 'key' ? 'deploy-key' : 'public-repo'); + case 'image': return resourceLogo('docker-image'); + case 'dockerfile': return resourceLogo('dockerfile'); + case 'service': return d.mode === 'compose' ? resourceLogo('docker-compose') : (d.type ? serviceLogo(d.type) : null); + case 'database': return d.engine ? resourceLogo(DATABASE_LOGOS[d.engine] ?? d.engine, ['keydb', 'dragonfly'].includes(d.engine)) : null; + default: return null; + } +} + +export const TYPES = { + start: { + title: 'Start', sub: 'Which project and environment', stage: 'start', icon: ICONS.start, + fields: [ + // `add`: a "New …" row at the end of the list creates one and selects it. + { key: 'project', label: 'Project', kind: 'select', load: () => LOADERS.projects(), rerender: true, required: true, + add: { label: 'New project', placeholder: 'Project name', list: () => 'projects', create: async name => (await api('POST', '/projects', { name })).uuid } }, + { key: 'environment', label: 'Environment', kind: 'select', load: d => LOADERS.envs(d.project), required: true, only: true, + add: { label: 'New environment', placeholder: 'Environment name', list: d => `envs:${d.project}`, create: async (name, d) => (await api('POST', `/projects/${d.project}/environments`, { name })).uuid } }, + ], + run: async (ctx, d) => { + ctx.project = d.project; + ctx.environment = d.environment; + return 'Project and environment set.'; + }, + }, + server: { + title: 'Server', sub: 'Where it runs', stage: 'infra', icon: ICONS.server, + fields: [ + { key: 'server', label: 'Server', kind: 'select', load: () => LOADERS.servers(), rerender: true, required: true, only: true }, + { key: 'destination', label: 'Network', kind: 'select', load: d => LOADERS.destinations(d.server), optional: 'Default' }, + ], + run: async (ctx, d) => { + ctx.server = d.server; + ctx.destination = d.destination || undefined; + return 'Server picked.'; + }, + }, + git: { + title: 'Git repository', sub: 'Build from source', stage: 'source', kind: 'application', icon: ICONS.git, + fields: [ + { key: 'mode', label: 'Access', kind: 'segmented', options: [['public', 'Public'], ['github', 'GitHub app'], ['key', 'Deploy key']], rerender: true, value: 'public' }, + { key: 'repository', label: 'Repository URL', kind: 'text', placeholder: 'https://github.com/coollabsio/coolify-examples', show: d => d.mode !== 'github', required: true, secret: v => /^\w+:\/\/[^/]*@/.test(v) }, + { key: 'ghApp', label: 'GitHub app', kind: 'select', load: () => LOADERS.ghApps(), show: d => d.mode === 'github', rerender: true, required: true }, + { key: 'ghRepo', label: 'Repository', kind: 'select', load: d => LOADERS.repos(d.ghApp), show: d => d.mode === 'github', rerender: true, required: true }, + { key: 'key', label: 'Deploy key', kind: 'select', load: () => LOADERS.keys(), show: d => d.mode === 'key', required: true }, + { key: 'branch', label: 'Branch', kind: 'text', placeholder: 'main', value: 'main', show: d => d.mode !== 'github', required: true }, + { key: 'ghBranch', label: 'Branch', kind: 'select', load: d => LOADERS.branches(d.ghApp, d.ghRepo), show: d => d.mode === 'github', required: true }, + { key: 'buildPack', label: 'Build pack', kind: 'select', options: BUILD_PACKS, value: 'railpack', required: true }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '3000', value: '3000' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const base = common(ctx, d); + Object.assign(base, { build_pack: d.buildPack, ports_exposes: d.ports || undefined }); + let res; + if (d.mode === 'github') { + res = await api('POST', '/applications/private-github-app', { ...base, github_app_uuid: d.ghApp.split('|')[1], git_repository: d.ghRepo, git_branch: d.ghBranch }); + } else if (d.mode === 'key') { + res = await api('POST', '/applications/private-deploy-key', { ...base, private_key_uuid: d.key, git_repository: d.repository, git_branch: d.branch }); + } else { + res = await api('POST', '/applications/public', { ...base, git_repository: d.repository, git_branch: d.branch }); + } + ctx.resource = { kind: 'application', sub: 'git', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + image: { + title: 'Docker image', sub: 'Run a prebuilt image', stage: 'source', kind: 'application', icon: ICONS.image, + fields: [ + { key: 'image', label: 'Image', kind: 'text', placeholder: 'nginx', required: true }, + { key: 'tag', label: 'Tag', kind: 'text', placeholder: 'latest, or the tag in the image' }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const { name, tag } = splitImage(d.image, d.tag); + const res = await api('POST', '/applications/dockerimage', { ...common(ctx, d), docker_registry_image_name: name, docker_registry_image_tag: tag, ports_exposes: d.ports || undefined }); + ctx.resource = { kind: 'application', sub: 'image', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + dockerfile: { + title: 'Dockerfile', sub: 'Build from a Dockerfile', stage: 'source', kind: 'application', icon: ICONS.file, + fields: [ + { key: 'dockerfile', label: 'Dockerfile', kind: 'textarea', placeholder: 'FROM nginx:alpine\nCOPY . /usr/share/nginx/html', required: true }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const res = await api('POST', '/applications/dockerfile', { ...common(ctx, d), dockerfile: b64(d.dockerfile), ports_exposes: d.ports || undefined }); + ctx.resource = { kind: 'application', sub: 'dockerfile', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + service: { + title: 'Service', sub: 'One-click template or compose', stage: 'source', kind: 'service', icon: ICONS.layers, + fields: [ + { key: 'mode', label: 'From', kind: 'segmented', options: [['template', 'Template'], ['compose', 'Compose']], rerender: true, value: 'template' }, + { key: 'type', label: 'Template', kind: 'select', load: () => templateOptions(), show: d => d.mode !== 'compose', required: true }, + { key: 'compose', label: 'docker-compose.yml', kind: 'textarea', placeholder: 'services:\n app:\n image: nginx', show: d => d.mode === 'compose', required: true, secret: true }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + const body = common(ctx, d); + if (d.mode === 'compose') body.docker_compose_raw = b64(d.compose); + else body.type = d.type.trim(); + const res = await api('POST', '/services', body); + ctx.resource = { kind: 'service', uuid: res.uuid }; + return 'Service created.'; + }, + }, + database: { + title: 'Database', sub: 'A managed database', stage: 'source', kind: 'database', icon: ICONS.db, + fields: [ + // A new engine starts with its default image (PostgreSQL: PostgreSQL 18). + { key: 'engine', label: 'Engine', kind: 'select', options: DATABASES, value: 'postgresql', required: true, + after: d => { d.image = d.engine === 'postgresql' ? POSTGRES_IMAGES[0][0] : ''; } }, + { key: 'image', label: 'Image', kind: 'select', options: POSTGRES_IMAGES, value: POSTGRES_IMAGES[0][0], show: d => d.engine === 'postgresql', required: true }, + { key: 'image', label: 'Image', kind: 'text', placeholder: 'Default for the engine', show: d => d.engine !== 'postgresql' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + const body = common(ctx, d); + if (d.image) body.image = d.image; + const res = await api('POST', `/databases/${d.engine}`, body); + ctx.resource = { kind: 'database', uuid: res.uuid }; + return 'Database created.'; + }, + }, + build: { + title: 'Build settings', sub: 'Directories and commands', stage: 'config', icon: ICONS.sliders, for: ['git'], + fields: [ + { key: 'base_directory', label: 'Base directory', kind: 'text', placeholder: '/' }, + { key: 'publish_directory', label: 'Publish directory', kind: 'text', placeholder: '/dist' }, + { key: 'install_command', label: 'Install command', kind: 'text', placeholder: 'Detected' }, + { key: 'build_command', label: 'Build command', kind: 'text', placeholder: 'Detected' }, + { key: 'start_command', label: 'Start command', kind: 'text', placeholder: 'Detected' }, + ], + run: async (ctx, d) => { + const body = Object.fromEntries(['base_directory', 'publish_directory', 'install_command', 'build_command', 'start_command'].filter(k => d[k]).map(k => [k, d[k]])); + if (!Object.keys(body).length) return 'Nothing to change.'; + await api('PATCH', `/applications/${ctx.resource.uuid}`, body); + return 'Build settings saved.'; + }, + }, + env: { + title: 'Environment variables', sub: 'KEY=value, one per line', stage: 'config', icon: ICONS.variables, for: ['application', 'service'], + fields: [ + { key: 'vars', label: 'Variables', kind: 'textarea', placeholder: 'NODE_ENV=production\nAPI_URL=https://api.example.com', required: true, mono: true, secret: true }, + ], + run: async (ctx, d) => { + const data = parseEnvVars(d.vars); + if (!data.length) return 'No variables.'; + const kind = ctx.resource.kind === 'service' ? 'services' : 'applications'; + await api('PATCH', `/${kind}/${ctx.resource.uuid}/envs/bulk`, { data }); + return `${data.length} variable${data.length === 1 ? '' : 's'} saved.`; + }, + }, + domain: { + title: 'Domains', sub: 'Your own public addresses', stage: 'config', icon: ICONS.globe, for: ['git', 'image', 'dockerfile'], + fields: [ + { key: 'domains', label: 'Domains', kind: 'textarea', placeholder: 'https://app.example.com', required: true, mono: true, rows: 2 }, + { key: 'note', kind: 'note', text: 'Without this step, Coolify makes an address from the server’s wildcard domain (or sslip.io).' }, + ], + run: async (ctx, d) => { + const domains = domainList(d.domains); + if (ctx.createdDomains === domains) return `Set at creation: ${domains.replace(/,/g, ', ')}.`; + const res = await api('PATCH', `/applications/${ctx.resource.uuid}`, { domains }); + return `Domains saved${res?.uuid ? '' : ''}: ${domains.replace(/,/g, ', ')}.`; + }, + }, + deploy: { + title: 'Deploy', sub: 'Save or deploy every resource', stage: 'ship', icon: ICONS.deploy, + fields: [], + }, +}; +// "nginx:alpine" → nginx + alpine; "ghcr.io:443/a/b@sha256:…" → digest tag +// (Coolify's "sha256-…" form). A tag in the image wins over the Tag field. +export function splitImage(image, tagField) { + let name = String(image || '').trim(); + let tag = ''; + const at = name.indexOf('@sha256:'); + if (at >= 0) { + tag = `sha256-${name.slice(at + 8)}`; + name = name.slice(0, at); + } else { + const colon = name.lastIndexOf(':'); + if (colon > name.lastIndexOf('/')) { + tag = name.slice(colon + 1); + name = name.slice(0, colon); + } + } + return { name, tag: tag || String(tagField || '').trim() || 'latest' }; +} +// Fields common to every create call. Domains from a Domains step later in +// the chain go in at creation (with no generated domain), as Coolify's +// create endpoints accept them. +function common(ctx, d) { + const body = { project_uuid: ctx.project, environment_uuid: ctx.environment, server_uuid: ctx.server, instant_deploy: false }; + if (ctx.destination) body.destination_uuid = ctx.destination; + if (d.name) body.name = d.name; + if (ctx.domains && ctx.applicationSource) { + body.domains = ctx.domains; + body.autogenerate_domain = false; + } + return body; +} +// "KEY=value" lines → [{ key, value }]; skips blanks and # comments, strips quotes. +export function parseEnvVars(text) { + return String(text || '').split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#') && line.includes('=')).map(line => { + const i = line.indexOf('='); + return { key: line.slice(0, i).trim(), value: line.slice(i + 1).trim().replace(/^(['"])(.*)\1$/, '$2') }; + }); +} +export const domainList = text => String(text || '').split(/[\n,]/).map(v => v.trim()).filter(Boolean).join(','); +export const PALETTE = [ + ['Infrastructure', ['server']], + ['Source', ['git', 'image', 'dockerfile', 'service', 'database']], + ['Configure', ['build', 'env', 'domain']], +]; + +// ── Flow state ───────────────────────────────────────────────────────── +// Every deploy needs a server, so a new canvas has one, already connected +// (id 'server': it cannot be removed, nor its connection to Start). +export const fresh = () => withDeploy({ + nodes: [{ id: 'start', type: 'start', x: 80, y: 160, data: {} }, { id: 'server', type: 'server', x: 456, y: 160, data: {} }], + edges: [{ from: 'start', to: 'server' }], +}); +// A select with `only: true` takes its option when it has exactly one. +export const onlyOption = (list, value) => (!value && list?.length === 1 ? list[0][0] : value); +// Every flow ends in one Deploy step. Resource lines reach it on their own, so +// edges into it (from older saved flows) are dropped. +export function withDeploy(f) { + if (!f.nodes.some(n => n.type === 'deploy')) f.nodes.push({ id: 'deploy', type: 'deploy', x: 1240, y: 160, data: {} }); + const deployIds = new Set(f.nodes.filter(n => n.type === 'deploy').map(n => n.id)); + f.edges = f.edges.filter(e => !deployIds.has(e.to) && !deployIds.has(e.from)); + return f; +} +// The environment that opened the canvas (?project=…&environment=…, from the +// New resource page), or null. Both values must look like UUIDs. +export function targetFrom(search) { + const params = new URLSearchParams(search); + const project = params.get('project'); + const environment = params.get('environment'); + const valid = value => /^[A-Za-z0-9_-]+$/.test(value ?? ''); + return valid(project) && valid(environment) ? { project, environment } : null; +} +// The Start step targets that environment, also in a flow restored from an earlier visit. +export function withTarget(f, t) { + const start = f.nodes.find(n => n.type === 'start'); + if (t && start) start.data = { project: t.project, environment: t.environment }; + return f; +} +// Secret fields (`secret: true`, or a test on the value) stay in memory only; +// they are never written to localStorage. +export function withoutSecrets(f) { + const nodes = f.nodes.map(n => { + const data = { ...n.data }; + for (const { key, secret } of TYPES[n.type]?.fields || []) { + if (secret === true || (typeof secret === 'function' && data[key] && secret(data[key]))) delete data[key]; + } + return { ...n, data }; + }); + return { ...f, nodes }; +} +// The flow is a tree: Start → Servers → one line per resource (source → +// settings…), and every line ends in the one Deploy step. +export const nodeById = (id, f) => f.nodes.find(n => n.id === id); +export const outsOf = (id, f) => f.edges.filter(e => e.from === id).map(e => e.to); +export const nextOf = (id, f) => outsOf(id, f)[0]; +export const prevOf = (id, f) => f.edges.find(e => e.to === id)?.from; +// Start and Server fan out; only Deploy takes more than one line in. +export const multiOut = n => ['start', 'infra'].includes(TYPES[n.type].stage); +// The source node above a node in its line (what the settings apply to). +export function sourceAbove(id, f) { + const seen = new Set(); + for (let cur = id; cur && !seen.has(cur); cur = prevOf(cur, f)) { + seen.add(cur); + const n = nodeById(cur, f); + if (n && TYPES[n.type].stage === 'source') return n; + } + return null; +} +// Can a `type` step come after `prev`? With `target`, also: the link makes no loop. +export function canFollow(prev, type, target, f) { + const t = TYPES[type]; + const p = TYPES[prev.type]; + if (type === 'start' || type === 'deploy' || prev.type === 'deploy') return false; + if (target) { + const seen = new Set(); + for (let cur = prev.id; cur && !seen.has(cur); cur = prevOf(cur, f)) { + if (cur === target.id) return false; + seen.add(cur); + } + } + if (p.stage === 'start') return type === 'server'; + if (p.stage === 'infra') return t.stage === 'source'; + const src = sourceAbove(prev.id, f); + if (!src) return false; + if (t.stage === 'ship') return true; + if (t.stage !== 'config') return false; + const sub = src.type === 'git' || src.type === 'image' || src.type === 'dockerfile' ? src.type : null; + return t.for.includes(TYPES[src.type].kind) || (sub !== null && t.for.includes(sub)); +} +// What a run does: each resource line under each Server under Start. +export function planOf(f) { + const servers = outsOf('start', f).map(id => nodeById(id, f)).filter(n => n?.type === 'server'); + const lines = []; + for (const server of servers) { + for (const id of outsOf(server.id, f)) { + const source = nodeById(id, f); + if (!source || TYPES[source.type].stage !== 'source') continue; + const settings = []; + const seen = new Set([source.id]); + for (let cur = nextOf(source.id, f); cur && !seen.has(cur); cur = nextOf(cur, f)) { + seen.add(cur); + const n = nodeById(cur, f); + if (!n) break; + settings.push(n); + } + lines.push({ server, source, settings, end: settings.at(-1) || source }); + } + } + return { start: nodeById('start', f), servers, lines, deploy: f.nodes.find(n => n.type === 'deploy') || null }; +} +// Source id → label; same labels get a number ("Docker image · nginx #2"). +export function uniqueLabels(sources) { + const labels = sources.map(resourceLabel); + const seen = {}; + return new Map(sources.map((src, i) => { + const label = labels[i]; + if (labels.filter(l => l === label).length < 2) return [src.id, label]; + seen[label] = (seen[label] || 0) + 1; + return [src.id, `${label} #${seen[label]}`]; + })); +} +// "Docker image · nginx": what a source step makes, for lists and logs. +export function resourceLabel(src) { + const d = src.data; + const detail = d.name || { + git: d.mode === 'github' ? d.ghRepo : d.repository, + image: d.image, + service: d.mode === 'compose' ? 'compose' : d.type, + database: d.engine, + }[src.type]; + return detail ? `${TYPES[src.type].title} · ${detail}` : TYPES[src.type].title; +} +export function defaults(type) { + const data = {}; + for (const f of TYPES[type].fields) if (f.value !== undefined) data[f.key] = f.value; + return data; +} +// The Coolify page of a created resource (its deployment, while one runs). +export function resourceHref(ctx) { + const base = `/project/${ctx.project}/environment/${ctx.environment}`; + const kind = { application: 'application', service: 'service', database: 'database' }[ctx.resource.kind]; + return ctx.deployment && kind === 'application' ? `${base}/application/${ctx.resource.uuid}/deployment/${ctx.deployment}` : `${base}/${kind}/${ctx.resource.uuid}`; +} diff --git a/resources/js/deploy-canvas.test.js b/resources/js/deploy-canvas.test.js new file mode 100644 index 0000000000..e4f9be5461 --- /dev/null +++ b/resources/js/deploy-canvas.test.js @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { freeSpot } from './canvas-core.js'; +import { canFollow, domainList, fresh, logoOf, onlyOption, parseEnvVars, planOf, splitImage, targetFrom, uniqueLabels, withDeploy, withoutSecrets, withTarget } from './deploy-canvas.js'; + +test('splits the tag out of an image name', () => { + assert.deepEqual(splitImage('nginx:alpine', 'latest'), { name: 'nginx', tag: 'alpine' }); + assert.deepEqual(splitImage('ghcr.io:443/org/app', ''), { name: 'ghcr.io:443/org/app', tag: 'latest' }); + assert.deepEqual(splitImage('ghcr.io:443/org/app', 'v2'), { name: 'ghcr.io:443/org/app', tag: 'v2' }); +}); + +test('turns an image digest into the sha256- tag form', () => { + assert.deepEqual(splitImage('redis@sha256:abc123', 'latest'), { name: 'redis', tag: 'sha256-abc123' }); +}); + +test('parses KEY=value lines and skips comments and blanks', () => { + assert.deepEqual(parseEnvVars('# comment\n\nNODE_ENV=production\nURL="https://a.b/?x=1"\nNOPE\n'), [ + { key: 'NODE_ENV', value: 'production' }, + { key: 'URL', value: 'https://a.b/?x=1' }, + ]); +}); + +test('joins domains from lines and commas', () => { + assert.equal(domainList(' https://a.com\nhttps://b.com, https://c.com \n'), 'https://a.com,https://b.com,https://c.com'); +}); + +// Start → Server → (Service, Dockerfile → Env), and the one Deploy step. +const node = (id, type, data = {}) => ({ id, type, x: 0, y: 0, data }); +const fanOut = () => ({ + nodes: [ + node('start', 'start'), + node('srv', 'server'), + node('svc', 'service', { mode: 'template', type: 'umami' }), + node('df', 'dockerfile'), + node('env', 'env'), + node('dep', 'deploy'), + ], + edges: [ + { from: 'start', to: 'srv' }, + { from: 'srv', to: 'svc' }, + { from: 'srv', to: 'df' }, + { from: 'df', to: 'env' }, + ], +}); + +test('plans one line per resource under a server, each ending at its last step', () => { + const plan = planOf(fanOut()); + assert.equal(plan.deploy.id, 'dep'); + assert.deepEqual(plan.lines.map((l) => [l.server.id, l.source.id, l.settings.map((n) => n.id), l.end.id]), [ + ['srv', 'svc', [], 'svc'], + ['srv', 'df', ['env'], 'env'], + ]); +}); + +test('adds the deploy step to a flow without one and drops edges into it', () => { + const flow = withDeploy({ + nodes: [node('start', 'start'), node('srv', 'server'), node('img', 'image'), node('old', 'deploy')], + edges: [{ from: 'start', to: 'srv' }, { from: 'srv', to: 'img' }, { from: 'img', to: 'old' }], + }); + assert.deepEqual(flow.edges, [{ from: 'start', to: 'srv' }, { from: 'srv', to: 'img' }]); + assert.equal(flow.nodes.filter((n) => n.type === 'deploy').length, 1); + + const empty = withDeploy({ nodes: [node('start', 'start')], edges: [] }); + assert.equal(empty.nodes.filter((n) => n.type === 'deploy').length, 1); +}); + +test('allows only steps that fit after a step', () => { + const flow = fanOut(); + const byId = (id) => flow.nodes.find((n) => n.id === id); + assert.equal(canFollow(byId('srv'), 'image', null, flow), true); + assert.equal(canFollow(byId('start'), 'image', null, flow), false); + assert.equal(canFollow(byId('env'), 'deploy', byId('dep'), flow), false); // Deploy is connected on its own + assert.equal(canFollow(byId('svc'), 'build', null, flow), false); // build settings are for git only + assert.equal(canFollow(byId('dep'), 'env', null, flow), false); +}); + +test('refuses a link that would make a loop', () => { + const flow = fanOut(); + flow.nodes.push(node('env2', 'env')); + flow.edges.push({ from: 'env', to: 'env2' }); + const byId = (id) => flow.nodes.find((n) => n.id === id); + assert.equal(canFollow(byId('env2'), 'env', byId('env'), flow), false); +}); + +test('numbers resources that would have the same name', () => { + const names = uniqueLabels([ + node('a', 'image', { image: 'nginx' }), + node('b', 'image', { image: 'nginx' }), + node('c', 'service', { mode: 'template', type: 'umami' }), + ]); + assert.deepEqual([...names.values()], ['Docker image · nginx #1', 'Docker image · nginx #2', 'Service · umami']); +}); + +test('keeps secrets out of the stored flow', () => { + const flow = { + nodes: [ + node('env', 'env', { vars: 'TOKEN=abc' }), + node('svc', 'service', { mode: 'compose', compose: 'services: {}', name: 'web' }), + node('git', 'git', { repository: 'https://user:tok@github.com/a/b', branch: 'main' }), + node('pub', 'git', { repository: 'https://github.com/a/b' }), + ], + edges: [], + }; + const stored = withoutSecrets(flow); + assert.deepEqual(stored.nodes.map((n) => n.data), [{}, { mode: 'compose', name: 'web' }, { branch: 'main' }, { repository: 'https://github.com/a/b' }]); + assert.equal(flow.nodes[0].data.vars, 'TOKEN=abc'); // the open canvas keeps them +}); + +test('reads the environment that opened the canvas from the query string', () => { + assert.deepEqual(targetFrom('?project=p1a2&environment=e_3-x'), { project: 'p1a2', environment: 'e_3-x' }); + assert.equal(targetFrom(''), null); + assert.equal(targetFrom('?project=p1a2'), null); + assert.equal(targetFrom('?project=../servers&environment=e1'), null); + assert.equal(targetFrom('?project=p1&environment='), null); +}); + +test('points the Start step of a restored flow at the opening environment', () => { + const flow = { + nodes: [node('start', 'start', { project: 'old', environment: 'old-env' }), node('srv', 'server', { server: 's1' })], + edges: [{ from: 'start', to: 'srv' }], + }; + withTarget(flow, { project: 'p1', environment: 'e1' }); + assert.deepEqual(flow.nodes.map((n) => n.data), [{ project: 'p1', environment: 'e1' }, { server: 's1' }]); + assert.deepEqual(withTarget(flow, null).nodes[0].data, { project: 'p1', environment: 'e1' }); +}); + +test('a new canvas starts with a Server step connected to Start', () => { + const plan = planOf(fresh()); + assert.deepEqual(plan.servers.map((n) => n.id), ['server']); + assert.equal(plan.deploy?.type, 'deploy'); + assert.deepEqual(plan.lines, []); +}); + +test('selects the only option, and only when nothing is selected', () => { + assert.equal(onlyOption([['srv-1', 'localhost']], ''), 'srv-1'); + assert.equal(onlyOption([['srv-1', 'localhost']], undefined), 'srv-1'); + assert.equal(onlyOption([['srv-1', 'localhost']], 'srv-old'), 'srv-old'); + assert.equal(onlyOption([['srv-1', 'a'], ['srv-2', 'b']], ''), ''); + assert.equal(onlyOption([], ''), ''); + assert.equal(onlyOption(null, ''), ''); // still loading +}); + +test('keeps a dragged card off the other cards', () => { + const other = { x: 0, y: 0, w: 100, h: 100 }; + const free = { x: 300, y: 0, w: 100, h: 100 }; + assert.deepEqual(freeSpot(free, [other]), free); + // Overlaps from the right: pushed to the right edge, plus the gap. + assert.deepEqual(freeSpot({ x: 90, y: 10, w: 100, h: 100 }, [other], 16), { x: 116, y: 10, w: 100, h: 100 }); + // Overlaps from below: pushed under it. + assert.deepEqual(freeSpot({ x: 5, y: 80, w: 100, h: 100 }, [other], 16), { x: 5, y: 116, w: 100, h: 100 }); +}); + +test('finds a corner spot when two cards block the way', () => { + const cards = [{ x: 0, y: 0, w: 100, h: 100 }, { x: 116, y: 0, w: 100, h: 100 }]; + const spot = freeSpot({ x: 60, y: 20, w: 100, h: 100 }, cards, 16); + for (const c of cards) { + const apart = spot.x >= c.x + c.w + 16 || spot.x + spot.w + 16 <= c.x || spot.y >= c.y + c.h + 16 || spot.y + spot.h + 16 <= c.y; + assert.ok(apart, 'the spot does not touch a card'); + } + assert.deepEqual(spot, { x: 60, y: 116, w: 100, h: 100 }); +}); + +test('shows the logo of what a source step creates, like the rest of Coolify', () => { + const src = (type, data) => logoOf(node('n', type, data))?.src ?? null; + assert.equal(src('image', {}), '/svgs/resources/docker-image.svg'); + assert.equal(src('dockerfile', {}), '/svgs/resources/dockerfile.svg'); + assert.equal(src('database', { engine: 'postgresql' }), '/svgs/resources/postgres.svg'); + assert.equal(src('database', { engine: 'mysql' }), '/svgs/resources/mysql.svg'); + assert.equal(logoOf(node('n', 'database', { engine: 'keydb' })).dark, '/svgs/resources/keydb-dark.svg'); + assert.equal(src('git', { mode: 'public', buildPack: 'railpack' }), '/svgs/resources/public-repo.svg'); + assert.equal(src('git', { mode: 'github', buildPack: 'railpack' }), '/svgs/resources/github-app.svg'); + assert.equal(src('git', { mode: 'key', buildPack: 'railpack' }), '/svgs/resources/deploy-key.svg'); + assert.equal(src('git', { mode: 'public', buildPack: 'dockerfile' }), '/svgs/resources/dockerfile.svg'); + assert.equal(src('service', { mode: 'compose' }), '/svgs/resources/docker-compose.svg'); + // No engine or template yet, or a step that creates nothing: the step keeps its glyph. + assert.equal(src('database', {}), null); + assert.equal(src('server', {}), null); +}); diff --git a/resources/js/environment-canvas.js b/resources/js/environment-canvas.js new file mode 100644 index 0000000000..ed2f105912 --- /dev/null +++ b/resources/js/environment-canvas.js @@ -0,0 +1,621 @@ +// Environment canvas (Beta): pure helpers and constants. The Svelte +// components live in canvas/environment/. +// +// DOM-free: `node --test` imports this file. + +import { apart, BOX_GAP, MAX_ZOOM, MIN_ZOOM } from './canvas-core.js'; + +export const CARD_WIDTH = 260; +export const COLUMN_GAP = 48; +export const ROW_GAP = 24; +export const ZONE_GAP = 72; +export const ZONE_PADDING = 24; +export const ZONE_HEADER = 40; +export const MAX_CHILDREN = 5; +export const MAX_VOLUMES = 3; +export const MAX_ROWS = 6; // auto placement wraps a type into another column after this many cards +export const NO_SERVER = 'No server'; +export const DOUBLE_CLICK_MS = 400; +export const SOURCES_ZONE = 'sources'; +export const CONNECTED_ZONE = 'connected'; + +const COLUMNS = { application: 0, service: 1, database: 2 }; +/** Connect view: shared variable and tag cards are link sources; every other card is a resource. */ +export const isSource = node => node?.type === 'variable' || node?.type === 'tag'; +const columnOf = node => COLUMNS[node?.type] ?? 0; +const columnLeft = (originX, column) => originX + column * (CARD_WIDTH + COLUMN_GAP); +const isPoint = p => !!p && Number.isFinite(Number(p.x)) && Number.isFinite(Number(p.y)); +const storedPositions = graph => (graph?.positions && !Array.isArray(graph.positions) ? graph.positions : {}); + +// ── Pure helpers (tested) ───────────────────────────────────────────── + +/** Estimated card height in px; matches the rendered card closely enough for auto placement. */ +export function estimateNodeHeight(node) { + let height = 58; // padding + icon/name row + if (node?.urls?.length) height += 20; + if (node?.extra_zones?.length || node?.volumes?.length) height += 28; + const children = node?.children?.length || 0; + if (children) height += 12 + Math.min(children, MAX_CHILDREN) * 20 + (children > MAX_CHILDREN ? 20 : 0); + return height; +} + +/** Zones in display order (graph.zones order, then "No server"), each with its nodes. Empty zones are skipped. */ +export function zoneGroups(graph) { + const zones = graph?.zones || []; + const known = new Set(zones.map(z => z.id)); + const groups = zones.map(zone => ({ id: zone.id, name: zone.name, zone, nodes: [] })); + const none = { id: null, name: NO_SERVER, zone: null, nodes: [] }; + for (const node of graph?.nodes || []) { + if (node.zone !== null && node.zone !== undefined && known.has(node.zone)) { + groups.find(g => g.id === node.zone).nodes.push(node); + } else { + none.nodes.push(node); + } + } + return [...groups, none].filter(g => g.nodes.length); +} + +/** + * Card positions (top-left, world px) for every node. Stored positions win; + * other nodes are placed in their zone: applications left, services middle, + * databases right. Zones without stored cards stack below everything else. + */ +export function layoutGraph(graph) { + if (graph?.layout === 'server') return layoutServerView(graph); + if (graph?.layout === 'connect') return layoutConnectView(graph); + const stored = storedPositions(graph); + const out = {}; + const placedBottom = []; + const fresh = []; + for (const group of zoneGroups(graph)) { + const fixed = group.nodes.filter(n => isPoint(stored[n.id])); + if (!fixed.length) { + fresh.push(group); + continue; + } + for (const n of fixed) out[n.id] = { x: Number(stored[n.id].x), y: Number(stored[n.id].y) }; + const originX = Math.min(...fixed.map(n => out[n.id].x)); + const fixedBottom = Math.max(...fixed.map(n => out[n.id].y + estimateNodeHeight(n))); + // New cards go in a grid below the saved ones, so they never cover a saved card. + const bottom = placeGrid(group.nodes.filter(n => !out[n.id]), originX, fixedBottom + ROW_GAP, out); + placedBottom.push(Math.max(fixedBottom, bottom)); + } + let y = placedBottom.length ? Math.max(...placedBottom) + ZONE_PADDING + ZONE_GAP : 0; + for (const group of fresh) { + y = placeGrid(group.nodes, 0, y + ZONE_HEADER + ZONE_PADDING, out) + ZONE_PADDING + ZONE_GAP; + } + return out; +} + +/** + * Places nodes in a grid from (originX, top): applications, then services, then + * databases, each type in its own column(s) that wrap after MAX_ROWS cards. + * Returns the bottom of the lowest card (top when there are no nodes). + */ +function placeGrid(nodes, originX, top, out) { + let firstColumn = 0; + let bottom = top; + for (const column of [0, 1, 2]) { + const ofType = nodes.filter(n => columnOf(n) === column); + const nextY = []; + ofType.forEach((n, i) => { + const sub = Math.floor(i / MAX_ROWS); + nextY[sub] ??= top; + out[n.id] = { x: columnLeft(originX, firstColumn + sub), y: nextY[sub] }; + nextY[sub] += estimateNodeHeight(n) + ROW_GAP; + }); + firstColumn += nextY.length; + bottom = Math.max(bottom, ...nextY.map(v => v - ROW_GAP)); + } + return bottom; +} + +// ── Server view: project / environment groups, linked cards side by side ── + +export const GROUP_PADDING = 16; +export const GROUP_HEADER = 32; +export const GROUP_GAP = 32; +export const GROUPS_PER_ROW = 3; +export const SINGLE_COLUMNS = 3; + +const contextGroups = nodes => { + const groups = new Map(); + for (const n of nodes) { + const key = n.context || ''; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(n); + } + // Server view: the unmanaged containers group comes first, so it is not lost below many projects. + const unmanaged = ([, members]) => (members[0]?.type === 'container' ? 0 : 1); + return [...groups.entries()].sort((a, b) => unmanaged(a) - unmanaged(b) || a[0].localeCompare(b[0])); +}; +const byTypeAndName = (a, b) => columnOf(a) - columnOf(b) || String(a.name).localeCompare(String(b.name)); + +/** + * Card positions inside one group, from (0, 0). Linked cards form chains from + * left to right (source before target, one column per step); every chain gets + * its own rows. Cards without links follow in a grid of SINGLE_COLUMNS. + */ +export function layoutBlock(nodes, links = []) { + const ids = new Set(nodes.map(n => n.id)); + const inside = links.filter(l => ids.has(l.source) && ids.has(l.target) && l.source !== l.target); + const parent = new Map(nodes.map(n => [n.id, n.id])); + const find = id => (parent.get(id) === id ? id : find(parent.get(id))); + for (const l of inside) parent.set(find(l.source), find(l.target)); + const order = [...nodes].sort(byTypeAndName); + const components = new Map(); + for (const n of order) { + const root = find(n.id); + if (!components.has(root)) components.set(root, []); + components.get(root).push(n); + } + + const out = {}; + const step = CARD_WIDTH + COLUMN_GAP; + let top = 0; + let columns = 0; + const singles = []; + for (const members of components.values()) { + if (members.length === 1) { + singles.push(members[0]); + continue; + } + // Depth: the longest path from a card without incoming links (cycles stop at the size of the chain). + const own = new Set(members.map(n => n.id)); + const edges = inside.filter(l => own.has(l.source)); + const depth = new Map(members.filter(n => !edges.some(l => l.target === n.id)).map(n => [n.id, 0])); + if (!depth.size) depth.set(members[0].id, 0); + for (let changed = true, rounds = 0; changed && rounds < members.length; rounds++) { + changed = false; + for (const l of edges) { + const next = (depth.get(l.source) ?? -1) + 1; + if (next > 0 && next > (depth.get(l.target) ?? -1)) { + depth.set(l.target, next); + changed = true; + } + } + } + const nextY = []; + for (const n of members) { + const d = depth.get(n.id) ?? 0; + nextY[d] ??= top; + out[n.id] = { x: d * step, y: nextY[d] }; + nextY[d] += estimateNodeHeight(n) + ROW_GAP; + } + columns = Math.max(columns, nextY.length); + top = Math.max(...nextY.filter(v => v !== undefined)) + ROW_GAP; + } + let rowHeight = 0; + singles.forEach((n, i) => { + const column = i % SINGLE_COLUMNS; + if (column === 0 && i > 0) { + top += rowHeight + ROW_GAP; + rowHeight = 0; + } + out[n.id] = { x: column * step, y: top }; + rowHeight = Math.max(rowHeight, estimateNodeHeight(n)); + }); + if (singles.length) columns = Math.max(columns, Math.min(singles.length, SINGLE_COLUMNS)); + const bottom = singles.length ? top + rowHeight : Math.max(0, top - ROW_GAP); + return { positions: out, width: columns * step - COLUMN_GAP, height: bottom }; +} + +/** + * A project / environment group with cards saved on its environment canvas: + * the same placement as that canvas (saved cards, then a grid below them), + * moved so the top-left card starts at (0, 0). Groups without saved cards use layoutBlock. + */ +export function layoutStoredBlock(nodes, stored, links = []) { + if (!nodes.some(n => isPoint(stored[n.id]))) return layoutBlock(nodes, links); + const zone = nodes[0].zone; + const placed = layoutGraph({ zones: [{ id: zone }], nodes: nodes.map(n => ({ ...n, zone })), positions: stored }); + const minX = Math.min(...nodes.map(n => placed[n.id].x)); + const minY = Math.min(...nodes.map(n => placed[n.id].y)); + const positions = Object.fromEntries(nodes.map(n => [n.id, { x: placed[n.id].x - minX, y: placed[n.id].y - minY }])); + return { + positions, + origin: { x: minX, y: minY }, + width: Math.max(...nodes.map(n => positions[n.id].x + CARD_WIDTH)), + height: Math.max(...nodes.map(n => positions[n.id].y + estimateNodeHeight(n))), + }; +} + +/** + * Top-left of the given cards where their environment canvas places them. A group saved for the first time + * from the server view goes there, so it does not cover the cards of the environment on other servers. + */ +function environmentOrigin(graph, nodes) { + const environment = nodes[0]?.environment?.uuid; + if (!environment) return { x: 0, y: 0 }; + const placed = layoutGraph({ + zones: graph.zones, + nodes: (graph.nodes || []).filter(n => n.environment?.uuid === environment), + positions: storedPositions(graph), + }); + return { x: Math.min(...nodes.map(n => placed[n.id].x)), y: Math.min(...nodes.map(n => placed[n.id].y)) }; +} + +/** + * Server view: zones stack downwards; inside a zone, one block per project / environment, GROUPS_PER_ROW per row. + * When `offsets` is given, it gets per card the shift from its environment canvas position to its server view + * position, so a moved card can be saved on its environment canvas. + */ +export function layoutServerView(graph, offsets = null) { + const stored = storedPositions(graph); + const out = {}; + let y = 0; + for (const zone of zoneGroups(graph)) { + const innerTop = y + ZONE_HEADER + ZONE_PADDING; + let x = 0; + let rowTop = innerTop; + let rowBottom = innerTop; + contextGroups(zone.nodes).forEach(([, nodes], i) => { + if (i > 0 && i % GROUPS_PER_ROW === 0) { + x = 0; + rowTop = rowBottom + GROUP_GAP; + } + const block = layoutStoredBlock(nodes, stored, graph.links || []); + const origin = offsets ? (block.origin ?? environmentOrigin(graph, nodes)) : null; + for (const [id, p] of Object.entries(block.positions)) { + out[id] = { x: x + GROUP_PADDING + p.x, y: rowTop + GROUP_HEADER + GROUP_PADDING + p.y }; + if (offsets) offsets[id] = { x: out[id].x - origin.x - p.x, y: out[id].y - origin.y - p.y }; + } + x += block.width + GROUP_PADDING * 2 + GROUP_GAP; + rowBottom = Math.max(rowBottom, rowTop + GROUP_HEADER + GROUP_PADDING * 2 + block.height); + }); + y = rowBottom + ZONE_PADDING + ZONE_GAP; + } + return out; +} + +/** + * Shared variables view: variable cards in one column on the left (by name). Resources that use a + * variable sit next to them in one column, grouped by project / environment, so every link runs + * straight from left to right. Other resources follow below in the server view grid. + */ +export function layoutConnectView(graph) { + const out = {}; + const groups = zoneGroups(graph); + const variables = groups.find(g => g.id === SOURCES_ZONE); + const connected = groups.find(g => g.id === CONNECTED_ZONE); + let y = ZONE_HEADER + ZONE_PADDING; + for (const n of [...(variables?.nodes || [])].sort((a, b) => String(a.name).localeCompare(String(b.name)))) { + out[n.id] = { x: 0, y }; + y += estimateNodeHeight(n) + ROW_GAP; + } + const offset = variables ? CARD_WIDTH + ZONE_PADDING * 2 + ZONE_GAP * 3 : 0; + let top = 0; + if (connected) { + y = ZONE_HEADER + ZONE_PADDING; + for (const [, nodes] of contextGroups(connected.nodes)) { + y += GROUP_HEADER + GROUP_PADDING; + for (const n of [...nodes].sort(byTypeAndName)) { + out[n.id] = { x: offset + GROUP_PADDING, y }; + y += estimateNodeHeight(n) + ROW_GAP; + } + y += GROUP_PADDING - ROW_GAP + GROUP_GAP; + } + top = y - GROUP_GAP + ZONE_PADDING + ZONE_GAP; + } + const others = groups.filter(g => g.id !== SOURCES_ZONE && g.id !== CONNECTED_ZONE); + const placed = layoutServerView({ zones: others.map(g => g.zone || { id: g.id }), nodes: others.flatMap(g => g.nodes) }); + for (const [id, p] of Object.entries(placed)) out[id] = { x: p.x + offset, y: p.y + top }; + return out; +} + +/** + * Shared variables view: moves the resources that use a shown variable into the "connected" zone, + * so they show apart from the other resources. Runs after filterGraph and after every link change. + */ +export function groupByUse(graph) { + if (graph?.layout !== 'connect') return graph; + const used = new Set((graph.links || []).map(l => l.target)); + return { + ...graph, + nodes: (graph.nodes || []).map(n => (!isSource(n) && used.has(n.id) ? { ...n, zone: CONNECTED_ZONE } : n)), + }; +} + +/** Frames of the project / environment groups of the given cards (server view). */ +export function groupFrames(nodes, positions, sizes = {}) { + const frames = []; + for (const [name, members] of contextGroups(nodes)) { + const rects = members.map(n => cardRect(n, positions, sizes)).filter(Boolean); + if (!rects.length) continue; + const minX = Math.min(...rects.map(r => r.x)); + const minY = Math.min(...rects.map(r => r.y)); + frames.push({ + name, + link: members[0].context_link, + x: minX - GROUP_PADDING, + y: minY - GROUP_PADDING - GROUP_HEADER, + width: Math.max(...rects.map(r => r.x + r.width)) - minX + GROUP_PADDING * 2, + height: Math.max(...rects.map(r => r.y + r.height)) - minY + GROUP_PADDING * 2 + GROUP_HEADER, + }); + } + return frames; +} + +/** Card rect from positions and measured sizes (falls back to the estimate). */ +export function cardRect(node, positions, sizes = {}) { + const p = positions?.[node.id]; + if (!p) return null; + return { + x: p.x, + y: p.y, + width: sizes[node.id]?.width ?? CARD_WIDTH, + height: sizes[node.id]?.height ?? estimateNodeHeight(node), + }; +} + +/** Frame per zone: bbox of its cards (or of its group frames) + padding, with room for the header on top. */ +export function zoneBoxes(graph, positions, sizes = {}) { + const boxes = []; + for (const group of zoneGroups(graph)) { + const rects = group.nodes.some(n => n.context) + ? groupFrames(group.nodes, positions, sizes) + : group.nodes.map(n => cardRect(n, positions, sizes)).filter(Boolean); + if (!rects.length) continue; + const minX = Math.min(...rects.map(r => r.x)); + const minY = Math.min(...rects.map(r => r.y)); + const maxX = Math.max(...rects.map(r => r.x + r.width)); + const maxY = Math.max(...rects.map(r => r.y + r.height)); + boxes.push({ + id: group.id, + name: group.name, + zone: group.zone, + nodes: group.nodes.map(n => n.id), + x: minX - ZONE_PADDING, + y: minY - ZONE_PADDING - ZONE_HEADER, + width: maxX - minX + ZONE_PADDING * 2, + height: maxY - minY + ZONE_PADDING * 2 + ZONE_HEADER, + }); + } + return boxes; +} + +const toBox = r => ({ x: r.x, y: r.y, w: r.width, h: r.height }); +// A frame reaches `side` past its cards left, right and below, and `top` above them. +const grow = (b, side, top) => ({ x: b.x - side, y: b.y - top, w: b.w + side * 2, h: b.h + side + top }); +const frameAround = (boxes, side, top) => { + const x = Math.min(...boxes.map(b => b.x)); + const y = Math.min(...boxes.map(b => b.y)); + return grow({ x, y, w: Math.max(...boxes.map(b => b.x + b.w)) - x, h: Math.max(...boxes.map(b => b.y + b.h)) - y }, side, top); +}; + +/** + * What a dragged card must keep clear of, for freeSpot(): `others` are the + * other cards and the other zone frames (and, server view, the other project / + * environment frames in its zone), grown by the room the card's own frames + * take around it. `fits` checks the card's own frames, which also span its + * other cards, against those frames. + */ +export function dragRules(graph, positions, sizes, id) { + const node = graph.nodes.find(n => n.id === id); + const own = zoneGroups(graph).find(z => z.nodes.some(n => n.id === id)); + const grouped = !!own?.nodes.some(n => n.context); + const context = n => n.context || ''; + const rects = nodes => nodes.filter(n => n.id !== id).map(n => cardRect(n, positions, sizes)).filter(Boolean).map(toBox); + const groupSide = grouped ? GROUP_PADDING : 0; + const groupTop = grouped ? GROUP_PADDING + GROUP_HEADER : 0; + const side = ZONE_PADDING + groupSide; + const top = ZONE_PADDING + ZONE_HEADER + groupTop; + const zones = zoneBoxes(graph, positions, sizes).filter(z => !z.nodes.includes(id)).map(toBox); + const groups = grouped ? groupFrames(own.nodes.filter(n => context(n) !== context(node)), positions, sizes).map(toBox) : []; + const zoneMates = rects(own?.nodes || []); + const groupMates = grouped ? rects(own.nodes.filter(n => context(n) === context(node))) : []; + // The other box must keep the frame's room on the side that faces the card. + const room = (b, s, t) => ({ x: b.x - s, y: b.y - s, w: b.w + s * 2, h: b.h + s + t }); + return { + others: [...rects(graph.nodes), ...zones.map(z => room(z, side, top)), ...groups.map(g => room(g, groupSide, groupTop))], + fits: box => zones.every(z => apart(frameAround([...zoneMates, box], side, top), z, BOX_GAP)) + && (!grouped || groups.every(g => apart(frameAround([...groupMates, box], groupSide, groupTop), g, BOX_GAP))), + }; +} + +/** + * SVG path between two card rects. Target right of source: right edge → + * left edge; target left of source: left edge → right edge; overlapping + * columns (stacked): bottom → top (or top → bottom when the target is above). + */ +export function linkPath(a, b) { + let side; + let from; + let to; + let c1; + let c2; + if (b.x >= a.x + a.width) { + side = 'right'; + from = { x: a.x + a.width, y: a.y + a.height / 2 }; + to = { x: b.x, y: b.y + b.height / 2 }; + } else if (b.x + b.width <= a.x) { + side = 'left'; + from = { x: a.x, y: a.y + a.height / 2 }; + to = { x: b.x + b.width, y: b.y + b.height / 2 }; + } else if (b.y + b.height / 2 >= a.y + a.height / 2) { + side = 'down'; + from = { x: a.x + a.width / 2, y: a.y + a.height }; + to = { x: b.x + b.width / 2, y: b.y }; + } else { + side = 'up'; + from = { x: a.x + a.width / 2, y: a.y }; + to = { x: b.x + b.width / 2, y: b.y + b.height }; + } + if (side === 'right' || side === 'left') { + const dx = Math.max(40, Math.abs(to.x - from.x) / 2) * (side === 'right' ? 1 : -1); + c1 = { x: from.x + dx, y: from.y }; + c2 = { x: to.x - dx, y: to.y }; + } else { + const dy = Math.max(40, Math.abs(to.y - from.y) / 2) * (side === 'down' ? 1 : -1); + c1 = { x: from.x, y: from.y + dy }; + c2 = { x: to.x, y: to.y - dy }; + } + const mid = { + x: (from.x + 3 * c1.x + 3 * c2.x + to.x) / 8, + y: (from.y + 3 * c1.y + 3 * c2.y + to.y) / 8, + }; + const r = v => Math.round(v * 10) / 10; + const d = `M ${r(from.x)} ${r(from.y)} C ${r(c1.x)} ${r(c1.y)}, ${r(c2.x)} ${r(c2.y)}, ${r(to.x)} ${r(to.y)}`; + return { d, side, from, to, mid }; +} + +const BUSY = ['starting', 'restarting', 'created', 'removing', 'in_progress', 'queued', 'deploying', 'building']; +const BAD = ['exited', 'stopped', 'dead', 'failed', 'error']; + +/** Coolify status string → 'ok' | 'warn' | 'bad' | 'idle' | 'busy'. */ +export function statusTone(status) { + const value = String(status ?? '').trim().toLowerCase(); + if (!value) return 'idle'; + const [state, health = ''] = value.replace(/\)$/, '').split(/\s*[:(]\s*/); + if (BUSY.includes(state)) return 'busy'; + if (BAD.includes(state)) return 'bad'; + if (state === 'degraded') return 'warn'; + if (state === 'running' || state === 'healthy') return health === 'unhealthy' ? 'warn' : 'ok'; + return 'idle'; +} + +/** "running:healthy" → "Running (healthy)". */ +export function statusLabel(status) { + const value = String(status ?? '').trim(); + if (!value) return 'Unknown'; + const [state, health] = value.replace(/\)$/, '').split(/\s*[:(]\s*/); + const word = state.charAt(0).toUpperCase() + state.slice(1).replace(/_/g, ' '); + return health ? `${word} (${health})` : word; +} + +/** + * Connect and server views: projects of the resources, each with its environments, sorted by name. + * @returns {{uuid: string, name: string, environments: {uuid: string, name: string}[]}[]} + */ +export function filterOptions(graph) { + const projects = new Map(); + for (const n of graph?.nodes || []) { + if (!n.project || !n.environment) continue; + if (!projects.has(n.project.uuid)) projects.set(n.project.uuid, { ...n.project, environments: new Map() }); + projects.get(n.project.uuid).environments.set(n.environment.uuid, n.environment); + } + const byName = (a, b) => String(a.name).localeCompare(String(b.name)); + return [...projects.values()] + .map(p => ({ uuid: p.uuid, name: p.name, environments: [...p.environments.values()].sort(byName) })) + .sort(byName); +} + +/** + * Connect and server views: only the resources of the chosen project / environment and their links. + * Variables always stay, so they can be added to the shown resources. + */ +export function filterGraph(graph, filter = {}) { + if (!graph || (!filter.project && !filter.environment)) return graph; + const keep = n => isSource(n) + || ((!filter.project || n.project?.uuid === filter.project) && (!filter.environment || n.environment?.uuid === filter.environment)); + const nodes = (graph.nodes || []).filter(keep); + const ids = new Set(nodes.map(n => n.id)); + return { ...graph, nodes, links: (graph.links || []).filter(l => ids.has(l.source) && ids.has(l.target)) }; +} + +/** + * Connect view: links go from a shared variable (not a deleted one) or a tag to a resource. + * Server view: links are saved on an environment canvas, so both cards must be in the same environment. + */ +export function canConnect(graph, source, target) { + if (graph?.layout === 'server') { + const environment = graph.nodes?.find(n => n.id === source)?.environment?.uuid; + return !!environment && source !== target && graph.nodes.find(n => n.id === target)?.environment?.uuid === environment; + } + if (graph?.layout !== 'connect') return !!source && !!target && source !== target; + const from = graph.nodes?.find(n => n.id === source); + const to = graph.nodes?.find(n => n.id === target); + return isSource(from) && !from.missing && !!to && !isSource(to); +} + +/** A link may be drawn between two different nodes that are not linked yet (either direction). */ +export function canLink(links, source, target) { + if (!source || !target || source === target) return false; + return !(links || []).some(l => (l.source === source && l.target === target) || (l.source === target && l.target === source)); +} + +// ── Card and panel constants (used by the Svelte components) ────────── + +// Application and service events go to the team channel; database events go to the user channel. +export const STATUS_CHANNELS = [['team', ['ApplicationStatusChanged', 'ServiceStatusChanged']], ['user', ['DatabaseStatusChanged']]]; +export const TYPE_LABELS = { application: 'Application', service: 'Service', database: 'Database', container: 'Unmanaged container', variable: 'Shared variable', tag: 'Tag' }; +// Wording of the connect view, per data-mode (shared variables or tags). +export const CONNECT_TEXT = { + variables: { + source: 'Variable', + added: (source, target) => `Added ${source} to ${target}. Redeploy it to use the variable.`, + removed: (source, target) => `Removed ${source} from ${target}. Redeploy it to apply.`, + already: (source, target) => `${target} already uses ${source}.`, + usedBy: 'Used by', + noTargets: 'No resource uses this variable.', + allTargets: 'Every resource already uses this variable.', + port: 'Drag to a resource to add this variable to it', + noSources: 'This resource uses no shared variable of this scope.', + sources: 'Shared variables', + addSources: 'Add shared variables', + searchSources: 'Search variables', + noMoreSources: 'No other shared variable to add.', + note: node => `Adding creates a ${node.name} variable with the value ${node.reference} on the resource. Redeploy or restart the resource to apply changes.`, + viewOnly: 'You can look around. Only team admins can add shared variables to resources.', + empty: 'No shared variables or resources yet', + }, + tags: { + source: 'Tag', + added: (source, target) => `Tagged ${target} with ${source}.`, + removed: (source, target) => `Removed the ${source} tag from ${target}.`, + already: (source, target) => `${target} already has the ${source} tag.`, + usedBy: 'Tagged', + noTargets: 'No resource has this tag.', + allTargets: 'Every resource already has this tag.', + port: 'Drag to a resource to tag it', + noSources: 'This resource has no tag.', + sources: 'Tags', + addSources: 'Add tags', + searchSources: 'Search tags', + noMoreSources: 'No other tag to add. Create tags on the Tags page.', + note: () => 'The deploy webhook of a tag deploys every tagged application and service.', + viewOnly: 'You can look around. Only team admins can tag resources.', + empty: 'No tags or resources yet', + }, +}; +export const TONE_DOT = { + ok: 'bg-emerald-500', + warn: 'bg-warning', + bad: 'bg-red-500', + idle: 'bg-neutral-400 dark:bg-neutral-500', + busy: 'bg-warning', +}; +// Outline icons, drawn from local paths. +export const ICONS = { + application: ['M3 6.5A2.5 2.5 0 0 1 5.5 4h13A2.5 2.5 0 0 1 21 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5z', 'M3 9h18', 'M9.5 13l-2 2 2 2', 'M14.5 13l2 2-2 2'], + service: ['M12 4 3 8.5l9 4.5 9-4.5z', 'M3 12.5l9 4.5 9-4.5', 'M3 16.5l9 4.5 9-4.5'], + database: ['M5 6c0-1.66 3.13-3 7-3s7 1.34 7 3-3.13 3-7 3-7-1.34-7-3z', 'M5 6v12c0 1.66 3.13 3 7 3s7-1.34 7-3V6', 'M5 12c0 1.66 3.13 3 7 3s7-1.34 7-3'], + container: ['M12 3 4 7.5v9L12 21l8-4.5v-9z', 'M4 7.5 12 12l8-4.5', 'M12 12v9'], + server: ['M4 4h16v6H4z', 'M4 14h16v6H4z', 'M7.5 7h.01', 'M7.5 17h.01'], + refresh: ['M20 11a8 8 0 0 0-14.9-3.9', 'M4 4v4h4', 'M4 13a8 8 0 0 0 14.9 3.9', 'M20 20v-4h-4'], + plus: ['M12 5v14', 'M5 12h14'], + minus: ['M5 12h14'], + fit: ['M4 9V4h5', 'M20 9V4h-5', 'M4 15v5h5', 'M20 15v5h-5'], + x: ['M6 6l12 12', 'M18 6 6 18'], + external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4'], + link: ['M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1', 'M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1'], + variable: ['M8 4H7a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2 2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1', 'M16 4h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2 2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-1'], + search: ['M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z', 'M20 20l-4-4'], + tag: ['M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z', 'M7.5 7.5h.01'], + info: ['M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z', 'M12 11v5', 'M12 8h.01'], +}; + +/** A saved { x, y, z } canvas view, or null when it is missing or not valid. */ +export function readView(key, storage = globalThis.localStorage) { + try { + const v = JSON.parse(storage?.getItem(key) ?? 'null'); + const ok = v && [v.x, v.y, v.z].every(Number.isFinite) && v.z >= MIN_ZOOM && v.z <= MAX_ZOOM; + return ok ? { x: v.x, y: v.y, z: v.z } : null; + } catch { + return null; + } +} + +// Small screens: a notice that the canvas is not optimized for mobile. Shown below 768px (md); +// closed once, it stays closed on every canvas (same key as the deploy canvas notice). +export const MOBILE_NOTICE_KEY = 'coolify-canvas:mobile-notice-dismissed'; diff --git a/resources/js/environment-canvas.test.js b/resources/js/environment-canvas.test.js new file mode 100644 index 0000000000..5d7c096be9 --- /dev/null +++ b/resources/js/environment-canvas.test.js @@ -0,0 +1,437 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + CARD_WIDTH, + MAX_ROWS, + COLUMN_GAP, + ROW_GAP, + ZONE_HEADER, + ZONE_PADDING, + canConnect, + canLink, + dragRules, + filterGraph, + groupByUse, + filterOptions, + groupFrames, + layoutBlock, + estimateNodeHeight, + layoutGraph, + layoutServerView, + linkPath, + statusLabel, + statusTone, + zoneBoxes, + readView, +} from './environment-canvas.js'; +import { freeSpot } from './canvas-core.js'; + +const node = (id, type, zone, extra = {}) => ({ id, type, zone, name: id, status: 'running:healthy', extra_zones: [], urls: [], children: [], volumes: [], ...extra }); +const graph = (nodes, extra = {}) => ({ + zones: [{ id: 's1', name: 'One' }, { id: 's2', name: 'Two' }], + nodes, + links: [], + positions: {}, + ...extra, +}); +const column = n => n * (CARD_WIDTH + COLUMN_GAP); + +test('places applications, services, and databases in their own columns', () => { + const p = layoutGraph(graph([node('db', 'database', 's1'), node('app', 'application', 's1'), node('svc', 'service', 's1')])); + assert.equal(p.app.x, column(0)); + assert.equal(p.svc.x, column(1)); + assert.equal(p.db.x, column(2)); + const top = ZONE_HEADER + ZONE_PADDING; + assert.deepEqual([p.app.y, p.svc.y, p.db.y], [top, top, top]); +}); + +test('stacks rows of the same type inside a column', () => { + const first = node('a1', 'application', 's1', { urls: ['https://a.test'] }); + const p = layoutGraph(graph([first, node('a2', 'application', 's1')])); + assert.equal(p.a2.x, p.a1.x); + assert.equal(p.a2.y, p.a1.y + estimateNodeHeight(first) + ROW_GAP); +}); + +test('wraps a long list into another column and shifts later types right', () => { + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`a${i}`, 'application', 's1')); + const p = layoutGraph(graph([...apps, node('db', 'database', 's1')])); + assert.equal(p[`a${MAX_ROWS - 1}`].x, column(0)); + assert.deepEqual(p[`a${MAX_ROWS}`], { x: column(1), y: ZONE_HEADER + ZONE_PADDING }); + assert.equal(p.db.x, column(2)); +}); + +test('stacks zones in graph order with the "No server" zone last', () => { + const p = layoutGraph(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')])); + assert.ok(p.a.y < p.b.y, 'first server zone on top'); + assert.ok(p.b.y < p.orphan.y, 'no-server zone at the bottom'); + const boxes = zoneBoxes(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')]), p); + assert.deepEqual(boxes.map(b => b.name), ['One', 'Two', 'No server']); + for (let i = 1; i < boxes.length; i++) assert.ok(boxes[i].y > boxes[i - 1].y + boxes[i - 1].height, 'zones do not overlap'); +}); + +test('treats a zone id that is not in the zone list as no server', () => { + const g = graph([node('x', 'database', 'gone')]); + assert.deepEqual(zoneBoxes(g, layoutGraph(g)).map(b => b.id), [null]); +}); + +test('stored positions win over auto placement', () => { + const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: { app: { x: 500, y: -40 } } })); + assert.deepEqual(p.app, { x: 500, y: -40 }); +}); + +test('treats an empty positions list from PHP as nothing stored', () => { + const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: [] })); + assert.equal(p.app.x, 0); +}); + +test('places new nodes in a wrapped grid below the stored cards of their zone', () => { + const stored = node('a1', 'application', 's1'); + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`n${i}`, 'application', 's1')); + const p = layoutGraph(graph([stored, ...apps, node('d1', 'database', 's1')], { positions: { a1: { x: 100, y: 200 } } })); + const top = 200 + estimateNodeHeight(stored) + ROW_GAP; + assert.deepEqual(p.n0, { x: 100, y: top }); + assert.deepEqual(p[`n${MAX_ROWS}`], { x: 100 + column(1), y: top }, 'wraps after MAX_ROWS cards'); + assert.deepEqual(p.d1, { x: 100 + column(2), y: top }, 'databases go right of the application columns'); +}); + +test('auto-placed zones go below zones with stored cards', () => { + const p = layoutGraph(graph([node('a', 'application', 's1'), node('b', 'application', 's2')], { positions: { a: { x: 0, y: 1000 } } })); + assert.ok(p.b.y > 1000 + estimateNodeHeight(node('a', 'application', 's1')) + ZONE_PADDING + ZONE_HEADER); +}); + +test('estimates taller cards for urls, chips, and children', () => { + const plain = estimateNodeHeight(node('a', 'service', 's1')); + const rich = estimateNodeHeight(node('b', 'service', 's1', { urls: ['https://x'], volumes: ['v'], children: [{ name: 'c' }, { name: 'd' }] })); + assert.ok(rich > plain); + const capped = estimateNodeHeight(node('c', 'service', 's1', { children: Array.from({ length: 20 }, (_, i) => ({ name: `c${i}` })) })); + const six = estimateNodeHeight(node('d', 'service', 's1', { children: Array.from({ length: 6 }, (_, i) => ({ name: `c${i}` })) })); + assert.equal(capped, six, 'children beyond the cap add one "+N more" row'); +}); + +test('zone boxes wrap their cards with padding and header', () => { + const g = graph([node('a', 'application', 's1'), node('d', 'database', 's1')]); + const positions = { a: { x: 0, y: 0 }, d: { x: 400, y: 50 } }; + const sizes = { a: { width: 260, height: 100 }, d: { width: 260, height: 80 } }; + const [box] = zoneBoxes(g, positions, sizes); + assert.deepEqual( + { id: box.id, x: box.x, y: box.y, width: box.width, height: box.height, nodes: box.nodes }, + { id: 's1', x: -ZONE_PADDING, y: -ZONE_PADDING - ZONE_HEADER, width: 660 + ZONE_PADDING * 2, height: 130 + ZONE_PADDING * 2 + ZONE_HEADER, nodes: ['a', 'd'] }, + ); +}); + +test('zone boxes skip zones without cards', () => { + assert.deepEqual(zoneBoxes(graph([node('a', 'application', 's2')]), { a: { x: 0, y: 0 } }).map(b => b.id), ['s2']); +}); + +const rect = (x, y) => ({ x, y, width: 260, height: 100 }); + +test('links go from the right edge to the left edge when the target is on the right', () => { + const path = linkPath(rect(0, 0), rect(400, 200)); + assert.equal(path.side, 'right'); + assert.deepEqual(path.from, { x: 260, y: 50 }); + assert.deepEqual(path.to, { x: 400, y: 250 }); + assert.match(path.d, /^M 260 50 C /); +}); + +test('links go from the left edge to the right edge when the target is on the left', () => { + const path = linkPath(rect(400, 0), rect(0, 0)); + assert.equal(path.side, 'left'); + assert.deepEqual(path.from, { x: 400, y: 50 }); + assert.deepEqual(path.to, { x: 260, y: 50 }); +}); + +test('links run vertically between stacked cards', () => { + const down = linkPath(rect(0, 0), rect(40, 300)); + assert.equal(down.side, 'down'); + assert.deepEqual([down.from, down.to], [{ x: 130, y: 100 }, { x: 170, y: 300 }]); + const up = linkPath(rect(0, 300), rect(0, 0)); + assert.equal(up.side, 'up'); + assert.deepEqual([up.from, up.to], [{ x: 130, y: 300 }, { x: 130, y: 100 }]); +}); + +test('link midpoint sits between both ends', () => { + const { mid } = linkPath(rect(0, 0), rect(600, 0)); + assert.deepEqual(mid, { x: 430, y: 50 }); +}); + +test('maps Coolify statuses to tones', () => { + const cases = { + 'running:healthy': 'ok', + 'running:unknown': 'ok', + running: 'ok', + 'running (healthy)': 'ok', + 'running:unhealthy': 'warn', + 'degraded:unhealthy': 'warn', + 'exited:unhealthy': 'bad', + exited: 'bad', + stopped: 'bad', + restarting: 'busy', + 'starting:unknown': 'busy', + paused: 'idle', + '': 'idle', + null: 'idle', + }; + for (const [status, tone] of Object.entries(cases)) { + assert.equal(statusTone(status === 'null' ? null : status), tone, status); + } +}); + +test('formats status labels', () => { + assert.equal(statusLabel('running:healthy'), 'Running (healthy)'); + assert.equal(statusLabel('exited'), 'Exited'); + assert.equal(statusLabel(''), 'Unknown'); +}); + +test('refuses self links and duplicates in either direction', () => { + const links = [{ id: 'l1', source: 'a', target: 'b', label: null }]; + assert.equal(canLink(links, 'a', 'a'), false); + assert.equal(canLink(links, 'a', 'b'), false); + assert.equal(canLink(links, 'b', 'a'), false); + assert.equal(canLink(links, 'a', 'c'), true); +}); + +test('a server view card has the same height as on the environment canvas', () => { + const card = { id: 'a', type: 'application' }; + // The group frame names the project / environment, so the card has no extra line for it. + assert.equal(estimateNodeHeight({ ...card, context: 'Acme / production' }), estimateNodeHeight(card)); +}); + +// ── Server view ── +const srv = (id, type, context, extra = {}) => ({ id, type, name: id, zone: 'z1', context, ...extra }); + +test('server view: a chain of linked cards runs left to right, source before target', () => { + const nodes = [srv('db', 'database', 'A / prod'), srv('web', 'application', 'A / prod'), srv('api', 'application', 'A / prod')]; + const { positions } = layoutBlock(nodes, [{ source: 'web', target: 'api' }, { source: 'api', target: 'db' }]); + assert.equal(positions.web.y, positions.api.y); + assert.equal(positions.api.y, positions.db.y); + assert.ok(positions.web.x < positions.api.x && positions.api.x < positions.db.x); + assert.equal(positions.api.x - positions.web.x, CARD_WIDTH + COLUMN_GAP); +}); + +test('server view: cards without links go below the chains, in a grid', () => { + const nodes = [srv('a', 'application', 'g'), srv('b', 'application', 'g'), srv('c', 'service', 'g'), srv('d', 'database', 'g'), srv('e', 'database', 'g')]; + const { positions } = layoutBlock(nodes, [{ source: 'a', target: 'd' }]); + assert.equal(positions.a.y, positions.d.y); // linked pair side by side + for (const id of ['b', 'c', 'e']) assert.ok(positions[id].y > positions.a.y, `${id} is below the chain`); + assert.equal(positions.b.y, positions.c.y); // one grid row +}); + +test('server view: a link cycle still gets every card a position', () => { + const nodes = [srv('x', 'application', 'g'), srv('y', 'application', 'g')]; + const { positions } = layoutBlock(nodes, [{ source: 'x', target: 'y' }, { source: 'y', target: 'x' }]); + assert.ok(positions.x && positions.y); + assert.notDeepEqual(positions.x, positions.y); +}); + +test('server view: one frame per project / environment, and the frames do not overlap', () => { + const graph = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')], + links: [{ id: 'l', source: 'a', target: 'b' }], + positions: {}, + }; + const positions = layoutGraph(graph); + const frames = groupFrames(graph.nodes, positions); + assert.deepEqual(frames.map(f => f.name), ['Blog / prod', 'Shop / prod']); + const [f1, f2] = frames; + assert.ok(f1.x + f1.width <= f2.x || f2.x + f2.width <= f1.x || f1.y + f1.height <= f2.y || f2.y + f2.height <= f1.y); + assert.equal(positions.a.y, positions.b.y); +}); + +test('server view: a group keeps the arrangement saved on its environment canvas', () => { + const graph = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'service', 'Shop / prod')], + links: [], + positions: { a: { x: 900, y: 500 }, b: { x: 600, y: 800 } }, + }; + const positions = layoutGraph(graph); + assert.equal(positions.a.x - positions.b.x, 300); + assert.equal(positions.b.y - positions.a.y, 300); + assert.ok(positions.c.y >= positions.b.y + estimateNodeHeight(graph.nodes[1]), 'a card without a saved position goes below'); +}); + +const envNode = (id, type, zone, environment, extra = {}) => srv(id, type, `Shop / ${environment}`, { zone, project: { uuid: 'p' }, environment: { uuid: environment }, ...extra }); + +test('server view: cards link only to cards of the same environment', () => { + const g = { + layout: 'server', + nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z2', 'prod'), envNode('c', 'application', 'z1', 'dev'), { id: 'container:1', type: 'container', zone: 'z1' }], + }; + assert.equal(canConnect(g, 'a', 'b'), true); + assert.equal(canConnect(g, 'a', 'c'), false); + assert.equal(canConnect(g, 'a', 'a'), false); + assert.equal(canConnect(g, 'a', 'container:1'), false); + assert.equal(canConnect(g, 'container:1', 'a'), false); +}); + +test('server view: a moved group saved on its environment canvas keeps its arrangement', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }, { id: 'z2', name: 'db' }], + nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z1', 'prod'), envNode('c', 'service', 'z2', 'prod'), envNode('d', 'application', 'z1', 'dev')], + links: [{ id: 'l', source: 'a', target: 'b' }], + positions: {}, + }; + const offsets = {}; + const positions = layoutServerView(g, offsets); + // The user drags b 100px down; the canvas saves its group (a, b) in environment canvas coordinates. + positions.b = { x: positions.b.x, y: positions.b.y + 100 }; + const saved = Object.fromEntries(['a', 'b'].map(id => [id, { x: positions[id].x - offsets[id].x, y: positions[id].y - offsets[id].y }])); + const again = layoutServerView({ ...g, positions: saved }); + assert.deepEqual(again.a, positions.a); + assert.deepEqual(again.b, positions.b); + // On the environment canvas the saved cards do not cover the card of the same environment on the other server. + const environment = layoutGraph({ zones: g.zones, nodes: g.nodes.filter(n => n.environment.uuid === 'prod'), positions: saved }); + const bottom = Math.max(...['a', 'b'].map(id => environment[id].y + estimateNodeHeight(g.nodes.find(n => n.id === id)))); + assert.ok(environment.c.y > bottom); +}); + +test('variables view: variables stack on the left by name, resources sit to their right', () => { + const g = { + layout: 'connect', + zones: [{ id: 'sources', name: 'Team variables', icon: 'variable' }, { id: 'resources', name: 'Resources', icon: 'service' }], + nodes: [ + node('variable:2', 'variable', 'sources', { name: 'TOKEN' }), + node('variable:1', 'variable', 'sources', { name: 'API_URL' }), + node('app', 'application', 'resources', { context: 'Shop / prod' }), + ], + links: [{ id: 'variable:1|app', source: 'variable:1', target: 'app' }], + positions: {}, + }; + const p = layoutGraph(g); + + assert.equal(p['variable:1'].x, 0); + assert.equal(p['variable:2'].x, 0); + assert.ok(p['variable:1'].y < p['variable:2'].y); + assert.ok(p.app.x > CARD_WIDTH + ZONE_PADDING * 2); + const [variables, resources] = zoneBoxes(g, p); + assert.ok(variables.x + variables.width < resources.x, 'zones do not overlap'); +}); + +test('variables view: links only go from an existing shared variable to a resource', () => { + const g = { + layout: 'connect', + nodes: [ + node('variable:1', 'variable', 'sources'), + node('variable:2', 'variable', 'sources'), + node('missing:OLD', 'variable', 'sources', { missing: true }), + node('app', 'application', 'resources'), + ], + }; + + assert.equal(canConnect(g, 'variable:1', 'app'), true); + assert.equal(canConnect(g, 'app', 'variable:1'), false); + assert.equal(canConnect(g, 'variable:1', 'variable:2'), false); + assert.equal(canConnect(g, 'missing:OLD', 'app'), false); + assert.equal(canConnect({ nodes: g.nodes }, 'app', 'variable:1'), true, 'other canvases link any two cards'); +}); + +test('variables view: filter resources by project and environment, keep every variable', () => { + const shop = { uuid: 'p1', name: 'Shop' }; + const blog = { uuid: 'p2', name: 'Blog' }; + const g = { + layout: 'connect', + nodes: [ + node('variable:1', 'variable', 'sources'), + node('a', 'application', 'resources', { project: shop, environment: { uuid: 'e1', name: 'production' } }), + node('b', 'application', 'resources', { project: shop, environment: { uuid: 'e2', name: 'staging' } }), + node('c', 'application', 'resources', { project: blog, environment: { uuid: 'e3', name: 'production' } }), + ], + links: [{ id: 'l1', source: 'variable:1', target: 'a' }, { id: 'l2', source: 'variable:1', target: 'c' }], + }; + + assert.deepEqual(filterOptions(g).map(p => [p.name, p.environments.map(e => e.name)]), [['Blog', ['production']], ['Shop', ['production', 'staging']]]); + assert.equal(filterGraph(g, {}), g); + const shopOnly = filterGraph(g, { project: 'p1' }); + assert.deepEqual(shopOnly.nodes.map(n => n.id), ['variable:1', 'a', 'b']); + assert.deepEqual(shopOnly.links.map(l => l.id), ['l1']); + assert.deepEqual(filterGraph(g, { environment: 'e2' }).nodes.map(n => n.id), ['variable:1', 'b']); + assert.deepEqual(filterGraph(g, { environment: 'e2' }).links, []); +}); + +test('variables view: resources that use a variable get their own zone, in one column', () => { + const ctx = { context: 'Shop / prod' }; + const g = groupByUse({ + layout: 'connect', + zones: [{ id: 'sources', icon: 'variable' }, { id: 'connected', icon: 'link' }, { id: 'resources', icon: 'service' }], + nodes: [ + node('variable:1', 'variable', 'sources'), + node('app', 'application', 'resources', ctx), + node('db', 'database', 'resources', ctx), + node('idle', 'application', 'resources', ctx), + ], + links: [{ id: 'l1', source: 'variable:1', target: 'app' }, { id: 'l2', source: 'variable:1', target: 'db' }], + }); + assert.deepEqual(g.nodes.map(n => n.zone), ['sources', 'connected', 'connected', 'resources']); + + const p = layoutGraph(g); + assert.equal(p.app.x, p.db.x, 'connected cards share one column, so no link runs behind a card'); + assert.ok(p.db.y > p.app.y); + const boxes = zoneBoxes(g, p); + const connected = boxes.find(b => b.id === 'connected'); + const others = boxes.find(b => b.id === 'resources'); + assert.ok(connected.y + connected.height < others.y, 'other resources sit below the connected zone'); +}); + +test('readView restores a saved zoom and pan, and ignores bad data', () => { + const store = values => ({ getItem: key => values[key] ?? null }); + assert.deepEqual(readView('k', store({ k: '{"x":-120.5,"y":40,"z":0.6}' })), { x: -120.5, y: 40, z: 0.6 }); + assert.equal(readView('k', store({})), null); + assert.equal(readView('k', store({ k: 'not json' })), null); + assert.equal(readView('k', store({ k: '{"x":1,"y":2}' })), null); + assert.equal(readView('k', store({ k: '{"x":1,"y":2,"z":9}' })), null); +}); + +test('server view: the unmanaged containers group comes first in its zone', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [ + node('a', 'application', 'z1', { context: 'Alpha / prod' }), + node('container:1', 'container', 'z1', { context: 'Unmanaged containers' }), + ], + links: [], + positions: {}, + }; + const positions = layoutServerView(g); + assert.ok(positions['container:1'].x < positions.a.x); +}); + +const apart = (a, b) => a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y; +// Drag card `id` to the top-left of `onto`, as the canvas does: stop at the nearest free spot. +const dragOnto = (g, positions, id, onto) => { + const { others, fits } = dragRules(g, positions, {}, id); + const card = g.nodes.find(n => n.id === id); + const start = { ...positions[id], w: CARD_WIDTH, h: estimateNodeHeight(card) }; + assert.deepEqual(freeSpot(start, others, undefined, fits), start, 'the laid out card is already free, so it does not jump'); + const spot = freeSpot({ ...start, ...positions[onto] }, others, undefined, fits); + return { ...positions, [id]: { x: spot.x, y: spot.y } }; +}; + +test('a dragged card stops before it covers a card or another zone', () => { + const g = graph([node('a', 'application', 's1'), node('b', 'database', 's1'), node('c', 'application', 's2')]); + const before = layoutGraph(g); + for (const [id, onto] of [['a', 'b'], ['a', 'c'], ['c', 'a']]) { + const positions = dragOnto(g, before, id, onto); + const rects = Object.fromEntries(g.nodes.map(n => [n.id, ({ ...positions[n.id], width: CARD_WIDTH, height: estimateNodeHeight(n) })])); + for (const n of g.nodes) if (n.id !== id) assert.ok(apart(rects[id], rects[n.id]), `${id} does not cover ${n.id}`); + const [z1, z2] = zoneBoxes(g, positions); + assert.ok(apart(z1, z2), 'the zones do not overlap'); + } +}); + +test('server view: a dragged card stops before its group covers another group', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')], + links: [], + positions: {}, + }; + const positions = dragOnto(g, layoutGraph(g), 'a', 'c'); + const [f1, f2] = groupFrames(g.nodes, positions); + assert.ok(apart(f1, f2), 'the project / environment frames do not overlap'); +}); diff --git a/resources/views/components/canvas-view-toggle.blade.php b/resources/views/components/canvas-view-toggle.blade.php new file mode 100644 index 0000000000..d3db4e10d0 --- /dev/null +++ b/resources/views/components/canvas-view-toggle.blade.php @@ -0,0 +1,51 @@ +@props(['listUrl', 'canvasUrl', 'onCanvas' => false, 'views' => null]) +@php + $item = 'flex size-7.5 items-center justify-center rounded-md transition-colors'; + $idle = 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'; + $canvasItem = 'relative flex h-7.5 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium transition-colors'; + $views ??= ['table' => ['Table view', 'unordered-list'], 'grid' => ['Grid view', 'grid']]; + // One list view (no table / grid choice): the list page shows it selected. + $singleList = count($views) === 1; +@endphp +{{-- Table / grid / canvas switcher. On the list page, table and grid switch the + parent's Alpine viewMode; on the canvas page they link back to the list + (?view= makes the list the remembered view again, see RemembersCanvasView). + Pages with one list view pass :views="['list' => [...]]". --}} +
    merge(['class' => 'view-toggle']) }}> + @foreach ($views as $mode => [$label, $icon]) + @if ($onCanvas) + + + + @elseif ($singleList) + + + + @else + + @endif + @endforeach + @if ($onCanvas) + + + Canvas + + @else + {{-- A label and a purple "Try it" badge, so people notice the canvas view. --}} + + + Canvas + + Try it + + + @endif +
    diff --git a/resources/views/components/dashboard/navbar.blade.php b/resources/views/components/dashboard/navbar.blade.php index db6242f56a..8e7e390b81 100644 --- a/resources/views/components/dashboard/navbar.blade.php +++ b/resources/views/components/dashboard/navbar.blade.php @@ -109,7 +109,7 @@ @if ($showNav) - +
    @if ($currentRoute === 'project.service.configuration') @@ -132,25 +136,7 @@

    @elseif ($currentRoute === 'project.service.scheduled-tasks.show') - @elseif ($currentRoute === 'project.service.scheduled-tasks') - @elseif ($currentRoute === 'project.service.webhooks') @elseif ($currentRoute === 'project.service.resource-operations') diff --git a/resources/views/livewire/project/service/index.blade.php b/resources/views/livewire/project/service/index.blade.php index 268eaae499..76e9cb4c20 100644 --- a/resources/views/livewire/project/service/index.blade.php +++ b/resources/views/livewire/project/service/index.blade.php @@ -7,11 +7,11 @@ @if (! $embedded && $resourceType === 'database') @elseif (! $embedded) - + @endif
    @if ($resourceType === 'application') diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index acde772a26..ddd7228de2 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -76,7 +76,7 @@ - @foreach (['general', 's3', 'retention', 'danger'] as $modalSection) + @foreach (['general', 'danger'] as $modalSection)
    @if ($selectedDatabaseBackup) -
    -
    - @if ($type === 'application') - - @elseif ($type === 'service') - - @else - - @endif -
    +
    + @if ($type === 'application') + + @elseif ($type === 'service') + + @else + + @endif
    + @if ($selectedTask) + + + + + + @endif + @@ -32,13 +41,10 @@ 'running' => 'Running', default => 'Not run yet', }; - $taskRoute = $resource->type() === 'application' - ? 'project.application.scheduled-tasks' - : 'project.service.scheduled-tasks'; @endphp - + @empty -
    +
    + + + - @if ($resource->isRunning()) @can('update', $resource) @@ -16,13 +28,6 @@ {{ $isEnabled ? 'Disable' : 'Enable' }} - @endcan @@ -47,8 +52,28 @@ - + + @can('update', $resource) +
    + + +

    The task stops running and cannot be restored.

    + + + +
    +
    +
    + @endcan
    diff --git a/resources/views/livewire/project/shared/storages/volume-backups.blade.php b/resources/views/livewire/project/shared/storages/volume-backups.blade.php index cc354e88d5..052aa48f5e 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups.blade.php @@ -1,9 +1,22 @@ -
    - @if ($section === 'retention') - @include('livewire.project.shared.storages.volume-backups.retention') - @elseif ($section === 's3') - @include('livewire.project.shared.storages.volume-backups.s3') - @elseif ($section === 'executions') +
    + @if (count($pendingDownloads) > 0) + + @endif + @if ($actionsInHeader && $backup) + @can('update', $resource) + @teleport('#resource-action-hud-slot') + + @endteleport + @endcan + @endif + @if ($section === 'executions') @include('livewire.project.shared.storages.volume-backups.executions') @elseif ($section === 'danger') @include('livewire.project.shared.storages.volume-backups.danger') @@ -14,7 +27,11 @@ @script @endscript diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index 041fb4470d..dabc86e905 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -137,7 +137,7 @@ - @if ($execution->status === 'success' && ! $execution->local_storage_deleted) + @if ($execution->status === 'success' && $execution->isDownloadable()) - -
    -
    +
    diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 9567a3f7fa..28f419d03c 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -34,7 +34,7 @@ 'label' => 'Danger Zone', 'route' => 'source.github.danger', 'active' => $activeTab === 'danger', - 'icon' => 'shield-alert', + 'icon' => 'danger', ], ]; @endphp @@ -47,12 +47,14 @@ @if ($showSettingsSidebar)
    - +
    @if (!data_get($github_app, 'installation_id') && $activeTab === 'general') diff --git a/resources/views/livewire/storage/index.blade.php b/resources/views/livewire/storage/index.blade.php index 30f492705e..ddb23ab6ea 100644 --- a/resources/views/livewire/storage/index.blade.php +++ b/resources/views/livewire/storage/index.blade.php @@ -44,7 +44,7 @@ matches(values) { const query = this.search.trim().toLowerCase(); return !query || values.some(value => String(value || '').toLowerCase().includes(query)); }, setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-s3-storages-view', mode); } }"> - @include('livewire.shared.list-search-controls', ['placeholder' => 'Search S3 storages', 'singular' => 'storage', 'plural' => 'storages']) + @include('livewire.shared.list-search-controls', ['placeholder' => 'Search S3 storages'])
    @foreach ($s3 as $storage) 'Danger Zone', 'route' => 'storage.danger', 'active' => $currentRoute === 'storage.danger', - 'icon' => 'shield-alert', + 'icon' => 'danger', ], ]; @endphp @@ -36,12 +36,14 @@ @if ($showSettingsSidebar)
    - +
    @if ($currentRoute === 'storage.show') diff --git a/resources/views/livewire/tags/canvas.blade.php b/resources/views/livewire/tags/canvas.blade.php index 3a01be7f6f..246addc1e6 100644 --- a/resources/views/livewire/tags/canvas.blade.php +++ b/resources/views/livewire/tags/canvas.blade.php @@ -2,7 +2,7 @@ Tags > Canvas | Coolify -
    {{-- The canvas moves this group into its toolbar (page actions last, at the right end), diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index fc93d4a72c..2a3ebe330b 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -71,7 +71,7 @@ matches(values) { const query = this.search.trim().toLowerCase(); return !query || values.some(value => String(value || '').toLowerCase().includes(query)); }, setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-sources-view', mode); } }"> - @include('livewire.shared.list-search-controls', ['placeholder' => 'Search sources', 'singular' => 'source', 'plural' => 'sources']) + @include('livewire.shared.list-search-controls', ['placeholder' => 'Search sources'])
    @foreach ($sources as $source) @if ($source->getMorphClass() === 'App\Models\GithubApp') diff --git a/routes/web.php b/routes/web.php index 4dde2c922f..a484892564 100644 --- a/routes/web.php +++ b/routes/web.php @@ -26,6 +26,7 @@ use App\Livewire\Admin\Index as AdminIndex; use App\Livewire\Analytics; use App\Livewire\Boarding\Index as BoardingIndex; use App\Livewire\Dashboard; +use App\Livewire\Dashboard\Canvas as DashboardCanvas; use App\Livewire\DeployCanvas; use App\Livewire\Destination\Index as DestinationIndex; use App\Livewire\Destination\Resources as DestinationResources; @@ -192,6 +193,7 @@ Route::middleware(['auth', 'verified'])->group(function () { }); Route::get('/', Dashboard::class)->name('dashboard'); + Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); Route::get('/analytics', Analytics::class)->name('analytics'); Route::get('/metrics', FleetMetrics::class)->name('metrics'); Route::get('/canvas', DeployCanvas::class)->name('deploy-canvas'); @@ -220,8 +222,10 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::delete('/shared-variables/{scope}/{uuid}/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); Route::get('/servers', [ServersController::class, 'servers'])->middleware(['api.ability:read']); Route::get('/servers/canvas', [ServerCanvasController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/dashboard/canvas', [ServerCanvasController::class, 'dashboard'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/canvas', [ServerCanvasController::class, 'show'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/canvas/containers', [ServerCanvasController::class, 'containers'])->middleware(['api.ability:read']); + Route::get('/servers/{uuid}/canvas/usage', [ServerCanvasController::class, 'usage'])->middleware(['api.ability:read']); Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']); @@ -387,8 +391,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/persistent-storage', ApplicationConfiguration::class)->name('project.application.persistent-storage'); Route::get('/backups', ApplicationBackupIndex::class)->name('project.application.backup.index'); Route::get('/backups/{backup_uuid}', ApplicationBackupShow::class)->name('project.application.backup.show'); - Route::get('/backups/{backup_uuid}/s3', ApplicationBackupShow::class)->name('project.application.backup.s3'); - Route::get('/backups/{backup_uuid}/retention', ApplicationBackupShow::class)->name('project.application.backup.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/application/{application_uuid}/backups/{backup_uuid}'); + Route::redirect('/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/application/{application_uuid}/backups/{backup_uuid}'); Route::get('/backups/{backup_uuid}/executions', ApplicationBackupShow::class)->name('project.application.backup.executions'); Route::get('/backups/{backup_uuid}/danger', ApplicationBackupShow::class)->name('project.application.backup.danger'); Route::get('/source', ApplicationConfiguration::class)->name('project.application.source'); @@ -410,7 +415,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/logs', Logs::class)->name('project.application.logs'); Route::get('/terminal', ExecuteContainerCommand::class)->name('project.application.command')->middleware('can.access.terminal'); Route::get('/files', FileBrowser::class)->name('project.application.files')->middleware('can.access.terminal'); - Route::get('/tasks/{task_uuid}', ApplicationConfiguration::class)->name('project.application.scheduled-tasks'); + // Tasks open in a modal on the list page; keep old links (e.g. notifications) working. + Route::get('/tasks/{task_uuid}', fn () => redirect()->route('project.application.scheduled-tasks.show', get_route_parameters()))->name('project.application.scheduled-tasks'); }); Route::prefix('project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}')->group(function () { Route::get('/', DatabaseConfiguration::class)->name('project.database.configuration'); @@ -431,8 +437,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/files', FileBrowser::class)->name('project.database.files')->middleware('can.access.terminal'); Route::get('/backups', DatabaseBackupIndex::class)->name('project.database.backup.index'); Route::get('/backups/{backup_uuid}', DatabaseBackupExecution::class)->name('project.database.backup.execution'); - Route::get('/backups/{backup_uuid}/s3', DatabaseBackupExecution::class)->name('project.database.backup.s3'); - Route::get('/backups/{backup_uuid}/retention', DatabaseBackupExecution::class)->name('project.database.backup.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}/backups/{backup_uuid}'); + Route::redirect('/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}/backups/{backup_uuid}'); Route::get('/backups/{backup_uuid}/executions', DatabaseBackupExecution::class)->name('project.database.backup.executions'); Route::get('/backups/{backup_uuid}/danger', DatabaseBackupExecution::class)->name('project.database.backup.danger'); }); @@ -447,8 +454,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/import-backup/{stack_service_uuid}', ServiceImportBackup::class)->name('project.service.import-backup.database')->middleware('can.update.resource'); Route::get('/storage-backups', Index::class)->name('project.service.volume-backups.index'); Route::get('/storage-backups/{backup_uuid}', Show::class)->name('project.service.volume-backups.show'); - Route::get('/storage-backups/{backup_uuid}/s3', Show::class)->name('project.service.volume-backups.s3'); - Route::get('/storage-backups/{backup_uuid}/retention', Show::class)->name('project.service.volume-backups.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/storage-backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/storage-backups/{backup_uuid}'); + Route::redirect('/storage-backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/storage-backups/{backup_uuid}'); Route::get('/storage-backups/{backup_uuid}/executions', Show::class)->name('project.service.volume-backups.executions'); Route::get('/storage-backups/{backup_uuid}/danger', Show::class)->name('project.service.volume-backups.danger'); Route::get('/scheduled-tasks', ServiceConfiguration::class)->name('project.service.scheduled-tasks.show'); @@ -460,14 +468,15 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/files', FileBrowser::class)->name('project.service.files')->middleware('can.access.terminal'); Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}', ServiceDatabaseBackups::class)->name('project.service.database.backup.show'); - Route::get('/{stack_service_uuid}/backups/{backup_uuid}/s3', ServiceDatabaseBackups::class)->name('project.service.database.backup.s3'); - Route::get('/{stack_service_uuid}/backups/{backup_uuid}/retention', ServiceDatabaseBackups::class)->name('project.service.database.backup.retention'); + Route::redirect('/{stack_service_uuid}/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/{stack_service_uuid}/backups/{backup_uuid}'); + Route::redirect('/{stack_service_uuid}/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/{stack_service_uuid}/backups/{backup_uuid}'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}/executions', ServiceDatabaseBackups::class)->name('project.service.database.backup.executions'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}/danger', ServiceDatabaseBackups::class)->name('project.service.database.backup.danger'); Route::get('/{stack_service_uuid}/import', ServiceImportBackup::class)->name('project.service.database.import')->middleware('can.update.resource'); Route::get('/{stack_service_uuid}/advanced', ServiceIndex::class)->name('project.service.index.advanced'); Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index'); - Route::get('/tasks/{task_uuid}', ServiceConfiguration::class)->name('project.service.scheduled-tasks'); + // Tasks open in a modal on the list page; keep old links (e.g. notifications) working. + Route::get('/tasks/{task_uuid}', fn () => redirect()->route('project.service.scheduled-tasks.show', get_route_parameters()))->name('project.service.scheduled-tasks'); }); Route::get('/servers', ServerIndex::class)->name('server.index'); @@ -570,15 +579,29 @@ Route::middleware(['auth'])->group(function () { } } $filename = data_get($execution, 'filename'); - if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) { - $server = $execution->scheduledDatabaseBackup->database->service->destination->server; - } else { - $server = $execution->scheduledDatabaseBackup->database->destination->server; + if (! $execution->local_storage_deleted && filled($filename)) { + if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) { + $server = $execution->scheduledDatabaseBackup->database->service->destination->server; + } else { + $server = $execution->scheduledDatabaseBackup->database->destination->server; + } + + try { + return streamBackupFromServer($server, $filename, 'application/octet-stream'); + } catch (FileNotFoundException) { + // Fall back to an S3 copy. + } } - return streamBackupFromServer($server, $filename, 'application/octet-stream'); - } catch (FileNotFoundException) { - if (isset($execution) && $execution->scheduledDatabaseBackup->disable_local_backup === true && $execution->scheduledDatabaseBackup->save_s3 === true) { + // Only storages of the team that owns the database can serve the copy. + $s3DownloadUrl = is_null($execution_team_id) + ? null + : $execution->s3DownloadUrl($execution_team_id, $execution->scheduledDatabaseBackup->s3_storage_id); + if ($s3DownloadUrl) { + return redirect()->away($s3DownloadUrl); + } + + if ($execution->scheduledDatabaseBackup->disable_local_backup && $execution->scheduledDatabaseBackup->save_s3) { return response()->json(['message' => 'Backup not available locally, but available on S3.'], 404); } @@ -605,17 +628,29 @@ Route::middleware(['auth'])->group(function () { if ($team->id !== 0 && $team->id !== $execution->scheduledVolumeBackup->team_id) { return response()->json(['message' => 'Permission denied.'], 403); } - if ($execution->local_storage_deleted || blank($execution->filename)) { + if (blank($execution->filename)) { return response()->json(['message' => 'Backup not found locally on the server.'], 404); } - $server = $execution->scheduledVolumeBackup->server(); - if (! $server) { + $server = $execution->local_storage_deleted ? null : $execution->scheduledVolumeBackup->server(); + if ($server) { + try { + return streamBackupFromServer($server, $execution->filename, 'application/gzip'); + } catch (FileNotFoundException) { + // Fall back to an S3 copy. + } + } + + // Only storages of the team that owns the backup can serve the copy. + $s3DownloadUrl = $execution->s3DownloadUrl($execution->scheduledVolumeBackup->team_id, $execution->s3_storage_id); + if ($s3DownloadUrl) { + return redirect()->away($s3DownloadUrl); + } + + if (! $execution->local_storage_deleted && ! $server) { return response()->json(['message' => 'Server not found.'], 404); } - return streamBackupFromServer($server, $execution->filename, 'application/gzip'); - } catch (FileNotFoundException) { return response()->json(['message' => 'Backup not found locally on the server.'], 404); } catch (Throwable) { return response()->json(['message' => 'Failed to download backup.'], 500); diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 3479e8db11..93d38c78e5 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -86,7 +86,6 @@ it('enables and disables S3 backups from the S3 title action', function () { $component = Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, - 'section' => 's3', ]) ->assertSee('Enable S3') ->call('toggleS3') @@ -117,7 +116,6 @@ it('shows and saves S3 retention while S3 backups are disabled', function () { Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, - 'section' => 'retention', ]) ->assertSee('S3 backups') ->set('databaseBackupRetentionAmountS3', 12) @@ -147,34 +145,18 @@ it('splits standalone database backup settings and executions across dedicated u $this->get($generalUrl) ->assertOk() ->assertSee('General') - ->assertSee('S3 storage') - ->assertSee('Retention') ->assertSee('Executions') ->assertSee('Danger Zone') ->assertSee('Frequency') - ->assertDontSee('Enable S3') - ->assertDontSee('Disable S3') - ->assertDontSee('Backups to keep') - ->assertDontSee('Clean failed backups') - ->assertDontSee('Delete schedule'); - - $this->get($generalUrl.'/s3') - ->assertOk() ->assertSeeText('No validated S3 storage') - ->assertDontSee('Local copy') - ->assertDontSee('Enable S3') - ->assertDontSee('Disable S3') - ->assertDontSee('Backups to keep') - ->assertDontSee('Frequency') - ->assertDontSee('Clean failed backups'); - - $this->get($generalUrl.'/retention') - ->assertOk() ->assertSee('Local backups') ->assertSee('S3 backups') ->assertSee('Backups to keep') - ->assertDontSee('Frequency') - ->assertDontSee('Clean failed backups'); + ->assertDontSee('Clean failed backups') + ->assertDontSee('Delete schedule'); + + $this->get($generalUrl.'/s3')->assertRedirect($generalUrl); + $this->get($generalUrl.'/retention')->assertRedirect($generalUrl); $this->get($generalUrl.'/executions') ->assertOk() @@ -478,22 +460,21 @@ it('shows available S3 storages even when S3 backup is disabled', function () { 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSee('First S3') ->assertSee('Second S3'); }); -it('shows only an empty S3 state when no storages are available', function () { +it('shows an empty S3 state when no storages are available', function () { $backup = createBackupForEditValidationTest($this->team, [ 'save_s3' => false, 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSeeText('No validated S3 storage') ->assertSeeHtml('href="'.route('storage.index').'"') ->assertSeeText('Open S3 storage') - ->assertDontSee('Save') ->assertDontSee('Enable S3') ->assertDontSee('Disable S3') ->assertDontSee('S3 Storage') @@ -510,7 +491,6 @@ it('allows S3 backups to be disabled when no usable storage remains', function ( $component = Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => collect(), - 'section' => 's3', ]) ->assertSet('saveS3', true) ->assertSeeText('No validated S3 storage') @@ -532,7 +512,7 @@ it('shows when S3 backups are currently disabled', function () { 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) // Disabled S3 backups are indicated by the "Enable S3" title action ->assertSee('S3 storage') ->assertSee('Enable S3') @@ -617,3 +597,84 @@ it('disables Back up now after refresh when the database stops', function () { expect($component->html())->toMatch('/]*wire:click="backupNow"/s'); }); + +it('shows the backup actions in the header bar on every database backup page', function () { + config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']); + $backup = createBackupForEditValidationTest($this->team); + $database = $backup->database; + $database->update(['status' => 'running:healthy']); + $generalUrl = route('project.database.backup.execution', [ + 'project_uuid' => $database->project()->uuid, + 'environment_uuid' => $database->environment->uuid, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backup->uuid, + ]); + + foreach (['', '/executions', '/danger'] as $suffix) { + expect($this->get($generalUrl.$suffix)->assertOk()->getContent()) + ->toMatch('/x-teleport="#resource-action-hud-slot".*?wire:click="toggleEnabled".*?Disable backup.*?wire:click="backupNow"/s'); + } +}); + +it('hides the header Back up now button from team members', function () { + config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']); + $backup = createBackupForEditValidationTest($this->team); + $database = $backup->database; + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + + $this->get(route('project.database.backup.executions', [ + 'project_uuid' => $database->project()->uuid, + 'environment_uuid' => $database->environment->uuid, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backup->uuid, + ])) + ->assertOk() + ->assertDontSee('wire:click="backupNow"', false); +}); + +it('denies Back up now for team members', function () { + Queue::fake(); + $backup = createBackupForEditValidationTest($this->team); + $backup->database->update(['status' => 'running:healthy']); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + + Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ]) + ->call('backupNow') + ->assertNoRedirect(); + + Queue::assertNotPushed(DatabaseBackupJob::class); +}); + +it('syncs the backup form when the header toggles the schedule', function () { + $backup = createBackupForEditValidationTest($this->team, ['enabled' => true]); + + $component = Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ])->assertSet('backupEnabled', true); + + $backup->update(['enabled' => false]); + + $component->dispatch('backupEnabledChanged') + ->assertSet('backupEnabled', false) + ->call('submit'); + + expect((bool) $backup->fresh()->enabled)->toBeFalse(); +}); + +it('toggles the schedule from the form and notifies the page header', function () { + $backup = createBackupForEditValidationTest($this->team, ['enabled' => true]); + + Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ]) + ->call('toggleEnabled') + ->assertSet('backupEnabled', false) + ->assertDispatched('backupEnabledChanged'); + + expect((bool) $backup->fresh()->enabled)->toBeFalse(); +}); diff --git a/tests/Feature/BackupS3DestinationsFormTest.php b/tests/Feature/BackupS3DestinationsFormTest.php index 211360408a..2e025fda21 100644 --- a/tests/Feature/BackupS3DestinationsFormTest.php +++ b/tests/Feature/BackupS3DestinationsFormTest.php @@ -107,7 +107,7 @@ beforeEach(function () { it('saves two S3 destinations for a database backup schedule', function () { $backup = createDatabaseBackupForDestinationsFormTest($this->database); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSee('First S3') ->assertSee('Second S3') ->assertSet('s3StorageIds', [$this->firstStorage->id]) @@ -304,12 +304,12 @@ it('keeps a selected destination that is unusable for a while when other setting $volumeBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); $this->secondStorage->update(['is_usable' => false]); - Livewire::test(BackupEdit::class, ['backup' => $databaseBackup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $databaseBackup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSee('Second S3 (unavailable)') ->set('timeout', 7200) ->call('submit') ->assertHasNoErrors(); - Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application, 'section' => 's3']) + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]) ->assertSee('Second S3 (unavailable)') ->set('stopDuringBackup', true) ->call('save') diff --git a/tests/Feature/BackupS3DownloadFallbackTest.php b/tests/Feature/BackupS3DownloadFallbackTest.php new file mode 100644 index 0000000000..5eccab54b6 --- /dev/null +++ b/tests/Feature/BackupS3DownloadFallbackTest.php @@ -0,0 +1,183 @@ + 'file']); + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, + ]); + $database = StandalonePostgresql::create([ + 'name' => 's3-download-pg', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + $this->primaryS3 = createS3ForDownloadFallbackTest($this->team, 'primary'); + $this->replicaS3 = createS3ForDownloadFallbackTest($this->team, 'replica'); + + $this->databaseBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'save_s3' => true, + 'disable_local_backup' => true, + 's3_storage_id' => $this->primaryS3->id, + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + ]); + $this->databaseExecution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $this->databaseBackup->id, + 'status' => 'success', + 'database_name' => 'postgres', + 'filename' => '/data/coolify/backups/databases/team/pg/pg-dump-postgres-1.dmp', + 'local_storage_deleted' => true, + ]); + + $this->volumeBackup = $volume->scheduledBackups()->create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + ]); + $this->volumeExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $this->volumeBackup->id, + 's3_storage_id' => $this->primaryS3->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/team/app/volume-app-data-1.tar.gz', + 'local_storage_deleted' => true, + ]); +}); + +function createS3ForDownloadFallbackTest(Team $team, string $name, bool $isUsable = true): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => "{$name}-{$team->id}-bucket", + 'endpoint' => 'https://1.1.1.1', + 'team_id' => $team->id, + 'is_usable' => $isUsable, + ]); +} + +function signInForDownloadFallbackTest($testCase, Team $team, string $role = 'owner'): void +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role]); + $testCase->actingAs($user); + session(['currentTeam' => $team]); +} + +/** + * @return array{0: string, 1: ScheduledDatabaseBackupExecution|ScheduledVolumeBackupExecution} + */ +function downloadFallbackTarget($testCase, string $type): array +{ + return $type === 'database' + ? ['download.backup', $testCase->databaseExecution] + : ['download.volume-backup', $testCase->volumeExecution]; +} + +function assertPresignedBackupRedirect($response, S3Storage $storage, string $filename): void +{ + $location = $response->assertRedirect()->headers->get('Location'); + $url = parse_url($location); + parse_str($url['query'], $query); + + expect("{$url['scheme']}://{$url['host']}{$url['path']}")->toBe("https://1.1.1.1/{$storage->bucket}".$filename) + ->and($query)->toHaveKeys(['X-Amz-Signature', 'X-Amz-Expires']) + ->and((int) $query['X-Amz-Expires'])->toBeLessThanOrEqual(15 * 60) + ->and($query['response-content-disposition'])->toContain('attachment; filename="'.basename($filename).'"'); +} + +it('redirects to a presigned URL of the primary S3 copy when no local copy exists', function (string $type) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + assertPresignedBackupRedirect($this->get(route($route, $execution->id)), $this->primaryS3, $execution->filename); +})->with(['database', 'volume']); + +it('uses a replica when the primary S3 copy is deleted or its storage is unusable', function (string $type, bool $primaryDeleted) { + [$route, $execution] = downloadFallbackTarget($this, $type); + if (! $primaryDeleted) { + $this->primaryS3->update(['is_usable' => false]); + } + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true, 's3_storage_deleted' => $primaryDeleted]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + assertPresignedBackupRedirect($this->get(route($route, $execution->id)), $this->replicaS3, $execution->filename); +})->with(['database', 'volume'])->with(['primary deleted' => true, 'primary unusable' => false]); + +it('keeps the not found message when no usable S3 copy exists', function (string $type, string $message) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => false]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true, 's3_storage_deleted' => true]); + signInForDownloadFallbackTest($this, $this->team); + + $this->get(route($route, $execution->id))->assertNotFound()->assertJson(['message' => $message]); +})->with([ + 'database' => ['database', 'Backup not available locally, but available on S3.'], + 'volume' => ['volume', 'Backup not found locally on the server.'], +]); + +it('never uses an S3 storage of another team', function (string $type) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $foreignS3 = createS3ForDownloadFallbackTest($this->otherTeam, 'foreign'); + $execution->s3Replicas()->create(['s3_storage_id' => $foreignS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + $this->get(route($route, $execution->id))->assertNotFound(); +})->with(['database', 'volume']); + +it('forbids members and other teams from getting a presigned URL', function (string $type, string $user) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true]); + $user === 'member' + ? signInForDownloadFallbackTest($this, $this->team, 'member') + : signInForDownloadFallbackTest($this, $this->otherTeam); + + $this->get(route($route, $execution->id))->assertForbidden(); +})->with(['database', 'volume'])->with(['member', 'other team owner']); diff --git a/tests/Feature/BackupS3DownloadTest.php b/tests/Feature/BackupS3DownloadTest.php new file mode 100644 index 0000000000..a04d90950e --- /dev/null +++ b/tests/Feature/BackupS3DownloadTest.php @@ -0,0 +1,287 @@ + 'file', 'constants.ssh.mux_enabled' => false]); + Storage::fake('ssh-keys'); + Server::flushIdentityMap(); + Once::flush(); + InstanceSettings::forceCreate(['id' => 0]); + Team::factory()->create(['id' => 0]); + $this->downloadStorage = S3Storage::create([ + 'name' => 'Downloads', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'downloads', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => 0, + ]); + InstanceSettings::find(0)->update(['file_download_s3_storage_id' => $this->downloadStorage->id]); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]); + $this->database = StandalonePostgresql::create([ + 'name' => 'orders-db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', + 'image' => 'postgres:17', 'environment_id' => $environment->id, + 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), + ]); + $this->backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + ]); + $this->execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $this->backup->id, + 'status' => 'success', + 'filename' => BACKUP_S3_DOWNLOAD_FILE, + ]); + $this->service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $environment->id, + ]); + + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'admin']); + $this->user->teams()->attach($this->otherTeam, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + Bus::fake([PrepareFileDownloadJob::class]); +}); + +function fakeBackupFileSize(int|string $size): void +{ + Process::fake(['*' => Process::result(output: (string) $size)]); +} + +it('uploads a database backup through S3 for an admin of its team after a team switch', function () { + fakeBackupFileSize(1024); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]); + session(['currentTeam' => $this->otherTeam]); + + $component->call('download_file', $this->execution->id) + ->assertDispatched('info') + ->assertNotDispatched('open-backup-download') + ->assertCount('pendingDownloads', 1); + + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === BACKUP_S3_DOWNLOAD_FILE + && $job->serverId === $this->server->id + && $job->downloadName === 'pg-dump-orders-1.dmp' + && $job->s3StorageId === $this->downloadStorage->id + && $job->userId === $this->user->id); +}); + +it('streams through the download route when no download storage is set', function () { + InstanceSettings::find(0)->update(['file_download_s3_storage_id' => null]); + Once::flush(); + Process::fake(); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('streams through the download route when the backup is larger than one S3 upload', function () { + fakeBackupFileSize(FileDownloadStorageService::MAX_SIZE + 1); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('lets the download route answer when the backup file is not on the server', function () { + fakeBackupFileSize(''); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('refuses team members', function () { + Process::fake(); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]); + + $component->call('download_file', $this->execution->id) + ->assertNotDispatched('open-backup-download'); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('does not download an execution of another backup', function () { + Process::fake(); + $foreignBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->otherTeam->id, + 'frequency' => 'daily', + 'database_id' => $this->database->id + 1000, + 'database_type' => $this->database->getMorphClass(), + ]); + $foreignExecution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $foreignBackup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/databases/other/secret.dmp', + ]); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $foreignExecution->id) + ->assertDispatched('error', 'Backup execution not found.') + ->assertNotDispatched('open-backup-download'); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('uploads a volume backup through S3 and refuses members', function () { + fakeBackupFileSize(2048); + $application = Application::factory()->create([ + 'environment_id' => $this->database->environment_id, + 'destination_id' => $this->database->destination_id, + 'destination_type' => $this->database->destination_type, + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $backup = $volume->scheduledBackups()->create(['team_id' => $this->team->id, 'frequency' => 'daily']); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/app/archive.tar.gz', + ]); + $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]); + session(['currentTeam' => $this->otherTeam]); + + $component->call('downloadBackup', $execution->id)->assertCount('pendingDownloads', 1); + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === '/data/coolify/backups/volumes/app/archive.tar.gz' + && $job->serverId === $this->server->id); + + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $component->call('downloadBackup', $execution->id)->assertForbidden(); + Bus::assertDispatchedTimes(PrepareFileDownloadJob::class, 1); +}); + +it('uploads a service database backup through S3', function () { + fakeBackupFileSize(1024); + $serviceDatabase = ServiceDatabase::create([ + 'service_id' => $this->service->id, + 'name' => 'service-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'database_id' => $serviceDatabase->id, + 'database_type' => $serviceDatabase->getMorphClass(), + ]); + $execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/service/db.dmp', + ]); + + Livewire::test(ServiceBackupExecutions::class, ['service' => $this->service]) + ->call('downloadExecution', $execution->uuid) + ->assertCount('pendingDownloads', 1); + + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === '/data/coolify/backups/service/db.dmp'); +}); + +it('opens a finished link only for the user who asked for it', function () { + fakeBackupFileSize(1024); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id); + $downloadId = $component->get('pendingDownloads')[0]; + + Cache::put(PrepareFileDownloadJob::cacheKey($downloadId), ['user_id' => $this->user->id + 1, 'status' => 'ready', 'url' => 'https://s3.example.com/other']); + $component->call('checkDownloads')->assertNotDispatched('open-backup-download')->assertCount('pendingDownloads', 1); + + Cache::put(PrepareFileDownloadJob::cacheKey($downloadId), ['user_id' => $this->user->id, 'status' => 'ready', 'url' => 'https://s3.example.com/get']); + $component->call('checkDownloads') + ->assertDispatched('open-backup-download', url: 'https://s3.example.com/get') + ->assertCount('pendingDownloads', 0); +}); + +it('uploads the backup file from the server host with curl', function () { + Bus::fake([DeleteFileDownloadJob::class]); + Process::fake(['*' => Process::result(output: '')]); + $downloads = Mockery::mock(FileDownloadStorageService::class); + $downloads->shouldReceive('uploadUrl')->andReturn(['url' => 'https://s3.example.com/put?X-Amz-Signature=abc&x=1', 'headers' => ['Content-Disposition' => 'attachment']]); + $downloads->shouldReceive('downloadUrl')->andReturn('https://s3.example.com/get'); + + (new PrepareFileDownloadJob('dl-host', $this->user->id, $this->server->id, null, BACKUP_S3_DOWNLOAD_FILE, false, 'pg.dmp', $this->downloadStorage->id))->handle($downloads); + + expect(Cache::get(PrepareFileDownloadJob::cacheKey('dl-host')))->toMatchArray(['status' => 'ready', 'url' => 'https://s3.example.com/get']); + Process::assertRan(fn ($process) => str_contains($process->command, 'curl -fsS') + && str_contains($process->command, BACKUP_S3_DOWNLOAD_FILE) + && str_contains($process->command, 'X-Amz-Signature=abc&x=1') + && ! str_contains($process->command, 'docker exec')); +}); + +it('builds host file commands that the non-root sudo parser keeps whole', function () { + $nonRoot = Server::factory()->make(['user' => 'deploy']); + $upload = FileDownloadStorageService::buildHostFileUploadCommand("/data/it's.dmp", 'https://s3.example.com/put?a=1&b=2', ['Content-Disposition' => 'attachment']); + $size = FileDownloadStorageService::buildHostFileSizeCommand("/data/it's.dmp"); + + expect(parseCommandsByLineForSudo(collect([$upload, $size]), $nonRoot))->toBe(['sudo '.$upload, 'sudo '.$size]) + ->and(fn () => FileDownloadStorageService::buildHostFileSizeCommand('/data/a;rm -rf /'))->toThrow(Exception::class); +}); diff --git a/tests/Feature/BackupS3StorageListResourceTeamTest.php b/tests/Feature/BackupS3StorageListResourceTeamTest.php index 90f87493b4..d4b61b16b4 100644 --- a/tests/Feature/BackupS3StorageListResourceTeamTest.php +++ b/tests/Feature/BackupS3StorageListResourceTeamTest.php @@ -112,7 +112,7 @@ test('database backup page offers only usable S3 storages of the database team', 'database_id' => $this->postgres->id, 'database_type' => $this->postgres->getMorphClass(), ]); - $url = route('project.database.backup.s3', [ + $url = route('project.database.backup.execution', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, 'database_uuid' => $this->postgres->uuid, diff --git a/tests/Feature/DashboardCanvasTest.php b/tests/Feature/DashboardCanvasTest.php new file mode 100644 index 0000000000..9204b3e77a --- /dev/null +++ b/tests/Feature/DashboardCanvasTest.php @@ -0,0 +1,132 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true])); + Server::flushIdentityMap(); + + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->admin = User::factory()->create(); + $this->team->members()->attach($this->admin->id, ['role' => 'admin']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'web-server']); + $this->environment = Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, + ]); +}); + +function dashboardCanvasApp(Environment $environment, Server $server, string $name): Application +{ + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + + return Application::factory()->create([ + 'name' => $name, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +function dashboardCanvasDeployment(Application $application, Server $server, ApplicationDeploymentStatus $status): ApplicationDeploymentQueue +{ + return ApplicationDeploymentQueue::query()->forceCreate([ + 'application_id' => (string) $application->id, + 'application_name' => $application->name, + 'deployment_uuid' => (string) str()->uuid(), + 'deployment_url' => '/deployment/'.$status->value, + 'server_id' => $server->id, + 'server_name' => $server->name, + 'status' => $status->value, + ]); +} + +function dashboardCanvasSession(object $test, User $user, Team $team): object +{ + return $test->actingAs($user)->withSession(['currentTeam' => $team]) + ->withHeader('X-Canvas-Team', (string) $team->id); +} + +test('the dashboard canvas marks applications with an active deployment and lists recent deployments', function () { + $deploying = dashboardCanvasApp($this->environment, $this->server, 'storefront'); + $queued = dashboardCanvasApp($this->environment, $this->server, 'orders-api'); + $idle = dashboardCanvasApp($this->environment, $this->server, 'docs'); + dashboardCanvasDeployment($deploying, $this->server, ApplicationDeploymentStatus::IN_PROGRESS); + dashboardCanvasDeployment($queued, $this->server, ApplicationDeploymentStatus::QUEUED); + dashboardCanvasDeployment($idle, $this->server, ApplicationDeploymentStatus::FINISHED); + + $response = dashboardCanvasSession($this, $this->admin, $this->team) + ->getJson('/canvas/api/dashboard/canvas') + ->assertOk() + ->assertJsonPath('title', 'Dashboard') + ->assertJsonPath('zones.0.id', $this->server->uuid); + $nodes = collect($response->json('nodes'))->keyBy('id'); + + expect($nodes[$deploying->uuid]['deployment'])->toBe(['status' => 'in_progress', 'link' => '/deployment/in_progress', 'pull_request_id' => null]) + ->and($nodes[$queued->uuid]['deployment']['status'])->toBe('queued') + ->and($nodes[$idle->uuid])->not->toHaveKey('deployment') + // The deployment panel lists running and queued deployments first, then the finished ones. + ->and(collect($response->json('deployments'))->map(fn (array $row) => [$row['application'], $row['status']])->all())->toBe([ + [$deploying->uuid, 'in_progress'], + [$queued->uuid, 'queued'], + [$idle->uuid, 'finished'], + ]); +}); + +test('the dashboard canvas never shows another team servers, resources, or deployments', function () { + $otherTeam = Team::factory()->create(['show_boarding' => false]); + $outsider = User::factory()->create(); + $otherTeam->members()->attach($outsider->id, ['role' => 'owner']); + $this->team->members()->attach($outsider->id, ['role' => 'member']); + $app = dashboardCanvasApp($this->environment, $this->server, 'storefront'); + dashboardCanvasDeployment($app, $this->server, ApplicationDeploymentStatus::IN_PROGRESS); + + $response = dashboardCanvasSession($this, $outsider, $otherTeam) + ->getJson('/canvas/api/dashboard/canvas') + ->assertOk(); + + expect($response->json('zones'))->toBe([]) + ->and($response->json('nodes'))->toBe([]) + ->and($response->json('deployments'))->toBe([]); +}); + +test('the dashboard opens the canvas again when the user left it there', function () { + $cookie = "coolify_{$this->admin->id}_dashboard_view"; + + dashboardCanvasSession($this, $this->admin, $this->team) + ->withoutVite() + ->get(route('dashboard.canvas')) + ->assertOk() + ->assertSee('data-endpoint="/dashboard/canvas"', false) + ->assertCookie($cookie, 'canvas'); + + dashboardCanvasSession($this, $this->admin, $this->team) + ->withCookie($cookie, 'canvas') + ->get(route('dashboard')) + ->assertRedirect(route('dashboard.canvas')); + + // The list link on the canvas opens the list and makes it the remembered view. + dashboardCanvasSession($this, $this->admin, $this->team) + ->withCookie($cookie, 'canvas') + ->get(route('dashboard', ['view' => 'list'])) + ->assertOk() + ->assertCookie($cookie, 'list'); +}); diff --git a/tests/Feature/DashboardDeploymentsTest.php b/tests/Feature/DashboardDeploymentsTest.php index 6ef6f05ab0..1fa548da4f 100644 --- a/tests/Feature/DashboardDeploymentsTest.php +++ b/tests/Feature/DashboardDeploymentsTest.php @@ -55,7 +55,6 @@ function createDashboardDeployment(array $overrides = []): ApplicationDeployment it('hides the deployments section when there is nothing to show', function () { Livewire::test(ActiveDeployments::class) - ->assertDontSee('Active and recent deployment activity') ->assertDontSee('Running or queued right now') ->assertDontSee('Latest completed deployments') ->assertDontSee('No active deployments') diff --git a/tests/Feature/DashboardViewModeTest.php b/tests/Feature/DashboardViewModeTest.php new file mode 100644 index 0000000000..a18145b0f9 --- /dev/null +++ b/tests/Feature/DashboardViewModeTest.php @@ -0,0 +1,44 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('shows the projects and servers sections as tables by default', function () { + Livewire::test(Dashboard::class) + ->assertSee("viewMode: 'table'", false) + ->assertDontSee("viewMode: 'grid'", false); +}); + +it('remembers the view of each dashboard section on its own', function () { + Livewire::test(Dashboard::class)->call('saveListView', 'dashboard_projects', 'grid'); + + expect(Cookie::queued("coolify_{$this->user->id}_dashboard_projects_view")?->getValue())->toBe('grid') + ->and(Cookie::queued("coolify_{$this->user->id}_dashboard_servers_view"))->toBeNull(); + + $this->withCookie("coolify_{$this->user->id}_dashboard_projects_view", 'grid') + ->get(route('dashboard')) + ->assertOk() + ->assertSee("viewMode: 'grid'", false) + ->assertSee("viewMode: 'table'", false); +}); diff --git a/tests/Feature/EnvironmentCanvasTest.php b/tests/Feature/EnvironmentCanvasTest.php index 8d28c0cea8..ab4e403489 100644 --- a/tests/Feature/EnvironmentCanvasTest.php +++ b/tests/Feature/EnvironmentCanvasTest.php @@ -133,9 +133,9 @@ describe('GET canvas', function () { 'kind' => 'nixpacks', 'subtitle' => 'Nixpacks · main', 'children' => [], - 'volumes' => ['web-data'], 'link' => $application->link(), 'logo' => ['src' => resource_logo_url('public-repo'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => 'https://web.example.com/favicon.ico'], + 'saved' => ['x' => 10, 'y' => -20], ]) ->and($nodes[$service->uuid])->toEqual([ 'id' => $service->uuid, @@ -151,9 +151,9 @@ describe('GET canvas', function () { ['name' => 'ghost', 'type' => 'application', 'status' => 'running:healthy'], ['name' => 'mysql', 'type' => 'database', 'status' => 'running:healthy'], ], - 'volumes' => [], 'link' => $service->link(), 'logo' => null, + 'saved' => null, ]) ->and($nodes[$database->uuid])->toMatchArray([ 'type' => 'database', @@ -162,8 +162,8 @@ describe('GET canvas', function () { 'urls' => [], 'kind' => 'postgresql', 'subtitle' => 'PostgreSQL · postgres:17-alpine', - 'volumes' => ["postgres-data-{$database->uuid}"], 'logo' => ['src' => resource_logo_url('postgres'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => null], + 'saved' => null, ]) ->and($response->getContent())->not->toContain('canvas-secret-password'); }); diff --git a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php index 2cbac04a38..551e0cef0f 100644 --- a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php +++ b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php @@ -93,7 +93,7 @@ it('saves several S3 destinations for the instance backup and drops storages of // The session team can change in another tab after the page loads. session(['currentTeam' => ['id' => $this->otherTeam->id]]); - Livewire::test(BackupEdit::class, ['backup' => $this->backup->fresh(), 'availableS3Storages' => collect(), 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $this->backup->fresh(), 'availableS3Storages' => collect()]) ->set('saveS3', true) ->set('s3StorageIds', [$this->firstStorage->id, $this->secondStorage->id, $this->foreignStorage->id]) ->call('submit') diff --git a/tests/Feature/NavbarSubmenuTest.php b/tests/Feature/NavbarSubmenuTest.php new file mode 100644 index 0000000000..b336b46a80 --- /dev/null +++ b/tests/Feature/NavbarSubmenuTest.php @@ -0,0 +1,133 @@ + 0]); + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); +}); + +function renderNavbarAt(string $path, bool $mobile = false): string +{ + app()->instance('request', Request::create($path)); + + return (string) test()->blade($mobile ? '' : ''); +} + +function loginNavbarUser(string $role): void +{ + test()->team->members()->attach(test()->user->id, ['role' => $role]); + test()->actingAs(test()->user); + session(['currentTeam' => test()->team]); +} + +test('a submenu page opens its submenu with the current page active', function () { + loginNavbarUser('owner'); + + $html = renderNavbarAt('/metrics'); + + expect($html) + ->toContain('Back to the main menu') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Metrics#s'); +}); + +test('a page outside every submenu starts on the main menu', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/servers'))->not->toContain('data-active-submenu="'); +}); + +test('a submenu entry links to its first visible page', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/servers'))->toContain('href="'.route('analytics').'"'); +}); + +test('the registries page is hidden from members in the integrations submenu', function () { + loginNavbarUser('member'); + + expect(renderNavbarAt('/sources')) + ->toContain('Destinations') + ->not->toContain('href="'.route('registries.index').'"'); +}); + +test('the team submenu lists the team pages by role', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/team/members')) + ->toContain('href="'.route('team.member.index').'"') + ->toContain('href="'.route('team.audit-log').'"') + ->toContain('href="'.route('team.danger-zone').'"'); +}); + +test('members do not see the audit log or danger zone in the team submenu', function () { + loginNavbarUser('member'); + + expect(renderNavbarAt('/team')) + ->toContain('href="'.route('team.member.index').'"') + ->not->toContain('href="'.route('team.audit-log').'"') + ->not->toContain('href="'.route('team.danger-zone').'"'); +}); + +test('notifications and keys & tokens open their own submenus', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/notifications/discord')) + ->toContain('href="'.route('notifications.slack').'"') + ->toMatch('#aria-current="page"[^>]*>.*?discord\.svg#s'); + + expect(renderNavbarAt('/security/api-tokens')) + ->toContain('href="'.route('security.private-key.index').'"') + ->toContain('Back to the main menu'); +}); + +test('the admin link belongs to the main menu, not to submenus', function () { + config()->set('app.env', 'local'); + $root = User::factory()->create(['id' => 0]); + test()->team->members()->attach($root->id, ['role' => 'owner']); + test()->actingAs($root); + session(['currentTeam' => test()->team]); + + expect(renderNavbarAt('/team')) + ->toMatch('#]*>\s*toContain('Back to the main menu') + ->toContain('href="'.route('shared-variables.team.index').'"') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Servers#s'); +}); + +test('instance settings open their own submenu for instance admins', function () { + config()->set('app.env', 'local'); + $root = User::factory()->create(['id' => 0]); + test()->team->members()->attach($root->id, ['role' => 'owner']); + test()->actingAs($root); + session(['currentTeam' => test()->team]); + + expect(renderNavbarAt('/settings/email')) + ->toContain('Back to the main menu') + ->toContain('href="'.route('settings.oauth').'"') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Email#s'); +}); + +test('a parent entry opens its submenu in place on mobile and desktop', function () { + loginNavbarUser('owner'); + + foreach ([renderNavbarAt('/servers', mobile: true), renderNavbarAt('/servers')] as $html) { + expect($html) + ->toContain('x-show="submenu === \'Monitoring\'"') + ->toMatch('# @@ -17,4 +29,7 @@ {#if onrefresh} {/if} + {#if children} +
    {@render children()}
    + {/if}
    diff --git a/resources/js/canvas/deploy/DeployCanvas.svelte b/resources/js/canvas/deploy/DeployCanvas.svelte index 853f1ee749..89dab86f61 100644 --- a/resources/js/canvas/deploy/DeployCanvas.svelte +++ b/resources/js/canvas/deploy/DeployCanvas.svelte @@ -8,7 +8,7 @@ import PaletteItem from './PaletteItem.svelte'; import RunPanel from './RunPanel.svelte'; import CanvasControls from '../CanvasControls.svelte'; - import { closeMenu, cv, dock, els, fit, hideResult, leave, pan, pickNext, PORT_Y, resetCanvas } from './state.svelte.js'; + import { closeMenu, cv, els, fit, hideResult, leave, pan, pickNext, PORT_Y, resetCanvas } from './state.svelte.js'; // The deploy canvas: header, step palette, and the canvas with the step // cards. The state lives in state.svelte.js (init() runs before the mount). @@ -19,6 +19,10 @@ // Small screens: a notice that the canvas is not optimized for mobile. CSS // shows it below 768px; closed once, it stays closed. let noticeOpen = $state(!load('mobile-notice-dismissed', false)); + // Desktop: the header sits in the zoom controls bar (bottom left), as on the + // other canvases. Small screens: a box above the canvas. + const desktopQuery = matchMedia('(min-width: 1024px)'); + let desktop = $state(desktopQuery.matches); const stop = e => e.stopPropagation(); // A pill joined to the Deploy card: each address opens in a new tab. @@ -60,7 +64,10 @@ const reset = () => { if (els.root?.isConnected) resetCanvas(); }; addEventListener('keydown', escape); addEventListener('deploy-canvas-reset', reset); + const media = () => { desktop = desktopQuery.matches; }; + desktopQuery.addEventListener('change', media); return () => { + desktopQuery.removeEventListener('change', media); theme.disconnect(); removeEventListener('keydown', escape); removeEventListener('deploy-canvas-reset', reset); @@ -69,20 +76,21 @@ }); -
    - -
    -
    - {#if cv.target} - - - {/if} - Beta -
    -
    - -
    +{#snippet header()} +
    + {#if cv.target} + + + {/if} + Beta
    + +{/snippet} + +
    + {#if !desktop} +
    {@render header()}
    + {/if} {#if noticeOpen}
    @@ -134,7 +142,6 @@ {/if}
    - els.pz.zoomTo(z)} onfit={fit} /> {#if cv.menu}
    @@ -149,6 +156,9 @@ {/if}
    + + els.pz.zoomTo(z)} onfit={fit} children={desktop ? header : null} /> + {#if cv.dropdown} {#key cv.dropdown?.trigger} diff --git a/resources/js/canvas/deploy/state.svelte.js b/resources/js/canvas/deploy/state.svelte.js index 60331ea9f2..2cfb6c3c24 100644 --- a/resources/js/canvas/deploy/state.svelte.js +++ b/resources/js/canvas/deploy/state.svelte.js @@ -85,62 +85,10 @@ export function pan(viewport) { canPan: e => [viewport, world, edges].includes(e.target), onPanStart: closeMenu, }); -} - -const DESKTOP = '(min-width: 1024px)'; -/** - * use:dock on the header. Desktop: it docks into the app top bar, or with the - * floating nav it is a separate panel next to the top bar (as on the - * environment canvas) when it fits there. Small screens: a box above the canvas. - */ -export function dock(top) { - const root = top.closest('.cv-root'); - const stage = root.querySelector('.cv-stage'); - function place() { - const desktop = matchMedia(DESKTOP).matches; - const slot = desktop && document.getElementById('page-toolbar-slot'); - const topbar = document.querySelector('.app-topbar'); - top.removeAttribute('style'); - if (desktop && topbar && document.documentElement.dataset.navStyle === 'floating') { - top.className = 'fixed z-40 flex items-center gap-3 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 dark:border-white/[0.08] dark:bg-panel'; - root.append(top); - const gap = 8; - const head = topbar.getBoundingClientRect(); - const width = top.offsetWidth; - if (head.right + gap + width <= innerWidth - gap) { - // Centered over the canvas, but never over the top bar. - const box = stage.getBoundingClientRect(); - const left = Math.max(box.left + (box.width - width) / 2, head.right + gap); - Object.assign(top.style, { top: `${head.top}px`, left: `${left}px`, minHeight: `${head.height}px` }); - root.classList.add('cv-docked'); - return; - } - top.removeAttribute('style'); - } - top.className = slot ? 'flex items-center gap-3 pl-3' : 'cv-island cv-top'; - root.classList.toggle('cv-docked', !!slot); - if (slot) slot.replaceChildren(top); - else root.prepend(top); - } - place(); - const replace = () => root.isConnected && place(); - // The floating header is centered over the canvas next to the app top bar, so follow their sizes. - const sizes = new ResizeObserver(replace); - for (const target of [stage, document.querySelector('.app-topbar')].filter(Boolean)) sizes.observe(target); - const media = matchMedia(DESKTOP); - media.addEventListener('change', replace); - addEventListener('resize', replace); - const navStyle = new MutationObserver(replace); - navStyle.observe(document.documentElement, { attributes: true, attributeFilter: ['data-nav-style'] }); - return { - destroy() { - sizes.disconnect(); - media.removeEventListener('change', replace); - removeEventListener('resize', replace); - navStyle.disconnect(); - top.remove(); // it may sit in the app top bar - }, - }; + // The root paints the dots, so move them with the viewport when the Steps panel folds or the window resizes. + const sizes = new ResizeObserver(() => els.pz.apply()); + sizes.observe(viewport); + return { destroy: () => sizes.disconnect() }; } /** use:track on a card: its element (for measuring) and its size (for the lines). */ diff --git a/resources/js/canvas/deploy/styles.js b/resources/js/canvas/deploy/styles.js index e05434669b..869e76d236 100644 --- a/resources/js/canvas/deploy/styles.js +++ b/resources/js/canvas/deploy/styles.js @@ -22,6 +22,11 @@ const CSS = ` display: grid; grid-template-columns: 15.5rem minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); gap: 8px; padding: 8px; background: var(--cv-backdrop); color: var(--cv-text); + /* The dots cover the whole backdrop, also under the Steps panel. pan() + (canvas-core.js) lines them up with the viewport through these variables. */ + background-image: var(--canvas-dots-image, radial-gradient(circle, light-dark(rgb(0 0 0 / 0.13), rgb(255 255 255 / 0.09)) 1px, transparent 1.3px)); + background-size: var(--canvas-dots-size, 20px 20px); + background-position: var(--canvas-dots-position, 0 0); color-scheme: light; font: 13px/1.4 var(--font-sans, ui-sans-serif, system-ui, sans-serif); -webkit-font-smoothing: antialiased; } @@ -32,7 +37,7 @@ const CSS = ` .cv-root svg { width: 1rem; height: 1rem; flex: none; } .cv-root button:not(.button, [data-canvas-controls] button) { font: inherit; } - /* Header island on small screens; on desktop the header docks into the app top bar. */ + /* Header island on small screens; on desktop the header is in the zoom controls bar. */ .cv-top { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; padding: 0.625rem 0.75rem; } .cv-root.cv-docked { grid-template-rows: minmax(0, 1fr); } .cv-btn { @@ -61,7 +66,9 @@ const CSS = ` /* Palette island */ /* Hugs its steps instead of filling the row; scrolls when the screen is short. */ - .cv-palette { display: flex; flex-direction: column; align-self: start; max-height: 100%; overflow-y: auto; padding: 1rem 0.625rem 0.75rem; } + /* The zoom controls sit below the Steps panel, so the panel leaves room for them. */ + .cv-palette { display: flex; flex-direction: column; align-self: start; max-height: calc(100% - 3.25rem); overflow-y: auto; padding: 1rem 0.625rem 0.75rem; } + .cv-root > [data-canvas-controls] { bottom: 8px; left: 8px; } .cv-palette-head { padding: 0 0.5rem 0.75rem; margin: 0 -0.625rem 0.5rem; padding-inline: 1.125rem; border-bottom: 1px solid var(--cv-line); } .cv-palette-head b { display: block; font-size: 14px; font-weight: 600; } .cv-palette-head span { color: var(--cv-subtle); font-size: 12px; } @@ -95,7 +102,8 @@ const CSS = ` .cv-stage { position: relative; overflow: hidden; border: 0; background: transparent; } .cv-viewport { position: absolute; inset: 0; cursor: grab; touch-action: none; - background-image: radial-gradient(circle, light-dark(rgb(0 0 0 / 0.13), rgb(255 255 255 / 0.09)) 1px, transparent 1.3px); + /* The dots are on .cv-root, so the viewport has none of its own. */ + background-image: none; } .cv-viewport.panning { cursor: grabbing; } .cv-world { position: absolute; top: 0; left: 0; transform-origin: 0 0; } diff --git a/resources/js/canvas/environment/EmptyCard.svelte b/resources/js/canvas/environment/EmptyCard.svelte new file mode 100644 index 0000000000..dc6721258d --- /dev/null +++ b/resources/js/canvas/environment/EmptyCard.svelte @@ -0,0 +1,24 @@ + + + +
    + +
    +
    +
    {node.name}
    +
    {node.subtitle}
    +
    +
    diff --git a/resources/js/canvas/environment/EnvironmentCanvas.svelte b/resources/js/canvas/environment/EnvironmentCanvas.svelte index e8f854f30b..3baf907f12 100644 --- a/resources/js/canvas/environment/EnvironmentCanvas.svelte +++ b/resources/js/canvas/environment/EnvironmentCanvas.svelte @@ -17,6 +17,8 @@ filterGraph, filterOptions, fitView, + GROUP_HEADER, + GROUP_PADDING, groupByUse, groupFrames, groupKey, @@ -33,6 +35,7 @@ } from '../../environment-canvas.js'; import Card from './Card.svelte'; import Deployments from './Deployments.svelte'; + import EmptyCard from './EmptyCard.svelte'; import Icon from './Icon.svelte'; import Panel from './Panel.svelte'; import Toolbar from './Toolbar.svelte'; @@ -103,10 +106,12 @@ let pz; const canEdit = $derived(!!graph?.can_edit); - // Only the environment canvas saves card positions; server, project and connect views are auto-placed. + // Only the environment canvas has its own card positions; server and project views save on it (canMoveGroups). const canMove = $derived(canEdit && graph?.can_move !== false); - // Server canvas: admins drag whole project / environment groups; each environment saves its group position. + // Server canvas: admins drag whole project / environment groups and their cards; each environment saves them. const canMoveGroups = $derived(canEdit && !!graph?.can_move_groups); + /** Server view: key of the card's group when the card can be moved there (it is saved on its environment canvas). */ + const movableGroupOf = node => (canMoveGroups && node ? groupKey([node], node.zone) : null); let groupMeta = {}; // server view: frame top-left and zone inner top-left per group key (see layoutServerView) // Environment canvas endpoints of a card (the server view has no endpoints of its own for changes). const environmentBase = node => (serverView @@ -468,23 +473,32 @@ if (e.button !== 0 || e.target.closest('a,button,input,[data-ec-port]')) return; e.stopPropagation(); const start = { cx: e.clientX, cy: e.clientY, ...positions[id] }; + const key = movableGroupOf(nodeById(id)); + const meta = key ? groupMeta[key] : null; + const movable = canMove || !!meta; // The other cards and frames stay put during the drag, so measure them once. const rules = dragRules(graph, positions, sizes, id); + // Server view: the group frame stays inside its zone, below the zone header. + const fits = meta + ? b => b.x >= meta.inner.x + GROUP_PADDING && b.y >= meta.inner.y + GROUP_HEADER + GROUP_PADDING && rules.fits(b) + : rules.fits; let moved = false; capture(card, e.pointerId); begin(`card:${id}`); track(card, ev => { if (!moved && Math.hypot(ev.clientX - start.cx, ev.clientY - start.cy) < 4) return; - if (!canMove) return; + if (!movable) return; const rect = rectOf(id); const wanted = { x: start.x + (ev.clientX - start.cx) / view.z, y: start.y + (ev.clientY - start.cy) / view.z, w: rect.width, h: rect.height }; // Cards cannot cover each other or another zone: stop at the edge of what is in the way. - const spot = freeSpot(wanted, rules.others, BOX_GAP, rules.fits); + const spot = freeSpot(wanted, rules.others, BOX_GAP, fits); if (!spot) return; moved = true; positions[id] = { x: spot.x, y: spot.y }; }, () => { - if (moved) queueSave({ [id]: { ...positions[id] } }); + // Server view: save the whole group, so its other cards keep the place they have here. + if (moved && meta) saveGroup(graph.nodes.filter(n => groupKey([n], n.zone) === key).map(n => n.id), meta.shift); + else if (moved) queueSave({ [id]: { ...positions[id] } }); else select({ kind: 'node', id }); end(`card:${id}`); }); @@ -933,24 +947,28 @@
    {#each graph?.nodes || [] as node (node.id)} - startCardDrag(node.id, card, e)} - onconnect={(e, port) => startConnect(node.id, port, e)} - onselect={() => select({ kind: 'node', id: node.id })} - onhover={on => hoverCard(node.id, on)} - /> + {#if node.type === 'empty'} + + {:else} + startCardDrag(node.id, card, e)} + onconnect={(e, port) => startConnect(node.id, port, e)} + onselect={() => select({ kind: 'node', id: node.id })} + onhover={on => hoverCard(node.id, on)} + /> + {/if} {/each}
    diff --git a/resources/js/deploy-canvas.test.js b/resources/js/deploy-canvas.test.js index e4f9be5461..a8fbecc2d7 100644 --- a/resources/js/deploy-canvas.test.js +++ b/resources/js/deploy-canvas.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { freeSpot } from './canvas-core.js'; +import { freeSpot, zoomShortcut } from './canvas-core.js'; import { canFollow, domainList, fresh, logoOf, onlyOption, parseEnvVars, planOf, splitImage, targetFrom, uniqueLabels, withDeploy, withoutSecrets, withTarget } from './deploy-canvas.js'; test('splits the tag out of an image name', () => { @@ -176,3 +176,14 @@ test('shows the logo of what a source step creates, like the rest of Coolify', ( assert.equal(src('database', {}), null); assert.equal(src('server', {}), null); }); + +test('reads the browser zoom shortcut so the canvas can zoom instead', () => { + const key = (key, mods = {}) => zoomShortcut({ key, ctrlKey: false, metaKey: false, altKey: false, ...mods }); + assert.equal(key('=', { metaKey: true }), 'in'); + assert.equal(key('+', { ctrlKey: true }), 'in'); + assert.equal(key('-', { ctrlKey: true }), 'out'); + assert.equal(key('0', { metaKey: true }), 'reset'); + assert.equal(key('-'), null); + assert.equal(key('-', { ctrlKey: true, altKey: true }), null); + assert.equal(key('s', { metaKey: true }), null); +}); diff --git a/resources/js/environment-canvas.js b/resources/js/environment-canvas.js index 3149f366d3..9faeb3b3fe 100644 --- a/resources/js/environment-canvas.js +++ b/resources/js/environment-canvas.js @@ -135,7 +135,8 @@ const byTypeAndName = (a, b) => columnOf(a) - columnOf(b) || String(a.name).loca /** * Card positions inside one group, from (0, 0). Cards with a position saved on their environment - * canvas (node.saved) keep that arrangement; the other cards follow below them. Otherwise linked + * canvas (node.saved) keep that arrangement; the other cards follow below them in the grid the environment + * canvas uses, so both canvases show the group the same way. Otherwise linked * cards form chains from left to right (source before target, one column per step); every chain * gets its own rows. Cards without links follow in a grid of SINGLE_COLUMNS. */ @@ -150,9 +151,9 @@ export function layoutBlock(nodes, links = []) { const bottom = Math.max(...saved.map(n => out[n.id].y + estimateNodeHeight(n))); const rest = nodes.filter(n => !out[n.id]); if (!rest.length) return { positions: out, width, height: bottom }; - const below = layoutBlock(rest, links); - for (const [id, p] of Object.entries(below.positions)) out[id] = { x: p.x, y: bottom + ROW_GAP + p.y }; - return { positions: out, width: Math.max(width, below.width), height: bottom + ROW_GAP + below.height }; + // Same grid as the environment canvas (layoutGraph) puts below the saved cards, so both canvases match. + const height = placeGrid(rest, 0, bottom + ROW_GAP, out); + return { positions: out, width: Math.max(...Object.values(out).map(p => p.x)) + CARD_WIDTH, height }; } const ids = new Set(nodes.map(n => n.id)); const inside = links.filter(l => ids.has(l.source) && ids.has(l.target) && l.source !== l.target); @@ -217,9 +218,12 @@ export function layoutBlock(nodes, links = []) { return { positions: out, width: columns * step - COLUMN_GAP, height: bottom }; } -/** Server view: key of a project / environment group on one server (":"). */ +/** + * Server view: key of a project / environment group on one server (":"). + * Groups of an empty environment (placeholder card) have none, so they cannot be moved. + */ export const groupKey = (nodes, zoneId) => { - const environment = nodes.find(n => n.environment?.uuid)?.environment?.uuid; + const environment = nodes.find(n => n.type !== 'empty' && n.environment?.uuid)?.environment?.uuid; return environment && zoneId ? `${environment}:${zoneId}` : null; }; @@ -230,7 +234,7 @@ export const groupKey = (nodes, zoneId) => { * GROUPS_PER_ROW per row. When `groups` is given, it gets per group key its frame top-left, the zone's inner * top-left, and `shift`: environment canvas position = server view position - shift. */ -export function layoutServerView(graph, groups = null) { +export function layoutServerView(graph, groups = null, { compact = false } = {}) { const out = {}; let y = 0; for (const zone of zoneGroups(graph)) { @@ -240,15 +244,20 @@ export function layoutServerView(graph, groups = null) { const block = layoutBlock(nodes, graph.links || []); return { key: groupKey(nodes, zone.id), + nodes, block, size: { w: block.width + GROUP_PADDING * 2, h: GROUP_HEADER + GROUP_PADDING * 2 + block.height }, at: saved.length ? { x: Math.min(...saved.map(n => Number(n.saved.x))), y: Math.min(...saved.map(n => Number(n.saved.y))) } : null, }; }); + // Empty environments (placeholder cards only) go next to the other groups after these are placed. + const isEmpty = b => b.nodes.every(n => n.type === 'empty'); const pinned = blocks.filter(b => b.at).sort((a, b) => a.at.y - b.at.y || a.at.x - b.at.x); // The environment canvas puts its first cards below a zone header; those land at the zone's top-left here. const shift = { x: inner.x + GROUP_PADDING, y: inner.y + GROUP_HEADER + GROUP_PADDING - ZONE_HEADER - ZONE_PADDING }; + const frames = []; const place = ({ key, block, size }, left, top) => { + frames.push({ x: left, y: top }); for (const [id, p] of Object.entries(block.positions)) { out[id] = { x: left + GROUP_PADDING + p.x, y: top + GROUP_HEADER + GROUP_PADDING + p.y }; } @@ -266,21 +275,39 @@ export function layoutServerView(graph, groups = null) { ...b.size, }; const spot = freeSpot(wanted, taken, GROUP_GAP, inside) || wanted; - taken.push(spot); + taken.push({ ...spot, project: b.nodes[0]?.project?.uuid }); bottom = Math.max(bottom, place(b, spot.x, spot.y)); } let x = 0; let rowTop = pinned.length ? bottom + GROUP_GAP : inner.y; let rowBottom = rowTop; - blocks.filter(b => !b.at).forEach((b, i) => { + blocks.filter(b => !b.at && !isEmpty(b)).forEach((b, i) => { if (i > 0 && i % GROUPS_PER_ROW === 0) { x = 0; rowTop = rowBottom + GROUP_GAP; } + taken.push({ x, y: rowTop, ...b.size, project: b.nodes[0]?.project?.uuid }); rowBottom = Math.max(rowBottom, place(b, x, rowTop)); x += b.size.w + GROUP_GAP; }); - y = Math.max(bottom, rowBottom) + ZONE_PADDING + ZONE_GAP; + bottom = Math.max(bottom, rowBottom); + // Right of the last group of the same project (or of the zone), at the nearest free spot. + for (const b of blocks.filter(b => !b.at && isEmpty(b))) { + const project = b.nodes[0]?.project?.uuid; + const anchor = taken.filter(t => t.project === project).at(-1) || taken.at(-1); + const wanted = anchor ? { x: anchor.x + anchor.w + GROUP_GAP, y: anchor.y, ...b.size } : { ...inner, ...b.size }; + const spot = freeSpot(wanted, taken, GROUP_GAP, inside) || wanted; + taken.push({ ...spot, project }); + bottom = Math.max(bottom, place(b, spot.x, spot.y)); + } + if (compact && frames.length) { + // The groups keep their arrangement, but start at the zone's top-left (no empty space from saved positions). + const dx = inner.x - Math.min(...frames.map(f => f.x)); + const dy = inner.y - Math.min(...frames.map(f => f.y)); + for (const n of zone.nodes) if (out[n.id]) out[n.id] = { x: out[n.id].x + dx, y: out[n.id].y + dy }; + bottom += dy; + } + y = bottom + ZONE_PADDING + ZONE_GAP; } return out; } @@ -300,7 +327,7 @@ export function layoutConnectView(graph) { out[n.id] = { x: 0, y }; y += estimateNodeHeight(n) + ROW_GAP; } - const offset = variables ? CARD_WIDTH + ZONE_PADDING * 2 + ZONE_GAP * 3 : 0; + const offset = variables ? CARD_WIDTH + ZONE_PADDING * 2 + ZONE_GAP * 2 : 0; let top = 0; if (connected) { y = ZONE_HEADER + ZONE_PADDING; @@ -315,7 +342,8 @@ export function layoutConnectView(graph) { top = y - GROUP_GAP + ZONE_PADDING + ZONE_GAP; } const others = groups.filter(g => g.id !== SOURCES_ZONE && g.id !== CONNECTED_ZONE); - const placed = layoutServerView({ zones: others.map(g => g.zone || { id: g.id }), nodes: others.flatMap(g => g.nodes) }); + // Compact: groups cannot be moved here, so positions saved on the environment canvas must not leave gaps. + const placed = layoutServerView({ zones: others.map(g => g.zone || { id: g.id }), nodes: others.flatMap(g => g.nodes) }, null, { compact: true }); for (const [id, p] of Object.entries(placed)) out[id] = { x: p.x + offset, y: p.y + top }; return out; } diff --git a/resources/js/environment-canvas.test.js b/resources/js/environment-canvas.test.js index 920f2bc841..e4240b62bf 100644 --- a/resources/js/environment-canvas.test.js +++ b/resources/js/environment-canvas.test.js @@ -7,6 +7,7 @@ import { ROW_GAP, ZONE_HEADER, ZONE_PADDING, + ZONE_GAP, canConnect, canLink, dragRules, @@ -21,6 +22,7 @@ import { statusWord, READABLE_ZOOM, groupFrames, + groupKey, layoutBlock, estimateNodeHeight, layoutGraph, @@ -291,6 +293,28 @@ test('server view: cards keep the arrangement saved on their environment canvas, assert.ok(p.c.y >= p.b.y + estimateNodeHeight(g.nodes[1]) + ROW_GAP); }); +test('server view: a group with one saved card matches the environment canvas arrangement', () => { + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => envNode(`n${i}`, 'application', 'z1', 'prod')); + const nodes = [envNode('a', 'application', 'z1', 'prod', { saved: { x: 1000, y: 697 } }), ...apps, envNode('d', 'database', 'z1', 'prod')]; + const env = layoutGraph({ zones: [{ id: 'z1', name: 'web' }], nodes, links: [], positions: { a: { x: 1000, y: 697 } } }); + const server = layoutServerView({ layout: 'server', zones: [{ id: 'z1', name: 'web' }], nodes, links: [{ source: 'n0', target: 'd' }] }); + for (const n of nodes) { + assert.deepEqual({ x: server[n.id].x - server.a.x, y: server[n.id].y - server.a.y }, { x: env[n.id].x - env.a.x, y: env[n.id].y - env.a.y }, n.id); + } +}); + +test('server view: a moved card saved with its group on the environment canvas stays where it was dropped', () => { + const nodes = [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'service', 'z1', 'prod'), envNode('d', 'database', 'z1', 'prod')]; + const g = { layout: 'server', zones: [{ id: 'z1', name: 'web' }], nodes, links: [] }; + const groups = {}; + const before = layoutServerView(g, groups); + const { shift } = groups['prod:z1']; + const dropped = { ...before, b: { x: before.b.x + 120, y: before.b.y + 400 } }; + const saved = Object.fromEntries(nodes.map(n => [n.id, { x: dropped[n.id].x - shift.x, y: dropped[n.id].y - shift.y }])); + const after = layoutServerView({ ...g, nodes: nodes.map(n => ({ ...n, saved: saved[n.id] })) }); + assert.deepEqual(after, dropped); +}); + test('server view: zones with server details get the taller header', () => { const g = { layout: 'server', @@ -561,3 +585,43 @@ test('server view: a dragged card stops before its group covers another group', const [f1, f2] = groupFrames(g.nodes, positions); assert.ok(apart(f1, f2), 'the project / environment frames do not overlap'); }); + +test('groupKey: a group with only an empty-environment placeholder has no key, so it cannot be moved', () => { + const environment = { uuid: 'env', name: 'production' }; + assert.equal(groupKey([{ id: 'empty:env', type: 'empty', environment }], 'empty'), null); + assert.equal(groupKey([{ id: 'a', type: 'application', environment }], 'srv'), 'env:srv'); +}); + +test('layoutServerView: an empty environment sits right of the other groups of its project, not below them', () => { + const shop = { uuid: 'shop', name: 'Shop' }; + const node = (id, context, extra = {}) => ({ id, type: 'application', zone: 'srv', context, project: shop, environment: { uuid: context }, ...extra }); + const graph = { + layout: 'server', + zones: [{ id: 'srv', name: 'srv' }], + nodes: [ + node('a', 'Shop / staging', { saved: { x: 0, y: 0 } }), + node('empty:prod', 'Shop / production', { type: 'empty' }), + ], + links: [], + }; + const out = layoutServerView(graph); + assert.equal(out['empty:prod'].y, out.a.y); + assert.ok(out['empty:prod'].x >= out.a.x + CARD_WIDTH); +}); + +test('variables view: saved environment canvas positions do not push resources away from the variables', () => { + const g = { + layout: 'connect', + zones: [{ id: 'sources', name: 'Server variables', icon: 'variable' }, { id: 'resources', name: 'Other resources', icon: 'service' }], + nodes: [ + node('variable:1', 'variable', 'sources', { name: 'COOLIFY_SERVER_NAME' }), + node('app', 'application', 'resources', { context: 'Shop / prod', environment: { uuid: 'e1' }, saved: { x: 900, y: 1200 } }), + ], + links: [], + positions: {}, + }; + const [variables, resources] = zoneBoxes(g, layoutGraph(g)); + + assert.equal(resources.y, variables.y, 'both zones start at the top'); + assert.ok(resources.x - (variables.x + variables.width) <= ZONE_GAP * 2, 'resources sit next to the variables'); +}); diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 93934db322..8b61ea5b72 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -1,4 +1,4 @@ -@props(['application', 'currentRoute', 'flush' => false]) +@props(['application', 'currentRoute', 'flush' => false, 'navOnly' => false]) @php $applicationRouteParameters = [ @@ -258,17 +258,14 @@ @php // The main sidebar shows this menu on wide screens (see components/navbar.blade.php). - request()->attributes->set('mainNavContext', [ - 'label' => 'Dashboard', - 'backHref' => route('dashboard'), - 'backTitle' => 'Back to the dashboard', - ]); + registerMainNavContext(0, 'Dashboard', 'Back to the main menu'); @endphp -{{-- Rendered twice: in the page (below xl), and teleported into the main sidebar (xl and up). --}} -@foreach (['page', 'main-nav'] as $placement) +{{-- Rendered twice: in the page (below xl), and teleported into the main sidebar (xl and up). + navOnly renders only the sidebar copy, as the parent menu of a deeper page (e.g. a backup). --}} +@foreach ($navOnly ? ['main-nav'] : ['page', 'main-nav'] as $placement) @if ($placement === 'main-nav') -@teleport('#main-nav-context') +@teleport('#main-nav-context-0')