v1.0.23
This commit is contained in:
1 parent
e9a9f0ca25
commit
0bb10cabb9
42 files changed
+7147
-239
No files matched your search
+5
-3
@@ -75,9 +75,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& cp "$(dpkg -L libnss-wrapper | grep 'libnss_wrapper\.so$')" /usr/local/lib/libnss_wrapper.so
|
&& 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 ./
|
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 source code and build
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -85,7 +86,8 @@ RUN npm run build
|
|||||||
|
|
||||||
# Production dependencies only (rebuilds native addons like better-sqlite3)
|
# Production dependencies only (rebuilds native addons like better-sqlite3)
|
||||||
RUN rm -rf node_modules \
|
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
|
&& rm -rf node_modules/@types
|
||||||
|
|
||||||
# Build Go collector
|
# 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,
|
"when": 1767687362730,
|
||||||
"tag": "0003_add_stack_paths",
|
"tag": "0003_add_stack_paths",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1767689000000,
|
||||||
"tag": "0003_add_stack_paths",
|
"tag": "0003_add_stack_paths",
|
||||||
"breakpoints": true
|
"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",
|
"name": "dockhand",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.22",
|
"version": "1.0.23",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npx vite dev",
|
"dev": "npx vite dev",
|
||||||
@@ -70,25 +70,25 @@
|
|||||||
"@codemirror/theme-one-dark": "6.1.3",
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
"@codemirror/view": "6.39.11",
|
"@codemirror/view": "6.39.11",
|
||||||
"@lezer/highlight": "1.2.3",
|
"@lezer/highlight": "1.2.3",
|
||||||
"@lucide/lab": "^0.1.2",
|
"@lucide/lab": "0.1.2",
|
||||||
"ansi_up": "6.0.6",
|
"ansi_up": "6.0.6",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "0.41.1",
|
||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "11.7.0",
|
||||||
"codemirror": "6.0.2",
|
|
||||||
"croner": "9.1.0",
|
"croner": "9.1.0",
|
||||||
"cronstrue": "3.9.0",
|
"cronstrue": "3.9.0",
|
||||||
"devalue": "5.6.4",
|
"devalue": "5.6.4",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
"js-yaml": "^4.1.1",
|
"fast-xml-parser": "5.5.8",
|
||||||
"ldapts": "^8.1.3",
|
"js-yaml": "4.1.1",
|
||||||
"nodemailer": "^7.0.12",
|
"ldapts": "8.1.3",
|
||||||
"otpauth": "^9.4.1",
|
"nodemailer": "8.0.4",
|
||||||
|
"otpauth": "9.4.1",
|
||||||
"postgres": "3.4.8",
|
"postgres": "3.4.8",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"svelte-dnd-action": "0.9.69",
|
"rollup": "4.60.0",
|
||||||
"svelte-sonner": "1.0.7",
|
"svelte-sonner": "1.0.7",
|
||||||
"undici": "7.24.5",
|
"undici": "7.24.5",
|
||||||
"ws": "^8.18.0"
|
"ws": "8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@internationalized/date": "^3.10.1",
|
"@internationalized/date": "^3.10.1",
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/nodemailer": "7.0.5",
|
"@types/nodemailer": "7.0.11",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/ws": "^8.5.13",
|
"@types/ws": "^8.5.13",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
@@ -122,7 +122,6 @@
|
|||||||
"svelte": "5.53.5",
|
"svelte": "5.53.5",
|
||||||
"svelte-check": "^4.3.5",
|
"svelte-check": "^4.3.5",
|
||||||
"svelte-easy-crop": "^5.0.0",
|
"svelte-easy-crop": "^5.0.0",
|
||||||
"svelte-virtual-scroll-list": "^1.3.0",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
|||||||
@@ -455,3 +455,5 @@ function handleHawserConnection(ws, connId, remoteIp) {
|
|||||||
server.listen(PORT, HOST, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`);
|
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -1,44 +1,60 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Sun, Moon } from 'lucide-svelte';
|
import { Sun, Moon, Monitor } from 'lucide-svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { onDarkModeChange } from '$lib/stores/theme';
|
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(() => {
|
onMount(() => {
|
||||||
// Check for saved preference or system preference
|
const saved = localStorage.getItem('theme') as ThemeMode | null;
|
||||||
const saved = localStorage.getItem('theme');
|
mode = saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system';
|
||||||
if (saved) {
|
|
||||||
isDark = saved === 'dark';
|
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
} else {
|
mediaQuery.addEventListener('change', onSystemChange);
|
||||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
}
|
applyMode();
|
||||||
updateTheme();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
if (isDark) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
}
|
}
|
||||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
|
||||||
// Apply the correct theme colors for the new mode
|
|
||||||
onDarkModeChange();
|
onDarkModeChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function cycleTheme() {
|
||||||
isDark = !isDark;
|
const order: ThemeMode[] = ['light', 'dark', 'system'];
|
||||||
updateTheme();
|
mode = order[(order.indexOf(mode) + 1) % order.length];
|
||||||
|
localStorage.setItem('theme', mode);
|
||||||
|
applyMode();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" onclick={toggleTheme} class="h-9 w-9">
|
<Button variant="ghost" size="icon" onclick={cycleTheme} class="h-9 w-9" title={mode === 'system' ? 'Theme: system' : mode === 'dark' ? 'Theme: dark' : 'Theme: light'}>
|
||||||
{#if isDark}
|
{#if mode === 'dark'}
|
||||||
|
<Moon class="h-4 w-4" />
|
||||||
|
{:else if mode === 'light'}
|
||||||
<Sun class="h-4 w-4" />
|
<Sun class="h-4 w-4" />
|
||||||
{:else}
|
{:else}
|
||||||
<Moon class="h-4 w-4" />
|
<Monitor class="h-4 w-4" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="sr-only">Toggle theme</span>
|
<span class="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</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",
|
"version": "1.0.22",
|
||||||
"comingSoon": true,
|
"date": "2026-03-21",
|
||||||
"changes": [
|
"changes": [
|
||||||
{ "type": "feature", "text": "dashboard list view with inline search and connection filters (#740)" },
|
{ "type": "feature", "text": "dashboard list view with inline search and connection filters (#740)" },
|
||||||
{ "type": "feature", "text": "custom environment icon (#754)" },
|
{ "type": "feature", "text": "custom environment icon (#754)" },
|
||||||
|
|||||||
+52
-1
@@ -2055,6 +2055,9 @@ export interface GitStackData {
|
|||||||
autoUpdateCron: string;
|
autoUpdateCron: string;
|
||||||
webhookEnabled: boolean;
|
webhookEnabled: boolean;
|
||||||
webhookSecret: string | null;
|
webhookSecret: string | null;
|
||||||
|
buildOnDeploy: boolean;
|
||||||
|
repullImages: boolean;
|
||||||
|
forceRedeploy: boolean;
|
||||||
lastSync: string | null;
|
lastSync: string | null;
|
||||||
lastCommit: string | null;
|
lastCommit: string | null;
|
||||||
syncStatus: GitSyncStatus;
|
syncStatus: GitSyncStatus;
|
||||||
@@ -2144,6 +2147,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
|
|||||||
autoUpdateCron: row.autoUpdateCron,
|
autoUpdateCron: row.autoUpdateCron,
|
||||||
webhookEnabled: row.webhookEnabled,
|
webhookEnabled: row.webhookEnabled,
|
||||||
webhookSecret: row.webhookSecret,
|
webhookSecret: row.webhookSecret,
|
||||||
|
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||||
|
repullImages: row.repullImages ?? false,
|
||||||
|
forceRedeploy: row.forceRedeploy ?? false,
|
||||||
lastSync: row.lastSync,
|
lastSync: row.lastSync,
|
||||||
lastCommit: row.lastCommit,
|
lastCommit: row.lastCommit,
|
||||||
syncStatus: row.syncStatus,
|
syncStatus: row.syncStatus,
|
||||||
@@ -2174,6 +2180,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
|
|||||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||||
webhookEnabled: gitStacks.webhookEnabled,
|
webhookEnabled: gitStacks.webhookEnabled,
|
||||||
webhookSecret: gitStacks.webhookSecret,
|
webhookSecret: gitStacks.webhookSecret,
|
||||||
|
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||||
|
repullImages: gitStacks.repullImages,
|
||||||
|
forceRedeploy: gitStacks.forceRedeploy,
|
||||||
lastSync: gitStacks.lastSync,
|
lastSync: gitStacks.lastSync,
|
||||||
lastCommit: gitStacks.lastCommit,
|
lastCommit: gitStacks.lastCommit,
|
||||||
syncStatus: gitStacks.syncStatus,
|
syncStatus: gitStacks.syncStatus,
|
||||||
@@ -2202,6 +2211,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
|
|||||||
autoUpdateCron: row.autoUpdateCron,
|
autoUpdateCron: row.autoUpdateCron,
|
||||||
webhookEnabled: row.webhookEnabled,
|
webhookEnabled: row.webhookEnabled,
|
||||||
webhookSecret: row.webhookSecret,
|
webhookSecret: row.webhookSecret,
|
||||||
|
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||||
|
repullImages: row.repullImages ?? false,
|
||||||
|
forceRedeploy: row.forceRedeploy ?? false,
|
||||||
lastSync: row.lastSync,
|
lastSync: row.lastSync,
|
||||||
lastCommit: row.lastCommit,
|
lastCommit: row.lastCommit,
|
||||||
syncStatus: row.syncStatus,
|
syncStatus: row.syncStatus,
|
||||||
@@ -2231,6 +2243,9 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
|||||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||||
webhookEnabled: gitStacks.webhookEnabled,
|
webhookEnabled: gitStacks.webhookEnabled,
|
||||||
webhookSecret: gitStacks.webhookSecret,
|
webhookSecret: gitStacks.webhookSecret,
|
||||||
|
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||||
|
repullImages: gitStacks.repullImages,
|
||||||
|
forceRedeploy: gitStacks.forceRedeploy,
|
||||||
lastSync: gitStacks.lastSync,
|
lastSync: gitStacks.lastSync,
|
||||||
lastCommit: gitStacks.lastCommit,
|
lastCommit: gitStacks.lastCommit,
|
||||||
syncStatus: gitStacks.syncStatus,
|
syncStatus: gitStacks.syncStatus,
|
||||||
@@ -2260,6 +2275,9 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
|||||||
autoUpdateCron: row.autoUpdateCron,
|
autoUpdateCron: row.autoUpdateCron,
|
||||||
webhookEnabled: row.webhookEnabled,
|
webhookEnabled: row.webhookEnabled,
|
||||||
webhookSecret: row.webhookSecret,
|
webhookSecret: row.webhookSecret,
|
||||||
|
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||||
|
repullImages: row.repullImages ?? false,
|
||||||
|
forceRedeploy: row.forceRedeploy ?? false,
|
||||||
lastSync: row.lastSync,
|
lastSync: row.lastSync,
|
||||||
lastCommit: row.lastCommit,
|
lastCommit: row.lastCommit,
|
||||||
syncStatus: row.syncStatus,
|
syncStatus: row.syncStatus,
|
||||||
@@ -2289,6 +2307,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
|
|||||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||||
webhookEnabled: gitStacks.webhookEnabled,
|
webhookEnabled: gitStacks.webhookEnabled,
|
||||||
webhookSecret: gitStacks.webhookSecret,
|
webhookSecret: gitStacks.webhookSecret,
|
||||||
|
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||||
|
repullImages: gitStacks.repullImages,
|
||||||
|
forceRedeploy: gitStacks.forceRedeploy,
|
||||||
lastSync: gitStacks.lastSync,
|
lastSync: gitStacks.lastSync,
|
||||||
lastCommit: gitStacks.lastCommit,
|
lastCommit: gitStacks.lastCommit,
|
||||||
syncStatus: gitStacks.syncStatus,
|
syncStatus: gitStacks.syncStatus,
|
||||||
@@ -2323,6 +2344,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
|
|||||||
autoUpdateCron: row.autoUpdateCron,
|
autoUpdateCron: row.autoUpdateCron,
|
||||||
webhookEnabled: row.webhookEnabled,
|
webhookEnabled: row.webhookEnabled,
|
||||||
webhookSecret: row.webhookSecret,
|
webhookSecret: row.webhookSecret,
|
||||||
|
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||||
|
repullImages: row.repullImages ?? false,
|
||||||
|
forceRedeploy: row.forceRedeploy ?? false,
|
||||||
lastSync: row.lastSync,
|
lastSync: row.lastSync,
|
||||||
lastCommit: row.lastCommit,
|
lastCommit: row.lastCommit,
|
||||||
syncStatus: row.syncStatus,
|
syncStatus: row.syncStatus,
|
||||||
@@ -2352,6 +2376,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
|
|||||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||||
webhookEnabled: gitStacks.webhookEnabled,
|
webhookEnabled: gitStacks.webhookEnabled,
|
||||||
webhookSecret: gitStacks.webhookSecret,
|
webhookSecret: gitStacks.webhookSecret,
|
||||||
|
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||||
|
repullImages: gitStacks.repullImages,
|
||||||
|
forceRedeploy: gitStacks.forceRedeploy,
|
||||||
lastSync: gitStacks.lastSync,
|
lastSync: gitStacks.lastSync,
|
||||||
lastCommit: gitStacks.lastCommit,
|
lastCommit: gitStacks.lastCommit,
|
||||||
syncStatus: gitStacks.syncStatus,
|
syncStatus: gitStacks.syncStatus,
|
||||||
@@ -2381,6 +2408,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
|
|||||||
autoUpdateCron: row.autoUpdateCron,
|
autoUpdateCron: row.autoUpdateCron,
|
||||||
webhookEnabled: row.webhookEnabled,
|
webhookEnabled: row.webhookEnabled,
|
||||||
webhookSecret: row.webhookSecret,
|
webhookSecret: row.webhookSecret,
|
||||||
|
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||||
|
repullImages: row.repullImages ?? false,
|
||||||
|
forceRedeploy: row.forceRedeploy ?? false,
|
||||||
lastSync: row.lastSync,
|
lastSync: row.lastSync,
|
||||||
lastCommit: row.lastCommit,
|
lastCommit: row.lastCommit,
|
||||||
syncStatus: row.syncStatus,
|
syncStatus: row.syncStatus,
|
||||||
@@ -2408,6 +2438,9 @@ export async function createGitStack(data: {
|
|||||||
autoUpdateCron?: string;
|
autoUpdateCron?: string;
|
||||||
webhookEnabled?: boolean;
|
webhookEnabled?: boolean;
|
||||||
webhookSecret?: string | null;
|
webhookSecret?: string | null;
|
||||||
|
buildOnDeploy?: boolean;
|
||||||
|
repullImages?: boolean;
|
||||||
|
forceRedeploy?: boolean;
|
||||||
}): Promise<GitStackWithRepo> {
|
}): Promise<GitStackWithRepo> {
|
||||||
const result = await db.insert(gitStacks).values({
|
const result = await db.insert(gitStacks).values({
|
||||||
stackName: data.stackName,
|
stackName: data.stackName,
|
||||||
@@ -2419,7 +2452,10 @@ export async function createGitStack(data: {
|
|||||||
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
|
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
|
||||||
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
|
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
|
||||||
webhookEnabled: data.webhookEnabled || false,
|
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();
|
}).returning();
|
||||||
return getGitStack(result[0].id) as Promise<GitStackWithRepo>;
|
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.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron;
|
||||||
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
|
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
|
||||||
if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret;
|
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.lastSync !== undefined) updateData.lastSync = data.lastSync;
|
||||||
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
|
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
|
||||||
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
|
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
|
||||||
@@ -2470,6 +2509,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
|
|||||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||||
webhookEnabled: gitStacks.webhookEnabled,
|
webhookEnabled: gitStacks.webhookEnabled,
|
||||||
webhookSecret: gitStacks.webhookSecret,
|
webhookSecret: gitStacks.webhookSecret,
|
||||||
|
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||||
|
repullImages: gitStacks.repullImages,
|
||||||
|
forceRedeploy: gitStacks.forceRedeploy,
|
||||||
lastSync: gitStacks.lastSync,
|
lastSync: gitStacks.lastSync,
|
||||||
lastCommit: gitStacks.lastCommit,
|
lastCommit: gitStacks.lastCommit,
|
||||||
syncStatus: gitStacks.syncStatus,
|
syncStatus: gitStacks.syncStatus,
|
||||||
@@ -2497,6 +2539,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
|
|||||||
autoUpdateCron: row.autoUpdateCron,
|
autoUpdateCron: row.autoUpdateCron,
|
||||||
webhookEnabled: row.webhookEnabled,
|
webhookEnabled: row.webhookEnabled,
|
||||||
webhookSecret: row.webhookSecret,
|
webhookSecret: row.webhookSecret,
|
||||||
|
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||||
|
repullImages: row.repullImages ?? false,
|
||||||
|
forceRedeploy: row.forceRedeploy ?? false,
|
||||||
lastSync: row.lastSync,
|
lastSync: row.lastSync,
|
||||||
lastCommit: row.lastCommit,
|
lastCommit: row.lastCommit,
|
||||||
syncStatus: row.syncStatus,
|
syncStatus: row.syncStatus,
|
||||||
@@ -2525,6 +2570,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
|
|||||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||||
webhookEnabled: gitStacks.webhookEnabled,
|
webhookEnabled: gitStacks.webhookEnabled,
|
||||||
webhookSecret: gitStacks.webhookSecret,
|
webhookSecret: gitStacks.webhookSecret,
|
||||||
|
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||||
|
repullImages: gitStacks.repullImages,
|
||||||
|
forceRedeploy: gitStacks.forceRedeploy,
|
||||||
lastSync: gitStacks.lastSync,
|
lastSync: gitStacks.lastSync,
|
||||||
lastCommit: gitStacks.lastCommit,
|
lastCommit: gitStacks.lastCommit,
|
||||||
syncStatus: gitStacks.syncStatus,
|
syncStatus: gitStacks.syncStatus,
|
||||||
@@ -2551,6 +2599,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
|
|||||||
autoUpdateCron: row.autoUpdateCron,
|
autoUpdateCron: row.autoUpdateCron,
|
||||||
webhookEnabled: row.webhookEnabled,
|
webhookEnabled: row.webhookEnabled,
|
||||||
webhookSecret: row.webhookSecret,
|
webhookSecret: row.webhookSecret,
|
||||||
|
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||||
|
repullImages: row.repullImages ?? false,
|
||||||
|
forceRedeploy: row.forceRedeploy ?? false,
|
||||||
lastSync: row.lastSync,
|
lastSync: row.lastSync,
|
||||||
lastCommit: row.lastCommit,
|
lastCommit: row.lastCommit,
|
||||||
syncStatus: row.syncStatus,
|
syncStatus: row.syncStatus,
|
||||||
|
|||||||
@@ -315,6 +315,9 @@ export const gitStacks = sqliteTable('git_stacks', {
|
|||||||
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
|
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
|
||||||
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
|
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
|
||||||
webhookSecret: text('webhook_secret'),
|
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'),
|
lastSync: text('last_sync'),
|
||||||
lastCommit: text('last_commit'),
|
lastCommit: text('last_commit'),
|
||||||
syncStatus: text('sync_status').default('pending'),
|
syncStatus: text('sync_status').default('pending'),
|
||||||
|
|||||||
@@ -318,6 +318,9 @@ export const gitStacks = pgTable('git_stacks', {
|
|||||||
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
|
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
|
||||||
webhookEnabled: boolean('webhook_enabled').default(false),
|
webhookEnabled: boolean('webhook_enabled').default(false),
|
||||||
webhookSecret: text('webhook_secret'),
|
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' }),
|
lastSync: timestamp('last_sync', { mode: 'string' }),
|
||||||
lastCommit: text('last_commit'),
|
lastCommit: text('last_commit'),
|
||||||
syncStatus: text('sync_status').default('pending'),
|
syncStatus: text('sync_status').default('pending'),
|
||||||
|
|||||||
+18
-4
@@ -153,6 +153,11 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
|||||||
SSH_AUTH_SOCK: ''
|
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
|
// Ensure current UID is resolvable for SSH/git operations
|
||||||
await ensurePasswdEntry(env);
|
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
|
// Check if there are changes - skip redeploy if no changes and not forced
|
||||||
// Note: For new stacks (first deploy), syncResult.updated will be true
|
// Note: For new stacks (first deploy), syncResult.updated will be true
|
||||||
if (!force && !syncResult.updated) {
|
// forceRedeploy setting overrides the skip logic for webhooks/scheduled syncs
|
||||||
console.log(`${logPrefix} No changes detected and force=false, skipping redeploy`);
|
const shouldDeploy = force || gitStack.forceRedeploy || syncResult.updated;
|
||||||
|
if (!shouldDeploy) {
|
||||||
|
console.log(`${logPrefix} No changes detected and force=false, forceRedeploy=false, skipping redeploy`);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
output: 'No changes detected, skipping redeploy',
|
output: 'No changes detected, skipping redeploy',
|
||||||
@@ -943,6 +950,9 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
|||||||
|
|
||||||
const forceRecreate = syncResult.updated;
|
const forceRecreate = syncResult.updated;
|
||||||
console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${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
|
// Deploy using unified function - handles both new and existing stacks
|
||||||
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
|
// 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
|
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
|
||||||
composeFileName: syncResult.composeFileName, // Use original compose filename from repo
|
composeFileName: syncResult.composeFileName, // Use original compose filename from repo
|
||||||
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
|
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} ----------------------------------------`);
|
console.log(`${logPrefix} ----------------------------------------`);
|
||||||
@@ -1214,7 +1226,9 @@ export async function deployGitStackWithProgress(
|
|||||||
envId: gitStack.environmentId,
|
envId: gitStack.environmentId,
|
||||||
sourceDir: composeDir, // Copy entire directory from git repo
|
sourceDir: composeDir, // Copy entire directory from git repo
|
||||||
composeFileName: basename(gitStack.composePath), // Use original compose filename from 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) {
|
if (result.success) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { getEnvironment, getEnvSetting, getSetting } from './db';
|
|||||||
import { sendEventNotification } from './notifications';
|
import { sendEventNotification } from './notifications';
|
||||||
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnNetworkMode } from './host-path';
|
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnNetworkMode } from './host-path';
|
||||||
import { resolve } from 'node: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';
|
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);
|
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,
|
runScheduleCleanupJob,
|
||||||
runEventCleanupJob,
|
runEventCleanupJob,
|
||||||
runVolumeHelperCleanupJob,
|
runVolumeHelperCleanupJob,
|
||||||
|
runScannerCacheCleanupJob,
|
||||||
SYSTEM_SCHEDULE_CLEANUP_ID,
|
SYSTEM_SCHEDULE_CLEANUP_ID,
|
||||||
SYSTEM_EVENT_CLEANUP_ID,
|
SYSTEM_EVENT_CLEANUP_ID,
|
||||||
SYSTEM_VOLUME_HELPER_CLEANUP_ID
|
SYSTEM_VOLUME_HELPER_CLEANUP_ID,
|
||||||
|
SYSTEM_SCANNER_CLEANUP_ID
|
||||||
} from './tasks/system-cleanup';
|
} from './tasks/system-cleanup';
|
||||||
|
|
||||||
// Store all active cron jobs
|
// Store all active cron jobs
|
||||||
@@ -57,6 +59,7 @@ const activeJobs: Map<string, Cron> = new Map();
|
|||||||
let cleanupJob: Cron | null = null;
|
let cleanupJob: Cron | null = null;
|
||||||
let eventCleanupJob: Cron | null = null;
|
let eventCleanupJob: Cron | null = null;
|
||||||
let volumeHelperCleanupJob: Cron | null = null;
|
let volumeHelperCleanupJob: Cron | null = null;
|
||||||
|
let scannerCacheCleanupJob: Cron | null = null;
|
||||||
|
|
||||||
// Scheduler state
|
// Scheduler state
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
@@ -131,10 +134,35 @@ export async function startScheduler(): Promise<void> {
|
|||||||
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
|
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 schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
|
||||||
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
|
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
|
||||||
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${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
|
// Register all dynamic schedules from database
|
||||||
await refreshAllSchedules();
|
await refreshAllSchedules();
|
||||||
@@ -164,6 +192,10 @@ export function stopScheduler(): void {
|
|||||||
volumeHelperCleanupJob.stop();
|
volumeHelperCleanupJob.stop();
|
||||||
volumeHelperCleanupJob = null;
|
volumeHelperCleanupJob = null;
|
||||||
}
|
}
|
||||||
|
if (scannerCacheCleanupJob) {
|
||||||
|
scannerCacheCleanupJob.stop();
|
||||||
|
scannerCacheCleanupJob = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Stop all dynamic jobs
|
// Stop all dynamic jobs
|
||||||
for (const [key, job] of activeJobs.entries()) {
|
for (const [key, job] of activeJobs.entries()) {
|
||||||
@@ -487,6 +519,9 @@ export async function refreshSystemJobs(): Promise<void> {
|
|||||||
if (volumeHelperCleanupJob) {
|
if (volumeHelperCleanupJob) {
|
||||||
volumeHelperCleanupJob.stop();
|
volumeHelperCleanupJob.stop();
|
||||||
}
|
}
|
||||||
|
if (scannerCacheCleanupJob) {
|
||||||
|
scannerCacheCleanupJob.stop();
|
||||||
|
}
|
||||||
|
|
||||||
// Re-create with new timezone
|
// Re-create with new timezone
|
||||||
cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
|
cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
|
||||||
@@ -501,9 +536,18 @@ export async function refreshSystemJobs(): Promise<void> {
|
|||||||
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
|
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 schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
|
||||||
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
|
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
|
||||||
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${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
|
cleanupExpiredVolumeHelpers
|
||||||
});
|
});
|
||||||
return { success: true };
|
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 {
|
} else {
|
||||||
return { success: false, error: 'Unknown system job ID' };
|
return { success: false, error: 'Unknown system job ID' };
|
||||||
}
|
}
|
||||||
@@ -694,6 +745,16 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
|
|||||||
nextRun: getNextRun('*/30 * * * *')?.toISOString() ?? null,
|
nextRun: getNextRun('*/30 * * * *')?.toISOString() ?? null,
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: 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_SCHEDULE_CLEANUP_ID = 1;
|
||||||
export const SYSTEM_EVENT_CLEANUP_ID = 2;
|
export const SYSTEM_EVENT_CLEANUP_ID = 2;
|
||||||
export const SYSTEM_VOLUME_HELPER_CLEANUP_ID = 3;
|
export const SYSTEM_VOLUME_HELPER_CLEANUP_ID = 3;
|
||||||
|
export const SYSTEM_SCANNER_CLEANUP_ID = 4;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute schedule execution cleanup job.
|
* 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 { json } from '@sveltejs/kit';
|
||||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||||
|
import { prefersJSON } from '$lib/server/sse-parser';
|
||||||
|
|
||||||
/**
|
// Re-export pure parsing utilities (no server deps) for backward compat
|
||||||
* Check if the client prefers JSON over SSE.
|
export { prefersJSON, sseToJSON } from '$lib/server/sse-parser';
|
||||||
* 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' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job-based response for long-running operations.
|
* Job-based response for long-running operations.
|
||||||
|
|||||||
+70
-29
@@ -58,6 +58,8 @@ export interface StackOperationResult {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
output?: string;
|
output?: string;
|
||||||
error?: 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;
|
envId?: number | null;
|
||||||
sourceDir?: string; // Directory to copy all files from (for git stacks)
|
sourceDir?: string; // Directory to copy all files from (for git stacks)
|
||||||
forceRecreate?: boolean;
|
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)
|
composePath?: string; // Custom compose file path (for adopted/imported stacks)
|
||||||
envPath?: string; // Custom env 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
|
composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks
|
||||||
@@ -788,6 +792,8 @@ interface ComposeCommandOptions {
|
|||||||
stackName: string;
|
stackName: string;
|
||||||
envId?: number | null;
|
envId?: number | null;
|
||||||
forceRecreate?: boolean;
|
forceRecreate?: boolean;
|
||||||
|
build?: boolean; // Build images before starting (--build)
|
||||||
|
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
|
||||||
removeVolumes?: boolean;
|
removeVolumes?: boolean;
|
||||||
stackFiles?: Record<string, string>; // All files to send to Hawser
|
stackFiles?: Record<string, string>; // All files to send to Hawser
|
||||||
/** Working directory for compose execution (for imported stacks) */
|
/** Working directory for compose execution (for imported stacks) */
|
||||||
@@ -848,7 +854,9 @@ async function executeLocalCompose(
|
|||||||
customComposePath?: string,
|
customComposePath?: string,
|
||||||
customEnvPath?: string,
|
customEnvPath?: string,
|
||||||
useOverrideFile?: boolean,
|
useOverrideFile?: boolean,
|
||||||
serviceName?: string
|
serviceName?: string,
|
||||||
|
build?: boolean,
|
||||||
|
pullPolicy?: string
|
||||||
): Promise<StackOperationResult> {
|
): Promise<StackOperationResult> {
|
||||||
const logPrefix = `[Stack:${stackName}]`;
|
const logPrefix = `[Stack:${stackName}]`;
|
||||||
|
|
||||||
@@ -1040,6 +1048,8 @@ async function executeLocalCompose(
|
|||||||
case 'up':
|
case 'up':
|
||||||
args.push('up', '-d', '--remove-orphans');
|
args.push('up', '-d', '--remove-orphans');
|
||||||
if (forceRecreate) args.push('--force-recreate');
|
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 targeting a specific service, only update that service
|
||||||
if (serviceName) {
|
if (serviceName) {
|
||||||
args.push(serviceName);
|
args.push(serviceName);
|
||||||
@@ -1067,11 +1077,13 @@ async function executeLocalCompose(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commandStr = args.join(' ');
|
||||||
|
|
||||||
console.log(`${logPrefix} ----------------------------------------`);
|
console.log(`${logPrefix} ----------------------------------------`);
|
||||||
console.log(`${logPrefix} EXECUTE LOCAL COMPOSE`);
|
console.log(`${logPrefix} EXECUTE LOCAL COMPOSE`);
|
||||||
console.log(`${logPrefix} ----------------------------------------`);
|
console.log(`${logPrefix} ----------------------------------------`);
|
||||||
console.log(`${logPrefix} Operation:`, operation);
|
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} Working directory:`, stackDir);
|
||||||
console.log(`${logPrefix} Compose file:`, composeFile);
|
console.log(`${logPrefix} Compose file:`, composeFile);
|
||||||
console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)');
|
console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)');
|
||||||
@@ -1141,20 +1153,23 @@ async function executeLocalCompose(
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
output: stdout,
|
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) {
|
if (code === 0) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`
|
output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`,
|
||||||
|
command: commandStr
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
output: stdout,
|
output: stdout,
|
||||||
error: stderr || `docker compose ${operation} exited with code ${code}`
|
error: stderr || `docker compose ${operation} exited with code ${code}`,
|
||||||
|
command: commandStr
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1165,7 +1180,8 @@ async function executeLocalCompose(
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
output: '',
|
output: '',
|
||||||
error: `Failed to run docker compose ${operation}: ${err.message}`
|
error: `Failed to run docker compose ${operation}: ${err.message}`,
|
||||||
|
command: commandStr
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
// Cleanup temp override file from host path translation
|
// Cleanup temp override file from host path translation
|
||||||
@@ -1207,7 +1223,9 @@ async function executeComposeViaHawser(
|
|||||||
removeVolumes?: boolean,
|
removeVolumes?: boolean,
|
||||||
stackFiles?: Record<string, string>,
|
stackFiles?: Record<string, string>,
|
||||||
serviceName?: string,
|
serviceName?: string,
|
||||||
composeFileName?: string
|
composeFileName?: string,
|
||||||
|
build?: boolean,
|
||||||
|
pullPolicy?: string
|
||||||
): Promise<StackOperationResult> {
|
): Promise<StackOperationResult> {
|
||||||
const logPrefix = `[Stack:${stackName}]`;
|
const logPrefix = `[Stack:${stackName}]`;
|
||||||
// Import dockerFetch dynamically to avoid circular dependency
|
// Import dockerFetch dynamically to avoid circular dependency
|
||||||
@@ -1280,6 +1298,8 @@ async function executeComposeViaHawser(
|
|||||||
files, // Files including .env (secrets NOT in .env file)
|
files, // Files including .env (secrets NOT in .env file)
|
||||||
forceRecreate: forceRecreate || false,
|
forceRecreate: forceRecreate || false,
|
||||||
removeVolumes: removeVolumes || false,
|
removeVolumes: removeVolumes || false,
|
||||||
|
build: build || false,
|
||||||
|
pullPolicy: pullPolicy || '',
|
||||||
registries, // Registry credentials for docker login
|
registries, // Registry credentials for docker login
|
||||||
serviceName // Target specific service only (with --no-deps)
|
serviceName // Target specific service only (with --no-deps)
|
||||||
});
|
});
|
||||||
@@ -1347,7 +1367,7 @@ async function executeComposeCommand(
|
|||||||
envVars?: Record<string, string>,
|
envVars?: Record<string, string>,
|
||||||
secretVars?: Record<string, string>
|
secretVars?: Record<string, string>
|
||||||
): Promise<StackOperationResult> {
|
): 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
|
// Get environment configuration
|
||||||
const env = envId ? await getEnvironment(envId) : null;
|
const env = envId ? await getEnvironment(envId) : null;
|
||||||
@@ -1369,7 +1389,9 @@ async function executeComposeCommand(
|
|||||||
composePath,
|
composePath,
|
||||||
envPath,
|
envPath,
|
||||||
useOverrideFile,
|
useOverrideFile,
|
||||||
serviceName
|
serviceName,
|
||||||
|
build,
|
||||||
|
pullPolicy
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1431,7 +1453,9 @@ async function executeComposeCommand(
|
|||||||
removeVolumes,
|
removeVolumes,
|
||||||
hawserStackFiles,
|
hawserStackFiles,
|
||||||
serviceName,
|
serviceName,
|
||||||
composeFileName
|
composeFileName,
|
||||||
|
build,
|
||||||
|
pullPolicy
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1462,7 +1486,9 @@ async function executeComposeCommand(
|
|||||||
composePath,
|
composePath,
|
||||||
envPath,
|
envPath,
|
||||||
useOverrideFile,
|
useOverrideFile,
|
||||||
serviceName
|
serviceName,
|
||||||
|
build,
|
||||||
|
pullPolicy
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1483,7 +1509,9 @@ async function executeComposeCommand(
|
|||||||
composePath,
|
composePath,
|
||||||
envPath,
|
envPath,
|
||||||
useOverrideFile,
|
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)
|
* - envPath: Path to the .env file (Docker Compose reads non-secrets from it)
|
||||||
* - needsFileLocation: true if stack needs user to specify file paths
|
* - needsFileLocation: true if stack needs user to specify file paths
|
||||||
*/
|
*/
|
||||||
async function requireComposeFile(
|
export async function requireComposeFile(
|
||||||
stackName: string,
|
stackName: string,
|
||||||
envId?: number | null,
|
envId?: number | null,
|
||||||
composeConfigPath?: string
|
composeConfigPath?: string
|
||||||
@@ -1877,12 +1905,19 @@ export async function stopStack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restart a stack using docker compose restart
|
* Restart a stack using docker compose restart or stop+up (recreate mode).
|
||||||
* Falls back to individual container restart for stacks without compose files
|
*
|
||||||
|
* 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(
|
export async function restartStack(
|
||||||
stackName: string,
|
stackName: string,
|
||||||
envId?: number | null
|
envId?: number | null,
|
||||||
|
mode: 'restart' | 'recreate' = 'restart'
|
||||||
): Promise<StackOperationResult> {
|
): Promise<StackOperationResult> {
|
||||||
const result = await requireComposeFile(stackName, envId);
|
const result = await requireComposeFile(stackName, envId);
|
||||||
|
|
||||||
@@ -1891,13 +1926,17 @@ export async function restartStack(
|
|||||||
return withContainerFallback(stackName, envId, 'restart');
|
return withContainerFallback(stackName, envId, 'restart');
|
||||||
}
|
}
|
||||||
|
|
||||||
const composeResult = await executeComposeCommand(
|
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath };
|
||||||
'restart',
|
|
||||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
let composeResult: StackOperationResult;
|
||||||
result.content!,
|
|
||||||
result.nonSecretVars,
|
if (mode === 'recreate') {
|
||||||
result.secretVars
|
// 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
|
// Restart any dynamically-spawned child containers not in the compose file
|
||||||
await cleanupOrphanStackContainers(stackName, envId, 'restart');
|
await cleanupOrphanStackContainers(stackName, envId, 'restart');
|
||||||
@@ -2133,7 +2172,7 @@ export async function removeStack(
|
|||||||
* Uses stack locking to prevent concurrent deployments.
|
* Uses stack locking to prevent concurrent deployments.
|
||||||
*/
|
*/
|
||||||
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
|
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}]`;
|
const logPrefix = `[Stack:${name}]`;
|
||||||
|
|
||||||
console.log(`${logPrefix} ========================================`);
|
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} Read ${Object.keys(stackFiles).length} files from source directory`);
|
||||||
console.log(`${logPrefix} Files:`, Object.keys(stackFiles).join(', '));
|
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...`);
|
console.log(`${logPrefix} Copying source directory to stack directory...`);
|
||||||
if (existsSync(workingDir)) {
|
mkdirSync(workingDir, { recursive: true });
|
||||||
rmSync(workingDir, { recursive: true, force: true });
|
cpSync(sourceDir, workingDir, { recursive: true, force: true });
|
||||||
}
|
|
||||||
cpSync(sourceDir, workingDir, { recursive: true });
|
|
||||||
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
|
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
|
||||||
} else {
|
} else {
|
||||||
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
|
// 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,
|
stackName: name,
|
||||||
envId,
|
envId,
|
||||||
forceRecreate,
|
forceRecreate,
|
||||||
|
build,
|
||||||
|
pullPolicy,
|
||||||
stackFiles,
|
stackFiles,
|
||||||
workingDir,
|
workingDir,
|
||||||
composePath: actualComposePath,
|
composePath: actualComposePath,
|
||||||
|
|||||||
@@ -19,6 +19,83 @@ export const USER_OPTIONS = [
|
|||||||
{ value: '', label: 'Container default' }
|
{ 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 {
|
export interface ShellDetectionResult {
|
||||||
shells: string[];
|
shells: string[];
|
||||||
defaultShell: string | null;
|
defaultShell: string | null;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
||||||
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
|
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 { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||||
|
|
||||||
@@ -156,8 +156,9 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
const imageName = config.Image;
|
const imageName = config.Image;
|
||||||
const currentImageId = inspectData.Image;
|
const currentImageId = inspectData.Image;
|
||||||
|
|
||||||
// Skip Dockhand container - cannot update itself
|
// Skip system containers (Dockhand, Hawser)
|
||||||
if (isDockhandContainer(imageName)) {
|
const systemType = isSystemContainer(imageName);
|
||||||
|
if (systemType) {
|
||||||
sendData({
|
sendData({
|
||||||
type: 'progress',
|
type: 'progress',
|
||||||
containerId,
|
containerId,
|
||||||
@@ -166,7 +167,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
current: i + 1,
|
current: i + 1,
|
||||||
total: containerIds.length,
|
total: containerIds.length,
|
||||||
success: true,
|
success: true,
|
||||||
message: `Skipping ${containerName} - cannot update Dockhand itself`
|
message: `Skipping ${containerName} - cannot update ${systemType} container`
|
||||||
});
|
});
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
continue;
|
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
|
const vulnerabilities = scanResults
|
||||||
.flatMap(r => r.vulnerabilities || [])
|
.flatMap(r => r.vulnerabilities || [])
|
||||||
|
.sort((a, b) => (severityOrder[a.severity] ?? 9) - (severityOrder[b.severity] ?? 9))
|
||||||
.slice(0, 100)
|
.slice(0, 100)
|
||||||
.map(v => ({
|
.map(v => ({
|
||||||
id: v.id,
|
id: v.id,
|
||||||
@@ -345,11 +348,11 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
scanner: v.scanner
|
scanner: v.scanner
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Build scan message from individual results
|
// Derive combined totals from the displayed (sliced) array so summary matches the table
|
||||||
const totalCritical = individualScannerResults.reduce((s, r) => s + r.critical, 0);
|
const totalCritical = vulnerabilities.filter(v => v.severity === 'critical').length;
|
||||||
const totalHigh = individualScannerResults.reduce((s, r) => s + r.high, 0);
|
const totalHigh = vulnerabilities.filter(v => v.severity === 'high').length;
|
||||||
const totalMedium = individualScannerResults.reduce((s, r) => s + r.medium, 0);
|
const totalMedium = vulnerabilities.filter(v => v.severity === 'medium').length;
|
||||||
const totalLow = individualScannerResults.reduce((s, r) => s + r.low, 0);
|
const totalLow = vulnerabilities.filter(v => v.severity === 'low').length;
|
||||||
const hasVulns = totalCritical + totalHigh + totalMedium + totalLow > 0;
|
const hasVulns = totalCritical + totalHigh + totalMedium + totalLow > 0;
|
||||||
|
|
||||||
sendData({
|
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()));
|
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
|
// Save containers with updates to the database for persistence
|
||||||
if (envIdNum) {
|
if (envIdNum) {
|
||||||
|
|||||||
@@ -119,7 +119,10 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
|
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
|
||||||
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
|
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
|
||||||
webhookEnabled: data.webhookEnabled || false,
|
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
|
// 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,
|
autoUpdateSchedule: data.autoUpdateSchedule,
|
||||||
autoUpdateCron: data.autoUpdateCron,
|
autoUpdateCron: data.autoUpdateCron,
|
||||||
webhookEnabled: data.webhookEnabled,
|
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
|
// If stack name changed, update related records
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ function isDockerHub(url: string): boolean {
|
|||||||
lower.includes('registry.hub.docker.com');
|
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 }) => {
|
export const DELETE: RequestHandler = async ({ url }) => {
|
||||||
try {
|
try {
|
||||||
const registryId = url.searchParams.get('registry');
|
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`);
|
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 = {
|
// Step 1: Resolve manifest digest. Try each type individually because
|
||||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
// the registry may only serve certain types, and DELETE requires the
|
||||||
};
|
// Accept header to match the stored manifest type.
|
||||||
|
|
||||||
if (authHeader) {
|
|
||||||
headers['Authorization'] = authHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Get the manifest digest
|
|
||||||
const manifestUrl = `${baseUrl}/v2/${imageName}/manifests/${tag}`;
|
const manifestUrl = `${baseUrl}/v2/${imageName}/manifests/${tag}`;
|
||||||
const headResponse = await fetch(manifestUrl, {
|
let digest: string | null = null;
|
||||||
method: 'HEAD',
|
let matchedType: string | null = null;
|
||||||
headers
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
if (headResponse.status === 401) {
|
||||||
return json({ error: 'Authentication failed' }, { 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 || !matchedType) {
|
||||||
if (!digest) {
|
return json({ error: 'Image or tag not found' }, { status: 404 });
|
||||||
return json({ error: 'Could not get image digest. Registry may not support deletion.' }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 deleteUrl = `${baseUrl}/v2/${imageName}/manifests/${digest}`;
|
||||||
const deleteResponse = await fetch(deleteUrl, {
|
const deleteResponse = await fetch(deleteUrl, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers
|
headers: deleteHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!deleteResponse.ok) {
|
if (!deleteResponse.ok) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
checkScannerAvailability,
|
checkScannerAvailability,
|
||||||
getScannerVersions,
|
getScannerVersions,
|
||||||
checkScannerUpdates,
|
checkScannerUpdates,
|
||||||
cleanupScannerVolumes,
|
cleanupScannerCache,
|
||||||
getGlobalScannerDefaults,
|
getGlobalScannerDefaults,
|
||||||
type ScannerType
|
type ScannerType
|
||||||
} from '$lib/server/scanner';
|
} from '$lib/server/scanner';
|
||||||
@@ -195,8 +195,8 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also cleanup scanner database volumes
|
// Also cleanup scanner database cache (volumes + bind mount dirs)
|
||||||
await cleanupScannerVolumes(parsedEnvId);
|
await cleanupScannerCache(parsedEnvId);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
success: true,
|
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 });
|
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mode = url.searchParams.get('mode') === 'recreate' ? 'recreate' : 'restart';
|
||||||
|
|
||||||
return createJobResponse(async (send) => {
|
return createJobResponse(async (send) => {
|
||||||
try {
|
try {
|
||||||
const stackName = decodeURIComponent(params.name);
|
const stackName = decodeURIComponent(params.name);
|
||||||
const result = await restartStack(stackName, envIdNum);
|
const result = await restartStack(stackName, envIdNum, mode);
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await auditStack(event, 'restart', stackName, envIdNum);
|
await auditStack(event, 'restart', stackName, envIdNum);
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
import { watchJob } from '$lib/utils/sse-fetch';
|
import { watchJob } from '$lib/utils/sse-fetch';
|
||||||
import { ipToNumber } from '$lib/utils/ip';
|
import { ipToNumber } from '$lib/utils/ip';
|
||||||
import { formatHostPortUrl } from '$lib/utils/url';
|
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 { DataGrid } from '$lib/components/data-grid';
|
||||||
import type { ColumnConfig } from '$lib/types';
|
import type { ColumnConfig } from '$lib/types';
|
||||||
import type { DataGridRowState } from '$lib/components/data-grid/types';
|
import type { DataGridRowState } from '$lib/components/data-grid/types';
|
||||||
@@ -243,6 +243,8 @@
|
|||||||
let terminalPopoverStates = $state<Record<string, boolean>>({});
|
let terminalPopoverStates = $state<Record<string, boolean>>({});
|
||||||
let terminalShell = $state('/bin/bash');
|
let terminalShell = $state('/bin/bash');
|
||||||
let terminalUser = $state('root');
|
let terminalUser = $state('root');
|
||||||
|
let terminalCustomUser = $state('');
|
||||||
|
let terminalCustomUsers = $state<string[]>([]);
|
||||||
|
|
||||||
// Confirmation popover state
|
// Confirmation popover state
|
||||||
let confirmStopId = $state<string | null>(null);
|
let confirmStopId = $state<string | null>(null);
|
||||||
@@ -470,7 +472,7 @@
|
|||||||
// Unlock button width
|
// Unlock button width
|
||||||
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
|
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);
|
const failed = data.results.filter((r: any) => r.error && !r.hasUpdate);
|
||||||
failedUpdateChecks = failed.map((r: any) => ({
|
failedUpdateChecks = failed.map((r: any) => ({
|
||||||
containerName: r.containerName,
|
containerName: r.containerName,
|
||||||
@@ -1041,13 +1043,18 @@
|
|||||||
currentTerminalContainerId = container.id;
|
currentTerminalContainerId = container.id;
|
||||||
terminalPopoverStates[container.id] = false;
|
terminalPopoverStates[container.id] = false;
|
||||||
} else {
|
} else {
|
||||||
|
// Restore saved user for this container
|
||||||
|
const savedUser = getSavedUser(container.id);
|
||||||
|
terminalUser = savedUser ?? 'root';
|
||||||
|
terminalCustomUsers = getCustomUsers();
|
||||||
// Show popover to configure new terminal
|
// Show popover to configure new terminal
|
||||||
terminalPopoverStates[container.id] = true;
|
terminalPopoverStates[container.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTerminal(container: ContainerInfo) {
|
function startTerminal(container: ContainerInfo) {
|
||||||
// Create new terminal session
|
saveUserForContainer(container.id, terminalUser);
|
||||||
|
terminalCustomUsers = getCustomUsers();
|
||||||
const terminal: ActiveTerminal = {
|
const terminal: ActiveTerminal = {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
containerName: container.name,
|
containerName: container.name,
|
||||||
@@ -1336,6 +1343,7 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
loadLayoutMode();
|
loadLayoutMode();
|
||||||
loadStatusFilter();
|
loadStatusFilter();
|
||||||
|
terminalCustomUsers = getCustomUsers();
|
||||||
|
|
||||||
// Load persisted pending updates from database
|
// Load persisted pending updates from database
|
||||||
loadPendingUpdates();
|
loadPendingUpdates();
|
||||||
@@ -2138,7 +2146,7 @@
|
|||||||
<Select.Root type="single" bind:value={terminalUser}>
|
<Select.Root type="single" bind:value={terminalUser}>
|
||||||
<Select.Trigger class="w-full h-8 text-xs">
|
<Select.Trigger class="w-full h-8 text-xs">
|
||||||
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
<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.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each userOptions as option}
|
{#each userOptions as option}
|
||||||
@@ -2147,6 +2155,35 @@
|
|||||||
{option.label}
|
{option.label}
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
{/each}
|
{/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.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -189,7 +189,7 @@
|
|||||||
{#each oidcProviders as provider}
|
{#each oidcProviders as provider}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-full justify-start gap-3"
|
class="w-full justify-center gap-3"
|
||||||
onclick={() => handleSsoLogin(provider)}
|
onclick={() => handleSsoLogin(provider)}
|
||||||
disabled={ssoLoading !== null}
|
disabled={ssoLoading !== null}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -499,7 +499,7 @@
|
|||||||
|
|
||||||
// Hostname pattern (allows letters, numbers, hyphens, dots)
|
// Hostname pattern (allows letters, numbers, hyphens, dots)
|
||||||
// Must start with letter/number, can contain dots for subdomains
|
// 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);
|
return hostnamePattern.test(host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,30 @@
|
|||||||
let eventPollInterval = $derived($appSettings.eventPollInterval);
|
let eventPollInterval = $derived($appSettings.eventPollInterval);
|
||||||
let metricsCollectionInterval = $derived($appSettings.metricsCollectionInterval);
|
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 }[] = [
|
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' },
|
||||||
{ 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>
|
<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>
|
||||||
|
<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.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-2 pt-2 flex-wrap">
|
<div class="flex items-center gap-2 pt-2 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
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 { 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';
|
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
import GitStackModal from './GitStackModal.svelte';
|
import GitStackModal from './GitStackModal.svelte';
|
||||||
import ImportStackModal from './ImportStackModal.svelte';
|
import ImportStackModal from './ImportStackModal.svelte';
|
||||||
import GitDeployProgressPopover from './GitDeployProgressPopover.svelte';
|
import GitDeployProgressPopover from './GitDeployProgressPopover.svelte';
|
||||||
|
import RedeployPopover from './RedeployPopover.svelte';
|
||||||
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
|
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
|
||||||
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
|
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
|
||||||
import LogsPanel from '../logs/LogsPanel.svelte';
|
import LogsPanel from '../logs/LogsPanel.svelte';
|
||||||
@@ -419,6 +421,7 @@
|
|||||||
|
|
||||||
// Stack operation loading state
|
// Stack operation loading state
|
||||||
let stackActionLoading = $state<string | null>(null);
|
let stackActionLoading = $state<string | null>(null);
|
||||||
|
let restartPopoverOpen = $state<Record<string, boolean>>({});
|
||||||
let stackDownLoading = $state<string | null>(null);
|
let stackDownLoading = $state<string | null>(null);
|
||||||
|
|
||||||
// Container-level confirmation popover state
|
// 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;
|
operationError = null;
|
||||||
stackActionLoading = name;
|
stackActionLoading = name;
|
||||||
try {
|
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);
|
const data = await readJobResponse(response);
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
showErrorDialog(`Failed to restart ${name}`, data.error || 'Failed to restart stack');
|
showErrorDialog(`Failed to restart ${name}`, data.error || 'Failed to restart stack');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`Restarted ${name}`);
|
toast.success(mode === 'recreate' ? `Recreated ${name}` : `Restarted ${name}`);
|
||||||
await fetchStacks();
|
await fetchStacks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to restart stack:', 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) {
|
async function downStack(name: string) {
|
||||||
operationError = null;
|
operationError = null;
|
||||||
stackActionLoading = name;
|
stackActionLoading = name;
|
||||||
@@ -1749,25 +1781,57 @@
|
|||||||
<ScrollText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
|
<ScrollText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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}
|
{#if stackActionLoading === stack.name}
|
||||||
<div class="p-1">
|
<div class="p-1">
|
||||||
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
|
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'}
|
{:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'}
|
||||||
{#if $canAccess('stacks', 'restart')}
|
{#if $canAccess('stacks', 'restart')}
|
||||||
<ConfirmPopover
|
<Popover.Root open={restartPopoverOpen[stack.name] ?? false} onOpenChange={(v) => restartPopoverOpen[stack.name] = v}>
|
||||||
open={false}
|
<Popover.Trigger asChild>
|
||||||
action="Restart"
|
{#snippet child({ props })}
|
||||||
itemType="stack"
|
<button
|
||||||
itemName={stack.name}
|
type="button"
|
||||||
title="Restart"
|
title="Restart"
|
||||||
onConfirm={() => restartStack(stack.name)}
|
{...props}
|
||||||
onOpenChange={() => {}}
|
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"
|
||||||
{#snippet children({ open })}
|
>
|
||||||
<RotateCcw class="w-3 h-3 {open ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500'}" />
|
<RotateCcw class="w-3 h-3 {restartPopoverOpen[stack.name] ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500'}" />
|
||||||
{/snippet}
|
</button>
|
||||||
</ConfirmPopover>
|
{/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}
|
||||||
{#if $canAccess('stacks', 'stop')}
|
{#if $canAccess('stacks', 'stop')}
|
||||||
<ConfirmPopover
|
<ConfirmPopover
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
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 * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||||
@@ -57,6 +57,9 @@
|
|||||||
autoUpdateCron: string;
|
autoUpdateCron: string;
|
||||||
webhookEnabled: boolean;
|
webhookEnabled: boolean;
|
||||||
webhookSecret: string | null;
|
webhookSecret: string | null;
|
||||||
|
buildOnDeploy: boolean;
|
||||||
|
repullImages: boolean;
|
||||||
|
forceRedeploy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -87,6 +90,9 @@
|
|||||||
let formAutoUpdateCron = $state('0 3 * * *');
|
let formAutoUpdateCron = $state('0 3 * * *');
|
||||||
let formWebhookEnabled = $state(false);
|
let formWebhookEnabled = $state(false);
|
||||||
let formWebhookSecret = $state('');
|
let formWebhookSecret = $state('');
|
||||||
|
let formBuildOnDeploy = $state(false);
|
||||||
|
let formRepullImages = $state(false);
|
||||||
|
let formForceRedeploy = $state(false);
|
||||||
let formDeployNow = $state(false);
|
let formDeployNow = $state(false);
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
let formSaving = $state(false);
|
let formSaving = $state(false);
|
||||||
@@ -357,6 +363,9 @@
|
|||||||
formAutoUpdateCron = gitStack.autoUpdateCron || '0 3 * * *';
|
formAutoUpdateCron = gitStack.autoUpdateCron || '0 3 * * *';
|
||||||
formWebhookEnabled = gitStack.webhookEnabled;
|
formWebhookEnabled = gitStack.webhookEnabled;
|
||||||
formWebhookSecret = gitStack.webhookSecret || '';
|
formWebhookSecret = gitStack.webhookSecret || '';
|
||||||
|
formBuildOnDeploy = gitStack.buildOnDeploy ?? false;
|
||||||
|
formRepullImages = gitStack.repullImages ?? false;
|
||||||
|
formForceRedeploy = gitStack.forceRedeploy ?? false;
|
||||||
formDeployNow = false;
|
formDeployNow = false;
|
||||||
|
|
||||||
// Load env files and overrides SYNCHRONOUSLY to avoid race conditions
|
// Load env files and overrides SYNCHRONOUSLY to avoid race conditions
|
||||||
@@ -381,6 +390,9 @@
|
|||||||
formAutoUpdateCron = '0 3 * * *';
|
formAutoUpdateCron = '0 3 * * *';
|
||||||
formWebhookEnabled = false;
|
formWebhookEnabled = false;
|
||||||
formWebhookSecret = '';
|
formWebhookSecret = '';
|
||||||
|
formBuildOnDeploy = false;
|
||||||
|
formRepullImages = false;
|
||||||
|
formForceRedeploy = false;
|
||||||
formDeployNow = false;
|
formDeployNow = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,6 +449,9 @@
|
|||||||
autoUpdateCron: formAutoUpdateCron,
|
autoUpdateCron: formAutoUpdateCron,
|
||||||
webhookEnabled: formWebhookEnabled,
|
webhookEnabled: formWebhookEnabled,
|
||||||
webhookSecret: formWebhookEnabled ? formWebhookSecret : null,
|
webhookSecret: formWebhookEnabled ? formWebhookSecret : null,
|
||||||
|
buildOnDeploy: formBuildOnDeploy,
|
||||||
|
repullImages: formRepullImages,
|
||||||
|
forceRedeploy: formForceRedeploy,
|
||||||
deployNow: deployAfterSave,
|
deployNow: deployAfterSave,
|
||||||
envVars: overrideVars.map(v => ({
|
envVars: overrideVars.map(v => ({
|
||||||
key: v.key.trim(),
|
key: v.key.trim(),
|
||||||
@@ -880,6 +895,41 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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) -->
|
<!-- Deploy now option (only for new stacks) -->
|
||||||
{#if !gitStack}
|
{#if !gitStack}
|
||||||
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
|
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
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 FilesystemBrowser, { type FileEntry } from './FilesystemBrowser.svelte';
|
||||||
import CodeEditor from '$lib/components/CodeEditor.svelte';
|
import CodeEditor from '$lib/components/CodeEditor.svelte';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
@@ -56,6 +56,11 @@
|
|||||||
// Look up the icon from the environments list since currentEnvironment doesn't store it
|
// Look up the icon from the environments list since currentEnvironment doesn't store it
|
||||||
const currentEnvData = $derived($environments.find(e => e.id === envId));
|
const currentEnvData = $derived($environments.find(e => e.id === envId));
|
||||||
const envIcon = $derived(currentEnvData?.icon || 'globe');
|
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
|
// Reset when modal closes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -299,9 +304,16 @@
|
|||||||
|
|
||||||
// Browser title with environment info
|
// Browser title with environment info
|
||||||
const browserTitle = $derived.by(() => {
|
const browserTitle = $derived.by(() => {
|
||||||
const envPart = envName ? ` · ${envName}` : '';
|
const envPart = envName ? ` to ${envName}` : '';
|
||||||
return `Adopt stacks${envPart}`;
|
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>
|
</script>
|
||||||
|
|
||||||
{#if view === 'browse'}
|
{#if view === 'browse'}
|
||||||
@@ -311,7 +323,7 @@
|
|||||||
bind:open
|
bind:open
|
||||||
title={browserTitle}
|
title={browserTitle}
|
||||||
icon={Import}
|
icon={Import}
|
||||||
description="Browse to a compose file or scan a directory for stacks."
|
description={browserDescription}
|
||||||
selectMode="adopt"
|
selectMode="adopt"
|
||||||
highlightFilter={/\.ya?ml$/i}
|
highlightFilter={/\.ya?ml$/i}
|
||||||
onFilePreview={handleFilePreview}
|
onFilePreview={handleFilePreview}
|
||||||
@@ -327,8 +339,7 @@
|
|||||||
<Dialog.Header class="px-6 py-4 border-b shrink-0">
|
<Dialog.Header class="px-6 py-4 border-b shrink-0">
|
||||||
<Dialog.Title class="flex items-center gap-2">
|
<Dialog.Title class="flex items-center gap-2">
|
||||||
<Import class="w-5 h-5" />
|
<Import class="w-5 h-5" />
|
||||||
Select stacks to adopt
|
Select stacks to adopt to
|
||||||
<span class="text-muted-foreground">·</span>
|
|
||||||
<EnvironmentIcon icon={envIcon} envId={envId} class="w-4 h-4 text-muted-foreground" />
|
<EnvironmentIcon icon={envIcon} envId={envId} class="w-4 h-4 text-muted-foreground" />
|
||||||
<span class="text-muted-foreground font-normal">{envName}</span>
|
<span class="text-muted-foreground font-normal">{envName}</span>
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
@@ -381,7 +392,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Adopt info -->
|
<!-- 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">
|
<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" />
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Adopt info -->
|
<!-- 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">
|
<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" />
|
<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>
|
<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 { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
|
||||||
import Terminal from './Terminal.svelte';
|
import Terminal from './Terminal.svelte';
|
||||||
import { NoEnvironment } from '$lib/components/ui/empty-state';
|
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
|
// Track if we've handled the initial container from URL
|
||||||
let initialContainerHandled = $state(false);
|
let initialContainerHandled = $state(false);
|
||||||
@@ -35,6 +35,8 @@
|
|||||||
// Shell/user options
|
// Shell/user options
|
||||||
let selectedShell = $state('/bin/bash');
|
let selectedShell = $state('/bin/bash');
|
||||||
let selectedUser = $state('root');
|
let selectedUser = $state('root');
|
||||||
|
let customUserInput = $state('');
|
||||||
|
let customUsers = $state<string[]>([]);
|
||||||
let terminalFontSize = $state(14);
|
let terminalFontSize = $state(14);
|
||||||
|
|
||||||
// Track previous shell/user for reconnection
|
// Track previous shell/user for reconnection
|
||||||
@@ -115,6 +117,16 @@
|
|||||||
if (bestShell && bestShell !== selectedShell) {
|
if (bestShell && bestShell !== selectedShell) {
|
||||||
selectedShell = bestShell;
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to detect shells:', error);
|
console.error('Failed to detect shells:', error);
|
||||||
} finally {
|
} 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
|
// Watch for shell/user changes while connected and trigger reconnect
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedContainer && connected && terminalComponent) {
|
if (selectedContainer && connected && terminalComponent) {
|
||||||
if (selectedShell !== prevShell || selectedUser !== prevUser) {
|
if (selectedShell !== prevShell || committedUser !== prevUser) {
|
||||||
// Reconnect with new shell/user
|
|
||||||
terminalComponent.reconnect();
|
terminalComponent.reconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevShell = selectedShell;
|
prevShell = selectedShell;
|
||||||
prevUser = selectedUser;
|
prevUser = committedUser;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Change font size
|
// Change font size
|
||||||
@@ -175,6 +213,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
customUsers = getCustomUsers();
|
||||||
await fetchContainers();
|
await fetchContainers();
|
||||||
|
|
||||||
// Check for container ID in URL query parameter
|
// Check for container ID in URL query parameter
|
||||||
@@ -336,10 +375,10 @@
|
|||||||
<!-- User selector - always visible -->
|
<!-- User selector - always visible -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Label class="text-sm text-muted-foreground">User:</Label>
|
<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">
|
<Select.Trigger class="h-9 w-48">
|
||||||
<User class="w-4 h-4 mr-2 text-muted-foreground" />
|
<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.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each USER_OPTIONS as option}
|
{#each USER_OPTIONS as option}
|
||||||
@@ -348,6 +387,36 @@
|
|||||||
{option.label}
|
{option.label}
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
{/each}
|
{/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.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
@@ -428,13 +497,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-h-0 w-full">
|
<div class="flex-1 min-h-0 w-full">
|
||||||
{#key `${selectedContainer.id}-${selectedShell}-${selectedUser}`}
|
{#key `${selectedContainer.id}-${selectedShell}-${committedUser}`}
|
||||||
<Terminal
|
<Terminal
|
||||||
bind:this={terminalComponent}
|
bind:this={terminalComponent}
|
||||||
containerId={selectedContainer.id}
|
containerId={selectedContainer.id}
|
||||||
containerName={selectedContainer.name}
|
containerName={selectedContainer.name}
|
||||||
shell={selectedShell}
|
shell={selectedShell}
|
||||||
user={selectedUser}
|
user={committedUser}
|
||||||
{envId}
|
{envId}
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user