optional stacks_dir flat layout for local stacks

This commit is contained in:
Jarek Krochmalski committed 2026-09-02 15:52:44 +02:00
1 parent 3fa75c9702
commit 002f53ce78
5 files changed
+92 -403

No files matched your search

+15 -54
View File
@@ -26,7 +26,6 @@ import { resolveNanoCpusConflict, resolvePodmanUsernsMode } from './hostconfig-r
import { parseImageReference } from './registry/image-ref';
export { parseImageReference } from './registry/image-ref';
import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge';
import { db, environments, eq } from './db/drizzle.js';
import { encodeRegistryAuth, fetchRegistryToken, isSafeRegistryHost } from './registry-auth';
import { classifyManifest, type ArtifactKind } from './semver/manifest-artifact';
import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils';
@@ -4093,47 +4092,23 @@ export async function dockerPing(envId: number): Promise<boolean> {
}
}
export interface HawserAgentInfo {
/**
* Get Hawser agent info (for hawser-standard mode)
* Returns agent info including uptime
*/
export async function getHawserInfo(envId: number): Promise<{
agentId: string;
agentName: string;
dockerVersion: string;
hawserVersion: string;
mode: string;
uptime: number;
/** Hawser agent STACKS_DIR — remote host path where compose stacks are written */
stacksDir?: string;
}
/**
* Get Hawser agent info (for hawser-standard mode)
* Returns agent info including uptime
*/
export async function getHawserInfo(envId: number): Promise<HawserAgentInfo | null> {
return getHawserInfoCached(envId);
}
const HAWSER_INFO_TTL_MS = 60_000;
const HAWSER_INFO_NEGATIVE_TTL_MS = 3_000;
const hawserInfoCache = new Map<number, { info: HawserAgentInfo | null; expiresAt: number }>();
/**
* Memoized wrapper around the /_hawser/info agent round-trip. Stacks listing
* resolves display paths per stack, so a single list request would otherwise
* hit the agent once per stack (N+1).
*/
async function getHawserInfoCached(envId: number): Promise<HawserAgentInfo | null> {
const cached = hawserInfoCache.get(envId);
if (cached && cached.expiresAt > Date.now()) {
return cached.info;
}
let info: HawserAgentInfo | null = null;
} | null> {
for (let attempt = 0; attempt < 2; attempt++) {
try {
const response = await dockerFetch('/_hawser/info', {}, envId);
if (response.ok) {
info = await response.json();
break;
return await response.json();
}
await drainResponse(response);
console.warn(`[Hawser] Info endpoint returned ${response.status} for env ${envId}`);
@@ -4142,27 +4117,7 @@ async function getHawserInfoCached(envId: number): Promise<HawserAgentInfo | nul
console.warn(`[Hawser] Failed to fetch info for env ${envId} (attempt ${attempt + 1}): ${msg}`);
}
}
// Persist the remote stacks dir so display-path resolution survives agent
// restarts and doesn't depend on container runtime state (labels) or a
// live info round-trip per request.
if (info?.stacksDir) {
try {
await db
.update(environments)
.set({ hawserStacksDir: info.stacksDir })
.where(eq(environments.id, envId));
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.warn(`[Hawser] Failed to persist stacksDir for env ${envId}: ${msg}`);
}
}
hawserInfoCache.set(envId, {
info,
expiresAt: Date.now() + (info ? HAWSER_INFO_TTL_MS : HAWSER_INFO_NEGATIVE_TTL_MS)
});
return info;
return null;
}
// Volume operations
@@ -5072,9 +5027,15 @@ export async function runContainerWithStreaming(options: {
HostConfig: {
Binds: options.binds || [],
AutoRemove: false,
// No log rotation: a helper's stdout IS its result, read via /logs after it
// exits. An empty Config inherits the daemon's default rotation (Synology sets
// max-size=10m,max-file=3), and a scanner writing a large JSON report line by
// line blows past the window - the head rotates away and /logs returns a
// truncated, mid-document buffer (#1496). One big file, no rotation; the log
// dies with the ephemeral container so it never accumulates on disk.
LogConfig: {
Type: 'json-file',
Config: {}
Config: { 'max-file': '1', 'max-size': '1g' }
}
}
};
+58 -295
View File
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSyn
import { join, resolve, dirname, basename, isAbsolute, normalize as pathNormalize, sep as pathSep } from 'node:path';
import { spawn as nodeSpawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { redactSecretVars } from './secret-redact';
import {
applyFileDeletions,
hashDirFiles,
@@ -18,13 +19,7 @@ import {
type DeletionApplyResult,
type DeletionSkipReason
} from './git-deletions';
import {
remapComposeContentsFromRemoteToStaging,
remapComposeContentsFromStagingToRemote,
remapPathsFromRemoteToStaging,
remapPathsFromStagingToRemote
} from './stacks-display-paths';
import { isPathUnderRoot } from './path-utils';
import { buildComposeOperationArgs, shouldRunSeparateBuildStep } from './compose-args';
import { findStackNameCollision, moveStackFilePathCrossDevice, resolveStackDirForLayout } from './stack-path-utils';
import { db, environments, eq } from './db/drizzle.js';
import { isAllowedStackFilename } from './stack-filename';
@@ -661,234 +656,6 @@ export function validateStacksDirAtStartup(): void {
console.log(`[StacksDir] Using STACKS_DIR=${resolved}`);
}
type HawserEnvLike = { connectionType?: string | null; hawserStacksDir?: string | null };
async function resolveHawserStackDirPair(
stackName: string,
environmentId: number,
env?: HawserEnvLike | null,
hints?: { workingDir: string | null; configFiles: string[] | null } | null
): Promise<{ stagingStackDir: string; remoteStackDir: string } | null> {
const resolvedEnv = env ?? (await getEnvironment(environmentId));
if (!isHawserConnection(resolvedEnv)) return null;
const stagingStackDir = resolve(
(await findStackDir(stackName, environmentId)) ?? (await getStackDir(stackName, environmentId))
);
let remoteStackDir: string | null = null;
const pathHints = hints ?? (await getStackPathHints(stackName, environmentId));
if (pathHints.workingDir) {
remoteStackDir = pathHints.workingDir;
} else if (pathHints.configFiles?.length) {
remoteStackDir = dirname(pathHints.configFiles[0]);
}
// DB-persisted remote stacksDir (survives agent restarts)
if (!remoteStackDir && resolvedEnv?.hawserStacksDir) {
remoteStackDir = join(resolvedEnv.hawserStacksDir, stackName);
}
if (!remoteStackDir) {
const { getHawserInfo } = await import('./docker.js');
const info = await getHawserInfo(environmentId);
if (info?.stacksDir) {
remoteStackDir = join(info.stacksDir, stackName);
try {
await db
.update(environments)
.set({ hawserStacksDir: info.stacksDir })
.where(eq(environments.id, environmentId));
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.warn(`[StacksDir] Failed to persist stacksDir for env ${environmentId}: ${msg}`);
}
}
}
if (!remoteStackDir) return null;
return { stagingStackDir, remoteStackDir: resolve(remoteStackDir) };
}
/**
* For Hawser environments, compose paths in the DB point at Dockhand's staging copy
* ($DATA_DIR/stacks/<env>/<stack>/). Deployed files live on the remote host under the
* Hawser agent's STACKS_DIR/<stackName>/.
*/
export async function remapHawserStagingDisplayPaths(
stackName: string,
environmentId: number | null | undefined,
paths: { composePath: string | null; composePaths: string[] },
env?: HawserEnvLike | null,
hints?: { workingDir: string | null; configFiles: string[] | null } | null
): Promise<{ composePath: string | null; composePaths: string[] }> {
if (!paths.composePath && paths.composePaths.length === 0) return paths;
if (environmentId == null) return paths;
const pathList =
paths.composePaths.length > 0
? paths.composePaths
: paths.composePath
? [paths.composePath]
: [];
const pair = await resolveHawserStackDirPair(stackName, environmentId, env, hints);
if (!pair) return paths;
const { stagingStackDir, remoteStackDir } = pair;
const usesStaging = pathList.some((p) => {
const resolved = resolve(p);
return isPathUnderRoot(resolved, stagingStackDir) || isManagedStagingDir(dirname(resolved));
});
if (!usesStaging) return paths;
const remapped = remapPathsFromStagingToRemote(stagingStackDir, remoteStackDir, pathList);
return {
composePath: remapped[0] ?? null,
composePaths: remapped
};
}
/**
* Remap composeContents keys from staging paths to Hawser remote display paths.
*/
export async function remapHawserStagingDisplayComposeContents(
stackName: string,
environmentId: number | null | undefined,
composeContents: Record<string, string> | null | undefined,
env?: HawserEnvLike | null,
hints?: { workingDir: string | null; configFiles: string[] | null } | null
): Promise<Record<string, string> | null | undefined> {
if (!composeContents || environmentId == null) return composeContents;
const pair = await resolveHawserStackDirPair(stackName, environmentId, env, hints);
if (!pair) return composeContents;
return remapComposeContentsFromStagingToRemote(
pair.stagingStackDir,
pair.remoteStackDir,
composeContents
);
}
/**
* Convert Hawser display paths from the UI back to Dockhand staging paths for disk writes.
*/
export async function unmapHawserDisplayComposeOptionsToStaging(
stackName: string,
environmentId: number | null | undefined,
options: {
composePath?: string;
composePaths?: string[] | null;
composeContents?: Record<string, string>;
envPath?: string;
moveFromDir?: string;
oldComposePath?: string;
oldEnvPath?: string;
}
): Promise<typeof options> {
if (!options || environmentId == null) return options;
const pair = await resolveHawserStackDirPair(stackName, environmentId);
if (!pair) return options;
const { stagingStackDir, remoteStackDir } = pair;
const result = { ...options };
if (options.composePath) {
result.composePath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [
options.composePath
])[0];
}
if (options.composePaths) {
result.composePaths = remapPathsFromRemoteToStaging(
stagingStackDir,
remoteStackDir,
options.composePaths
);
}
if (options.composeContents) {
result.composeContents = remapComposeContentsFromRemoteToStaging(
stagingStackDir,
remoteStackDir,
options.composeContents
);
}
if (options.envPath) {
result.envPath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [
options.envPath
])[0];
}
if (options.oldComposePath) {
result.oldComposePath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [
options.oldComposePath
])[0];
}
if (options.oldEnvPath) {
result.oldEnvPath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [
options.oldEnvPath
])[0];
}
return result;
}
/**
* Resolve stack source compose paths for UI display, including Hawser remote remapping.
*/
export async function resolveStackSourceDisplayPathsForEnv(
source: {
stackName: string;
environmentId?: number | null;
sourceType: string;
composePath?: string | null;
gitStack?: { contextDir?: string | null; composePath?: string } | null;
},
env?: HawserEnvLike | null,
hints?: { workingDir: string | null; configFiles: string[] | null } | null
): Promise<{ composePath: string | null; composePaths: string[] }> {
const base = resolveStackSourceDisplayPaths(source);
return remapHawserStagingDisplayPaths(source.stackName, source.environmentId, base, env, hints);
}
/**
* Build a map of compose project name -> path hints from a pre-fetched container
* list, so a stacks listing doesn't trigger one container listing per stack.
*/
export function buildStackPathHintsMap(
containers: any[]
): Map<string, { workingDir: string | null; configFiles: string[] | null }> {
const map = new Map<string, { workingDir: string | null; configFiles: string[] | null }>();
for (const container of containers) {
const labels = container.labels || {};
const project = labels['com.docker.compose.project'];
if (!project || map.has(project)) continue;
const workingDir = labels['com.docker.compose.project.working_dir'] || null;
const configFilesRaw = labels['com.docker.compose.project.config_files'] || null;
const configFiles = configFilesRaw ? configFilesRaw.split(',').map((f: string) => f.trim()) : null;
map.set(project, { workingDir, configFiles });
}
return map;
}
/**
* Resolve stack source compose paths to absolute on-disk paths for UI display.
* Git stacks store repo-relative paths in the DB; external/adopted stacks use absolute paths.
*/
export function resolveStackSourceDisplayPaths(
source: {
sourceType: string;
composePath?: string | null;
gitStack?: { contextDir?: string | null; composePath?: string } | null;
}
): { composePath: string | null; composePaths: string[] } {
if (!source.composePath) {
return { composePath: null, composePaths: [] };
}
const path = isAbsolute(source.composePath) ? source.composePath : resolve(source.composePath);
return { composePath: path, composePaths: [path] };
}
// =============================================================================
// COMPOSE FILE MANAGEMENT
// =============================================================================
@@ -1393,7 +1160,7 @@ function findComposeOverrideFile(stackDir: string, composeFileName: string): str
* @param customComposePath - Optional path to existing compose file (for imported stacks, skips writing)
*/
async function executeLocalCompose(
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull',
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
stackName: string,
composeContent: string,
dockerHost?: string,
@@ -1652,39 +1419,7 @@ async function executeLocalCompose(
console.log(`${logPrefix} [HostPath] Using stdin for compose content (paths translated)`);
}
switch (operation) {
case 'up':
args.push('up', '-d', '--remove-orphans');
if (forceRecreate) args.push('--force-recreate');
if (build) args.push('--build');
if (build && noBuildCache) args.push('--no-cache');
if (pullPolicy) args.push('--pull', pullPolicy);
// If targeting a specific service, only update that service
if (serviceName) {
args.push(serviceName);
}
break;
case 'down':
args.push('down', '--remove-orphans');
if (removeVolumes) args.push('--volumes');
break;
case 'stop':
args.push('stop');
break;
case 'start':
args.push('start');
break;
case 'restart':
args.push('restart');
break;
case 'pull':
args.push('pull');
// If targeting a specific service, pull only that service
if (serviceName) {
args.push(serviceName);
}
break;
}
args.push(...buildComposeOperationArgs(operation, { forceRecreate, removeVolumes, build, noBuildCache, pullPolicy, serviceName }));
const commandStr = args.join(' ');
@@ -1774,10 +1509,13 @@ async function executeLocalCompose(
command: commandStr
};
} else {
// stderr can echo an interpolated secret value (e.g. a failing
// command containing ${DB_PASSWORD}); redact before it can reach a
// notification channel, the DB errorMessage, or the client.
return {
success: false,
output: stdout,
error: stderr || `docker compose ${operation} exited with code ${code}`,
output: redactSecretVars(stdout, secretVars),
error: redactSecretVars(stderr, secretVars) || `docker compose ${operation} exited with code ${code}`,
command: commandStr
};
}
@@ -1789,7 +1527,7 @@ async function executeLocalCompose(
return {
success: false,
output: '',
error: `Failed to run docker compose ${operation}: ${err.message}`,
error: redactSecretVars(`Failed to run docker compose ${operation}: ${err.message}`, secretVars),
command: commandStr
};
} finally {
@@ -1822,7 +1560,7 @@ async function executeLocalCompose(
* @param secretVars - Secret environment variables (injected via shell env on Hawser, NEVER in .env)
*/
async function executeComposeViaHawser(
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull',
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
stackName: string,
composeContent: string,
envId: number,
@@ -1993,10 +1731,12 @@ async function executeComposeViaHawser(
deletion
};
} else {
// The agent's stderr can echo an interpolated secret value; redact before
// it reaches a notification channel, the DB errorMessage, or the client.
return {
success: false,
output: result.output || '',
error: result.error || `Compose ${operation} failed`,
output: redactSecretVars(result.output || '', secretVars),
error: redactSecretVars(result.error || `Compose ${operation} failed`, secretVars),
deletion
};
}
@@ -2008,7 +1748,7 @@ async function executeComposeViaHawser(
output: '',
error: isStringLength
? `Stack files too large to send via Hawser. The repository may contain large binary files. Consider using a .dockerignore or moving large files out of the compose directory.`
: `Failed to ${operation} via Hawser: ${err.message}`
: redactSecretVars(`Failed to ${operation} via Hawser: ${err.message}`, secretVars)
};
}
}
@@ -2020,7 +1760,7 @@ async function executeComposeViaHawser(
* @param secretVars - Secret environment variables (from DB, injected via shell env)
*/
async function executeComposeCommand(
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull',
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
options: ComposeCommandOptions,
composeContent: string,
envVars?: Record<string, string>,
@@ -2901,6 +2641,12 @@ export async function removeStack(
removeVolumes = false,
deleteFiles = true
): Promise<StackOperationResult> {
// Reject a name that isn't a plain stack name BEFORE any path construction. A
// traversal name (e.g. "..") would make getStackDir resolve to DATA_DIR's parent and
// rmSync it; the create/deploy paths already enforce this same shape.
if (!/^[a-z0-9][a-z0-9_-]*$/.test(stackName)) {
return { success: false, error: 'Invalid stack name' };
}
return withStackLock(stackName, async () => {
// Get compose file (may not exist for external stacks)
const composeResult = await getStackComposeFile(stackName, envId);
@@ -3053,7 +2799,10 @@ export async function removeStack(
// (Don't delete default-path files when an adopted stack has custom path outside DATA_DIR)
if (!stackDir && !stackSource?.composePath) {
const defaultDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
if (existsSync(defaultDir)) {
// Same #675 guard as the composePath branch: only a strict subdir of a managed
// stacks root whose basename is the stack name is deletable. Never DATA_DIR or a parent.
const deletableRoots = [getDefaultStacksDir(), ...(isStacksDirEnvSet() ? [getLocalStacksDir()] : [])];
if (deletableRoots.some((root) => isDeletableStackDir(defaultDir, root, stackName)) && existsSync(defaultDir)) {
stackDir = defaultDir;
}
}
@@ -3380,27 +3129,41 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
// so no override file is needed - only pass secrets for shell injection.
const isGitStack = !!sourceDir;
const cmdOptions: ComposeCommandOptions = {
stackName: name,
envId,
forceRecreate,
build,
noBuildCache,
pullPolicy,
stackFiles,
workingDir,
composePath: actualComposePath,
envPath: actualEnvPath,
useOverrideFile: isGitStack,
// Pass compose filename for Hawser (extracted from path or provided explicitly)
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined),
filesToDelete
};
const composeEnvVars = isGitStack ? dbNonSecretVars : undefined;
// `--no-cache` is a `build` flag, not an `up` flag (#1479). When a no-cache
// rebuild is requested, run a separate `docker compose build --no-cache` first,
// then a plain `up`. Skipped on Hawser (its agent has no build op) - the up below
// then omits --build for a no-cache request, so nothing crashes there.
const deployEnv = envId ? await getEnvironment(envId) : null;
if (shouldRunSeparateBuildStep(build, noBuildCache, deployEnv?.connectionType)) {
console.log(`${logPrefix} Running separate 'build --no-cache' step before up...`);
const buildResult = await executeComposeCommand('build', cmdOptions, compose, composeEnvVars, secretVars);
if (!buildResult.success) return buildResult;
}
console.log(`${logPrefix} Calling executeComposeCommand...`);
const result = await executeComposeCommand(
'up',
{
stackName: name,
envId,
forceRecreate,
build,
noBuildCache,
pullPolicy,
stackFiles,
workingDir,
composePath: actualComposePath,
envPath: actualEnvPath,
useOverrideFile: isGitStack,
// Pass compose filename for Hawser (extracted from path or provided explicitly)
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined),
filesToDelete
},
cmdOptions,
compose,
isGitStack ? dbNonSecretVars : undefined,
composeEnvVars,
secretVars
);
console.log(`${logPrefix} ========================================`);
+3 -23
View File
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import { join } from 'path';
import { getStackDir, isHawserConnection } from '$lib/server/stacks';
import { getStackDir } from '$lib/server/stacks';
import { getEnvironment } from '$lib/server/db';
import type { RequestHandler } from './$types';
@@ -17,15 +17,6 @@ import type { RequestHandler } from './$types';
* resp-200: {stackDir:string!, composePath:string!, envPath:string!, source:string!}
* resp-200-example: {"stackDir":"/data/stacks/prod/web","composePath":"/data/stacks/prod/web/compose.yaml","envPath":"/data/stacks/prod/web/.env","source":"default"}
* resp-400: Stack name is required
*
* Query params:
* - name: Stack name (required)
* - env: Environment ID (optional)
* - location: Custom base location path (optional)
*
* If location is provided, path will be: {location}/{envName}/{stackName}/
* Otherwise uses Dockhand's default via getStackDir (flat STACKS_DIR/<stackName>/
* for local envs when STACKS_DIR is set, else $DATA_DIR/stacks/<envName>/<stackName>/).
*/
export const GET: RequestHandler = async ({ url }) => {
const stackName = url.searchParams.get('name');
@@ -38,7 +29,6 @@ export const GET: RequestHandler = async ({ url }) => {
}
let stackDir: string;
let source = 'default';
if (location) {
// Custom location: {location}/{envName}/{stackName}/
@@ -54,23 +44,13 @@ export const GET: RequestHandler = async ({ url }) => {
}
} else {
// Dockhand default location
if (envIdNum) {
const env = await getEnvironment(envIdNum);
if (env && isHawserConnection(env) && env.hawserStacksDir) {
stackDir = join(env.hawserStacksDir, stackName);
source = 'hawser';
} else {
stackDir = await getStackDir(stackName, envIdNum);
}
} else {
stackDir = await getStackDir(stackName, envIdNum);
}
stackDir = await getStackDir(stackName, envIdNum);
}
return json({
stackDir,
composePath: `${stackDir}/compose.yaml`,
envPath: `${stackDir}/.env`,
source
source: 'default'
});
};
+11 -24
View File
@@ -1,9 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getStackSources, getEnvironment } from '$lib/server/db';
import { countStackEnvVars, resolveStackSourceDisplayPathsForEnv, buildStackPathHintsMap } from '$lib/server/stacks';
import { getStackSources } from '$lib/server/db';
import { countStackEnvVars } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { listContainers } from '$lib/server/docker';
/**
* @openapi
@@ -26,19 +25,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
try {
const sources = await getStackSources(envIdNum);
const envIds = [...new Set(sources.map((s) => s.environmentId).filter((id): id is number => id != null))];
const envs = await Promise.all(envIds.map((id) => getEnvironment(id)));
const envMap = new Map(envs.filter((e) => e !== undefined).map((e) => [e.id, e]));
const hintEnvIds = [...new Set(sources.map((s) => s.environmentId ?? null))];
const hintMaps = await Promise.all(
hintEnvIds.map(async (id) => {
const containers = await listContainers(true, id).catch(() => []);
return { id, map: buildStackPathHintsMap(containers) };
})
);
const hintsByEnv = new Map(hintMaps.map((h) => [String(h.id ?? 'null'), h.map]));
// Convert to a map for easier lookup in the frontend
const sourceMap: Record<
string,
{
@@ -50,6 +37,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
envVarCount?: number;
}
> = {};
// 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) =>
@@ -58,21 +50,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
: Promise.resolve(0)
)
);
for (const [i, source] of sources.entries()) {
const resolved = await resolveStackSourceDisplayPathsForEnv(
source,
source.environmentId != null ? envMap.get(source.environmentId) ?? null : null,
hintsByEnv.get(String(source.environmentId ?? 'null'))?.get(source.stackName) ?? null
);
sources.forEach((source, i) => {
sourceMap[source.stackName] = {
sourceType: source.sourceType,
composePath: resolved.composePath,
composePath: source.composePath,
repository: source.repository,
secretProviderId: source.secretProviderId,
icon: source.icon ?? null,
envVarCount: counts[i],
};
}
});
return json(sourceMap);
} catch (error) {
@@ -706,20 +706,18 @@
<p>
The environment
<code class="text-xs bg-muted px-1 py-0.5 rounded">{deleteEnvTarget.name}</code>
and the following directories will be permanently removed from the Dockhand host
(managed local stacks under <code class="text-xs">STACKS_DIR</code>, when configured, are removed per stack name):
and the following directories will be permanently removed from the Dockhand host:
</p>
<div class="space-y-1 text-xs font-mono bg-muted/40 rounded-md p-3 border overflow-x-auto">
<div class="flex items-center gap-2 whitespace-nowrap">
<Trash2 class="w-3.5 h-3.5 shrink-0 text-destructive" />
<code class="whitespace-nowrap">$DATA_DIR/stacks/{deleteEnvTarget.name}/</code>
<span class="text-muted-foreground shrink-0">(staging)</span>
</div>
<div class="flex items-center gap-2 whitespace-nowrap">
<Trash2 class="w-3.5 h-3.5 shrink-0 text-destructive" />
<code class="whitespace-nowrap">$DATA_DIR/git-repos/{deleteEnvTarget.name}/</code>
</div>
</div>
<p class="text-muted-foreground text-xs">
Shared git repository clones under <code class="text-xs">$DATA_DIR/git-repos/&lt;repoName&gt;/</code>
are not removed when an environment is deleted.
</p>
{#if deleteCountsLoading}
<p class="flex items-center gap-2">
<RefreshCw class="w-3.5 h-3.5 animate-spin" />