diff --git a/CHANGELOG.md b/CHANGELOG.md index 6290e382e5..727e31afc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Save the healthcheck **Check type** when it is the only change. Before, the "Save changes" bar did not open when you changed only the check type, for example from **Container command** back to **HTTP request** - Coolify Cloud: end a paid subscription that lost its link to Stripe when the customer cancels it. Before, it stayed active, and `cloud:sync-stripe-subscriptions` did not report it - Keep the dashboard proxy configuration of a server with the IP `host.docker.internal` that is not the default localhost server. Before, Coolify did not see this server as localhost after it saved the server, for example after a proxy restart, and deleted `coolify.yaml`, so the dashboard was not reachable at its domain ([#12444](https://github.com/coollabsio/coolify/issues/12444)) +- Show the disk usage of a server on the **Metrics** tab, on the **Fleet** page, and in the MCP `get_server_metrics` tool when Sentinel reports the Coolify data disk under a different mount point than `/`, `/data`, or `/data/coolify`. For example, this occurs on a NAS where `/data` is a symlink into a data volume. Before, the disk usage was always empty on these servers ([#12448](https://github.com/coollabsio/coolify/issues/12448)) ## [v4.6.0] - 2026-11-02 diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php index 45bb91aeaf..c03dd7a85f 100644 --- a/app/Services/SentinelMetricsClient.php +++ b/app/Services/SentinelMetricsClient.php @@ -313,14 +313,19 @@ class SentinelMetricsClient * Sentinel runs in a container and reports only the disks of its own mounts: * 1.1.2 and older as `/app/db`, 1.1.3 and newer by host mount point, which is * `/data` or `/data/coolify` when that directory is on its own disk. + * Sentinel 1.1.3 names a disk by its shortest host mount point, which can be any + * path (for example `/home` when `/data` is a symlink into a NAS volume). When none + * of ROOT_MOUNTS is reported, the only reported disk is the one under Sentinel's + * `/app/db`; with several, the right one is unknown. * * @param array> $rows */ public static function rootMountName(array $rows): ?string { - $mounts = array_column($rows, 'mount'); + $mounts = array_values(array_unique(array_column($rows, 'mount'))); - return collect(self::ROOT_MOUNTS)->first(fn ($mount) => in_array($mount, $mounts, true)); + return collect(self::ROOT_MOUNTS)->first(fn ($mount) => in_array($mount, $mounts, true)) + ?? (count($mounts) === 1 ? $mounts[0] : null); } /** @@ -352,13 +357,10 @@ class SentinelMetricsClient 'memory', 'disk', 'load', 'network' => "/{$metric}/history", default => '/cpu/history', }; - $query = self::historyQuery($range); - if ($metric === 'disk') { - // Sentinel 1.1.4+ returns only these mounts; older versions ignore the param. - $query['mount'] = implode(',', self::ROOT_MOUNTS); - } - return $this->url($path, $query); + // No `mount` filter for disk history: rootMountName() can pick a disk that is not in + // ROOT_MOUNTS, and Sentinel reports only the few disks of its own mounts. + return $this->url($path, self::historyQuery($range)); } /** diff --git a/tests/Feature/FleetMetricsPrefetchTest.php b/tests/Feature/FleetMetricsPrefetchTest.php index fa3f6094fd..a4403f6eda 100644 --- a/tests/Feature/FleetMetricsPrefetchTest.php +++ b/tests/Feature/FleetMetricsPrefetchTest.php @@ -64,7 +64,7 @@ it('fetches the page metrics of every server in one SSH command each', function expect($commands)->toHaveCount(2) ->and($commands[0])->toContain('/api/summary', '/api/containers/current', '/api/load/history', '/api/network/history', '-H @-') ->and($commands[0])->toContain('/api/disk/history?from=') - ->and($commands[0])->toContain('mount=%2F%2C%2Fapp%2Fdb%2C%2Fdata%2Fcoolify%2C%2Fdata'); + ->and($commands[0])->not->toContain('mount='); }); it('does not fetch each metric again when the batch fails', function () { diff --git a/tests/Unit/Metrics/HasMetricsMappingTest.php b/tests/Unit/Metrics/HasMetricsMappingTest.php index e6c70be05b..65dc52265f 100644 --- a/tests/Unit/Metrics/HasMetricsMappingTest.php +++ b/tests/Unit/Metrics/HasMetricsMappingTest.php @@ -67,13 +67,26 @@ it('returns null for a metric when metrics are unavailable', function () { expect($d->getLoadMetrics(5))->toBeNull(); }); -it('does not use non-root mounts when root disk samples are missing', function () { +it('does not guess between several non-root mounts when root disk samples are missing', function () { $double = new MetricsMappingDouble; - $double->rowsByType['disk'] = [['time' => 1000, 'mount' => '/boot', 'usedPercent' => 90]]; + $double->rowsByType['disk'] = [ + ['time' => 1000, 'mount' => '/boot', 'usedPercent' => 90], + ['time' => 1000, 'mount' => '/home', 'usedPercent' => 20], + ]; expect($double->getDiskMetrics(5))->toBe([]); }); +it('uses the only reported disk for disk metrics when it has another mount name', function () { + $double = new MetricsMappingDouble; + $double->rowsByType['disk'] = [ + ['time' => 1000, 'mount' => '/home', 'usedPercent' => 19], + ['time' => 2000, 'mount' => '/home', 'usedPercent' => 20], + ]; + + expect($double->getDiskMetrics(5))->toBe([[1000, 19.0], [2000, 20.0]]); +}); + it('uses the Sentinel data mount for disk metrics when no root mount is reported', function () { $double = new MetricsMappingDouble; $double->rowsByType['disk'] = [ diff --git a/tests/Unit/Services/SentinelMetricsClientTest.php b/tests/Unit/Services/SentinelMetricsClientTest.php index c8c42286b0..ef2ffbaf56 100644 --- a/tests/Unit/Services/SentinelMetricsClientTest.php +++ b/tests/Unit/Services/SentinelMetricsClientTest.php @@ -172,15 +172,32 @@ it('rounds the range start to the minute so history responses are cacheable', fu Carbon::setTestNow(); }); -it('does not substitute another mount when root disk history is absent', function () { +it('does not guess between several other mounts when root disk history is absent', function () { $client = fakeMetricsClient(); $client->bodies = ['/disk/history' => json_encode([ ['time' => 1700000000000, 'mount' => '/boot', 'usedPercent' => 90], + ['time' => 1700000000000, 'mount' => '/home', 'usedPercent' => 20], ])]; expect($client->history('disk', '24h'))->toBe([]); }); +it('uses the only reported disk when Sentinel names it by another host mount point', function () { + $client = fakeMetricsClient(); + $client->bodies = [ + '/summary' => json_encode(['disk' => [ + ['mount' => '/home', 'used' => 19, 'total' => 100, 'usedPercent' => 19.02], + ]]), + '/disk/history' => json_encode([ + ['time' => 1700000000000, 'mount' => '/home', 'usedPercent' => 19], + ['time' => 1700000300000, 'mount' => '/home', 'usedPercent' => 21], + ]), + ]; + + expect($client->summary()['diskPercent'])->toBe(19.02) + ->and($client->history('disk', '24h'))->toBe([[1699999800000, 19.0], [1700000100000, 21.0]]); +}); + it('supports disk history without mount fields', function () { $client = fakeMetricsClient(); $client->bodies = ['/disk/history' => json_encode([ @@ -190,10 +207,11 @@ it('supports disk history without mount fields', function () { expect($client->history('disk', '24h'))->toBe([[1699999800000, 40.0]]); }); -it('does not substitute another disk mount in the summary', function () { +it('does not guess between several other disk mounts in the summary', function () { $client = fakeMetricsClient(); $client->bodies = ['/summary' => json_encode(['disk' => [ ['mount' => '/boot', 'used' => 90, 'total' => 100, 'usedPercent' => 90], + ['mount' => '/home', 'used' => 20, 'total' => 100, 'usedPercent' => 20], ]])]; expect($client->summary()['diskPercent'])->toBeNull();