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

@@ -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>