stacks page: env-var counts from sources, icon batching, and an in-order restart

This commit is contained in:
Jarek Krochmalski committed 2026-08-27 19:48:35 +02:00
1 parent 3a60ca332f
commit ee4632a7b7
9 files changed
+229 -80

No files matched your search

+15 -17
View File
@@ -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 <img> 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);
</script>
{#if hasOverride && overrideSelfhst}
<img src="/api/icons/selfhst/{overrideSelfhst}" alt="" class="{className} object-contain shrink-0" />
{#if activeSelfhst && resolvedSelfhst}
<img src={resolvedSelfhst} alt="" class="{className} object-contain shrink-0" />
{:else if activeSelfhst && resolvedSelfhst === undefined}
<!-- batch in flight: hold the space so the row doesn't jump -->
<span class="{className} shrink-0"></span>
{:else if hasOverride && overrideCustom && overrideCustomUrl}
<img src={overrideCustomUrl} alt="" class="{className} object-contain shrink-0" />
{:else if hasOverride && OverrideLucide}
<OverrideLucide class="{className} shrink-0" />
{:else if enabled && ref && !imgFailed}
<img
src="/api/icons/selfhst/{ref}"
alt=""
class="{className} object-contain shrink-0"
onerror={() => (imgFailed = true)}
/>
{:else if showFallback}
<!-- Generic icon so the layout stays consistent even with no match. -->
<FallbackIcon class="{className} shrink-0 {fallbackClass}" />
+18 -2
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { Box } from 'lucide-svelte';
import { getStackIconComponent, isSelfhstIcon, selfhstRef, isCustomIcon } from '$lib/utils/icons';
import { selfhstIcons, requestSelfhst } from '$lib/stores/selfhst-icons';
interface Props {
/** Icon value: a lucide name, 'selfhst:<ref>', 'custom:<file>', or null/empty. */
@@ -23,12 +25,26 @@
const custom = $derived(isCustomIcon(icon));
// Lucide is the fallback for any non-selfhst, non-custom value (incl. null).
const LucideIcon = $derived(!isSelfhstIcon(icon) && !custom ? getStackIconComponent(icon || '') : null);
// Resolve the selfh.st icon via the shared batch store (one request for the whole
// list) instead of a per-icon <img src>. `resolved`: data URI when ready, '' when it
// could not resolve, undefined while the batch is still in flight.
$effect(() => {
if (selfhst) requestSelfhst(selfhst);
});
const resolved = $derived(selfhst ? $selfhstIcons[selfhst] : undefined);
</script>
{#if selfhst}
<img src="/api/icons/selfhst/{selfhst}" alt="" class="{className} object-contain shrink-0" />
{#if selfhst && resolved}
<img src={resolved} alt="" class="{className} object-contain shrink-0" />
{:else if selfhst && resolved === undefined}
<!-- batch in flight: keep space so the row doesn't jump -->
<span class="{className} shrink-0"></span>
{:else if custom && customUrl}
<img src={customUrl} alt="" class="{className} object-contain shrink-0" />
{:else if LucideIcon}
<LucideIcon class={className} />
{:else if selfhst}
<!-- a selfh.st ref that could not resolve (resolved === '') - show a generic box -->
<Box class="{className} shrink-0" />
{/if}
+12
View File
@@ -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",
+3 -3
View File
@@ -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",
+61 -4
View File
@@ -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<number> {
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<string>();
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:<container> dependencies.
* recreates containers (new IDs, re-pulls newer images), fixing network_mode: service:<container>.
*
* 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<StackOperationResult> {
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);
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Resolve selfh.st app icons through the BATCH endpoint instead of one
* `<img src="/api/icons/selfhst/<ref>">` 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<Record<string, string>>({});
const pending = new Set<string>();
const requested = new Set<string>(); // 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<string, string> } = 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);
}
}
@@ -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 {
+19 -3
View File
@@ -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) {
+25 -48
View File
@@ -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<string, number> = {};
for (const [name, src] of Object.entries(sourcesData as Record<string, { envVarCount?: number }>)) {
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<string, any>) {
// 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<string, number> = {};
// 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}
>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col gap-2 w-60">
<span class="text-xs text-muted-foreground">Restart stack <strong>{stack.name.length > 20 ? stack.name.slice(0, 20) + '...' : stack.name}</strong></span>
<div class="flex items-center gap-1.5">
<Button size="sm" variant="secondary" class="h-6 px-2 text-xs" onclick={() => { restartPopoverOpen[stack.name] = false; restartStack(stack.name, 'restart'); }}>
Restart
</Button>
<Button size="sm" variant="default" class="h-6 px-2 text-xs" onclick={() => { restartPopoverOpen[stack.name] = false; restartStack(stack.name, 'recreate'); }}>
Recreate (stop & up)
</Button>
</div>
<button class="flex flex-col items-start gap-0.5 rounded px-2 py-1.5 text-left hover:bg-muted" onclick={() => { restartPopoverOpen[stack.name] = false; restartStack(stack.name, 'restart'); }}>
<span class="text-xs font-medium">Restart</span>
<span class="text-[11px] text-muted-foreground">Fast in-place restart. Ignores depends_on ordering.</span>
</button>
<button class="flex flex-col items-start gap-0.5 rounded px-2 py-1.5 text-left hover:bg-muted" onclick={() => { restartPopoverOpen[stack.name] = false; restartStack(stack.name, 'ordered'); }}>
<span class="text-xs font-medium">Restart in order</span>
<span class="text-[11px] text-muted-foreground">Stop then start in depends_on order. Same container IDs.</span>
</button>
<button class="flex flex-col items-start gap-0.5 rounded px-2 py-1.5 text-left hover:bg-muted" onclick={() => { restartPopoverOpen[stack.name] = false; restartStack(stack.name, 'recreate'); }}>
<span class="text-xs font-medium">Recreate (stop &amp; up)</span>
<span class="text-[11px] text-muted-foreground">Recreate containers in order. New IDs, re-pulls newer images.</span>
</button>
</div>
</Popover.Content>
</Popover.Root>