This commit is contained in:
jarek committed 2026-05-17 08:02:31 +02:00
1 parent d9054ff347
commit e7100f8926
44 files changed
+1672 -226

No files matched your search

+14
View File
@@ -5,6 +5,8 @@
* - dockhand.update=false — Skip this container during auto-updates and batch updates
* - dockhand.hidden=true — Hide this container from the Dockhand UI
* - dockhand.notify=false — Suppress notifications for this container's events
* - dockhand.url=<url> — Custom clickable URL displayed alongside container ports
* - dockhand.port.<hostPort>.url=<url> — Override the click URL for a specific published port
*
* All label values are case-insensitive and accept: true/yes/1 and false/no/0.
* The opt-out model means labels override DB settings (label wins).
@@ -15,6 +17,7 @@ export const DOCKHAND_LABELS = {
UPDATE: 'dockhand.update',
HIDDEN: 'dockhand.hidden',
NOTIFY: 'dockhand.notify',
URL: 'dockhand.url',
} as const;
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
@@ -72,6 +75,15 @@ export function isNotifyDisabledByLabel(labels: Record<string, string> | undefin
return value === false; // explicitly disabled
}
/**
* Get the custom URL from dockhand.url label.
* Returns the URL string if set, or undefined.
*/
export function getCustomUrl(labels: Record<string, string> | undefined | null): string | undefined {
const value = getLabel(labels, DOCKHAND_LABELS.URL);
return value?.trim() || undefined;
}
/**
* Extract all Dockhand label states from a container's labels.
* Useful for including in API responses so the frontend knows about label overrides.
@@ -80,10 +92,12 @@ export function getDockhandLabels(labels: Record<string, string> | undefined | n
updateDisabled: boolean;
hidden: boolean;
notifyDisabled: boolean;
customUrl?: string;
} {
return {
updateDisabled: isUpdateDisabledByLabel(labels),
hidden: isHiddenByLabel(labels),
notifyDisabled: isNotifyDisabledByLabel(labels),
customUrl: getCustomUrl(labels),
};
}
+46
View File
@@ -1046,6 +1046,7 @@ export interface ContainerInfo {
networks: { [networkName: string]: { ipAddress: string } };
health?: string;
restartCount: number;
exitCode?: number;
mounts: Array<{ type: string; source: string; destination: string; mode: string; rw: boolean }>;
labels: { [key: string]: string };
command: string;
@@ -1081,6 +1082,39 @@ export async function listContainers(all = true, envId?: number | null): Promise
})
);
// Fetch health status via inspect for containers with healthchecks where Status
// string doesn't include health info (Podman compat API omits it from the list endpoint).
// Skip entirely if any container already has health in Status (Docker includes it natively).
const healthStatuses = new Map<string, string>();
const healthRegex = /\((healthy|unhealthy|health:\s*starting)\)/i;
const anyHasHealthInStatus = containers.some(c => healthRegex.test(c.Status || ''));
if (!anyHasHealthInStatus) {
// Single API call to find which containers have healthchecks configured
try {
const healthFiltered = await dockerJsonRequest<any[]>(
'/containers/json?filters=' + encodeURIComponent(JSON.stringify({ health: ['healthy', 'unhealthy', 'starting'] })),
{}, envId
);
const healthIds = new Set((healthFiltered || []).map((c: any) => c.Id));
// Only inspect those specific containers (typically very few)
if (healthIds.size > 0) {
await Promise.all(
[...healthIds].map(async (id) => {
try {
const inspect = await inspectContainer(id, envId);
const h = inspect.State?.Health?.Status;
if (h) healthStatuses.set(id, h);
} catch {
// Ignore errors
}
})
);
}
} catch {
// Ignore - health filter may not be supported
}
}
return containers.map((container) => {
// Extract network info with IP addresses
const networks: { [networkName: string]: { ipAddress: string } } = {};
@@ -1103,14 +1137,25 @@ export async function listContainers(all = true, envId?: number | null): Promise
// Extract health status from Status string
// Docker formats: "(healthy)", "(unhealthy)", "(health: starting)"
// Podman compat API omits health from Status string, so fall back to inspect data
let health: string | undefined;
const healthMatch = container.Status?.match(/\((healthy|unhealthy|health:\s*starting)\)/i);
if (healthMatch) {
const matched = healthMatch[1].toLowerCase();
// Normalize "health: starting" to just "starting"
health = matched.includes('starting') ? 'starting' : matched;
} else {
const inspectHealth = healthStatuses.get(container.Id);
if (inspectHealth) {
health = inspectHealth === 'health: starting' ? 'starting' : inspectHealth;
}
}
// Parse exit code from Status string (e.g., "Exited (0) 5 minutes ago")
const exitCode = container.State === 'exited'
? parseInt(container.Status?.match(/\((\d+)\)/)?.[1] ?? '-1')
: undefined;
return {
id: container.Id,
name: container.Names[0]?.replace(/^\//, '') || 'unnamed',
@@ -1122,6 +1167,7 @@ export async function listContainers(all = true, envId?: number | null): Promise
networks,
health,
restartCount: restartCounts.get(container.Id) || 0,
exitCode,
mounts,
labels: container.Labels || {},
command: container.Command || '',
+4
View File
@@ -109,6 +109,10 @@ if (!existsSync(GIT_REPOS_DIR)) {
mkdirSync(GIT_REPOS_DIR, { recursive: true });
}
export function getGitReposDir(): string {
return GIT_REPOS_DIR;
}
/**
* Redact all env var values for safe logging. Only key names are preserved.
*/
@@ -204,7 +204,7 @@ export async function runEnvUpdateCheckJob(
.map(u => {
const currentShort = u.currentDigest?.substring(0, 12) || 'unknown';
const newShort = u.newDigest?.substring(0, 12) || 'unknown';
return `- ${u.containerName} (${u.imageName})\n ${currentShort}... -> ${newShort}...`;
return `- ${u.containerName} (${u.imageName}): ${currentShort} → ${newShort}`;
})
.join('\n');
+18 -6
View File
@@ -77,7 +77,9 @@ export interface ContainerDetail {
networks: Array<{ name: string; ipAddress: string }>;
volumeCount: number;
restartCount: number;
exitCode?: number;
created: number;
labels: Record<string, string>;
}
/**
@@ -1545,6 +1547,12 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
const result: ComposeStackInfo[] = Array.from(stacks.entries()).map(([name, containerIds]) => {
const stackContainers = containers.filter((c) => containerIds.has(c.id));
const runningCount = stackContainers.filter((c) => c.state === 'running').length;
// Containers that exited with code 0 are "completed" (e.g., init/migration containers)
// and should not count against stack health
const completedCount = stackContainers.filter((c) =>
c.state === 'exited' && c.exitCode === 0
).length;
const activeTotal = stackContainers.length - completedCount;
const containerDetails: ContainerDetail[] = stackContainers
.map((c) => {
@@ -1580,7 +1588,9 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
networks,
volumeCount,
restartCount: c.restartCount || 0,
created: c.created
exitCode: c.exitCode,
created: c.created,
labels: c.labels || {}
};
})
.sort((a, b) => a.service.localeCompare(b.service));
@@ -1590,11 +1600,13 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
containers: Array.from(containerIds),
containerDetails,
status:
runningCount === stackContainers.length
? 'running'
: runningCount === 0
? 'stopped'
: 'partial'
activeTotal === 0
? 'stopped'
: runningCount >= activeTotal
? 'running'
: runningCount === 0
? 'stopped'
: 'partial'
};
});