diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json index 3432d27..864f78e 100644 --- a/src/lib/openapi.generated.json +++ b/src/lib/openapi.generated.json @@ -12505,6 +12505,9 @@ }, "name": { "type": "string" + }, + "memoryLimitSupported": { + "type": "boolean" } } }, @@ -12523,7 +12526,8 @@ "serverVersion": "string", "containers": 0, "images": 0, - "name": "string" + "name": "string", + "memoryLimitSupported": true }, "hawser": {} } diff --git a/src/lib/utils/memory-support.ts b/src/lib/utils/memory-support.ts new file mode 100644 index 0000000..1e92ad1 --- /dev/null +++ b/src/lib/utils/memory-support.ts @@ -0,0 +1,30 @@ +/** + * Whether the daemon's kernel supports per-container memory accounting, read from + * Docker `/info`. When the cgroup memory controller is disabled in the kernel boot + * config (common on Raspberry Pi and some ARM boards), the daemon reports + * `MemoryLimit: false` and every container's memory usage shows as 0. Docker itself + * detects this - we surface it so the user isn't left guessing why memory is blank. + */ + +export interface MemorySupportInfo { + /** true = the UI can show a warning that memory accounting is off. */ + warn: boolean; + /** Docker's own `/info.MemoryLimit`. */ + memoryLimitSupported: boolean; + /** Docker's own `/info.SwapLimit`. */ + swapLimitSupported: boolean; +} + +/** + * Derive the memory-support flags from a Docker `/info` response. `MemoryLimit`/ + * `SwapLimit` are booleans; a missing/undefined field is treated as SUPPORTED (no + * warning) so an older daemon or a partial info payload never produces a false alarm. + */ +export function memorySupportFromInfo(info: { MemoryLimit?: boolean; SwapLimit?: boolean } | null | undefined): MemorySupportInfo { + const memoryLimitSupported = info?.MemoryLimit !== false; + const swapLimitSupported = info?.SwapLimit !== false; + return { warn: !memoryLimitSupported, memoryLimitSupported, swapLimitSupported }; +} + +/** Link to the manual section explaining how to enable cgroup memory accounting. */ +export const MEMORY_SUPPORT_DOC_URL = 'https://dockhand.pro/manual/#troubleshooting-rpi-memory'; diff --git a/src/routes/api/environments/test/+server.ts b/src/routes/api/environments/test/+server.ts index b18bd2a..9703e42 100644 --- a/src/routes/api/environments/test/+server.ts +++ b/src/routes/api/environments/test/+server.ts @@ -4,6 +4,7 @@ import type { DockerClientConfig } from '$lib/server/docker'; import { getEnvironment } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { isSafeNotificationUrl } from '$lib/server/url-safety'; +import { memorySupportFromInfo } from '$lib/utils/memory-support'; import type { RequestHandler } from './$types'; interface TestConnectionRequest { @@ -58,7 +59,7 @@ function buildDockerClientConfig(config: TestConnectionRequest): DockerClientCon * description: Pass environmentId to fall back to that saved environment's stored secrets (hawserToken, tlsKey) for any secret field left blank in the body, so Test connection works when editing without re-entering the token. * body: {connectionType:string!, socketPath:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, hawserToken:string, environmentId:integer} * body-example: {"connectionType":"socket","socketPath":"/var/run/docker.sock"} - * resp-200: {success:boolean!, info:{serverVersion:string, containers:integer, images:integer, name:string}, hawser:{}} + * resp-200: {success:boolean!, info:{serverVersion:string, containers:integer, images:integer, name:string, memoryLimitSupported:boolean}, hawser:{}} * resp-200-desc: success:false with a human-readable error message is also returned as HTTP 200 (connection failures are not transport errors) * resp-400: Host is required for direct/hawser-standard connection types * resp-403: The caller cannot access the environment named by environmentId @@ -177,13 +178,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => { } } + const memSupport = memorySupportFromInfo(info); return json({ success: true, info: { serverVersion: info.ServerVersion, containers: info.Containers, images: info.Images, - name: info.Name + name: info.Name, + // The kernel's cgroup memory controller: false => per-container memory + // shows 0 (common on Raspberry Pi). The modal warns on this at create time. + memoryLimitSupported: memSupport.memoryLimitSupported }, hawser: hawserInfo }); diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte index f1ef14a..2a4690c 100644 --- a/src/routes/settings/environments/EnvironmentModal.svelte +++ b/src/routes/settings/environments/EnvironmentModal.svelte @@ -86,6 +86,7 @@ import { licenseStore } from '$lib/stores/license'; import { formatDateTime, formatDate } from '$lib/stores/settings'; import { getLabelColor, getLabelBgColor, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; + import { MEMORY_SUPPORT_DOC_URL } from '$lib/utils/memory-support'; import { labelColorOverrides } from '$lib/stores/label-colors'; import EventTypesEditor from './EventTypesEditor.svelte'; import UpdatesTab from './tabs/UpdatesTab.svelte'; @@ -494,6 +495,9 @@ // Test connection state let testingConnection = $state(false); let testResult = $state<{ success: boolean; info?: any; error?: string; isEdgeMode?: boolean } | null>(null); + // Shown when a successful Test connection reveals the daemon's kernel has cgroup + // memory accounting disabled (per-container memory shows 0 - common on Raspberry Pi). + let memWarnOpen = $state(false); // Socket detection state let detectingSockets = $state(false); @@ -758,6 +762,9 @@ toast.info('Edge mode - connection will be tested when agent connects'); } else { toast.success(`Connected! Docker ${result.info.serverVersion} - ${result.info.containers} containers`); + // Docker reports the kernel's cgroup memory controller is off -> container + // memory will show 0. Surface it now so the user isn't left guessing. + if (result.info.memoryLimitSupported === false) memWarnOpen = true; } } else { toast.error(result.error || 'Connection failed'); @@ -2005,10 +2012,11 @@ A direct daemon shares no filesystem with Dockhand. When set, Dockhand copies each stack's folder to <this path>/<stack> - on the remote host so the backup + on the remote host + on each deploy so the backup helper can read the compose and config, and rewrites relative binds (./data) to that host path so they resolve - on the remote daemon. + on the remote daemon. After setting this, redeploy a stack once so its files are staged there.

Leave empty to skip this: the stack won't be backupable and a relative bind resolves @@ -2034,6 +2042,8 @@ Absolute path on the remote host where Dockhand keeps this stack's files, so its compose and config are backupable and relative binds resolve on the remote daemon. Leave empty to use only absolute paths or named volumes. + Takes effect on the next deploy - after + setting this, redeploy each stack so Dockhand stages its files there. {/if}

@@ -3146,6 +3156,36 @@ + + + + + + + Container memory won't be shown + + +
+

+ This host's kernel has cgroup memory accounting disabled, so Docker can't report + per-container memory - it will show 0 B + for every container. This is common on Raspberry Pi and some ARM boards. +

+

+ It's fixed in the kernel boot config, not in Dockhand. See the guide: + + enabling container memory accounting + + +

+
+ + + +
+
+ diff --git a/static/openapi.json b/static/openapi.json index 3432d27..864f78e 100644 --- a/static/openapi.json +++ b/static/openapi.json @@ -12505,6 +12505,9 @@ }, "name": { "type": "string" + }, + "memoryLimitSupported": { + "type": "boolean" } } }, @@ -12523,7 +12526,8 @@ "serverVersion": "string", "containers": 0, "images": 0, - "name": "string" + "name": "string", + "memoryLimitSupported": true }, "hawser": {} } diff --git a/tests/memory-support.test.ts b/tests/memory-support.test.ts new file mode 100644 index 0000000..c6692dd --- /dev/null +++ b/tests/memory-support.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'bun:test'; +import { memorySupportFromInfo, MEMORY_SUPPORT_DOC_URL } from '../src/lib/utils/memory-support'; + +describe('memorySupportFromInfo', () => { + it('warns when MemoryLimit is false (cgroup memory off - Raspberry Pi case)', () => { + const r = memorySupportFromInfo({ MemoryLimit: false, SwapLimit: false }); + expect(r.warn).toBe(true); + expect(r.memoryLimitSupported).toBe(false); + }); + + it('does not warn when MemoryLimit is true', () => { + const r = memorySupportFromInfo({ MemoryLimit: true, SwapLimit: true }); + expect(r.warn).toBe(false); + expect(r.memoryLimitSupported).toBe(true); + }); + + it('treats a missing MemoryLimit as supported (no false alarm on older/partial info)', () => { + expect(memorySupportFromInfo({}).warn).toBe(false); + expect(memorySupportFromInfo(null).warn).toBe(false); + expect(memorySupportFromInfo(undefined).warn).toBe(false); + }); + + it('swap-only off does not warn (memory is what drives the 0 B display)', () => { + const r = memorySupportFromInfo({ MemoryLimit: true, SwapLimit: false }); + expect(r.warn).toBe(false); + expect(r.swapLimitSupported).toBe(false); + }); + + it('doc url points at the manual anchor', () => { + expect(MEMORY_SUPPORT_DOC_URL).toBe('https://dockhand.pro/manual/#troubleshooting-rpi-memory'); + }); +});