stacks page: env-var counts from sources, icon batching, and an in-order restart
This commit is contained in:
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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
Reference in new issue
Block a user