diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php index 7d11edd852..ab3ca4082a 100644 --- a/app/Services/SentinelMetricsClient.php +++ b/app/Services/SentinelMetricsClient.php @@ -141,7 +141,8 @@ class SentinelMetricsClient // Disk history interleaves every mount; keep the root filesystem only. if ($rootOnly && array_any($rows, fn ($row) => array_key_exists('mount', $row))) { - $rows = array_filter($rows, fn ($row) => ($row['mount'] ?? null) === '/'); + $mount = self::rootMountName($rows); + $rows = array_filter($rows, fn ($row) => ($row['mount'] ?? null) === $mount); } // Average raw samples into epoch-aligned buckets so every server shares the same @@ -159,14 +160,29 @@ class SentinelMetricsClient return array_map(fn ($bucket) => [$bucket, round($sums[$bucket] / $counts[$bucket], 2)], array_keys($sums)); } + /** + * The mount that stands for the root disk: `/`, else Sentinel's data bind mount. + * Sentinel 1.1.2 and older run in a container and report only their own mounts, + * so the host disk that holds /data/coolify is reported as `/app/db`. + * + * @param array> $rows + */ + private static function rootMountName(array $rows): ?string + { + $mounts = array_column($rows, 'mount'); + + return collect(['/', '/app/db'])->first(fn ($mount) => in_array($mount, $mounts, true)); + } + /** * @param array> $mounts * @return array{used: ?int, total: ?int, percent: ?float} */ private function rootMount(array $mounts): array { - $root = collect($mounts)->firstWhere('mount', '/'); - if ($root === null && ! array_any($mounts, fn ($mount) => array_key_exists('mount', $mount))) { + if (array_any($mounts, fn ($mount) => array_key_exists('mount', $mount))) { + $root = collect($mounts)->firstWhere('mount', self::rootMountName($mounts)); + } else { $root = $mounts[0] ?? null; } diff --git a/resources/css/app.css b/resources/css/app.css index 0674b14c48..368219b4ab 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -6422,10 +6422,22 @@ html.dark.is-scrolled:not([data-nav-style="floating"]) .app-topbar > div:not(.ap padding-top: 0; } - html:not([data-nav-style="floating"]) .app-main-full :is([data-ec-root], .cv-root) { + /* The deploy canvas Steps panel starts below the top bar, and its canvas + runs up under the top bar. */ + html:not([data-nav-style="floating"]) .app-main-full .cv-root { padding-top: calc(3rem + 0.5rem); } + html:not([data-nav-style="floating"]) .app-main-full .cv-root.cv-docked .cv-stage { + margin-top: calc(-3rem - 0.5rem); + } + + /* The resource canvases fill the space under the top bar, so the top bar + does not cut off the cards (fit keeps the cards below it). */ + html:not([data-nav-style="floating"]) .app-main-full [data-ec-root] { + padding: 0 !important; + } + /* The deploy canvas (.cv-root) paints its own dots. */ html:not([data-nav-style="floating"]) .app-main-full [data-ec-root] { background-image: var(--canvas-dots-image, radial-gradient(circle, rgb(0 0 0 / 0.13) 1px, transparent 1px)); diff --git a/resources/js/canvas/environment/EnvironmentCanvas.svelte b/resources/js/canvas/environment/EnvironmentCanvas.svelte index 76b63611a7..f2f7f70fa8 100644 --- a/resources/js/canvas/environment/EnvironmentCanvas.svelte +++ b/resources/js/canvas/environment/EnvironmentCanvas.svelte @@ -217,14 +217,15 @@ // so the top bar keeps only the brand and breadcrumb. const topbar = document.querySelector('.app-topbar'); const floatingBar = () => desktop.matches && !!topbar && document.documentElement.dataset.navStyle === 'floating'; - // Floating nav: the canvas fills the screen behind the top bar and the sidebar (see app.css), - // so fit and the side panel use only the part that the floating panels do not cover. + // Desktop: the canvas fills the space behind the top bar (and, with floating nav, the sidebar; see app.css), + // so fit and the side panel use only the part that the top bar and floating panels do not cover. function coveredInsets() { const gap = 8; const v = viewport.getBoundingClientRect(); // The bottom-left controls and deployments sit below the cards; fit keeps no side space for them. const deploymentsRight = 0; - if (!floatingBar()) return { top: 0, left: Math.max(0, deploymentsRight) }; + if (!desktop.matches || !topbar) return { top: 0, left: Math.max(0, deploymentsRight) }; + if (!floatingBar()) return { top: Math.max(0, topbar.getBoundingClientRect().bottom + gap - v.top), left: Math.max(0, deploymentsRight) }; // The main sidebar, and a page's fixed settings rail (server canvas) when it is on the left. const rail = document.querySelector('.application-settings-navigation'); const panels = [document.querySelector('.app-sidebar > div > nav'), rail && getComputedStyle(rail).position === 'fixed' ? rail : null] @@ -241,7 +242,7 @@ const inControls = () => !!bar?.closest('[data-canvas-controls]'); function alignFloatingBar() { if (!floatingBar()) { - panelTop = ''; + panelTop = desktop.matches && topbar ? `${coveredInsets().top}px` : ''; return; } if (inControls()) { diff --git a/tests/Unit/Services/SentinelMetricsClientTest.php b/tests/Unit/Services/SentinelMetricsClientTest.php index 3efbdf42b9..967ee7b52e 100644 --- a/tests/Unit/Services/SentinelMetricsClientTest.php +++ b/tests/Unit/Services/SentinelMetricsClientTest.php @@ -179,3 +179,29 @@ it('does not substitute another disk mount in the summary', function () { expect($client->summary()['diskPercent'])->toBeNull(); }); + +it('uses the Sentinel data mount as the root disk when no root mount is reported', function () { + $client = fakeMetricsClient(); + $client->bodies = [ + '/summary' => json_encode(['disk' => [ + ['mount' => '/app/db', 'used' => 30, 'total' => 100, 'usedPercent' => 30], + ]]), + '/disk/history' => json_encode([ + ['time' => 1700000000000, 'mount' => '/app/db', 'usedPercent' => 30], + ['time' => 1700000000000, 'mount' => '/boot', 'usedPercent' => 90], + ]), + ]; + + expect($client->summary()['diskPercent'])->toBe(30.0) + ->and($client->history('disk', '24h'))->toBe([[1699999800000, 30.0]]); +}); + +it('prefers the root mount over the Sentinel data mount', function () { + $client = fakeMetricsClient(); + $client->bodies = ['/disk/history' => json_encode([ + ['time' => 1700000000000, 'mount' => '/app/db', 'usedPercent' => 30], + ['time' => 1700000000000, 'mount' => '/', 'usedPercent' => 60], + ])]; + + expect($client->history('disk', '24h'))->toBe([[1699999800000, 60.0]]); +});