mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
v1.0.23
This commit is contained in:
+5
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -455,3 +455,5 @@ function handleHawserConnection(ws, connId, remoteIp) {
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user