This commit is contained in:
jarek
2026-04-03 11:51:42 +02:00
parent e9a9f0ca25
commit 0bb10cabb9
42 changed files with 7147 additions and 239 deletions
+5 -3
View File
@@ -75,9 +75,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& cp "$(dpkg -L libnss-wrapper | grep 'libnss_wrapper\.so$')" /usr/local/lib/libnss_wrapper.so
# Copy package files and install dependencies
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
COPY package.json package-lock.json ./
RUN npm ci
RUN npm ci --ignore-scripts \
&& npm rebuild better-sqlite3 argon2
# Copy source code and build
COPY . .
@@ -85,7 +86,8 @@ RUN npm run build
# Production dependencies only (rebuilds native addons like better-sqlite3)
RUN rm -rf node_modules \
&& npm ci --omit=dev \
&& npm ci --omit=dev --ignore-scripts \
&& npm rebuild better-sqlite3 argon2 \
&& rm -rf node_modules/@types
# Build Go collector
+1 -1
View File
@@ -1 +1 @@
v1.0.22
v1.0.23
@@ -0,0 +1,3 @@
ALTER TABLE "git_stacks" ADD COLUMN "build_on_deploy" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "repull_images" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "force_redeploy" boolean DEFAULT false;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1767687362730,
"tag": "0003_add_stack_paths",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1774155653752,
"tag": "0004_add_git_stack_deploy_options",
"breakpoints": true
}
]
}
@@ -0,0 +1,3 @@
ALTER TABLE `git_stacks` ADD `build_on_deploy` integer DEFAULT false;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `repull_images` integer DEFAULT false;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `force_redeploy` integer DEFAULT false;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1767689000000,
"tag": "0003_add_stack_paths",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1774155653752,
"tag": "0004_add_git_stack_deploy_options",
"breakpoints": true
}
]
}
+13 -14
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.22",
"version": "1.0.23",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -70,25 +70,25 @@
"@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "6.39.11",
"@lezer/highlight": "1.2.3",
"@lucide/lab": "^0.1.2",
"@lucide/lab": "0.1.2",
"ansi_up": "6.0.6",
"argon2": "^0.41.1",
"better-sqlite3": "^11.7.0",
"codemirror": "6.0.2",
"argon2": "0.41.1",
"better-sqlite3": "11.7.0",
"croner": "9.1.0",
"cronstrue": "3.9.0",
"devalue": "5.6.4",
"drizzle-orm": "0.45.1",
"js-yaml": "^4.1.1",
"ldapts": "^8.1.3",
"nodemailer": "^7.0.12",
"otpauth": "^9.4.1",
"fast-xml-parser": "5.5.8",
"js-yaml": "4.1.1",
"ldapts": "8.1.3",
"nodemailer": "8.0.4",
"otpauth": "9.4.1",
"postgres": "3.4.8",
"qrcode": "^1.5.4",
"svelte-dnd-action": "0.9.69",
"qrcode": "1.5.4",
"rollup": "4.60.0",
"svelte-sonner": "1.0.7",
"undici": "7.24.5",
"ws": "^8.18.0"
"ws": "8.18.0"
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
@@ -102,7 +102,7 @@
"@types/better-sqlite3": "^7.6.12",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.0",
"@types/nodemailer": "7.0.5",
"@types/nodemailer": "7.0.11",
"@types/qrcode": "^1.5.6",
"@types/ws": "^8.5.13",
"@xterm/addon-fit": "^0.11.0",
@@ -122,7 +122,6 @@
"svelte": "5.53.5",
"svelte-check": "^4.3.5",
"svelte-easy-crop": "^5.0.0",
"svelte-virtual-scroll-list": "^1.3.0",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18",
+2
View File
@@ -455,3 +455,5 @@ function handleHawserConnection(ws, connId, remoteIp) {
server.listen(PORT, HOST, () => {
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`);
});
+36 -20
View File
@@ -1,44 +1,60 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Sun, Moon } from 'lucide-svelte';
import { onMount } from 'svelte';
import { Sun, Moon, Monitor } from 'lucide-svelte';
import { onMount, onDestroy } from 'svelte';
import { onDarkModeChange } from '$lib/stores/theme';
let isDark = $state(false);
type ThemeMode = 'light' | 'dark' | 'system';
let mode = $state<ThemeMode>('system');
let mediaQuery: MediaQueryList | null = null;
onMount(() => {
// Check for saved preference or system preference
const saved = localStorage.getItem('theme');
if (saved) {
isDark = saved === 'dark';
} else {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
updateTheme();
const saved = localStorage.getItem('theme') as ThemeMode | null;
mode = saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system';
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', onSystemChange);
applyMode();
});
function updateTheme() {
onDestroy(() => {
mediaQuery?.removeEventListener('change', onSystemChange);
});
function onSystemChange() {
if (mode === 'system') {
applyMode();
}
}
function applyMode() {
const isDark = mode === 'dark' || (mode === 'system' && !!mediaQuery?.matches);
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', isDark ? 'dark' : 'light');
// Apply the correct theme colors for the new mode
onDarkModeChange();
}
function toggleTheme() {
isDark = !isDark;
updateTheme();
function cycleTheme() {
const order: ThemeMode[] = ['light', 'dark', 'system'];
mode = order[(order.indexOf(mode) + 1) % order.length];
localStorage.setItem('theme', mode);
applyMode();
}
</script>
<Button variant="ghost" size="icon" onclick={toggleTheme} class="h-9 w-9">
{#if isDark}
<Button variant="ghost" size="icon" onclick={cycleTheme} class="h-9 w-9" title={mode === 'system' ? 'Theme: system' : mode === 'dark' ? 'Theme: dark' : 'Theme: light'}>
{#if mode === 'dark'}
<Moon class="h-4 w-4" />
{:else if mode === 'light'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
<Monitor class="h-4 w-4" />
{/if}
<span class="sr-only">Toggle theme</span>
</Button>
+21 -1
View File
@@ -1,7 +1,27 @@
[
{
"version": "1.0.23",
"date": "2026-04-03",
"changes": [
{ "type": "feature", "text": "theme toggle with system option — auto-follows OS light/dark preference (#803)" },
{ "type": "feature", "text": "custom user option for terminal shell sessions, persisted per container (#830)" },
{ "type": "feature", "text": "redeploy button for internal stacks with pull/build/force-recreate options (#152)" },
{ "type": "feature", "text": "build, re-pull images and force redeployment options for git stacks (#792, #472)" },
{ "type": "fix", "text": "allow underscores in hostname validation (#790)" },
{ "type": "fix", "text": "HTTPS git repos with self-signed CA certificates fail to clone/pull (#842)" },
{ "type": "fix", "text": "stack restart fails for containers using network_mode: service:<container> — added recreate option (#844)" },
{ "type": "fix", "text": "git stack sync deletes data in relative volume paths (#831)" },
{ "type": "fix", "text": "batch update skips Hawser containers (#485)" },
{ "type": "fix", "text": "registry delete fails for multi-arch/OCI manifest images" },
{ "type": "fix", "text": "scanner cache cleanup to prevent volume bloat (#808)" },
{ "type": "fix", "text": "negotiate Docker API version for scanner/updater sidecar containers (#759)" },
{ "type": "fix", "text": "scan vulnerability counts mismatch with displayed list (#705)" }
],
"imageTag": "fnsys/dockhand:v1.0.23"
},
{
"version": "1.0.22",
"comingSoon": true,
"date": "2026-03-21",
"changes": [
{ "type": "feature", "text": "dashboard list view with inline search and connection filters (#740)" },
{ "type": "feature", "text": "custom environment icon (#754)" },
+52 -1
View File
@@ -2055,6 +2055,9 @@ export interface GitStackData {
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
buildOnDeploy: boolean;
repullImages: boolean;
forceRedeploy: boolean;
lastSync: string | null;
lastCommit: string | null;
syncStatus: GitSyncStatus;
@@ -2144,6 +2147,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2174,6 +2180,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2202,6 +2211,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2231,6 +2243,9 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2260,6 +2275,9 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2289,6 +2307,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2323,6 +2344,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2352,6 +2376,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2381,6 +2408,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2408,6 +2438,9 @@ export async function createGitStack(data: {
autoUpdateCron?: string;
webhookEnabled?: boolean;
webhookSecret?: string | null;
buildOnDeploy?: boolean;
repullImages?: boolean;
forceRedeploy?: boolean;
}): Promise<GitStackWithRepo> {
const result = await db.insert(gitStacks).values({
stackName: data.stackName,
@@ -2419,7 +2452,10 @@ export async function createGitStack(data: {
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: data.webhookSecret || null
webhookSecret: data.webhookSecret || null,
buildOnDeploy: data.buildOnDeploy ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
}).returning();
return getGitStack(result[0].id) as Promise<GitStackWithRepo>;
}
@@ -2436,6 +2472,9 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron;
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret;
if (data.buildOnDeploy !== undefined) updateData.buildOnDeploy = data.buildOnDeploy;
if (data.repullImages !== undefined) updateData.repullImages = data.repullImages;
if (data.forceRedeploy !== undefined) updateData.forceRedeploy = data.forceRedeploy;
if (data.lastSync !== undefined) updateData.lastSync = data.lastSync;
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
@@ -2470,6 +2509,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2497,6 +2539,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2525,6 +2570,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2551,6 +2599,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
+3
View File
@@ -315,6 +315,9 @@ export const gitStacks = sqliteTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
webhookSecret: text('webhook_secret'),
buildOnDeploy: integer('build_on_deploy', { mode: 'boolean' }).default(false),
repullImages: integer('repull_images', { mode: 'boolean' }).default(false),
forceRedeploy: integer('force_redeploy', { mode: 'boolean' }).default(false),
lastSync: text('last_sync'),
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
+3
View File
@@ -318,6 +318,9 @@ export const gitStacks = pgTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: boolean('webhook_enabled').default(false),
webhookSecret: text('webhook_secret'),
buildOnDeploy: boolean('build_on_deploy').default(false),
repullImages: boolean('repull_images').default(false),
forceRedeploy: boolean('force_redeploy').default(false),
lastSync: timestamp('last_sync', { mode: 'string' }),
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
+18 -4
View File
@@ -153,6 +153,11 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
SSH_AUTH_SOCK: ''
};
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js)
if (process.env.NODE_EXTRA_CA_CERTS) {
env.GIT_SSL_CAINFO = process.env.NODE_EXTRA_CA_CERTS;
}
// Ensure current UID is resolvable for SSH/git operations
await ensurePasswdEntry(env);
@@ -932,8 +937,10 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
// Check if there are changes - skip redeploy if no changes and not forced
// Note: For new stacks (first deploy), syncResult.updated will be true
if (!force && !syncResult.updated) {
console.log(`${logPrefix} No changes detected and force=false, skipping redeploy`);
// forceRedeploy setting overrides the skip logic for webhooks/scheduled syncs
const shouldDeploy = force || gitStack.forceRedeploy || syncResult.updated;
if (!shouldDeploy) {
console.log(`${logPrefix} No changes detected and force=false, forceRedeploy=false, skipping redeploy`);
return {
success: true,
output: 'No changes detected, skipping redeploy',
@@ -943,6 +950,9 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
const forceRecreate = syncResult.updated;
console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated})`);
console.log(`${logPrefix} Build on deploy:`, gitStack.buildOnDeploy);
console.log(`${logPrefix} Re-pull images:`, gitStack.repullImages);
console.log(`${logPrefix} Force redeploy setting:`, gitStack.forceRedeploy);
// Deploy using unified function - handles both new and existing stacks
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
@@ -960,7 +970,9 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
composeFileName: syncResult.composeFileName, // Use original compose filename from repo
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
forceRecreate
forceRecreate,
build: gitStack.buildOnDeploy,
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
console.log(`${logPrefix} ----------------------------------------`);
@@ -1214,7 +1226,9 @@ export async function deployGitStackWithProgress(
envId: gitStack.environmentId,
sourceDir: composeDir, // Copy entire directory from git repo
composeFileName: basename(gitStack.composePath), // Use original compose filename from repo
envFileName // Env file relative to compose dir (for --env-file flag, optional)
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
build: gitStack.buildOnDeploy,
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
if (result.success) {
+43 -1
View File
@@ -18,7 +18,7 @@ import { getEnvironment, getEnvSetting, getSetting } from './db';
import { sendEventNotification } from './notifications';
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnNetworkMode } from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown } from 'node:fs/promises';
import { mkdir, chown, rm } from 'node:fs/promises';
export type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
@@ -1147,3 +1147,45 @@ export async function cleanupScannerVolumes(envId?: number): Promise<void> {
console.error('[Scanner] Failed to cleanup scanner volumes:', errorMsg);
}
}
/**
* Clean up all scanner cache storage (volumes + bind mount directories).
* Handles both standard Docker (named volumes) and rootless Docker (bind mounts).
* Next scan after cleanup will re-download a fresh vulnerability database (~200MB).
*/
export async function cleanupScannerCache(envId?: number): Promise<{ volumes: string[]; dirs: string[] }> {
const removedVolumes: string[] = [];
const removedDirs: string[] = [];
// 1. Remove named volumes (standard Docker mode)
for (const volumeName of [GRYPE_VOLUME_NAME, TRIVY_VOLUME_NAME]) {
try {
await removeVolume(volumeName, true, envId);
removedVolumes.push(volumeName);
const envSuffix = envId ? ` (env ${envId})` : '';
console.log(`[Scanner] Removed volume: ${volumeName}${envSuffix}`);
} catch {
// Volume might not exist, ignore
}
}
// 2. Remove bind mount cache directories (rootless Docker mode, local only)
if (!envId) {
for (const scannerType of ['grype', 'trivy'] as const) {
const cachePath = resolve(DATA_DIR, SCANNER_CACHE_DIR, scannerType);
try {
await rm(cachePath, { recursive: true, force: true });
removedDirs.push(cachePath);
console.log(`[Scanner] Removed cache directory: ${cachePath}`);
} catch {
// Directory might not exist, ignore
}
}
}
if (removedVolumes.length > 0 || removedDirs.length > 0) {
console.log(`[Scanner] Cache cleanup complete: ${removedVolumes.length} volumes, ${removedDirs.length} directories removed`);
}
return { volumes: removedVolumes, dirs: removedDirs };
}
+62 -1
View File
@@ -45,9 +45,11 @@ import {
runScheduleCleanupJob,
runEventCleanupJob,
runVolumeHelperCleanupJob,
runScannerCacheCleanupJob,
SYSTEM_SCHEDULE_CLEANUP_ID,
SYSTEM_EVENT_CLEANUP_ID,
SYSTEM_VOLUME_HELPER_CLEANUP_ID
SYSTEM_VOLUME_HELPER_CLEANUP_ID,
SYSTEM_SCANNER_CLEANUP_ID
} from './tasks/system-cleanup';
// Store all active cron jobs
@@ -57,6 +59,7 @@ const activeJobs: Map<string, Cron> = new Map();
let cleanupJob: Cron | null = null;
let eventCleanupJob: Cron | null = null;
let volumeHelperCleanupJob: Cron | null = null;
let scannerCacheCleanupJob: Cron | null = null;
// Scheduler state
let isRunning = false;
@@ -131,10 +134,35 @@ export async function startScheduler(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
// Scanner cache cleanup runs weekly (Sunday 3am) to prevent DB volume bloat
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
const envs = await getEnvironments();
// Clean local cache (volumes + bind mount dirs)
const localResult = await cleanupScannerCache();
// Clean remote environment volumes
for (const env of envs) {
try {
const envResult = await cleanupScannerCache(env.id);
localResult.volumes.push(...envResult.volumes);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(`[Scanner] Skipping cache cleanup for env "${env.name}" (id=${env.id}): ${msg}`);
}
}
return localResult;
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
// Register all dynamic schedules from database
await refreshAllSchedules();
@@ -164,6 +192,10 @@ export function stopScheduler(): void {
volumeHelperCleanupJob.stop();
volumeHelperCleanupJob = null;
}
if (scannerCacheCleanupJob) {
scannerCacheCleanupJob.stop();
scannerCacheCleanupJob = null;
}
// Stop all dynamic jobs
for (const [key, job] of activeJobs.entries()) {
@@ -487,6 +519,9 @@ export async function refreshSystemJobs(): Promise<void> {
if (volumeHelperCleanupJob) {
volumeHelperCleanupJob.stop();
}
if (scannerCacheCleanupJob) {
scannerCacheCleanupJob.stop();
}
// Re-create with new timezone
cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
@@ -501,9 +536,18 @@ export async function refreshSystemJobs(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
}
// =============================================================================
@@ -637,6 +681,13 @@ export async function triggerSystemJob(jobId: string): Promise<{ success: boolea
cleanupExpiredVolumeHelpers
});
return { success: true };
} else if (jobId === String(SYSTEM_SCANNER_CLEANUP_ID) || jobId === 'scanner-cache-cleanup') {
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
runScannerCacheCleanupJob('manual', scannerCleanupFn);
return { success: true };
} else {
return { success: false, error: 'Unknown system job ID' };
}
@@ -694,6 +745,16 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
nextRun: getNextRun('*/30 * * * *')?.toISOString() ?? null,
isSystem: true,
enabled: true
},
{
id: SYSTEM_SCANNER_CLEANUP_ID,
type: 'system_cleanup' as const,
name: 'Scanner cache cleanup',
description: 'Removes scanner vulnerability database cache to reclaim disk space',
cronExpression: '0 3 * * 0',
nextRun: getNextRun('0 3 * * 0')?.toISOString() ?? null,
isSystem: true,
enabled: true
}
];
}
@@ -20,6 +20,7 @@ import {
export const SYSTEM_SCHEDULE_CLEANUP_ID = 1;
export const SYSTEM_EVENT_CLEANUP_ID = 2;
export const SYSTEM_VOLUME_HELPER_CLEANUP_ID = 3;
export const SYSTEM_SCANNER_CLEANUP_ID = 4;
/**
* Execute schedule execution cleanup job.
@@ -200,3 +201,66 @@ export async function runVolumeHelperCleanupJob(
});
}
}
/**
* Execute scanner cache cleanup job.
* Removes scanner database volumes and bind mount directories to reclaim disk space.
*/
export async function runScannerCacheCleanupJob(
triggeredBy: ScheduleTrigger = 'cron',
cleanupFn?: () => Promise<{ volumes: string[]; dirs: string[] }>
): Promise<void> {
const startTime = Date.now();
const execution = await createScheduleExecution({
scheduleType: 'system_cleanup',
scheduleId: SYSTEM_SCANNER_CLEANUP_ID,
environmentId: null,
entityName: 'Scanner cache cleanup',
triggeredBy,
status: 'running'
});
await updateScheduleExecution(execution.id, {
startedAt: new Date().toISOString()
});
const log = async (message: string) => {
console.log(`[Scanner Cache Cleanup] ${message}`);
await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`);
};
try {
await log('Starting scanner cache cleanup');
let result: { volumes: string[]; dirs: string[] };
if (cleanupFn) {
result = await cleanupFn();
} else {
const { cleanupScannerCache } = await import('../../scanner');
result = await cleanupScannerCache();
}
if (result.volumes.length > 0) {
await log(`Removed volumes: ${result.volumes.join(', ')}`);
}
if (result.dirs.length > 0) {
await log(`Removed directories: ${result.dirs.join(', ')}`);
}
await log(`Cleanup complete: ${result.volumes.length} volumes, ${result.dirs.length} directories removed`);
await updateScheduleExecution(execution.id, {
status: 'success',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { removedVolumes: result.volumes, removedDirs: result.dirs }
});
} catch (error: any) {
await log(`Error: ${error.message}`);
await updateScheduleExecution(execution.id, {
status: 'failed',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
errorMessage: error.message
});
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Pure SSE parsing utilities no server dependencies.
* Can be safely imported in unit tests and client code.
*/
/**
* Check if the client prefers JSON over SSE.
* Returns true when Accept header includes application/json but NOT text/event-stream.
*/
export function prefersJSON(request?: Request): boolean {
const accept = request?.headers.get('accept') || '';
return accept.includes('application/json') && !accept.includes('text/event-stream');
}
/**
* Wrap an SSE Response for JSON-preferring clients.
*
* Consumes the SSE stream using proper event framing (blank-line delimited,
* multi-line data joined with \n, CRLF stripped). Returns the `result` event
* data as a JSON response, or a fallback if no result event was emitted.
*
* Usage:
* if (prefersJSON(request)) return sseToJSON(buildSSEResponse());
* return buildSSEResponse();
*/
export async function sseToJSON(sseResponse: Response): Promise<Response> {
const reader = sseResponse.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = '';
let dataLines: string[] = [];
let resultData: unknown = null;
const dispatch = () => {
const data = dataLines.join('\n');
const type = eventType || 'message';
eventType = '';
dataLines = [];
if (type === 'result' && data) {
try {
resultData = JSON.parse(data);
} catch {
// keep previous resultData
}
}
};
const parseLine = (rawLine: string) => {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (line.startsWith(':')) return;
if (line === '') { dispatch(); return; }
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let val = colon === -1 ? '' : line.slice(colon + 1);
if (val.startsWith(' ')) val = val.slice(1);
if (field === 'event') eventType = val || 'message';
else if (field === 'data') dataLines.push(val);
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) parseLine(line);
}
// Flush remaining bytes and process trailing content
buffer += decoder.decode();
if (buffer) {
for (const line of buffer.split('\n')) parseLine(line);
}
// Final dispatch for servers missing trailing blank line
if (dataLines.length > 0) dispatch();
} catch {
// stream error, return what we have
} finally {
reader.releaseLock();
}
const body = resultData ?? { success: false, error: 'No result' };
return new Response(JSON.stringify(body), {
headers: { 'content-type': 'application/json' }
});
}
+3 -84
View File
@@ -1,90 +1,9 @@
import { json } from '@sveltejs/kit';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
import { prefersJSON } from '$lib/server/sse-parser';
/**
* Check if the client prefers JSON over SSE.
* Returns true when Accept header includes application/json but NOT text/event-stream.
*/
export function prefersJSON(request?: Request): boolean {
const accept = request?.headers.get('accept') || '';
return accept.includes('application/json') && !accept.includes('text/event-stream');
}
/**
* Wrap an SSE Response for JSON-preferring clients.
*
* Consumes the SSE stream using proper event framing (blank-line delimited,
* multi-line data joined with \n, CRLF stripped). Returns the `result` event
* data as a JSON response, or a fallback if no result event was emitted.
*
* Usage:
* if (prefersJSON(request)) return sseToJSON(buildSSEResponse());
* return buildSSEResponse();
*/
export async function sseToJSON(sseResponse: Response): Promise<Response> {
const reader = sseResponse.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = '';
let dataLines: string[] = [];
let resultData: unknown = null;
const dispatch = () => {
const data = dataLines.join('\n');
const type = eventType || 'message';
eventType = '';
dataLines = [];
if (type === 'result' && data) {
try {
resultData = JSON.parse(data);
} catch {
// keep previous resultData
}
}
};
const parseLine = (rawLine: string) => {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (line.startsWith(':')) return;
if (line === '') { dispatch(); return; }
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let val = colon === -1 ? '' : line.slice(colon + 1);
if (val.startsWith(' ')) val = val.slice(1);
if (field === 'event') eventType = val || 'message';
else if (field === 'data') dataLines.push(val);
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) parseLine(line);
}
// Flush remaining bytes and process trailing content
buffer += decoder.decode();
if (buffer) {
for (const line of buffer.split('\n')) parseLine(line);
}
// Final dispatch for servers missing trailing blank line
if (dataLines.length > 0) dispatch();
} catch {
// stream error, return what we have
} finally {
reader.releaseLock();
}
const body = resultData ?? { success: false, error: 'No result' };
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' }
});
}
// Re-export pure parsing utilities (no server deps) for backward compat
export { prefersJSON, sseToJSON } from '$lib/server/sse-parser';
/**
* Job-based response for long-running operations.
+70 -29
View File
@@ -58,6 +58,8 @@ export interface StackOperationResult {
success: boolean;
output?: string;
error?: string;
/** The docker compose command that was executed (for debugging/testing) */
command?: string;
}
/**
@@ -99,6 +101,8 @@ export interface DeployStackOptions {
envId?: number | null;
sourceDir?: string; // Directory to copy all files from (for git stacks)
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
composePath?: string; // Custom compose file path (for adopted/imported stacks)
envPath?: string; // Custom env file path (for adopted/imported stacks)
composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks
@@ -788,6 +792,8 @@ interface ComposeCommandOptions {
stackName: string;
envId?: number | null;
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
removeVolumes?: boolean;
stackFiles?: Record<string, string>; // All files to send to Hawser
/** Working directory for compose execution (for imported stacks) */
@@ -848,7 +854,9 @@ async function executeLocalCompose(
customComposePath?: string,
customEnvPath?: string,
useOverrideFile?: boolean,
serviceName?: string
serviceName?: string,
build?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -1040,6 +1048,8 @@ async function executeLocalCompose(
case 'up':
args.push('up', '-d', '--remove-orphans');
if (forceRecreate) args.push('--force-recreate');
if (build) args.push('--build');
if (pullPolicy) args.push('--pull', pullPolicy);
// If targeting a specific service, only update that service
if (serviceName) {
args.push(serviceName);
@@ -1067,11 +1077,13 @@ async function executeLocalCompose(
break;
}
const commandStr = args.join(' ');
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} EXECUTE LOCAL COMPOSE`);
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} Operation:`, operation);
console.log(`${logPrefix} Command:`, args.join(' '));
console.log(`${logPrefix} Command:`, commandStr);
console.log(`${logPrefix} Working directory:`, stackDir);
console.log(`${logPrefix} Compose file:`, composeFile);
console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)');
@@ -1141,20 +1153,23 @@ async function executeLocalCompose(
return {
success: false,
output: stdout,
error: `docker compose ${operation} timed out after ${COMPOSE_TIMEOUT_MS / 1000} seconds`
error: `docker compose ${operation} timed out after ${COMPOSE_TIMEOUT_MS / 1000} seconds`,
command: commandStr
};
}
if (code === 0) {
return {
success: true,
output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`
output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`,
command: commandStr
};
} else {
return {
success: false,
output: stdout,
error: stderr || `docker compose ${operation} exited with code ${code}`
error: stderr || `docker compose ${operation} exited with code ${code}`,
command: commandStr
};
}
} finally {
@@ -1165,7 +1180,8 @@ async function executeLocalCompose(
return {
success: false,
output: '',
error: `Failed to run docker compose ${operation}: ${err.message}`
error: `Failed to run docker compose ${operation}: ${err.message}`,
command: commandStr
};
} finally {
// Cleanup temp override file from host path translation
@@ -1207,7 +1223,9 @@ async function executeComposeViaHawser(
removeVolumes?: boolean,
stackFiles?: Record<string, string>,
serviceName?: string,
composeFileName?: string
composeFileName?: string,
build?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
// Import dockerFetch dynamically to avoid circular dependency
@@ -1280,6 +1298,8 @@ async function executeComposeViaHawser(
files, // Files including .env (secrets NOT in .env file)
forceRecreate: forceRecreate || false,
removeVolumes: removeVolumes || false,
build: build || false,
pullPolicy: pullPolicy || '',
registries, // Registry credentials for docker login
serviceName // Target specific service only (with --no-deps)
});
@@ -1347,7 +1367,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
const { stackName, envId, forceRecreate, build, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1369,7 +1389,9 @@ async function executeComposeCommand(
composePath,
envPath,
useOverrideFile,
serviceName
serviceName,
build,
pullPolicy
);
}
@@ -1431,7 +1453,9 @@ async function executeComposeCommand(
removeVolumes,
hawserStackFiles,
serviceName,
composeFileName
composeFileName,
build,
pullPolicy
);
}
@@ -1462,7 +1486,9 @@ async function executeComposeCommand(
composePath,
envPath,
useOverrideFile,
serviceName
serviceName,
build,
pullPolicy
);
}
@@ -1483,7 +1509,9 @@ async function executeComposeCommand(
composePath,
envPath,
useOverrideFile,
serviceName
serviceName,
build,
pullPolicy
);
}
}
@@ -1747,7 +1775,7 @@ export interface RequireComposeResult {
* - envPath: Path to the .env file (Docker Compose reads non-secrets from it)
* - needsFileLocation: true if stack needs user to specify file paths
*/
async function requireComposeFile(
export async function requireComposeFile(
stackName: string,
envId?: number | null,
composeConfigPath?: string
@@ -1877,12 +1905,19 @@ export async function stopStack(
}
/**
* Restart a stack using docker compose restart
* Falls back to individual container restart for stacks without compose files
* Restart a stack using docker compose restart or stop+up (recreate mode).
*
* mode='restart' (default): Uses 'docker compose restart' fast, in-place restart
* that preserves container IDs but won't fix stale network_mode references.
* mode='recreate': Uses 'docker compose stop' then 'docker compose up -d'
* recreates containers, fixing network_mode: service:<container> dependencies.
*
* Falls back to individual container restart for stacks without compose files.
*/
export async function restartStack(
stackName: string,
envId?: number | null
envId?: number | null,
mode: 'restart' | 'recreate' = 'restart'
): Promise<StackOperationResult> {
const result = await requireComposeFile(stackName, envId);
@@ -1891,13 +1926,17 @@ export async function restartStack(
return withContainerFallback(stackName, envId, 'restart');
}
const composeResult = await executeComposeCommand(
'restart',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.nonSecretVars,
result.secretVars
);
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath };
let composeResult: StackOperationResult;
if (mode === 'recreate') {
// Stop first, then bring up with --force-recreate to ensure new container IDs
await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars);
composeResult = await executeComposeCommand('up', { ...opts, forceRecreate: true }, result.content!, result.nonSecretVars, result.secretVars);
} else {
composeResult = await executeComposeCommand('restart', opts, result.content!, result.nonSecretVars, result.secretVars);
}
// Restart any dynamically-spawned child containers not in the compose file
await cleanupOrphanStackContainers(stackName, envId, 'restart');
@@ -2133,7 +2172,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options;
const { name, compose, envId, sourceDir, forceRecreate, build, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -2206,12 +2245,12 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
console.log(`${logPrefix} Read ${Object.keys(stackFiles).length} files from source directory`);
console.log(`${logPrefix} Files:`, Object.keys(stackFiles).join(', '));
// Copy source to stack directory
// Copy git source files to stack directory (overlay, not replace).
// Do NOT rmSync first — relative volume mounts (e.g., ./data) live here
// and would be destroyed, causing data loss (#831).
console.log(`${logPrefix} Copying source directory to stack directory...`);
if (existsSync(workingDir)) {
rmSync(workingDir, { recursive: true, force: true });
}
cpSync(sourceDir, workingDir, { recursive: true });
mkdirSync(workingDir, { recursive: true });
cpSync(sourceDir, workingDir, { recursive: true, force: true });
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
} else {
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
@@ -2275,6 +2314,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
stackName: name,
envId,
forceRecreate,
build,
pullPolicy,
stackFiles,
workingDir,
composePath: actualComposePath,
+77
View File
@@ -19,6 +19,83 @@ export const USER_OPTIONS = [
{ value: '', label: 'Container default' }
];
const TERMINAL_USER_STORAGE_KEY = 'dockhand-terminal-users';
const CUSTOM_USERS_STORAGE_KEY = 'dockhand-custom-users';
/** Get saved user for a container from localStorage */
export function getSavedUser(containerId: string): string | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem(TERMINAL_USER_STORAGE_KEY);
if (stored) {
const map = JSON.parse(stored) as Record<string, string>;
return map[containerId] ?? null;
}
} catch { /* ignore */ }
return null;
}
/** Save user choice for a container to localStorage */
export function saveUserForContainer(containerId: string, user: string) {
if (typeof window === 'undefined') return;
try {
const stored = localStorage.getItem(TERMINAL_USER_STORAGE_KEY);
const map = stored ? JSON.parse(stored) as Record<string, string> : {};
if (user === 'root') {
delete map[containerId];
} else {
map[containerId] = user;
}
localStorage.setItem(TERMINAL_USER_STORAGE_KEY, JSON.stringify(map));
} catch { /* ignore */ }
// Also track custom users globally
const isPreset = USER_OPTIONS.some(o => o.value === user);
if (!isPreset && user) {
addCustomUser(user);
}
}
/** Get all custom users ever used */
export function getCustomUsers(): string[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(CUSTOM_USERS_STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch { return []; }
}
/** Add a custom user to the global list */
function addCustomUser(user: string) {
if (typeof window === 'undefined') return;
try {
const users = getCustomUsers();
if (!users.includes(user)) {
users.push(user);
localStorage.setItem(CUSTOM_USERS_STORAGE_KEY, JSON.stringify(users));
}
} catch { /* ignore */ }
}
/** Remove a custom user from the global list and clear per-container references */
export function removeCustomUser(user: string) {
if (typeof window === 'undefined') return;
try {
const users = getCustomUsers().filter(u => u !== user);
localStorage.setItem(CUSTOM_USERS_STORAGE_KEY, JSON.stringify(users));
// Clear per-container entries that reference this user
const stored = localStorage.getItem(TERMINAL_USER_STORAGE_KEY);
if (stored) {
const map = JSON.parse(stored) as Record<string, string>;
for (const [id, u] of Object.entries(map)) {
if (u === user) delete map[id];
}
localStorage.setItem(TERMINAL_USER_STORAGE_KEY, JSON.stringify(map));
}
} catch { /* ignore */ }
}
export interface ShellDetectionResult {
shells: string[];
defaultShell: string | null;
@@ -14,7 +14,7 @@ import {
import { auditContainer } from '$lib/server/audit';
import { getScannerSettings, scanImage } from '$lib/server/scanner';
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
@@ -156,8 +156,9 @@ export const POST: RequestHandler = async (event) => {
const imageName = config.Image;
const currentImageId = inspectData.Image;
// Skip Dockhand container - cannot update itself
if (isDockhandContainer(imageName)) {
// Skip system containers (Dockhand, Hawser)
const systemType = isSystemContainer(imageName);
if (systemType) {
sendData({
type: 'progress',
containerId,
@@ -166,7 +167,7 @@ export const POST: RequestHandler = async (event) => {
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - cannot update Dockhand itself`
message: `Skipping ${containerName} - cannot update ${systemType} container`
});
skippedCount++;
continue;
@@ -331,9 +332,11 @@ export const POST: RequestHandler = async (event) => {
}
}
// Collect vulnerabilities from all scanners (cap at 100)
// Collect vulnerabilities from all scanners (sort by severity, cap at 100)
const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3, negligible: 4, unknown: 5 };
const vulnerabilities = scanResults
.flatMap(r => r.vulnerabilities || [])
.sort((a, b) => (severityOrder[a.severity] ?? 9) - (severityOrder[b.severity] ?? 9))
.slice(0, 100)
.map(v => ({
id: v.id,
@@ -345,11 +348,11 @@ export const POST: RequestHandler = async (event) => {
scanner: v.scanner
}));
// Build scan message from individual results
const totalCritical = individualScannerResults.reduce((s, r) => s + r.critical, 0);
const totalHigh = individualScannerResults.reduce((s, r) => s + r.high, 0);
const totalMedium = individualScannerResults.reduce((s, r) => s + r.medium, 0);
const totalLow = individualScannerResults.reduce((s, r) => s + r.low, 0);
// Derive combined totals from the displayed (sliced) array so summary matches the table
const totalCritical = vulnerabilities.filter(v => v.severity === 'critical').length;
const totalHigh = vulnerabilities.filter(v => v.severity === 'high').length;
const totalMedium = vulnerabilities.filter(v => v.severity === 'medium').length;
const totalLow = vulnerabilities.filter(v => v.severity === 'low').length;
const hasVulns = totalCritical + totalHigh + totalMedium + totalLow > 0;
sendData({
@@ -102,7 +102,7 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext()));
const updatesFound = results.filter(r => r.hasUpdate).length;
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer).length;
// Save containers with updates to the database for persistence
if (envIdNum) {
+4 -1
View File
@@ -119,7 +119,10 @@ export const POST: RequestHandler = async (event) => {
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: webhookSecret
webhookSecret: webhookSecret,
buildOnDeploy: data.buildOnDeploy ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
});
// Create stack_sources entry so the stack appears in the list immediately
+4 -1
View File
@@ -72,7 +72,10 @@ export const PUT: RequestHandler = async (event) => {
autoUpdateSchedule: data.autoUpdateSchedule,
autoUpdateCron: data.autoUpdateCron,
webhookEnabled: data.webhookEnabled,
webhookSecret: data.webhookSecret
webhookSecret: data.webhookSecret,
buildOnDeploy: data.buildOnDeploy,
repullImages: data.repullImages,
forceRedeploy: data.forceRedeploy
});
// If stack name changed, update related records
+30 -24
View File
@@ -10,6 +10,14 @@ function isDockerHub(url: string): boolean {
lower.includes('registry.hub.docker.com');
}
// Manifest types in priority order: single-platform first, then multi-arch
const MANIFEST_TYPES = [
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.index.v1+json'
];
export const DELETE: RequestHandler = async ({ url }) => {
try {
const registryId = url.searchParams.get('registry');
@@ -39,43 +47,41 @@ export const DELETE: RequestHandler = async ({ url }) => {
}
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull,push,delete`);
// Note: orgPath is not used here because imageName already contains the full repo path
const headers: HeadersInit = {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
// Step 1: Get the manifest digest
// Step 1: Resolve manifest digest. Try each type individually because
// the registry may only serve certain types, and DELETE requires the
// Accept header to match the stored manifest type.
const manifestUrl = `${baseUrl}/v2/${imageName}/manifests/${tag}`;
const headResponse = await fetch(manifestUrl, {
method: 'HEAD',
headers
});
let digest: string | null = null;
let matchedType: string | null = null;
if (!headResponse.ok) {
for (const mediaType of MANIFEST_TYPES) {
const headers: HeadersInit = { 'Accept': mediaType };
if (authHeader) headers['Authorization'] = authHeader;
const headResponse = await fetch(manifestUrl, { method: 'HEAD', headers });
if (headResponse.ok) {
digest = headResponse.headers.get('Docker-Content-Digest');
matchedType = mediaType;
break;
}
if (headResponse.status === 401) {
return json({ error: 'Authentication failed' }, { status: 401 });
}
if (headResponse.status === 404) {
return json({ error: 'Image or tag not found' }, { status: 404 });
}
return json({ error: `Failed to get manifest: ${headResponse.status}` }, { status: headResponse.status });
}
const digest = headResponse.headers.get('Docker-Content-Digest');
if (!digest) {
return json({ error: 'Could not get image digest. Registry may not support deletion.' }, { status: 400 });
if (!digest || !matchedType) {
return json({ error: 'Image or tag not found' }, { status: 404 });
}
// Step 2: Delete the manifest by digest
// Step 2: Delete the manifest by digest using the matched type
const deleteHeaders: HeadersInit = { 'Accept': matchedType };
if (authHeader) deleteHeaders['Authorization'] = authHeader;
const deleteUrl = `${baseUrl}/v2/${imageName}/manifests/${digest}`;
const deleteResponse = await fetch(deleteUrl, {
method: 'DELETE',
headers
headers: deleteHeaders
});
if (!deleteResponse.ok) {
+3 -3
View File
@@ -4,7 +4,7 @@ import {
checkScannerAvailability,
getScannerVersions,
checkScannerUpdates,
cleanupScannerVolumes,
cleanupScannerCache,
getGlobalScannerDefaults,
type ScannerType
} from '$lib/server/scanner';
@@ -195,8 +195,8 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
}
}
// Also cleanup scanner database volumes
await cleanupScannerVolumes(parsedEnvId);
// Also cleanup scanner database cache (volumes + bind mount dirs)
await cleanupScannerCache(parsedEnvId);
return json({
success: true,
+40
View File
@@ -0,0 +1,40 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { cleanupScannerCache } from '$lib/server/scanner';
import { authorize } from '$lib/server/authorize';
import { getEnvironments } from '$lib/server/db';
export const DELETE: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const envs = await getEnvironments();
// Clean local cache (volumes + bind mount dirs)
const result = await cleanupScannerCache();
// Clean remote environment volumes
const skippedEnvs: string[] = [];
for (const env of envs) {
try {
const envResult = await cleanupScannerCache(env.id);
result.volumes.push(...envResult.volumes);
} catch {
skippedEnvs.push(env.name);
}
}
return json({
success: true,
removedVolumes: result.volumes,
removedDirs: result.dirs,
skippedEnvironments: skippedEnvs
});
} catch (error) {
console.error('Failed to clear scanner cache:', error);
return json({ error: 'Failed to clear scanner cache' }, { status: 500 });
}
};
@@ -0,0 +1,80 @@
import { json } from '@sveltejs/kit';
import { deployStack, requireComposeFile, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies, request } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !(await auth.can('stacks', 'start', envIdNum))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
const body = await request.json();
const { pull, build, forceRecreate } = body as {
pull?: boolean;
build?: boolean;
forceRecreate?: boolean;
};
return createJobResponse(async (send) => {
try {
const stackName = decodeURIComponent(params.name);
send('progress', { status: 'Reading compose file...' });
const composeResult = await requireComposeFile(stackName, envIdNum);
if (!composeResult.success) {
send('result', {
success: false,
error: composeResult.needsFileLocation
? 'Stack compose file location not configured'
: composeResult.error || 'Compose file not found'
});
return;
}
send('progress', { status: 'Deploying stack...' });
const result = await deployStack({
name: stackName,
compose: composeResult.content!,
envId: envIdNum,
pullPolicy: pull ? 'always' : undefined,
build,
forceRecreate,
composePath: composeResult.composePath,
envPath: composeResult.envPath
});
// Audit log
await auditStack(event, 'deploy', stackName, envIdNum, {
pull, build, forceRecreate
});
if (!result.success) {
send('result', { success: false, error: result.error });
return;
}
send('result', { success: true, output: result.output });
} catch (error) {
if (error instanceof ComposeFileNotFoundError) {
send('result', { success: false, error: error.message });
return;
}
console.error('Error deploying compose stack:', error);
send('result', { success: false, error: 'Failed to deploy compose stack' });
}
}, event.request);
};
@@ -22,10 +22,12 @@ 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';
return createJobResponse(async (send) => {
try {
const stackName = decodeURIComponent(params.name);
const result = await restartStack(stackName, envIdNum);
const result = await restartStack(stackName, envIdNum, mode);
// Audit log
await auditStack(event, 'restart', stackName, envIdNum);
+41 -4
View File
@@ -84,7 +84,7 @@
import { watchJob } from '$lib/utils/sse-fetch';
import { ipToNumber } from '$lib/utils/ip';
import { formatHostPortUrl } from '$lib/utils/url';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellDetectionResult } from '$lib/utils/shell-detection';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, getSavedUser, saveUserForContainer, getCustomUsers, removeCustomUser, type ShellDetectionResult } from '$lib/utils/shell-detection';
import { DataGrid } from '$lib/components/data-grid';
import type { ColumnConfig } from '$lib/types';
import type { DataGridRowState } from '$lib/components/data-grid/types';
@@ -243,6 +243,8 @@
let terminalPopoverStates = $state<Record<string, boolean>>({});
let terminalShell = $state('/bin/bash');
let terminalUser = $state('root');
let terminalCustomUser = $state('');
let terminalCustomUsers = $state<string[]>([]);
// Confirmation popover state
let confirmStopId = $state<string | null>(null);
@@ -470,7 +472,7 @@
// Unlock button width
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate);
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate && !r.systemContainer);
const failed = data.results.filter((r: any) => r.error && !r.hasUpdate);
failedUpdateChecks = failed.map((r: any) => ({
containerName: r.containerName,
@@ -1041,13 +1043,18 @@
currentTerminalContainerId = container.id;
terminalPopoverStates[container.id] = false;
} else {
// Restore saved user for this container
const savedUser = getSavedUser(container.id);
terminalUser = savedUser ?? 'root';
terminalCustomUsers = getCustomUsers();
// Show popover to configure new terminal
terminalPopoverStates[container.id] = true;
}
}
function startTerminal(container: ContainerInfo) {
// Create new terminal session
saveUserForContainer(container.id, terminalUser);
terminalCustomUsers = getCustomUsers();
const terminal: ActiveTerminal = {
containerId: container.id,
containerName: container.name,
@@ -1336,6 +1343,7 @@
onMount(async () => {
loadLayoutMode();
loadStatusFilter();
terminalCustomUsers = getCustomUsers();
// Load persisted pending updates from database
loadPendingUpdates();
@@ -2138,7 +2146,7 @@
<Select.Root type="single" bind:value={terminalUser}>
<Select.Trigger class="w-full h-8 text-xs">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{userOptions.find(o => o.value === terminalUser)?.label || 'Select'}</span>
<span>{userOptions.find(o => o.value === terminalUser)?.label || terminalUser || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
@@ -2147,6 +2155,35 @@
{option.label}
</Select.Item>
{/each}
{#if terminalCustomUsers.length > 0}
<div class="h-px bg-border my-1"></div>
{#each terminalCustomUsers as cu}
<div class="flex items-center group">
<Select.Item value={cu} label={cu} class="flex-1">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{cu}
</Select.Item>
<button
type="button"
class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); terminalCustomUsers = getCustomUsers(); if (terminalUser === cu) { terminalUser = 'root'; } }}
title="Remove user"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
{/if}
<div class="h-px bg-border my-1"></div>
<div class="px-2 py-1">
<Input
class="h-7 text-xs"
placeholder="Add user... (Enter)"
bind:value={terminalCustomUser}
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Enter' && terminalCustomUser.trim()) { const u = terminalCustomUser.trim(); terminalUser = u; saveUserForContainer(container.id, u); terminalCustomUsers = getCustomUsers(); terminalCustomUser = ''; } }}
onclick={(e) => e.stopPropagation()}
/>
</div>
</Select.Content>
</Select.Root>
</div>
+1 -1
View File
@@ -189,7 +189,7 @@
{#each oidcProviders as provider}
<Button
variant="outline"
class="w-full justify-start gap-3"
class="w-full justify-center gap-3"
onclick={() => handleSsoLogin(provider)}
disabled={ssoLoading !== null}
>
@@ -499,7 +499,7 @@
// Hostname pattern (allows letters, numbers, hyphens, dots)
// Must start with letter/number, can contain dots for subdomains
const hostnamePattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
const hostnamePattern = /^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?)*$/;
return hostnamePattern.test(host);
}
@@ -40,6 +40,30 @@
let eventPollInterval = $derived($appSettings.eventPollInterval);
let metricsCollectionInterval = $derived($appSettings.metricsCollectionInterval);
let clearingCache = $state(false);
async function clearScannerCache() {
clearingCache = true;
try {
const res = await fetch('/api/settings/scanner/cache', { method: 'DELETE' });
const data = await res.json();
if (res.ok && data.success) {
const total = (data.removedVolumes?.length || 0) + (data.removedDirs?.length || 0);
if (total > 0) {
toast.success(`Scanner cache cleared (${total} items removed)`);
} else {
toast.info('Scanner cache was already empty');
}
} else {
toast.error(data.error || 'Failed to clear scanner cache');
}
} catch {
toast.error('Failed to clear scanner cache');
} finally {
clearingCache = false;
}
}
const dateFormatOptions: { value: DateFormat; label: string; example: string }[] = [
{ value: 'DD.MM.YYYY', label: 'DD.MM.YYYY', example: '31.12.2024' },
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY', example: '31/12/2024' },
@@ -455,6 +479,26 @@
/>
<p class="text-xs text-muted-foreground">Use <code class="bg-muted px-1 rounded">{'{image}'}</code> as placeholder for the image name</p>
</div>
<div class="pt-2 border-t">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Scanner cache</p>
<p class="text-xs text-muted-foreground">Remove cached vulnerability databases to free disk space. Next scan will re-download fresh data (~200MB).</p>
</div>
<Button
variant="outline"
size="sm"
disabled={clearingCache || !$canAccess('settings', 'edit')}
onclick={clearScannerCache}
>
{#if clearingCache}
Clearing...
{:else}
Clear cache
{/if}
</Button>
</div>
</div>
</Card.Content>
</Card.Root>
@@ -220,7 +220,7 @@
</div>
{/if}
<div class="flex gap-2 pt-2 flex-wrap">
<div class="flex items-center gap-2 pt-2 flex-wrap">
<Button
variant="outline"
size="sm"
+80 -16
View File
@@ -11,6 +11,7 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Popover from '$lib/components/ui/popover';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical } from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
@@ -20,6 +21,7 @@
import GitStackModal from './GitStackModal.svelte';
import ImportStackModal from './ImportStackModal.svelte';
import GitDeployProgressPopover from './GitDeployProgressPopover.svelte';
import RedeployPopover from './RedeployPopover.svelte';
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
import LogsPanel from '../logs/LogsPanel.svelte';
@@ -419,6 +421,7 @@
// Stack operation loading state
let stackActionLoading = $state<string | null>(null);
let restartPopoverOpen = $state<Record<string, boolean>>({});
let stackDownLoading = $state<string | null>(null);
// Container-level confirmation popover state
@@ -877,17 +880,21 @@
}
}
async function restartStack(name: string) {
async function restartStack(name: string, mode: 'restart' | 'recreate' = 'restart') {
operationError = null;
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId), { method: 'POST' });
let url = appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId);
if (mode === 'recreate') {
url += (url.includes('?') ? '&' : '?') + 'mode=recreate';
}
const response = await fetch(url, { method: 'POST' });
const data = await readJobResponse(response);
if (!data.success) {
showErrorDialog(`Failed to restart ${name}`, data.error || 'Failed to restart stack');
return;
}
toast.success(`Restarted ${name}`);
toast.success(mode === 'recreate' ? `Recreated ${name}` : `Restarted ${name}`);
await fetchStacks();
} catch (error) {
console.error('Failed to restart stack:', error);
@@ -898,6 +905,31 @@
}
}
async function redeployStack(name: string, options: { pull: boolean; build: boolean; forceRecreate: boolean }) {
operationError = null;
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/deploy`, envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options)
});
const data = await readJobResponse(response);
if (!data.success) {
showErrorDialog(`Failed to redeploy ${name}`, data.error || 'Failed to redeploy stack');
return;
}
toast.success(`Redeployed ${name}`);
await fetchStacks();
} catch (error) {
console.error('Failed to redeploy stack:', error);
const errorMsg = error instanceof Error ? error.message : 'Failed to redeploy stack';
showErrorDialog(`Failed to redeploy ${name}`, errorMsg);
} finally {
stackActionLoading = null;
}
}
async function downStack(name: string) {
operationError = null;
stackActionLoading = name;
@@ -1749,25 +1781,57 @@
<ScrollText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
</button>
{/if}
{#if source.sourceType !== 'git' && source.sourceType !== 'external' && $canAccess('stacks', 'start')}
<RedeployPopover
stackName={stack.name}
{envId}
disabled={stackActionLoading === stack.name}
onDeploy={(options) => redeployStack(stack.name, options)}
>
{#snippet children()}
<Rocket class="w-3 h-3 text-muted-foreground hover:text-violet-500" />
{/snippet}
</RedeployPopover>
{/if}
{#if stackActionLoading === stack.name}
<div class="p-1">
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
</div>
{:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'}
{#if $canAccess('stacks', 'restart')}
<ConfirmPopover
open={false}
action="Restart"
itemType="stack"
itemName={stack.name}
title="Restart"
onConfirm={() => restartStack(stack.name)}
onOpenChange={() => {}}
>
{#snippet children({ open })}
<RotateCcw class="w-3 h-3 {open ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500'}" />
{/snippet}
</ConfirmPopover>
<Popover.Root open={restartPopoverOpen[stack.name] ?? false} onOpenChange={(v) => restartPopoverOpen[stack.name] = v}>
<Popover.Trigger asChild>
{#snippet child({ props })}
<button
type="button"
title="Restart"
{...props}
onclick={(e) => { e.stopPropagation(); restartPopoverOpen[stack.name] = !restartPopoverOpen[stack.name]; }}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer inline-flex items-center"
>
<RotateCcw class="w-3 h-3 {restartPopoverOpen[stack.name] ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500'}" />
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content
class="w-auto p-2 z-[200]"
side="top"
align="end"
sideOffset={8}
>
<div class="flex flex-col gap-1.5">
<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>
</div>
</Popover.Content>
</Popover.Root>
{/if}
{#if $canAccess('stacks', 'stop')}
<ConfirmPopover
+51 -1
View File
@@ -6,7 +6,7 @@
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download } from 'lucide-svelte';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { copyToClipboard } from '$lib/utils/clipboard';
import CronEditor from '$lib/components/cron-editor.svelte';
@@ -57,6 +57,9 @@
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
buildOnDeploy: boolean;
repullImages: boolean;
forceRedeploy: boolean;
}
interface Props {
@@ -87,6 +90,9 @@
let formAutoUpdateCron = $state('0 3 * * *');
let formWebhookEnabled = $state(false);
let formWebhookSecret = $state('');
let formBuildOnDeploy = $state(false);
let formRepullImages = $state(false);
let formForceRedeploy = $state(false);
let formDeployNow = $state(false);
let formError = $state('');
let formSaving = $state(false);
@@ -357,6 +363,9 @@
formAutoUpdateCron = gitStack.autoUpdateCron || '0 3 * * *';
formWebhookEnabled = gitStack.webhookEnabled;
formWebhookSecret = gitStack.webhookSecret || '';
formBuildOnDeploy = gitStack.buildOnDeploy ?? false;
formRepullImages = gitStack.repullImages ?? false;
formForceRedeploy = gitStack.forceRedeploy ?? false;
formDeployNow = false;
// Load env files and overrides SYNCHRONOUSLY to avoid race conditions
@@ -381,6 +390,9 @@
formAutoUpdateCron = '0 3 * * *';
formWebhookEnabled = false;
formWebhookSecret = '';
formBuildOnDeploy = false;
formRepullImages = false;
formForceRedeploy = false;
formDeployNow = false;
}
}
@@ -437,6 +449,9 @@
autoUpdateCron: formAutoUpdateCron,
webhookEnabled: formWebhookEnabled,
webhookSecret: formWebhookEnabled ? formWebhookSecret : null,
buildOnDeploy: formBuildOnDeploy,
repullImages: formRepullImages,
forceRedeploy: formForceRedeploy,
deployNow: deployAfterSave,
envVars: overrideVars.map(v => ({
key: v.key.trim(),
@@ -880,6 +895,41 @@
{/if}
</div>
<!-- Deploy options section -->
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Deploy options</p>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-1">
<Hammer class="w-4 h-4 text-muted-foreground" />
<Label class="text-sm font-normal">Build images on deploy</Label>
</div>
<TogglePill bind:checked={formBuildOnDeploy} />
</div>
<p class="text-xs text-muted-foreground">
Run <code class="text-xs bg-muted px-1 rounded">--build</code> to build images from Dockerfiles before starting containers.
</p>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-1">
<ArrowDownToLine class="w-4 h-4 text-muted-foreground" />
<Label class="text-sm font-normal">Re-pull images</Label>
</div>
<TogglePill bind:checked={formRepullImages} />
</div>
<p class="text-xs text-muted-foreground">
Always pull latest images before deploying, even if the compose file hasn't changed. Useful for CI/CD workflows with static tags like <code class="text-xs bg-muted px-1 rounded">:latest</code>.
</p>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-1">
<Zap class="w-4 h-4 text-muted-foreground" />
<Label class="text-sm font-normal">Force redeployment</Label>
</div>
<TogglePill bind:checked={formForceRedeploy} />
</div>
<p class="text-xs text-muted-foreground">
Always redeploy the stack on webhook or scheduled sync, even if no git changes are detected.
</p>
</div>
<!-- Deploy now option (only for new stacks) -->
{#if !gitStack}
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
+30 -7
View File
@@ -3,7 +3,7 @@
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Import, Loader2, Play, Info } from 'lucide-svelte';
import { Import, Loader2, Play, Info, ServerCog } from 'lucide-svelte';
import FilesystemBrowser, { type FileEntry } from './FilesystemBrowser.svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import yaml from 'js-yaml';
@@ -56,6 +56,11 @@
// Look up the icon from the environments list since currentEnvironment doesn't store it
const currentEnvData = $derived($environments.find(e => e.id === envId));
const envIcon = $derived(currentEnvData?.icon || 'globe');
const isRemoteEnv = $derived(
currentEnvData?.connectionType === 'hawser-standard' ||
currentEnvData?.connectionType === 'hawser-edge' ||
(currentEnvData?.connectionType === 'direct' && !!currentEnvData?.host)
);
// Reset when modal closes
$effect(() => {
@@ -299,9 +304,16 @@
// Browser title with environment info
const browserTitle = $derived.by(() => {
const envPart = envName ? ` · ${envName}` : '';
const envPart = envName ? ` to ${envName}` : '';
return `Adopt stacks${envPart}`;
});
const browserDescription = $derived.by(() => {
if (isRemoteEnv) {
return `Browse the Dockhand host filesystem to find compose files. Files are managed locally — Hawser only proxies Docker API calls, not filesystem access.`;
}
return 'Browse to a compose file or scan a directory for stacks.';
});
</script>
{#if view === 'browse'}
@@ -311,7 +323,7 @@
bind:open
title={browserTitle}
icon={Import}
description="Browse to a compose file or scan a directory for stacks."
description={browserDescription}
selectMode="adopt"
highlightFilter={/\.ya?ml$/i}
onFilePreview={handleFilePreview}
@@ -327,8 +339,7 @@
<Dialog.Header class="px-6 py-4 border-b shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Import class="w-5 h-5" />
Select stacks to adopt
<span class="text-muted-foreground">·</span>
Select stacks to adopt to
<EnvironmentIcon icon={envIcon} envId={envId} class="w-4 h-4 text-muted-foreground" />
<span class="text-muted-foreground font-normal">{envName}</span>
</Dialog.Title>
@@ -381,7 +392,13 @@
</div>
</div>
<!-- Adopt info -->
<div class="px-4 py-3 border-t shrink-0">
<div class="px-4 py-3 border-t shrink-0 space-y-2">
{#if isRemoteEnv}
<div class="flex items-start gap-2.5 text-xs bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md px-3 py-2.5">
<ServerCog class="w-4 h-4 text-blue-500 shrink-0 mt-0.5" />
<span class="text-blue-700 dark:text-blue-300">These compose files are on the <span class="font-medium">Dockhand host</span>, not on {envName}. Docker commands will be sent to {envName} via Hawser, but the files are managed locally.</span>
</div>
{/if}
<div class="flex items-start gap-2.5 text-xs bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-md px-3 py-2.5">
<Info class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<span><span class="font-medium text-amber-600 dark:text-amber-400">What happens when you adopt:</span> <span class="text-zinc-600 dark:text-zinc-400">Dockhand will track these compose files, letting you edit, start, and stop the stacks from the UI. Your files stay in their current location.</span></span>
@@ -475,7 +492,13 @@
</div>
<!-- Adopt info -->
<div class="px-5 py-3 border-t shrink-0">
<div class="px-5 py-3 border-t shrink-0 space-y-2">
{#if isRemoteEnv}
<div class="flex items-start gap-2.5 text-xs bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md px-3 py-2.5">
<ServerCog class="w-4 h-4 text-blue-500 shrink-0 mt-0.5" />
<span class="text-blue-700 dark:text-blue-300">This compose file is on the <span class="font-medium">Dockhand host</span>, not on {envName}. Docker commands will be sent to {envName} via Hawser, but the file is managed locally.</span>
</div>
{/if}
<div class="flex items-start gap-2.5 text-xs bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-md px-3 py-2.5">
<Info class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<span><span class="font-medium text-amber-600 dark:text-amber-400">What happens when you adopt:</span> <span class="text-zinc-600 dark:text-zinc-400">Dockhand will track this compose file, letting you edit, start, and stop the stack from the UI. Your files stay in their current location.</span></span>
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Popover from '$lib/components/ui/popover';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Loader2 } from 'lucide-svelte';
import type { Snippet } from 'svelte';
interface Props {
stackName: string;
envId: number | null;
disabled?: boolean;
onDeploy: (options: { pull: boolean; build: boolean; forceRecreate: boolean }) => Promise<void>;
children: Snippet;
}
let {
stackName,
envId,
disabled = false,
onDeploy,
children
}: Props = $props();
let open = $state(false);
let pull = $state(true);
let build = $state(false);
let forceRecreate = $state(false);
let deploying = $state(false);
async function handleDeploy() {
deploying = true;
try {
await onDeploy({ pull, build, forceRecreate });
} finally {
deploying = false;
open = false;
}
}
function handleTriggerClick(e: MouseEvent) {
e.stopPropagation();
if (disabled) return;
open = !open;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger asChild>
{#snippet child({ props })}
<button
type="button"
title="Redeploy"
{...props}
onclick={handleTriggerClick}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer inline-flex items-center"
>
{@render children()}
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content
class="w-56 p-3 z-[200]"
side="top"
align="end"
sideOffset={8}
>
<div class="space-y-3">
<p class="text-xs font-medium">Redeploy stack</p>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<Checkbox bind:checked={pull} disabled={deploying} />
<span class="text-xs">Pull images</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<Checkbox bind:checked={build} disabled={deploying} />
<span class="text-xs">Build images</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<Checkbox bind:checked={forceRecreate} disabled={deploying} />
<span class="text-xs">Force recreate</span>
</label>
</div>
<Button
size="sm"
class="w-full h-7 text-xs"
onclick={handleDeploy}
disabled={deploying}
>
{#if deploying}
<Loader2 class="w-3 h-3 mr-1 animate-spin" />
Deploying...
{:else}
Deploy
{/if}
</Button>
</div>
</Popover.Content>
</Popover.Root>
+77 -8
View File
@@ -15,7 +15,7 @@
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
import Terminal from './Terminal.svelte';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellInfo, type ShellDetectionResult } from '$lib/utils/shell-detection';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, getSavedUser, saveUserForContainer, getCustomUsers, removeCustomUser, type ShellInfo, type ShellDetectionResult } from '$lib/utils/shell-detection';
// Track if we've handled the initial container from URL
let initialContainerHandled = $state(false);
@@ -35,6 +35,8 @@
// Shell/user options
let selectedShell = $state('/bin/bash');
let selectedUser = $state('root');
let customUserInput = $state('');
let customUsers = $state<string[]>([]);
let terminalFontSize = $state(14);
// Track previous shell/user for reconnection
@@ -115,6 +117,16 @@
if (bestShell && bestShell !== selectedShell) {
selectedShell = bestShell;
}
// Restore saved user for this container
const savedUser = getSavedUser(container.id);
if (savedUser !== null) {
selectedUser = savedUser;
committedUser = savedUser;
} else {
selectedUser = 'root';
committedUser = 'root';
}
} catch (error) {
console.error('Failed to detect shells:', error);
} finally {
@@ -151,16 +163,42 @@
}
}
// Committed user: only updates when a preset is selected or custom input is confirmed
let committedUser = $state('root');
function commitUser(user: string) {
committedUser = user;
if (selectedContainer) {
saveUserForContainer(selectedContainer.id, user);
customUsers = getCustomUsers();
}
}
// When a user is selected from dropdown, commit immediately
function onUserSelectChange(value: string) {
commitUser(value);
}
// Confirm custom user on Enter
function onCustomUserKeydown(e: KeyboardEvent) {
e.stopPropagation();
if (e.key === 'Enter' && customUserInput.trim()) {
const newUser = customUserInput.trim();
selectedUser = newUser;
commitUser(newUser);
customUserInput = '';
}
}
// Watch for shell/user changes while connected and trigger reconnect
$effect(() => {
if (selectedContainer && connected && terminalComponent) {
if (selectedShell !== prevShell || selectedUser !== prevUser) {
// Reconnect with new shell/user
if (selectedShell !== prevShell || committedUser !== prevUser) {
terminalComponent.reconnect();
}
}
prevShell = selectedShell;
prevUser = selectedUser;
prevUser = committedUser;
});
// Change font size
@@ -175,6 +213,7 @@
}
onMount(async () => {
customUsers = getCustomUsers();
await fetchContainers();
// Check for container ID in URL query parameter
@@ -336,10 +375,10 @@
<!-- User selector - always visible -->
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">User:</Label>
<Select.Root type="single" bind:value={selectedUser}>
<Select.Root type="single" bind:value={selectedUser} onValueChange={onUserSelectChange}>
<Select.Trigger class="h-9 w-48">
<User class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{USER_OPTIONS.find(o => o.value === selectedUser)?.label || 'Select'}</span>
<span>{USER_OPTIONS.find(o => o.value === selectedUser)?.label || selectedUser || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each USER_OPTIONS as option}
@@ -348,6 +387,36 @@
{option.label}
</Select.Item>
{/each}
{#if customUsers.length > 0}
<div class="h-px bg-border my-1"></div>
{#each customUsers as cu}
<div class="flex items-center group">
<Select.Item value={cu} label={cu} class="flex-1">
<User class="w-4 h-4 mr-2 text-muted-foreground" />
{cu}
</Select.Item>
<button
type="button"
class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); customUsers = getCustomUsers(); if (selectedUser === cu) { selectedUser = 'root'; commitUser('root'); } }}
title="Remove user"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
{/if}
<div class="h-px bg-border my-1"></div>
<!-- svelte-ignore a11y_autofocus -->
<div class="px-2 py-1">
<Input
class="h-7 text-xs"
placeholder="Add user... (Enter)"
bind:value={customUserInput}
onkeydown={onCustomUserKeydown}
onclick={(e) => e.stopPropagation()}
/>
</div>
</Select.Content>
</Select.Root>
</div>
@@ -428,13 +497,13 @@
</div>
</div>
<div class="flex-1 min-h-0 w-full">
{#key `${selectedContainer.id}-${selectedShell}-${selectedUser}`}
{#key `${selectedContainer.id}-${selectedShell}-${committedUser}`}
<Terminal
bind:this={terminalComponent}
containerId={selectedContainer.id}
containerName={selectedContainer.name}
shell={selectedShell}
user={selectedUser}
user={committedUser}
{envId}
fontSize={terminalFontSize}
/>