From ee4632a7b7fe99651e300581d6869e27de9e669f Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Thu, 27 Aug 2026 19:48:35 +0200 Subject: [PATCH] stacks page: env-var counts from sources, icon batching, and an in-order restart --- src/lib/components/ContainerIcon.svelte | 32 ++++---- src/lib/components/StackIcon.svelte | 20 ++++- src/lib/data/changelog.json | 12 +++ src/lib/openapi.generated.json | 6 +- src/lib/server/stacks.ts | 65 ++++++++++++++++- src/lib/stores/selfhst-icons.ts | 72 ++++++++++++++++++ .../api/stacks/[name]/restart/+server.ts | 7 +- src/routes/api/stacks/sources/+server.ts | 22 +++++- src/routes/stacks/+page.svelte | 73 +++++++------------ 9 files changed, 229 insertions(+), 80 deletions(-) create mode 100644 src/lib/stores/selfhst-icons.ts diff --git a/src/lib/components/ContainerIcon.svelte b/src/lib/components/ContainerIcon.svelte index baddf69..2658699 100644 --- a/src/lib/components/ContainerIcon.svelte +++ b/src/lib/components/ContainerIcon.svelte @@ -3,6 +3,7 @@ import { Box } from 'lucide-svelte'; import { appSettings } from '$lib/stores/settings'; import { selfhstMatcher, ensureSelfhstMatcher } from '$lib/stores/selfhst-refs'; + import { selfhstIcons, requestSelfhst } from '$lib/stores/selfhst-icons'; import { isSelfhstIcon, selfhstRef, isCustomIcon, getStackIconComponent } from '$lib/utils/icons'; interface Props { @@ -84,33 +85,30 @@ // Reactive match: recomputes when the matcher store loads or the image changes. const ref = $derived(enabled ? $selfhstMatcher(image, name) : null); - // If the can't load at all (a transport error - the endpoint serves a neutral - // placeholder SVG rather than a 404 for an unresolved icon), fall back to the glyph. - let imgFailed = $state(false); - $effect(() => { - // reset the failure flag when the resolved ref changes - void ref; - imgFailed = false; - }); // Show the fallback glyph when the toggle is on (no logo matched) or a caller that // always had an icon asked for it while off - but never on dense inline spots. const showFallback = $derived(!hideWhenNoMatch && (enabled || showFallbackWhenOff)); + + // Resolve selfh.st refs (override OR auto-matched) through the shared batch store so a + // list of containers makes ONE icon request, not one per row. `resolved`: data URI when + // ready, '' if unresolvable, undefined while the batch is in flight. + const activeSelfhst = $derived(hasOverride ? overrideSelfhst : enabled ? ref : null); + $effect(() => { + if (activeSelfhst) requestSelfhst(activeSelfhst); + }); + const resolvedSelfhst = $derived(activeSelfhst ? $selfhstIcons[activeSelfhst] : undefined); -{#if hasOverride && overrideSelfhst} - +{#if activeSelfhst && resolvedSelfhst} + +{:else if activeSelfhst && resolvedSelfhst === undefined} + + {:else if hasOverride && overrideCustom && overrideCustomUrl} {:else if hasOverride && OverrideLucide} -{:else if enabled && ref && !imgFailed} - (imgFailed = true)} - /> {:else if showFallback} diff --git a/src/lib/components/StackIcon.svelte b/src/lib/components/StackIcon.svelte index e168674..5ffa0e4 100644 --- a/src/lib/components/StackIcon.svelte +++ b/src/lib/components/StackIcon.svelte @@ -1,5 +1,7 @@ -{#if selfhst} - +{#if selfhst && resolved} + +{:else if selfhst && resolved === undefined} + + {:else if custom && customUrl} {:else if LucideIcon} +{:else if selfhst} + + {/if} diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 2852259..93e9ed0 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,16 @@ [ + { + "version": "1.0.46", + "date": "", + "comingSoon": true, + "changes": [ + { "type": "feature", "text": "restart a stack in depends_on order (#1480)" }, + { "type": "fix", "text": "cache app icons so the containers and stacks pages load faster" }, + { "type": "fix", "text": "upgrade svelte to 5.56.10 (#1476, PR#1477, @ThanatosDi)" }, + { "type": "fix", "text": "refresh the bundled docker-compose (5.5.0-r2)" } + ], + "imageTag": "fnsys/dockhand:v1.0.46" + }, { "version": "1.0.45", "date": "2026-08-27", diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json index eb86403..98fe578 100644 --- a/src/lib/openapi.generated.json +++ b/src/lib/openapi.generated.json @@ -25831,7 +25831,7 @@ "tags": [ "stacks" ], - "summary": "Restart a stack (mode=restart) or recreate its containers (mode=recreate); progress and the final result stream over Server-Sent Events", + "summary": "Restart a stack (mode=restart|ordered|recreate); progress and the final result stream over Server-Sent Events", "parameters": [ { "name": "name", @@ -25858,7 +25858,7 @@ "schema": { "type": "string" }, - "description": "Restart mode — \"recreate\" recreates containers, anything else performs a plain restart" + "description": "Restart mode — \"recreate\" recreates containers (new IDs, re-pull), \"ordered\" does a stop+start honoring depends_on ordering (same IDs), anything else is a plain in-place restart" } ], "responses": { @@ -26631,7 +26631,7 @@ "tags": [ "stacks" ], - "summary": "List stack source records (their stored compose/env paths and source type)", + "summary": "List stack source records (their stored compose/env paths and source type, plus an env-var count)", "parameters": [ { "name": "env", diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 555e3b9..f6f4f23 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -490,6 +490,55 @@ export async function findStackDir(stackName: string, envId?: number | null): Pr return null; } +/** Count the env vars GET /api/stacks/[name]/env would return for a stack, without + * reading values - just for the list badge. Mirrors that endpoint's build EXACTLY + * (same env param, same source lookup, git = all DB rows, internal = .env keys + + * DB secret rows) so the badge count equals what the env editor shows. Returns 0 on + * any error (a missing badge is harmless). */ +export async function countStackEnvVars(stackName: string, envId?: number | null): Promise { + try { + // Same three lookups GET /env does, with the same env param. + const dbVars = await getStackEnvVars(stackName, envId, true); + const src = await getStackSource(stackName, envId); + + if (src?.sourceType === 'git') { + // Git stacks: ALL vars (overrides + secrets) come from the DB. + return dbVars.length; + } + + // Internal/adopted: non-secrets from the .env file + secrets from the DB. + let count = dbVars.filter((v) => v.isSecret).length; + + let envFilePath: string | null = null; + if (src?.envPath === '') envFilePath = null; + else if (src?.envPath) envFilePath = src.envPath; + else if (src?.composePath) envFilePath = join(dirname(src.composePath), '.env'); + else { + const stackDir = await findStackDir(stackName, envId); + if (stackDir) envFilePath = join(stackDir, '.env'); + } + if (envFilePath && existsSync(envFilePath)) { + try { + // Same parse GET /env uses (key=value lines, skip blanks/comments). Inlined + // to count keys without the verbose git-env parser's per-stack logging. + const keys = new Set(); + for (const line of readFileSync(envFilePath, 'utf-8').split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#')) continue; + const eq = t.indexOf('='); + if (eq > 0) keys.add(t.slice(0, eq).trim()); + } + count += keys.size; + } catch { + // ignore file read errors, mirror GET /env + } + } + return count; + } catch { + return 0; + } +} + // ============================================================================= // COMPOSE FILE MANAGEMENT // ============================================================================= @@ -2362,19 +2411,22 @@ export async function stopStack( } /** - * Restart a stack using docker compose restart or stop+up (recreate mode). + * Restart a stack using docker compose restart, stop+start (ordered), or stop+up (recreate). * * mode='restart' (default): Uses 'docker compose restart' — fast, in-place restart - * that preserves container IDs but won't fix stale network_mode references. + * that preserves container IDs but does NOT honor depends_on startup ordering. + * mode='ordered': Uses 'docker compose stop' then 'docker compose start' — an in-place + * restart that respects depends_on ordering (start builds the dependency graph) while + * keeping the same container IDs and NOT re-pulling images. * mode='recreate': Uses 'docker compose stop' then 'docker compose up -d' — - * recreates containers, fixing network_mode: service: dependencies. + * recreates containers (new IDs, re-pulls newer images), fixing network_mode: service:. * * Falls back to individual container restart for stacks without compose files. */ export async function restartStack( stackName: string, envId?: number | null, - mode: 'restart' | 'recreate' = 'restart' + mode: 'restart' | 'ordered' | 'recreate' = 'restart' ): Promise { const result = await requireComposeFile(stackName, envId); @@ -2398,6 +2450,11 @@ export async function restartStack( await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars); await applyProviderSecretsToComposeResult(result, stackName, envId, `[Stack:${stackName}]`); composeResult = await executeComposeCommand('up', { ...opts, forceRecreate: true }, result.content!, result.nonSecretVars, result.secretVars); + } else if (mode === 'ordered') { + // Stop everything, then start in depends_on order (compose start honors the + // dependency graph). Same container IDs, no recreate, no re-pull. + await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars); + composeResult = await executeComposeCommand('start', opts, result.content!, result.nonSecretVars, result.secretVars); } else { composeResult = await executeComposeCommand('restart', opts, result.content!, result.nonSecretVars, result.secretVars); } diff --git a/src/lib/stores/selfhst-icons.ts b/src/lib/stores/selfhst-icons.ts new file mode 100644 index 0000000..7fbd789 --- /dev/null +++ b/src/lib/stores/selfhst-icons.ts @@ -0,0 +1,72 @@ +/** + * Resolve selfh.st app icons through the BATCH endpoint instead of one + * `` request per icon. A list with dozens of + * stacks/containers otherwise fires dozens of separate icon requests (which a WAF + * flags as crawling, #1467, and which is just slow). Components call `requestSelfhst(ref)` + * to register the refs they need and read the resolved `data:` URI from `selfhstIcons`; + * this store collects refs within a microtask window and resolves them in ONE POST. + */ +import { writable, get } from 'svelte/store'; + +/** ref -> resolved `data:image/svg+xml;base64,...` URI (or '' if it could not resolve). */ +export const selfhstIcons = writable>({}); + +const pending = new Set(); +const requested = new Set(); // refs already resolved or in flight (don't re-fetch) +let flushScheduled = false; + +const BATCH_MAX = 200; // matches the endpoint cap + +async function flush() { + flushScheduled = false; + const refs = [...pending].slice(0, BATCH_MAX); + if (refs.length === 0) return; + for (const r of refs) { + pending.delete(r); + requested.add(r); + } + // More than BATCH_MAX refs registered this tick: drain the rest in another batch. + if (pending.size > 0 && !flushScheduled) { + flushScheduled = true; + queueMicrotask(flush); + } + try { + const res = await fetch('/api/icons/selfhst/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refs }) + }); + const data: { icons?: Record } = res.ok ? await res.json() : {}; + const icons = data.icons ?? {}; + // Store resolved icons; mark unresolved refs as '' so we don't re-request them. + selfhstIcons.update((cur) => { + const next = { ...cur }; + for (const r of refs) next[r] = icons[r] ?? ''; + return next; + }); + } catch { + // leave refs unresolved; a component can fall back to a placeholder + selfhstIcons.update((cur) => { + const next = { ...cur }; + for (const r of refs) if (!(r in next)) next[r] = ''; + return next; + }); + } +} + +/** + * Register a selfh.st ref for resolution. Returns immediately; the resolved data URI + * appears in the `selfhstIcons` store once the next batch completes. Already-resolved or + * in-flight refs are skipped. + */ +export function requestSelfhst(ref: string | null | undefined): void { + if (!ref) return; + if (requested.has(ref) || pending.has(ref)) return; + if (ref in get(selfhstIcons)) return; + pending.add(ref); + if (!flushScheduled) { + flushScheduled = true; + // Coalesce all refs registered in this tick into one request. + queueMicrotask(flush); + } +} diff --git a/src/routes/api/stacks/[name]/restart/+server.ts b/src/routes/api/stacks/[name]/restart/+server.ts index d95d23b..721c892 100644 --- a/src/routes/api/stacks/[name]/restart/+server.ts +++ b/src/routes/api/stacks/[name]/restart/+server.ts @@ -7,10 +7,10 @@ import type { RequestHandler } from './$types'; /** * @openapi - * summary: Restart a stack (mode=restart) or recreate its containers (mode=recreate); progress and the final result stream over Server-Sent Events + * summary: Restart a stack (mode=restart|ordered|recreate); progress and the final result stream over Server-Sent Events * path: name:string! Stack name (from GET /api/stacks) * query: env:integer Environment ID the stack belongs to (from GET /api/environments) - * query: mode:string Restart mode — "recreate" recreates containers, anything else performs a plain restart + * query: mode:string Restart mode — "recreate" recreates containers (new IDs, re-pull), "ordered" does a stop+start honoring depends_on ordering (same IDs), anything else is a plain in-place restart * resp-200: Server-Sent-Events job stream with a final result event ({success, output}) * resp-403: Permission denied (requires stacks:restart, or environment access denied on enterprise) */ @@ -31,7 +31,8 @@ export const POST: RequestHandler = async (event) => { return json({ error: 'Access denied to this environment' }, { status: 403 }); } - const mode = url.searchParams.get('mode') === 'recreate' ? 'recreate' : 'restart'; + const modeParam = url.searchParams.get('mode'); + const mode = modeParam === 'recreate' ? 'recreate' : modeParam === 'ordered' ? 'ordered' : 'restart'; return createJobResponse(async (send) => { try { diff --git a/src/routes/api/stacks/sources/+server.ts b/src/routes/api/stacks/sources/+server.ts index bf04c51..7f5bebc 100644 --- a/src/routes/api/stacks/sources/+server.ts +++ b/src/routes/api/stacks/sources/+server.ts @@ -1,11 +1,12 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getStackSources } from '$lib/server/db'; +import { countStackEnvVars } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; /** * @openapi - * summary: List stack source records (their stored compose/env paths and source type) + * summary: List stack source records (their stored compose/env paths and source type, plus an env-var count) * query: env:integer Filter to a single environment id * resp-403: Permission denied (needs stacks:view) * resp-500: Failed to list stack sources @@ -33,17 +34,32 @@ export const GET: RequestHandler = async ({ url, cookies }) => { repository?: any; secretProviderId?: number | null; icon?: string | null; + envVarCount?: number; } > = {}; - for (const source of sources) { + // Count env vars server-side (one local read per stack) so the list badge does + // not need a /env fetch per stack. GET /env resolves its env param as + // `envId ? parseInt : null`, so pass the SAME null-when-absent value (not + // undefined - getStackEnvVars scopes differently for null vs undefined) so the + // count matches that endpoint's variable list exactly. + const countEnvId = envIdNum ?? null; + const counts = await Promise.all( + sources.map((s) => + s.sourceType === 'internal' || s.sourceType === 'git' + ? countStackEnvVars(s.stackName, countEnvId) + : Promise.resolve(0) + ) + ); + sources.forEach((source, i) => { sourceMap[source.stackName] = { sourceType: source.sourceType, composePath: source.composePath, repository: source.repository, secretProviderId: source.secretProviderId, icon: source.icon ?? null, + envVarCount: counts[i], }; - } + }); return json(sourceMap); } catch (error) { diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 8fa087e..8901d86 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -905,9 +905,15 @@ stacks = dockerStacks; - // Fetch env var counts for internal and git stacks (in background, don't block UI) - const allStackNames = stacks.map(s => s.name); - fetchEnvVarCounts(allStackNames, sourcesData); + // Env-var counts come from /api/stacks/sources (envVarCount per stack) - no + // per-stack /env fetch needed for the list badge. + const counts: Record = {}; + for (const [name, src] of Object.entries(sourcesData as Record)) { + if (src && typeof src.envVarCount === 'number' && src.envVarCount > 0) { + counts[name] = src.envVarCount; + } + } + stackEnvVarCounts = counts; } catch (error) { console.error('Failed to fetch stacks:', error); toast.error('Failed to load stacks'); @@ -917,39 +923,6 @@ } } - async function fetchEnvVarCounts(stackNames: string[], sources: Record) { - // Only fetch for stacks that can have env vars (internal or git) - const stacksToFetch = stackNames.filter(name => { - const source = sources[name]; - return source && (source.sourceType === 'internal' || source.sourceType === 'git'); - }); - - if (stacksToFetch.length === 0) { - stackEnvVarCounts = {}; - return; - } - - const counts: Record = {}; - - // Fetch in parallel with error handling - await Promise.all(stacksToFetch.map(async (stackName) => { - try { - const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId)); - if (response.ok) { - const data = await response.json(); - const varCount = data.variables?.length || 0; - if (varCount > 0) { - counts[stackName] = varCount; - } - } - } catch (e) { - // Ignore errors for individual stack env var fetches - } - })); - - stackEnvVarCounts = counts; - } - function getStackSource(stackName: string) { return stackSources[stackName] || { sourceType: 'external' }; } @@ -1031,13 +1004,13 @@ } } - async function restartStack(name: string, mode: 'restart' | 'recreate' = 'restart') { + async function restartStack(name: string, mode: 'restart' | 'ordered' | 'recreate' = 'restart') { operationError = null; stackActionLoading = name; try { let url = appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId); - if (mode === 'recreate') { - url += (url.includes('?') ? '&' : '?') + 'mode=recreate'; + if (mode === 'recreate' || mode === 'ordered') { + url += (url.includes('?') ? '&' : '?') + `mode=${mode}`; } const response = await fetch(url, { method: 'POST' }); const data = await readJobResponse(response); @@ -2170,16 +2143,20 @@ align="end" sideOffset={8} > -
+
Restart stack {stack.name.length > 20 ? stack.name.slice(0, 20) + '...' : stack.name} -
- - -
+ + +