1.0.7
This commit is contained in:
+24
-3
@@ -7,8 +7,9 @@
|
||||
# - Reproducible builds from open-source Wolfi packages
|
||||
# - Minimal attack surface with only required packages
|
||||
#
|
||||
# Bun is copied from the official oven/bun image (app-builder stage) to ensure
|
||||
# compatibility with all x86_64 CPUs (including those without AVX2 like Celeron).
|
||||
# Bun is copied from the official oven/bun image (app-builder stage).
|
||||
# For CPUs without AVX support (Celeron, Atom, pre-Haswell), build with:
|
||||
# docker build --build-arg BUN_VARIANT=baseline -t dockhand:baseline .
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -75,10 +76,15 @@ RUN apko build apko.yaml dockhand-base:latest output.tar \
|
||||
# Alpine's musl libc causes rayon/tokio thread pool panics during svelte-adapter-bun build
|
||||
FROM oven/bun:1.3.5-debian AS app-builder
|
||||
|
||||
# Build argument for Bun variant (regular or baseline)
|
||||
# baseline is for CPUs without AVX support (Celeron, Atom, pre-Haswell)
|
||||
ARG BUN_VARIANT=regular
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends jq git && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends jq git curl unzip ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy package files and install ALL dependencies (needed for build)
|
||||
COPY package.json bun.lock* bunfig.toml ./
|
||||
@@ -95,6 +101,19 @@ RUN NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128" bun run b
|
||||
RUN rm -rf node_modules && bun install --production --frozen-lockfile \
|
||||
&& rm -rf node_modules/@types node_modules/bun-types
|
||||
|
||||
# Download baseline Bun binary if BUN_VARIANT=baseline (for CPUs without AVX)
|
||||
# Only applies to amd64 - ARM64 doesn't have AVX concept
|
||||
ARG BUN_VERSION=1.3.5
|
||||
RUN if [ "$BUN_VARIANT" = "baseline" ] && [ "$TARGETARCH" = "amd64" ]; then \
|
||||
echo "Downloading Bun baseline binary for CPUs without AVX support..." && \
|
||||
curl -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" -o /tmp/bun.zip && \
|
||||
unzip -o /tmp/bun.zip -d /tmp && \
|
||||
cp /tmp/bun-linux-x64-baseline/bun /usr/local/bin/bun && \
|
||||
chmod +x /usr/local/bin/bun && \
|
||||
rm -rf /tmp/bun.zip /tmp/bun-linux-x64-baseline && \
|
||||
echo "Bun baseline binary installed successfully"; \
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 3: Final Image (Scratch + Custom Wolfi OS)
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -105,6 +124,8 @@ COPY --from=os-builder /work/rootfs/ /
|
||||
|
||||
# Copy Bun from official image - ensures compatibility with all x86_64 CPUs (no AVX2 requirement)
|
||||
# Wolfi's bun package requires AVX2 which breaks on Celeron/Atom CPUs
|
||||
# For baseline builds (BUN_VARIANT=baseline), this contains the baseline binary (no AVX requirement)
|
||||
# For regular builds, this contains the standard oven/bun binary
|
||||
COPY --from=app-builder /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1766763867484,
|
||||
"tag": "0002_add_pending_container_updates",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1767687362730,
|
||||
"tag": "0003_add_stack_paths",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1766763860091,
|
||||
"tag": "0002_add_pending_container_updates",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1767689000000,
|
||||
"tag": "0003_add_stack_paths",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+38
-30
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite dev",
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "6.20.0",
|
||||
"@codemirror/commands": "6.10.0",
|
||||
"@codemirror/commands": "6.10.1",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-html": "6.4.11",
|
||||
"@codemirror/lang-javascript": "6.2.4",
|
||||
@@ -48,63 +48,71 @@
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-sql": "6.10.0",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/language": "6.11.3",
|
||||
"@codemirror/lang-yaml": "6.1.2",
|
||||
"@codemirror/language": "6.12.1",
|
||||
"@codemirror/search": "6.5.11",
|
||||
"@codemirror/state": "6.5.3",
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
"@codemirror/view": "6.39.9",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lucide/lab": "^0.1.2",
|
||||
"codemirror": "6.0.2",
|
||||
"croner": "9.1.0",
|
||||
"cronstrue": "3.9.0",
|
||||
"drizzle-orm": "0.45.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"hash-wasm": "4.12.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"ldapts": "^8.0.9",
|
||||
"nodemailer": "^7.0.11",
|
||||
"ldapts": "^8.1.3",
|
||||
"nodemailer": "^7.0.12",
|
||||
"otpauth": "^9.4.1",
|
||||
"postgres": "3.4.7",
|
||||
"postgres": "3.4.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"svelte-dnd-action": "0.9.68",
|
||||
"svelte-dnd-action": "0.9.69",
|
||||
"svelte-sonner": "1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.38.8",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@layerstack/tailwind": "^1.0.1",
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@lucide/svelte": "^0.562.0",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@sveltejs/kit": "^2.48.5",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/bun": "^1.2.5",
|
||||
"@sveltejs/kit": "^2.49.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.3",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"bits-ui": "^2.14.4",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"bits-ui": "^2.15.4",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"cytoscape": "^3.33.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.2.0",
|
||||
"drizzle-kit": "0.31.8",
|
||||
"layerchart": "^1.0.12",
|
||||
"lucide-svelte": "^0.555.0",
|
||||
"layerchart": "^1.0.13",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.43.8",
|
||||
"svelte": "^5.46.1",
|
||||
"svelte-adapter-bun": "1.0.1",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-check": "^4.3.5",
|
||||
"svelte-easy-crop": "^5.0.0",
|
||||
"svelte-virtual-scroll-list": "^1.3.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.2"
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.5.3",
|
||||
"@codemirror/view": "6.39.9",
|
||||
"@codemirror/language": "6.12.1",
|
||||
"@lezer/common": "1.5.0",
|
||||
"@lezer/highlight": "1.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -4,6 +4,7 @@ import { startScheduler } from '$lib/server/scheduler';
|
||||
import { isAuthEnabled, validateSession } from '$lib/server/auth';
|
||||
import { setServerStartTime } from '$lib/server/uptime';
|
||||
import { checkLicenseExpiry, getHostname } from '$lib/server/license';
|
||||
import { initCryptoFallback } from '$lib/server/crypto-fallback';
|
||||
import type { HandleServerError, Handle } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
@@ -20,6 +21,9 @@ let initialized = false;
|
||||
|
||||
if (!initialized) {
|
||||
try {
|
||||
// Initialize crypto fallback first (detects old kernels and logs status)
|
||||
initCryptoFallback();
|
||||
|
||||
setServerStartTime(); // Track when server started
|
||||
initDatabase();
|
||||
// Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside)
|
||||
@@ -68,7 +72,8 @@ const PUBLIC_PATHS = [
|
||||
'/api/auth/oidc',
|
||||
'/api/license',
|
||||
'/api/changelog',
|
||||
'/api/dependencies'
|
||||
'/api/dependencies',
|
||||
'/api/health'
|
||||
];
|
||||
|
||||
// Check if path is public
|
||||
|
||||
@@ -225,6 +225,9 @@
|
||||
// Mutable ref for callback - allows updating without recreating editor
|
||||
let onchangeRef: ((value: string) => void) | undefined = onchange;
|
||||
|
||||
// Flag to suppress onchange during programmatic value sync
|
||||
let isSyncingExternalValue = false;
|
||||
|
||||
// Keep callback ref updated when prop changes
|
||||
$effect(() => {
|
||||
onchangeRef = onchange;
|
||||
@@ -660,8 +663,9 @@
|
||||
view.update(trs);
|
||||
|
||||
// Check if any transaction changed the document
|
||||
// Skip onchange during programmatic value sync (only fire for user edits)
|
||||
const lastChangingTr = trs.findLast(tr => tr.docChanged);
|
||||
if (lastChangingTr && onchangeRef) {
|
||||
if (lastChangingTr && onchangeRef && !isSyncingExternalValue) {
|
||||
// Defer callback to next microtask to avoid blocking input handling
|
||||
// This allows key repeat to work properly
|
||||
const newContent = lastChangingTr.newDoc.toString();
|
||||
@@ -801,9 +805,12 @@
|
||||
// Only update if the external value differs from editor content
|
||||
// This prevents feedback loops from editor changes
|
||||
if (externalValue !== currentContent) {
|
||||
// Suppress onchange during programmatic sync - only user edits should trigger it
|
||||
isSyncingExternalValue = true;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: currentContent.length, insert: externalValue }
|
||||
});
|
||||
isSyncingExternalValue = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<div class="space-y-3">
|
||||
<!-- Variables List -->
|
||||
<div class="space-y-3">
|
||||
{#each variables as variable, index (index)}
|
||||
{#each variables as variable, index (`${index}-${variable.key}`)}
|
||||
{@const source = getSource(variable.key)}
|
||||
{@const isVarRequired = isRequired(variable.key)}
|
||||
{@const isVarOptional = isOptional(variable.key)}
|
||||
|
||||
@@ -46,38 +46,35 @@
|
||||
let confirmClearOpen = $state(false);
|
||||
let contentAreaRef: HTMLDivElement;
|
||||
let parseWarnings = $state<string[]>([]);
|
||||
let hasMergedOnLoad = $state(false);
|
||||
|
||||
// Count of secrets (for display in hint)
|
||||
const secretCount = $derived(variables.filter(v => v.isSecret && v.key.trim()).length);
|
||||
|
||||
/**
|
||||
* Merge variables and rawContent on initial load.
|
||||
* Called by parent after setting both variables and rawContent.
|
||||
* This ensures both are in sync regardless of which view mode is active.
|
||||
* Sync variables with rawContent after initial load.
|
||||
* Pass the loaded data directly to avoid timing issues with bindable props.
|
||||
* Merges: secrets from loadedVars (DB) + non-secrets from loadedRaw (file).
|
||||
*/
|
||||
export function mergeOnLoad() {
|
||||
if (hasMergedOnLoad) return;
|
||||
hasMergedOnLoad = true;
|
||||
|
||||
// If rawContent exists, parse it and merge with variables (which may have secrets from DB)
|
||||
if (rawContent.trim()) {
|
||||
const { vars: rawVars } = parseRawContent(rawContent);
|
||||
const rawVarsByKey = new Map(rawVars.map(v => [v.key, v]));
|
||||
|
||||
// Secrets come from variables (DB), non-secrets come from rawContent (file)
|
||||
// But if a var exists in variables but not in rawContent, keep it (could be new)
|
||||
const secrets = variables.filter(v => v.isSecret);
|
||||
const nonSecretsFromRaw = rawVars;
|
||||
|
||||
// Also keep non-secrets from variables that aren't in raw (new vars added before first save)
|
||||
const rawKeys = new Set(rawVars.map(v => v.key));
|
||||
const newNonSecrets = variables.filter(v => !v.isSecret && v.key.trim() && !rawKeys.has(v.key));
|
||||
|
||||
variables = [...nonSecretsFromRaw, ...newNonSecrets, ...secrets];
|
||||
export function syncAfterLoad(loadedVars: EnvVar[], loadedRaw: string) {
|
||||
if (!loadedRaw.trim()) {
|
||||
// No raw content - just use the loaded variables as-is
|
||||
variables = loadedVars;
|
||||
rawContent = '';
|
||||
return;
|
||||
}
|
||||
// If no rawContent, variables is already correct (from DB), just need to generate raw
|
||||
// for when user switches to text view (done in handleViewModeChange)
|
||||
|
||||
const { vars: rawVars } = parseRawContent(loadedRaw);
|
||||
|
||||
// Secrets come from loadedVars (DB), non-secrets come from loadedRaw (file)
|
||||
const secrets = loadedVars.filter(v => v.isSecret);
|
||||
|
||||
// Also keep non-secrets from loadedVars that aren't in raw (new vars added before first save)
|
||||
const rawKeys = new Set(rawVars.map(v => v.key));
|
||||
const newNonSecrets = loadedVars.filter(v => !v.isSecret && v.key.trim() && !rawKeys.has(v.key));
|
||||
|
||||
// Set both at once to avoid any intermediate states
|
||||
variables = [...rawVars, ...newNonSecrets, ...secrets];
|
||||
rawContent = loadedRaw;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-[350px] p-0" align="start">
|
||||
<Popover.Content class="w-[350px] p-0 z-[200]" align="start">
|
||||
<Command.Root shouldFilter={false}>
|
||||
<Command.Input bind:value={searchQuery} placeholder="Search timezone..." />
|
||||
<Command.List class="max-h-[300px]">
|
||||
|
||||
@@ -496,34 +496,32 @@
|
||||
});
|
||||
|
||||
// Row state cache to prevent creating new objects on every scroll
|
||||
// Use $derived to track dependencies synchronously (unlike $effect which is async)
|
||||
let rowStateCache = new WeakMap<object, DataGridRowState>();
|
||||
let rowStateCacheDataRef: T[] | null = null;
|
||||
let rowStateCacheExpandedRef: Set<unknown> | null = null;
|
||||
let rowStateCacheSelectedRef: Set<unknown> | null = null;
|
||||
|
||||
// Clear row state cache when data or selection/expansion state changes
|
||||
$effect(() => {
|
||||
if (data !== rowStateCacheDataRef ||
|
||||
expandedKeys !== rowStateCacheExpandedRef ||
|
||||
selectedKeys !== rowStateCacheSelectedRef) {
|
||||
rowStateCache = new WeakMap();
|
||||
rowStateCacheDataRef = data;
|
||||
rowStateCacheExpandedRef = expandedKeys;
|
||||
rowStateCacheSelectedRef = selectedKeys;
|
||||
}
|
||||
});
|
||||
// Track cache invalidation keys - when these change, cache is stale
|
||||
let cachedSelectedKeysRef: Set<unknown> | null = null;
|
||||
let cachedExpandedKeysRef: Set<unknown> | null = null;
|
||||
let cachedHighlightedKeyRef: unknown = undefined;
|
||||
|
||||
// Helper to get row state (memoized via WeakMap)
|
||||
// Cache is invalidated synchronously when selection/expansion changes
|
||||
function getRowState(item: T, index: number): DataGridRowState {
|
||||
const actualIndex = virtualScroll ? startIndex + index : index;
|
||||
|
||||
// Check if cache needs to be cleared (synchronous check)
|
||||
if (selectedKeys !== cachedSelectedKeysRef ||
|
||||
expandedKeys !== cachedExpandedKeysRef ||
|
||||
highlightedKey !== cachedHighlightedKeyRef) {
|
||||
rowStateCache = new WeakMap();
|
||||
cachedSelectedKeysRef = selectedKeys;
|
||||
cachedExpandedKeysRef = expandedKeys;
|
||||
cachedHighlightedKeyRef = highlightedKey;
|
||||
}
|
||||
|
||||
// Try to get cached state
|
||||
const cached = rowStateCache.get(item as object);
|
||||
if (cached && cached.index === actualIndex) {
|
||||
// Update mutable fields that may have changed
|
||||
cached.isSelected = isSelected(item[keyField]);
|
||||
cached.isHighlighted = highlightedKey === item[keyField];
|
||||
cached.isExpanded = isExpanded(item[keyField]);
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -761,7 +759,7 @@
|
||||
e.stopPropagation();
|
||||
toggleSelection(item[keyField]);
|
||||
}}
|
||||
class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
|
||||
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
|
||||
>
|
||||
{#if rowState.isSelected}
|
||||
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
@@ -870,7 +868,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); toggleSelection(item[keyField]); }}
|
||||
class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
|
||||
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
|
||||
>
|
||||
{#if rowState.isSelected}
|
||||
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<CurrentIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-3" align="start">
|
||||
<Popover.Content class="w-80 p-3 z-[200]" align="start">
|
||||
<div class="space-y-3">
|
||||
<Input
|
||||
bind:value={searchQuery}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<span class="text-xs">{placeholder}</span>
|
||||
{/if}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-auto p-0" align="start">
|
||||
<Popover.Content class="w-auto p-0 z-[200]" align="start">
|
||||
<Calendar
|
||||
type="single"
|
||||
value={dateValue}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
"bg-background fixed start-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
|
||||
"bg-background fixed start-[50%] top-[50%] z-[150] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
|
||||
!className?.includes('max-w-') && "sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[150] bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
data-slot="dropdown-menu-content"
|
||||
{sideOffset}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-[200] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-dropdown-menu-content-transform-origin) z-[200] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-popover-content-transform-origin) outline-hidden z-[200] w-72 rounded-md border p-4 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
{preventScroll}
|
||||
data-slot="select-content"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-[200] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{sideOffset}
|
||||
{side}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground border shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-[100] w-fit fixed text-balance rounded-md px-3 py-1.5 text-xs",
|
||||
"bg-popover text-popover-foreground border shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-[200] w-fit fixed text-balance rounded-md px-3 py-1.5 text-xs",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -37,7 +37,8 @@ export const imageTagColumns: ColumnConfig[] = [
|
||||
{ id: 'id', label: 'ID', width: 120, minWidth: 80 },
|
||||
{ id: 'size', label: 'Size', width: 80, minWidth: 60 },
|
||||
{ id: 'created', label: 'Created', width: 140, minWidth: 100 },
|
||||
{ id: 'actions', label: '', fixed: 'end', width: 100, resizable: false }
|
||||
{ id: 'used', label: 'Used by', width: 100, minWidth: 70 },
|
||||
{ id: 'actions', label: '', fixed: 'end', width: 200, resizable: false }
|
||||
];
|
||||
|
||||
// Network grid columns
|
||||
@@ -58,7 +59,8 @@ export const stackColumns: ColumnConfig[] = [
|
||||
{ id: 'expand', label: '', fixed: 'start', width: 24, resizable: false },
|
||||
{ id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 180, minWidth: 100, grow: true },
|
||||
{ id: 'status', label: 'Status', sortable: true, sortField: 'status', width: 120, minWidth: 90 },
|
||||
{ id: 'source', label: 'Source', width: 100, minWidth: 60 },
|
||||
{ id: 'source', label: 'Source', width: 100, minWidth: 100, noTruncate: true },
|
||||
{ id: 'location', label: 'Location', width: 180, minWidth: 100 },
|
||||
{ id: 'containers', label: 'Containers', sortable: true, sortField: 'containers', width: 100, minWidth: 70 },
|
||||
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 60, minWidth: 50, align: 'right' },
|
||||
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 70, minWidth: 50, align: 'right' },
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.7",
|
||||
"date": "2026-01-06",
|
||||
"comingSoon": false,
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "Adopt stacks created outside Dockhand" },
|
||||
{ "type": "feature", "text": "Activity event collection mode (Stream/Poll) and metrics interval settings for reduced CPU usage" },
|
||||
{ "type": "feature", "text": "Baseline Docker images for CPUs without AVX support" },
|
||||
{ "type": "feature", "text": "Show amber \"Unused\" badge for images not used by any container" },
|
||||
{ "type": "feature", "text": "Prune unused button to remove all unused images (not just dangling)" },
|
||||
{ "type": "fix", "text": "Stack collision on disk - stacks are now saved in environment folders" },
|
||||
{ "type": "fix", "text": "Checkbox selection delay in datagrid" },
|
||||
{ "type": "fix", "text": "Crypto fallback for old Linux kernels (<3.17) that lack getrandom() syscall" },
|
||||
{ "type": "fix", "text": "Dashboard performance with many environments" },
|
||||
{ "type": "fix", "text": "Can't use authenticated custom registry"},
|
||||
{ "type": "fix", "text": "mTLS connections failing due to Bun TLS caching bug"}
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.7"
|
||||
},
|
||||
{
|
||||
"version": "1.0.6",
|
||||
"date": "2026-01-03",
|
||||
|
||||
+44
-278
@@ -11,6 +11,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/commands"
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/commands",
|
||||
"version": "6.10.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/commands"
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/lang-css",
|
||||
"version": "6.3.1",
|
||||
@@ -59,9 +65,15 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/lang-xml"
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/lang-yaml",
|
||||
"version": "6.1.2",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/lang-yaml"
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/language",
|
||||
"version": "6.11.3",
|
||||
"version": "6.12.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/language"
|
||||
},
|
||||
@@ -79,13 +91,19 @@
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/state",
|
||||
"version": "6.5.2",
|
||||
"version": "6.5.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/state"
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/theme-one-dark",
|
||||
"version": "6.1.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/theme-one-dark"
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/view",
|
||||
"version": "6.38.8",
|
||||
"version": "6.39.9",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/view"
|
||||
},
|
||||
@@ -121,7 +139,7 @@
|
||||
},
|
||||
{
|
||||
"name": "@lezer/common",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/lezer-parser/common"
|
||||
},
|
||||
@@ -179,6 +197,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/lezer-parser/xml"
|
||||
},
|
||||
{
|
||||
"name": "@lezer/yaml",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/lezer-parser/yaml"
|
||||
},
|
||||
{
|
||||
"name": "@lucide/lab",
|
||||
"version": "0.1.2",
|
||||
@@ -203,18 +227,6 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/acorn-typescript"
|
||||
},
|
||||
{
|
||||
"name": "@types/asn1",
|
||||
"version": "0.2.4",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/DefinitelyTyped/DefinitelyTyped"
|
||||
},
|
||||
{
|
||||
"name": "@types/better-sqlite3",
|
||||
"version": "7.6.13",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/DefinitelyTyped/DefinitelyTyped"
|
||||
},
|
||||
{
|
||||
"name": "@types/estree",
|
||||
"version": "1.0.8",
|
||||
@@ -257,51 +269,15 @@
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/A11yance/aria-query"
|
||||
},
|
||||
{
|
||||
"name": "asn1",
|
||||
"version": "0.2.6",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/joyent/node-asn1"
|
||||
},
|
||||
{
|
||||
"name": "axobject-query",
|
||||
"version": "4.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/A11yance/axobject-query"
|
||||
},
|
||||
{
|
||||
"name": "base64-js",
|
||||
"version": "1.5.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/beatgammit/base64-js"
|
||||
},
|
||||
{
|
||||
"name": "better-sqlite3",
|
||||
"version": "12.5.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/WiseLibs/better-sqlite3"
|
||||
},
|
||||
{
|
||||
"name": "bindings",
|
||||
"version": "1.5.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/TooTallNate/node-bindings"
|
||||
},
|
||||
{
|
||||
"name": "bl",
|
||||
"version": "4.1.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/rvagg/bl"
|
||||
},
|
||||
{
|
||||
"name": "buffer",
|
||||
"version": "5.7.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/feross/buffer"
|
||||
},
|
||||
{
|
||||
"name": "bun-types",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.5",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/oven-sh/bun"
|
||||
},
|
||||
@@ -311,12 +287,6 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/camelcase"
|
||||
},
|
||||
{
|
||||
"name": "chownr",
|
||||
"version": "1.1.4",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/isaacs/chownr"
|
||||
},
|
||||
{
|
||||
"name": "cliui",
|
||||
"version": "6.0.0",
|
||||
@@ -329,6 +299,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/lukeed/clsx"
|
||||
},
|
||||
{
|
||||
"name": "codemirror",
|
||||
"version": "6.0.2",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/basic-setup"
|
||||
},
|
||||
{
|
||||
"name": "color-convert",
|
||||
"version": "2.0.1",
|
||||
@@ -359,36 +335,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/bradymholt/cronstrue"
|
||||
},
|
||||
{
|
||||
"name": "debug",
|
||||
"version": "4.4.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/debug-js/debug"
|
||||
},
|
||||
{
|
||||
"name": "decamelize",
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/decamelize"
|
||||
},
|
||||
{
|
||||
"name": "decompress-response",
|
||||
"version": "6.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/decompress-response"
|
||||
},
|
||||
{
|
||||
"name": "deep-extend",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/unclechu/node-deep-extend"
|
||||
},
|
||||
{
|
||||
"name": "detect-libc",
|
||||
"version": "2.1.2",
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/lovell/detect-libc"
|
||||
},
|
||||
{
|
||||
"name": "devalue",
|
||||
"version": "5.5.0",
|
||||
@@ -409,7 +361,7 @@
|
||||
},
|
||||
{
|
||||
"name": "drizzle-orm",
|
||||
"version": "0.45.0",
|
||||
"version": "0.45.1",
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/drizzle-team/drizzle-orm"
|
||||
},
|
||||
@@ -419,12 +371,6 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mathiasbynens/emoji-regex"
|
||||
},
|
||||
{
|
||||
"name": "end-of-stream",
|
||||
"version": "1.4.5",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/end-of-stream"
|
||||
},
|
||||
{
|
||||
"name": "esm-env",
|
||||
"version": "1.2.2",
|
||||
@@ -437,30 +383,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/esrap"
|
||||
},
|
||||
{
|
||||
"name": "expand-template",
|
||||
"version": "2.0.3",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"repository": "https://github.com/ralphtheninja/expand-template"
|
||||
},
|
||||
{
|
||||
"name": "file-uri-to-path",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/TooTallNate/file-uri-to-path"
|
||||
},
|
||||
{
|
||||
"name": "find-up",
|
||||
"version": "4.1.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/find-up"
|
||||
},
|
||||
{
|
||||
"name": "fs-constants",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/fs-constants"
|
||||
},
|
||||
{
|
||||
"name": "get-caller-file",
|
||||
"version": "2.0.5",
|
||||
@@ -468,28 +396,10 @@
|
||||
"repository": "https://github.com/stefanpenner/get-caller-file"
|
||||
},
|
||||
{
|
||||
"name": "github-from-package",
|
||||
"version": "0.0.0",
|
||||
"name": "hash-wasm",
|
||||
"version": "4.12.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/substack/github-from-package"
|
||||
},
|
||||
{
|
||||
"name": "ieee754",
|
||||
"version": "1.2.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"repository": "https://github.com/feross/ieee754"
|
||||
},
|
||||
{
|
||||
"name": "inherits",
|
||||
"version": "2.0.4",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/isaacs/inherits"
|
||||
},
|
||||
{
|
||||
"name": "ini",
|
||||
"version": "1.3.8",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/isaacs/ini"
|
||||
"repository": "https://github.com/Daninet/hash-wasm"
|
||||
},
|
||||
{
|
||||
"name": "is-fullwidth-code-point",
|
||||
@@ -511,7 +421,7 @@
|
||||
},
|
||||
{
|
||||
"name": "ldapts",
|
||||
"version": "8.0.12",
|
||||
"version": "8.1.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/ldapts/ldapts"
|
||||
},
|
||||
@@ -533,54 +443,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Rich-Harris/magic-string"
|
||||
},
|
||||
{
|
||||
"name": "mimic-response",
|
||||
"version": "3.1.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/mimic-response"
|
||||
},
|
||||
{
|
||||
"name": "minimist",
|
||||
"version": "1.2.8",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/minimistjs/minimist"
|
||||
},
|
||||
{
|
||||
"name": "mkdirp-classic",
|
||||
"version": "0.5.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/mkdirp-classic"
|
||||
},
|
||||
{
|
||||
"name": "ms",
|
||||
"version": "2.1.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/vercel/ms"
|
||||
},
|
||||
{
|
||||
"name": "napi-build-utils",
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/inspiredware/napi-build-utils"
|
||||
},
|
||||
{
|
||||
"name": "node-abi",
|
||||
"version": "3.85.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/electron/node-abi"
|
||||
},
|
||||
{
|
||||
"name": "nodemailer",
|
||||
"version": "7.0.11",
|
||||
"version": "7.0.12",
|
||||
"license": "MIT-0",
|
||||
"repository": "https://github.com/nodemailer/nodemailer"
|
||||
},
|
||||
{
|
||||
"name": "once",
|
||||
"version": "1.4.0",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/isaacs/once"
|
||||
},
|
||||
{
|
||||
"name": "otpauth",
|
||||
"version": "9.4.1",
|
||||
@@ -619,22 +487,10 @@
|
||||
},
|
||||
{
|
||||
"name": "postgres",
|
||||
"version": "3.4.7",
|
||||
"version": "3.4.8",
|
||||
"license": "Unlicense",
|
||||
"repository": "https://github.com/porsager/postgres"
|
||||
},
|
||||
{
|
||||
"name": "prebuild-install",
|
||||
"version": "7.1.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/prebuild/prebuild-install"
|
||||
},
|
||||
{
|
||||
"name": "pump",
|
||||
"version": "3.0.3",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/pump"
|
||||
},
|
||||
{
|
||||
"name": "punycode",
|
||||
"version": "2.3.1",
|
||||
@@ -647,18 +503,6 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/soldair/node-qrcode"
|
||||
},
|
||||
{
|
||||
"name": "rc",
|
||||
"version": "1.2.8",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"repository": "https://github.com/dominictarr/rc"
|
||||
},
|
||||
{
|
||||
"name": "readable-stream",
|
||||
"version": "3.6.2",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/nodejs/readable-stream"
|
||||
},
|
||||
{
|
||||
"name": "require-directory",
|
||||
"version": "2.1.1",
|
||||
@@ -677,42 +521,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/svecosystem/runed"
|
||||
},
|
||||
{
|
||||
"name": "safe-buffer",
|
||||
"version": "5.2.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/feross/safe-buffer"
|
||||
},
|
||||
{
|
||||
"name": "safer-buffer",
|
||||
"version": "2.1.2",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/ChALkeR/safer-buffer"
|
||||
},
|
||||
{
|
||||
"name": "semver",
|
||||
"version": "7.7.3",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/npm/node-semver"
|
||||
},
|
||||
{
|
||||
"name": "set-blocking",
|
||||
"version": "2.0.0",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/yargs/set-blocking"
|
||||
},
|
||||
{
|
||||
"name": "simple-concat",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/feross/simple-concat"
|
||||
},
|
||||
{
|
||||
"name": "simple-get",
|
||||
"version": "4.0.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/feross/simple-get"
|
||||
},
|
||||
{
|
||||
"name": "strict-event-emitter-types",
|
||||
"version": "2.0.0",
|
||||
@@ -725,24 +539,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/string-width"
|
||||
},
|
||||
{
|
||||
"name": "string_decoder",
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/nodejs/string_decoder"
|
||||
},
|
||||
{
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/chalk/strip-ansi"
|
||||
},
|
||||
{
|
||||
"name": "strip-json-comments",
|
||||
"version": "2.0.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sindresorhus/strip-json-comments"
|
||||
},
|
||||
{
|
||||
"name": "style-mod",
|
||||
"version": "4.1.3",
|
||||
@@ -751,13 +553,13 @@
|
||||
},
|
||||
{
|
||||
"name": "svelte",
|
||||
"version": "5.45.5",
|
||||
"version": "5.46.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/svelte"
|
||||
},
|
||||
{
|
||||
"name": "svelte-dnd-action",
|
||||
"version": "0.9.68",
|
||||
"version": "0.9.69",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/isaacHagoel/svelte-dnd-action"
|
||||
},
|
||||
@@ -767,48 +569,18 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/wobsoriano/svelte-sonner"
|
||||
},
|
||||
{
|
||||
"name": "tar-fs",
|
||||
"version": "2.1.4",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/tar-fs"
|
||||
},
|
||||
{
|
||||
"name": "tar-stream",
|
||||
"version": "2.2.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/mafintosh/tar-stream"
|
||||
},
|
||||
{
|
||||
"name": "tr46",
|
||||
"version": "6.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/jsdom/tr46"
|
||||
},
|
||||
{
|
||||
"name": "tunnel-agent",
|
||||
"version": "0.6.0",
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/mikeal/tunnel-agent"
|
||||
},
|
||||
{
|
||||
"name": "undici-types",
|
||||
"version": "7.16.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/nodejs/undici"
|
||||
},
|
||||
{
|
||||
"name": "util-deprecate",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/TooTallNate/util-deprecate"
|
||||
},
|
||||
{
|
||||
"name": "uuid",
|
||||
"version": "13.0.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/uuidjs/uuid"
|
||||
},
|
||||
{
|
||||
"name": "w3c-keyname",
|
||||
"version": "2.2.8",
|
||||
@@ -839,12 +611,6 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/chalk/wrap-ansi"
|
||||
},
|
||||
{
|
||||
"name": "wrappy",
|
||||
"version": "1.0.2",
|
||||
"license": "ISC",
|
||||
"repository": "https://github.com/npm/wrappy"
|
||||
},
|
||||
{
|
||||
"name": "y18n",
|
||||
"version": "4.0.3",
|
||||
|
||||
+46
-10
@@ -9,8 +9,9 @@
|
||||
* - SameSite=Strict (CSRF protection)
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import { secureRandomBytes, usingFallback } from './crypto-fallback';
|
||||
import { argon2id, argon2Verify } from 'hash-wasm';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import {
|
||||
getAuthSettings,
|
||||
@@ -94,27 +95,62 @@ export interface LoginResult {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Password Hashing (Argon2id via Bun.password)
|
||||
// Password Hashing (Argon2id)
|
||||
// ============================================
|
||||
|
||||
// Argon2id parameters (matching Bun.password defaults)
|
||||
const ARGON2_MEMORY_COST = 65536; // 64 MB in kibibytes
|
||||
const ARGON2_TIME_COST = 3; // 3 iterations
|
||||
const ARGON2_PARALLELISM = 1; // Single-threaded
|
||||
const ARGON2_HASH_LENGTH = 32; // 256-bit output
|
||||
const ARGON2_SALT_LENGTH = 16; // 128-bit salt
|
||||
|
||||
/**
|
||||
* Hash a password using Argon2id via Bun's native password API
|
||||
* Hash a password using Argon2id
|
||||
*
|
||||
* On modern kernels (>=3.17): Uses Bun's native password API (faster)
|
||||
* On old kernels (<3.17): Uses hash-wasm (WASM-based, no getrandom dependency)
|
||||
*
|
||||
* Argon2id is the recommended variant - resistant to both side-channel and GPU attacks
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
// On old kernels, Bun.password.hash() crashes because it internally uses getrandom()
|
||||
// Use hash-wasm as a fallback which is pure WASM and doesn't depend on the syscall
|
||||
if (usingFallback()) {
|
||||
const salt = secureRandomBytes(ARGON2_SALT_LENGTH);
|
||||
return argon2id({
|
||||
password,
|
||||
salt,
|
||||
iterations: ARGON2_TIME_COST,
|
||||
parallelism: ARGON2_PARALLELISM,
|
||||
memorySize: ARGON2_MEMORY_COST,
|
||||
hashLength: ARGON2_HASH_LENGTH,
|
||||
outputType: 'encoded' // Returns PHC format: $argon2id$v=19$m=65536,t=3,p=1$...
|
||||
});
|
||||
}
|
||||
|
||||
// Modern kernels: use Bun's native implementation (faster)
|
||||
return Bun.password.hash(password, {
|
||||
algorithm: 'argon2id',
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3 // 3 iterations
|
||||
memoryCost: ARGON2_MEMORY_COST,
|
||||
timeCost: ARGON2_TIME_COST
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
* Uses constant-time comparison internally
|
||||
*
|
||||
* Both Bun.password and hash-wasm use the same PHC format, so hashes are compatible
|
||||
*/
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
try {
|
||||
// On old kernels, use hash-wasm for verification
|
||||
if (usingFallback()) {
|
||||
return await argon2Verify({ password, hash });
|
||||
}
|
||||
|
||||
// Modern kernels: use Bun's native implementation
|
||||
return await Bun.password.verify(password, hash);
|
||||
} catch {
|
||||
return false;
|
||||
@@ -130,7 +166,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
||||
* 32 bytes = 256 bits of entropy
|
||||
*/
|
||||
function generateSessionToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
return secureRandomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -411,7 +447,7 @@ export async function authenticateLocal(
|
||||
|
||||
if (!user) {
|
||||
// Use constant time to prevent timing attacks
|
||||
await Bun.password.hash('dummy', { algorithm: 'argon2id' });
|
||||
await hashPassword('dummy');
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
@@ -1127,7 +1163,7 @@ async function getOidcDiscovery(issuerUrl: string): Promise<OidcDiscoveryDocumen
|
||||
* Generate PKCE code verifier and challenge
|
||||
*/
|
||||
function generatePkce(): { codeVerifier: string; codeChallenge: string } {
|
||||
const codeVerifier = randomBytes(32).toString('base64url');
|
||||
const codeVerifier = secureRandomBytes(32).toString('base64url');
|
||||
const hasher = new Bun.CryptoHasher('sha256');
|
||||
hasher.update(codeVerifier);
|
||||
const codeChallenge = hasher.digest('base64url') as string;
|
||||
@@ -1150,8 +1186,8 @@ export async function buildOidcAuthorizationUrl(
|
||||
const discovery = await getOidcDiscovery(config.issuerUrl);
|
||||
|
||||
// Generate state, nonce, and PKCE
|
||||
const state = randomBytes(32).toString('base64url');
|
||||
const nonce = randomBytes(16).toString('base64url');
|
||||
const state = secureRandomBytes(32).toString('base64url');
|
||||
const nonce = secureRandomBytes(16).toString('base64url');
|
||||
const { codeVerifier, codeChallenge } = generatePkce();
|
||||
|
||||
// Store state for callback verification (expires in 10 minutes)
|
||||
|
||||
+166
-8
@@ -125,6 +125,11 @@ export async function getEnvironment(id: number): Promise<Environment | undefine
|
||||
return results[0];
|
||||
}
|
||||
|
||||
export async function getEnvironmentByName(name: string): Promise<Environment | undefined> {
|
||||
const results = await db.select().from(environments).where(eq(environments.name, name));
|
||||
return results[0];
|
||||
}
|
||||
|
||||
export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Environment> {
|
||||
const result = await db.insert(environments).values({
|
||||
name: env.name,
|
||||
@@ -2487,6 +2492,8 @@ export interface StackSourceData {
|
||||
sourceType: StackSourceType;
|
||||
gitRepositoryId: number | null;
|
||||
gitStackId: number | null;
|
||||
composePath: string | null;
|
||||
envPath: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -2527,9 +2534,10 @@ export async function getStackSource(stackName: string, environmentId?: number |
|
||||
|
||||
export async function getStackSources(environmentId?: number | null): Promise<StackSourceWithRepo[]> {
|
||||
let results;
|
||||
if (environmentId !== undefined) {
|
||||
if (environmentId !== undefined && environmentId !== null) {
|
||||
// Only get stacks for the specific environment
|
||||
results = await db.select().from(stackSources)
|
||||
.where(or(eq(stackSources.environmentId, environmentId), isNull(stackSources.environmentId)))
|
||||
.where(eq(stackSources.environmentId, environmentId))
|
||||
.orderBy(asc(stackSources.stackName));
|
||||
} else {
|
||||
results = await db.select().from(stackSources).orderBy(asc(stackSources.stackName));
|
||||
@@ -2563,6 +2571,8 @@ export async function upsertStackSource(data: {
|
||||
sourceType: StackSourceType;
|
||||
gitRepositoryId?: number | null;
|
||||
gitStackId?: number | null;
|
||||
composePath?: string | null;
|
||||
envPath?: string | null;
|
||||
}): Promise<StackSourceData> {
|
||||
const existing = await getStackSource(data.stackName, data.environmentId);
|
||||
|
||||
@@ -2572,6 +2582,8 @@ export async function upsertStackSource(data: {
|
||||
sourceType: data.sourceType,
|
||||
gitRepositoryId: data.gitRepositoryId || null,
|
||||
gitStackId: data.gitStackId || null,
|
||||
composePath: data.composePath ?? null,
|
||||
envPath: data.envPath ?? null,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(stackSources.id, existing.id));
|
||||
@@ -2582,12 +2594,33 @@ export async function upsertStackSource(data: {
|
||||
environmentId: data.environmentId ?? null,
|
||||
sourceType: data.sourceType,
|
||||
gitRepositoryId: data.gitRepositoryId || null,
|
||||
gitStackId: data.gitStackId || null
|
||||
gitStackId: data.gitStackId || null,
|
||||
composePath: data.composePath ?? null,
|
||||
envPath: data.envPath ?? null
|
||||
});
|
||||
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateStackSource(
|
||||
stackName: string,
|
||||
environmentId: number | null,
|
||||
updates: { composePath?: string | null; envPath?: string | null }
|
||||
): Promise<boolean> {
|
||||
const existing = await getStackSource(stackName, environmentId);
|
||||
if (!existing) return false;
|
||||
|
||||
await db.update(stackSources)
|
||||
.set({
|
||||
composePath: updates.composePath !== undefined ? updates.composePath : existing.composePath,
|
||||
envPath: updates.envPath !== undefined ? updates.envPath : existing.envPath,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(stackSources.id, existing.id));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
|
||||
// Delete matching record (either with specific envId or NULL)
|
||||
await db.delete(stackSources)
|
||||
@@ -3083,10 +3116,8 @@ export interface ContainerEventResult {
|
||||
}
|
||||
|
||||
export async function logContainerEvent(data: ContainerEventCreateData): Promise<ContainerEventData> {
|
||||
// Timestamp is always a string with nanosecond precision (stored as text in both SQLite and PostgreSQL)
|
||||
// For PostgreSQL, we convert to Date since the schema uses native timestamp type
|
||||
const timestamp = isPostgres ? new Date(data.timestamp) : data.timestamp;
|
||||
|
||||
// Timestamp is already an ISO-8601 string from event-subprocess
|
||||
// Both SQLite and PostgreSQL schemas use mode: 'string' so we pass it directly
|
||||
const result = await db.insert(containerEvents).values({
|
||||
environmentId: data.environmentId ?? null,
|
||||
containerId: data.containerId,
|
||||
@@ -3094,7 +3125,7 @@ export async function logContainerEvent(data: ContainerEventCreateData): Promise
|
||||
image: data.image ?? null,
|
||||
action: data.action,
|
||||
actorAttributes: data.actorAttributes ? JSON.stringify(data.actorAttributes) : null,
|
||||
timestamp
|
||||
timestamp: data.timestamp
|
||||
}).returning();
|
||||
|
||||
return getContainerEvent(result[0].id) as Promise<ContainerEventData>;
|
||||
@@ -3896,6 +3927,73 @@ export async function setEventCleanupEnabled(enabled: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXTERNAL STACK PATHS
|
||||
// =============================================================================
|
||||
|
||||
const EXTERNAL_STACK_PATHS_KEY = 'external_stack_paths';
|
||||
|
||||
export async function getExternalStackPaths(): Promise<string[]> {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
|
||||
if (result[0]) {
|
||||
try {
|
||||
const parsed = JSON.parse(result[0].value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function setExternalStackPaths(paths: string[]): Promise<void> {
|
||||
const jsonValue = JSON.stringify(paths);
|
||||
const existing = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
|
||||
if (existing.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value: jsonValue, updatedAt: new Date().toISOString() })
|
||||
.where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: EXTERNAL_STACK_PATHS_KEY,
|
||||
value: jsonValue
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PRIMARY STACK LOCATION
|
||||
// =============================================================================
|
||||
|
||||
const PRIMARY_STACK_LOCATION_KEY = 'primary_stack_location';
|
||||
|
||||
export async function getPrimaryStackLocation(): Promise<string | null> {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
|
||||
if (result[0]?.value) {
|
||||
return result[0].value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function setPrimaryStackLocation(path: string | null): Promise<void> {
|
||||
const existing = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
|
||||
if (path === null) {
|
||||
// Delete the setting if path is null
|
||||
if (existing.length > 0) {
|
||||
await db.delete(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
|
||||
}
|
||||
} else if (existing.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value: path, updatedAt: new Date().toISOString() })
|
||||
.where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: PRIMARY_STACK_LOCATION_KEY,
|
||||
value: path
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ENVIRONMENT UPDATE CHECK SETTINGS
|
||||
// =============================================================================
|
||||
@@ -3988,6 +4086,66 @@ export async function setDefaultTimezone(timezone: string): Promise<void> {
|
||||
await setSetting('default_timezone', timezone);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BACKGROUND MONITORING SETTINGS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get event collection mode ('stream' or 'poll').
|
||||
* Defaults to 'stream' for real-time event streaming.
|
||||
*/
|
||||
export async function getEventCollectionMode(): Promise<'stream' | 'poll'> {
|
||||
const value = await getSetting('event_collection_mode');
|
||||
return value || 'stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set event collection mode.
|
||||
*/
|
||||
export async function setEventCollectionMode(mode: 'stream' | 'poll'): Promise<void> {
|
||||
await setSetting('event_collection_mode', mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event poll interval in milliseconds.
|
||||
* Defaults to 60000ms (60 seconds).
|
||||
*/
|
||||
export async function getEventPollInterval(): Promise<number> {
|
||||
const value = await getSetting('event_poll_interval');
|
||||
return value || 60000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set event poll interval in milliseconds.
|
||||
* Valid range: 30000ms (30s) to 300000ms (5min).
|
||||
*/
|
||||
export async function setEventPollInterval(interval: number): Promise<void> {
|
||||
if (interval < 30000 || interval > 300000) {
|
||||
throw new Error('Event poll interval must be between 30s and 300s');
|
||||
}
|
||||
await setSetting('event_poll_interval', interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metrics collection interval in milliseconds.
|
||||
* Defaults to 30000ms (30 seconds) - changed from hardcoded 10s.
|
||||
*/
|
||||
export async function getMetricsCollectionInterval(): Promise<number> {
|
||||
const value = await getSetting('metrics_collection_interval');
|
||||
return value || 30000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set metrics collection interval in milliseconds.
|
||||
* Valid range: 10000ms (10s) to 300000ms (5min).
|
||||
*/
|
||||
export async function setMetricsCollectionInterval(interval: number): Promise<void> {
|
||||
if (interval < 10000 || interval > 300000) {
|
||||
throw new Error('Metrics collection interval must be between 10s and 300s');
|
||||
}
|
||||
await setSetting('metrics_collection_interval', interval);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STACK ENVIRONMENT VARIABLES OPERATIONS
|
||||
// =============================================================================
|
||||
|
||||
@@ -604,21 +604,21 @@ async function initializeDatabase() {
|
||||
logHeader('DATABASE INITIALIZATION');
|
||||
|
||||
if (isPostgres) {
|
||||
// PostgreSQL via Bun.sql
|
||||
// PostgreSQL via postgres-js (more stable than bun:sql for concurrent queries)
|
||||
validatePostgresUrl(config.databaseUrl!);
|
||||
|
||||
logInfo(`Database: PostgreSQL`);
|
||||
logInfo(`Connection: ${maskPassword(config.databaseUrl!)}`);
|
||||
|
||||
const { drizzle } = await import('drizzle-orm/bun-sql');
|
||||
const { SQL } = await import('bun');
|
||||
const { drizzle } = await import('drizzle-orm/postgres-js');
|
||||
const postgres = (await import('postgres')).default;
|
||||
|
||||
// Import PostgreSQL schema
|
||||
schema = await import('./schema/pg-schema.js');
|
||||
|
||||
if (verbose) logStep('Connecting to PostgreSQL...');
|
||||
try {
|
||||
rawClient = new SQL(config.databaseUrl!);
|
||||
rawClient = postgres(config.databaseUrl!);
|
||||
db = drizzle({ client: rawClient, schema });
|
||||
logSuccess('PostgreSQL connection established');
|
||||
} catch (error) {
|
||||
|
||||
@@ -332,6 +332,8 @@ export const stackSources = sqliteTable('stack_sources', {
|
||||
sourceType: text('source_type').notNull().default('internal'),
|
||||
gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }),
|
||||
gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }),
|
||||
composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
|
||||
envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
|
||||
@@ -335,6 +335,8 @@ export const stackSources = pgTable('stack_sources', {
|
||||
sourceType: text('source_type').notNull().default('internal'),
|
||||
gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }),
|
||||
gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }),
|
||||
composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
|
||||
envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
|
||||
+98
-23
@@ -469,13 +469,16 @@ export async function dockerFetch(
|
||||
streaming ? 300000 : 30000 // 5 min for streaming, 30s for normal requests
|
||||
);
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > 5000) {
|
||||
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
|
||||
if (elapsed > 5000 && !path.includes('/stats')) {
|
||||
console.warn(`[Docker] Edge env ${config.environmentId}: ${method} ${path} took ${elapsed}ms`);
|
||||
}
|
||||
return edgeResponseToResponse(edgeResponse);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms:`, error);
|
||||
// Log error message only, not full stack trace
|
||||
const msg = error?.message || String(error);
|
||||
console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
|
||||
throw DockerConnectionError.fromError(error);
|
||||
}
|
||||
}
|
||||
@@ -491,13 +494,16 @@ export async function dockerFetch(
|
||||
...bunOptions
|
||||
});
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > 5000) {
|
||||
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
|
||||
if (elapsed > 5000 && !path.includes('/stats')) {
|
||||
console.warn(`[Docker] Socket: ${method} ${path} took ${elapsed}ms`);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms:`, error);
|
||||
// Log error message only, not full stack trace
|
||||
const msg = error?.message || String(error);
|
||||
console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
|
||||
throw DockerConnectionError.fromError(error);
|
||||
}
|
||||
} else {
|
||||
@@ -516,21 +522,30 @@ export async function dockerFetch(
|
||||
}
|
||||
|
||||
// For HTTPS with TLS certificates, we need to configure TLS
|
||||
// IMPORTANT: Bun requires certificates as Buffer objects, not strings
|
||||
// Pass certificate strings directly to Bun's fetch - no temp files needed
|
||||
if (config.type === 'https') {
|
||||
const tlsOptions: Record<string, unknown> = {};
|
||||
|
||||
// CA certificate - must be array of Buffers for Bun
|
||||
// DISABLE TLS SESSION CACHING: Bun reuses TLS sessions across different hosts,
|
||||
// which causes client certificate mismatches in mTLS scenarios. By setting
|
||||
// sessionTimeout to 0, we force a fresh TLS handshake for every connection.
|
||||
tlsOptions.sessionTimeout = 0;
|
||||
|
||||
// Set explicit servername for SNI - helps isolate TLS contexts per host
|
||||
tlsOptions.servername = config.host;
|
||||
|
||||
// Load CA certificate (just this environment's CA, not composite)
|
||||
// The sessionTimeout=0 should prevent session reuse across hosts
|
||||
if (config.ca) {
|
||||
tlsOptions.ca = [Buffer.from(config.ca)];
|
||||
tlsOptions.ca = [config.ca];
|
||||
}
|
||||
|
||||
// Client certificate and key for mTLS - must be Buffers
|
||||
// Client cert and key for mTLS authentication
|
||||
if (config.cert) {
|
||||
tlsOptions.cert = Buffer.from(config.cert);
|
||||
tlsOptions.cert = [config.cert];
|
||||
}
|
||||
if (config.key) {
|
||||
tlsOptions.key = Buffer.from(config.key);
|
||||
tlsOptions.key = config.key;
|
||||
}
|
||||
|
||||
// Skip verification (self-signed without CA)
|
||||
@@ -541,8 +556,27 @@ export async function dockerFetch(
|
||||
}
|
||||
|
||||
if (Object.keys(tlsOptions).length > 0) {
|
||||
// @ts-ignore - Bun supports tls options with Buffer certs
|
||||
// @ts-ignore - Bun supports tls options with string certs
|
||||
finalOptions.tls = tlsOptions;
|
||||
// Force new connection for each request to prevent Bun from reusing
|
||||
// a TLS session with wrong client certificates (pool key doesn't include certs)
|
||||
// @ts-ignore - Bun supports keepalive option
|
||||
finalOptions.keepalive = false;
|
||||
}
|
||||
|
||||
// Explicitly close connection to prevent TLS session reuse issues
|
||||
// But only for non-streaming requests (logs, events, exec need keep-alive)
|
||||
if (!streaming) {
|
||||
finalOptions.headers = {
|
||||
...finalOptions.headers,
|
||||
'Connection': 'close'
|
||||
};
|
||||
}
|
||||
|
||||
// Optional verbose TLS debugging
|
||||
if (process.env.DEBUG_TLS) {
|
||||
// @ts-ignore - Bun-specific verbose option
|
||||
finalOptions.verbose = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,13 +584,16 @@ export async function dockerFetch(
|
||||
try {
|
||||
const response = await fetch(url, { ...finalOptions, ...bunOptions });
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > 5000) {
|
||||
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
|
||||
if (elapsed > 5000 && !path.includes('/stats')) {
|
||||
console.warn(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} took ${elapsed}ms`);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms:`, error);
|
||||
// Log error message only, not full stack trace
|
||||
const msg = error?.message || String(error);
|
||||
console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
|
||||
throw DockerConnectionError.fromError(error);
|
||||
}
|
||||
}
|
||||
@@ -994,12 +1031,31 @@ export async function updateContainer(id: string, options: CreateContainerOption
|
||||
|
||||
// Image operations
|
||||
export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
|
||||
const images = await dockerJsonRequest<any[]>('/images/json', {}, envId);
|
||||
// Fetch images and containers in parallel
|
||||
const [images, containers] = await Promise.all([
|
||||
dockerJsonRequest<any[]>('/images/json', {}, envId),
|
||||
dockerJsonRequest<any[]>('/containers/json?all=true', {}, envId).catch(() => [] as any[])
|
||||
]);
|
||||
|
||||
// Build a map of imageId -> container count
|
||||
// Docker may return -1 for Containers field on some hosts, so we compute it ourselves
|
||||
const imageContainerCount = new Map<string, number>();
|
||||
for (const container of containers) {
|
||||
const imageId = container.ImageID || container.Image;
|
||||
if (imageId) {
|
||||
imageContainerCount.set(imageId, (imageContainerCount.get(imageId) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return images.map((image) => ({
|
||||
id: image.Id,
|
||||
repoTags: image.RepoTags || [],
|
||||
tags: image.RepoTags || [],
|
||||
size: image.Size,
|
||||
created: image.Created
|
||||
virtualSize: image.VirtualSize || image.Size,
|
||||
created: image.Created,
|
||||
labels: image.Labels || {},
|
||||
containers: imageContainerCount.get(image.Id) || 0
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1943,7 +1999,10 @@ export async function pruneContainers(envId?: number | null) {
|
||||
}
|
||||
|
||||
export async function pruneImages(dangling = true, envId?: number | null) {
|
||||
const filters = dangling ? '{"dangling":["true"]}' : '{}';
|
||||
// dangling=true: only remove untagged images (default Docker behavior)
|
||||
// dangling=false: remove ALL unused images including tagged ones
|
||||
// Docker API quirk: to remove all unused, we pass dangling=false filter
|
||||
const filters = dangling ? '{"dangling":["true"]}' : '{"dangling":["false"]}';
|
||||
return dockerJsonRequest(`/images/prune?filters=${encodeURIComponent(filters)}`, { method: 'POST' }, envId);
|
||||
}
|
||||
|
||||
@@ -2042,18 +2101,30 @@ export async function execInContainer(
|
||||
}
|
||||
|
||||
// Get Docker events as a stream (for SSE)
|
||||
// For streaming mode: call with just filters
|
||||
// For polling mode: call with since and until to get a finite window of events
|
||||
export async function getDockerEvents(
|
||||
filters: Record<string, string[]>,
|
||||
envId?: number | null
|
||||
envId?: number | null,
|
||||
options?: { since?: string; until?: string }
|
||||
): Promise<ReadableStream<Uint8Array> | null> {
|
||||
const filterJson = JSON.stringify(filters);
|
||||
|
||||
// Build query string with optional since/until for polling mode
|
||||
let queryString = `filters=${encodeURIComponent(filterJson)}`;
|
||||
if (options?.since) {
|
||||
queryString += `&since=${encodeURIComponent(options.since)}`;
|
||||
}
|
||||
if (options?.until) {
|
||||
queryString += `&until=${encodeURIComponent(options.until)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
// Note: We use streaming: true to disable Bun's idle timeout for this long-lived connection.
|
||||
// The Docker events API keeps the connection open indefinitely, sending events as they occur.
|
||||
// Without streaming: true, Bun would terminate the connection after ~5 seconds of inactivity.
|
||||
const response = await dockerFetch(
|
||||
`/events?filters=${encodeURIComponent(filterJson)}`,
|
||||
`/events?${queryString}`,
|
||||
{ streaming: true },
|
||||
envId
|
||||
);
|
||||
@@ -3114,8 +3185,12 @@ async function cleanupStaleVolumeHelpersForEnv(envId?: number | null): Promise<n
|
||||
}
|
||||
|
||||
return removed;
|
||||
} catch (err) {
|
||||
console.warn('Failed to query stale volume helpers:', err);
|
||||
} catch (err: any) {
|
||||
// Don't spam logs for expected failures (e.g., edge agent offline)
|
||||
const msg = err?.message || String(err);
|
||||
if (!msg.includes('not connected') && !msg.includes('offline')) {
|
||||
console.warn('Failed to query stale volume helpers:', msg);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { db, hawserTokens, environments, eq } from './db/drizzle.js';
|
||||
import { logContainerEvent, saveHostMetric, type ContainerEventAction } from './db.js';
|
||||
import { containerEventEmitter } from './event-collector.js';
|
||||
import { sendEnvironmentNotification } from './notifications.js';
|
||||
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
||||
import { hashPassword, verifyPassword } from './auth.js';
|
||||
|
||||
// Protocol constants
|
||||
export const HAWSER_PROTOCOL_VERSION = '1.0';
|
||||
@@ -243,7 +245,7 @@ export async function validateHawserToken(
|
||||
// Check each token (tokens are hashed)
|
||||
for (const t of tokens) {
|
||||
try {
|
||||
const isValid = await Bun.password.verify(token, t.token);
|
||||
const isValid = await verifyPassword(token, t.token);
|
||||
if (isValid) {
|
||||
// Update last used timestamp
|
||||
await db
|
||||
@@ -292,16 +294,12 @@ export async function generateHawserToken(
|
||||
} else {
|
||||
// Generate a secure random token (32 bytes = 256 bits)
|
||||
const tokenBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(tokenBytes);
|
||||
secureGetRandomValues(tokenBytes);
|
||||
token = Buffer.from(tokenBytes).toString('base64url');
|
||||
}
|
||||
|
||||
// Hash the token for storage (using Bun's built-in Argon2id)
|
||||
const hashedToken = await Bun.password.hash(token, {
|
||||
algorithm: 'argon2id',
|
||||
memoryCost: 19456,
|
||||
timeCost: 2
|
||||
});
|
||||
// Hash the token for storage (using Argon2id)
|
||||
const hashedToken = await hashPassword(token);
|
||||
|
||||
// Get prefix for identification
|
||||
const tokenPrefix = token.substring(0, 8);
|
||||
@@ -477,7 +475,7 @@ export async function sendEdgeRequest(
|
||||
throw new Error('Edge agent not connected');
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
const requestId = secureRandomUUID();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
@@ -614,7 +612,7 @@ export function sendEdgeStreamRequest(
|
||||
return { requestId: '', cancel: () => {} };
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
const requestId = secureRandomUUID();
|
||||
|
||||
// Initialize pendingStreamRequests if not present (can happen in dev mode due to HMR)
|
||||
if (!connection.pendingStreamRequests) {
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import { saveHostMetric, getEnvironments, getEnvSetting } from './db';
|
||||
import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from './docker';
|
||||
import { sendEventNotification } from './notifications';
|
||||
import os from 'node:os';
|
||||
|
||||
const COLLECT_INTERVAL = 10000; // 10 seconds
|
||||
const DISK_CHECK_INTERVAL = 300000; // 5 minutes
|
||||
const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings
|
||||
|
||||
let collectorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let diskCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Track last disk warning sent per environment to avoid spamming
|
||||
const lastDiskWarning: Map<number, number> = new Map();
|
||||
const DISK_WARNING_COOLDOWN = 3600000; // 1 hour between warnings
|
||||
|
||||
/**
|
||||
* Collect metrics for a single environment
|
||||
*/
|
||||
async function collectEnvMetrics(env: { id: number; name: string; collectMetrics?: boolean }) {
|
||||
try {
|
||||
// Skip environments where metrics collection is disabled
|
||||
if (env.collectMetrics === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get running containers
|
||||
const containers = await listContainers(false, env.id); // Only running
|
||||
let totalCpuPercent = 0;
|
||||
let totalMemUsed = 0;
|
||||
|
||||
// Get stats for each running container
|
||||
const statsPromises = containers.map(async (container) => {
|
||||
try {
|
||||
const stats = await getContainerStats(container.id, env.id) as any;
|
||||
|
||||
// Calculate CPU percentage
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const cpuCount = stats.cpu_stats.online_cpus || os.cpus().length;
|
||||
|
||||
let cpuPercent = 0;
|
||||
if (systemDelta > 0 && cpuDelta > 0) {
|
||||
cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100;
|
||||
}
|
||||
|
||||
// Get container memory usage
|
||||
const memUsage = stats.memory_stats?.usage || 0;
|
||||
const memCache = stats.memory_stats?.stats?.cache || 0;
|
||||
// Subtract cache from usage to get actual memory used by the container
|
||||
const actualMemUsed = memUsage - memCache;
|
||||
|
||||
return { cpu: cpuPercent, mem: actualMemUsed > 0 ? actualMemUsed : memUsage };
|
||||
} catch {
|
||||
return { cpu: 0, mem: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
const statsResults = await Promise.all(statsPromises);
|
||||
totalCpuPercent = statsResults.reduce((sum, v) => sum + v.cpu, 0);
|
||||
totalMemUsed = statsResults.reduce((sum, v) => sum + v.mem, 0);
|
||||
|
||||
// Get host total memory from Docker info (this is the remote host's memory)
|
||||
const info = await getDockerInfo(env.id) as any;
|
||||
const memTotal = info.MemTotal || os.totalmem();
|
||||
|
||||
// Calculate memory percentage based on container usage vs host total
|
||||
const memPercent = memTotal > 0 ? (totalMemUsed / memTotal) * 100 : 0;
|
||||
|
||||
// Normalize CPU by number of cores from the remote host
|
||||
const cpuCount = info.NCPU || os.cpus().length;
|
||||
const normalizedCpu = totalCpuPercent / cpuCount;
|
||||
|
||||
// Save to database
|
||||
await saveHostMetric(
|
||||
normalizedCpu,
|
||||
memPercent,
|
||||
totalMemUsed,
|
||||
memTotal,
|
||||
env.id
|
||||
);
|
||||
} catch (error) {
|
||||
// Skip this environment if it fails (might be offline)
|
||||
console.error(`Failed to collect metrics for ${env.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectMetrics() {
|
||||
try {
|
||||
const environments = await getEnvironments();
|
||||
|
||||
// Filter enabled environments and collect metrics in parallel
|
||||
const enabledEnvs = environments.filter(env => env.collectMetrics !== false);
|
||||
|
||||
// Process all environments in parallel for better performance
|
||||
await Promise.all(enabledEnvs.map(env => collectEnvMetrics(env)));
|
||||
} catch (error) {
|
||||
console.error('Metrics collection error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check disk space for a single environment
|
||||
*/
|
||||
async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics?: boolean }) {
|
||||
try {
|
||||
// Skip environments where metrics collection is disabled
|
||||
if (env.collectMetrics === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're in cooldown for this environment
|
||||
const lastWarningTime = lastDiskWarning.get(env.id);
|
||||
if (lastWarningTime && Date.now() - lastWarningTime < DISK_WARNING_COOLDOWN) {
|
||||
return; // Skip this environment, still in cooldown
|
||||
}
|
||||
|
||||
// Get Docker disk usage data
|
||||
const diskData = await getDiskUsage(env.id) as any;
|
||||
if (!diskData) return;
|
||||
|
||||
// Calculate total Docker disk usage using reduce for cleaner code
|
||||
let totalUsed = 0;
|
||||
if (diskData.Images) {
|
||||
totalUsed += diskData.Images.reduce((sum: number, img: any) => sum + (img.Size || 0), 0);
|
||||
}
|
||||
if (diskData.Containers) {
|
||||
totalUsed += diskData.Containers.reduce((sum: number, c: any) => sum + (c.SizeRw || 0), 0);
|
||||
}
|
||||
if (diskData.Volumes) {
|
||||
totalUsed += diskData.Volumes.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0);
|
||||
}
|
||||
if (diskData.BuildCache) {
|
||||
totalUsed += diskData.BuildCache.reduce((sum: number, bc: any) => sum + (bc.Size || 0), 0);
|
||||
}
|
||||
|
||||
// Get Docker root filesystem info from Docker info
|
||||
const info = await getDockerInfo(env.id) as any;
|
||||
const driverStatus = info?.DriverStatus;
|
||||
|
||||
// Try to find "Data Space Total" from driver status
|
||||
let dataSpaceTotal = 0;
|
||||
let diskPercentUsed = 0;
|
||||
|
||||
if (driverStatus) {
|
||||
for (const [key, value] of driverStatus) {
|
||||
if (key === 'Data Space Total' && typeof value === 'string') {
|
||||
dataSpaceTotal = parseSize(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found total disk space, calculate percentage
|
||||
if (dataSpaceTotal > 0) {
|
||||
diskPercentUsed = (totalUsed / dataSpaceTotal) * 100;
|
||||
} else {
|
||||
// Fallback: just report absolute usage if we can't determine percentage
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
if (totalUsed > 50 * GB) {
|
||||
await sendEventNotification('disk_space_warning', {
|
||||
title: 'High Docker disk usage',
|
||||
message: `Environment "${env.name}" is using ${formatSize(totalUsed)} of Docker disk space`,
|
||||
type: 'warning'
|
||||
}, env.id);
|
||||
lastDiskWarning.set(env.id, Date.now());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check against threshold
|
||||
const threshold = await getEnvSetting('disk_warning_threshold', env.id) || DEFAULT_DISK_THRESHOLD;
|
||||
if (diskPercentUsed >= threshold) {
|
||||
console.log(`[Metrics] Docker disk usage for ${env.name}: ${diskPercentUsed.toFixed(1)}% (threshold: ${threshold}%)`);
|
||||
|
||||
await sendEventNotification('disk_space_warning', {
|
||||
title: 'Disk space warning',
|
||||
message: `Environment "${env.name}" Docker disk usage is at ${diskPercentUsed.toFixed(1)}% (${formatSize(totalUsed)} used)`,
|
||||
type: 'warning'
|
||||
}, env.id);
|
||||
|
||||
lastDiskWarning.set(env.id, Date.now());
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip this environment if it fails
|
||||
console.error(`Failed to check disk space for ${env.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Docker disk usage and send warnings if above threshold
|
||||
*/
|
||||
async function checkDiskSpace() {
|
||||
try {
|
||||
const environments = await getEnvironments();
|
||||
|
||||
// Filter enabled environments and check disk space in parallel
|
||||
const enabledEnvs = environments.filter(env => env.collectMetrics !== false);
|
||||
|
||||
// Process all environments in parallel for better performance
|
||||
await Promise.all(enabledEnvs.map(env => checkEnvDiskSpace(env)));
|
||||
} catch (error) {
|
||||
console.error('Disk space check error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse size string like "107.4GB" to bytes
|
||||
*/
|
||||
function parseSize(sizeStr: string): number {
|
||||
const units: Record<string, number> = {
|
||||
'B': 1,
|
||||
'KB': 1024,
|
||||
'MB': 1024 * 1024,
|
||||
'GB': 1024 * 1024 * 1024,
|
||||
'TB': 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
const match = sizeStr.match(/^([\d.]+)\s*([KMGT]?B)$/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
return value * (units[unit] || 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string
|
||||
*/
|
||||
function formatSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let unitIndex = 0;
|
||||
let size = bytes;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function startMetricsCollector() {
|
||||
if (collectorInterval) return; // Already running
|
||||
|
||||
console.log('Starting server-side metrics collector (every 10s)');
|
||||
|
||||
// Initial collection
|
||||
collectMetrics();
|
||||
|
||||
// Schedule regular collection
|
||||
collectorInterval = setInterval(collectMetrics, COLLECT_INTERVAL);
|
||||
|
||||
// Start disk space checking (every 5 minutes)
|
||||
console.log('Starting disk space monitoring (every 5 minutes)');
|
||||
checkDiskSpace(); // Initial check
|
||||
diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
export function stopMetricsCollector() {
|
||||
if (collectorInterval) {
|
||||
clearInterval(collectorInterval);
|
||||
collectorInterval = null;
|
||||
}
|
||||
if (diskCheckInterval) {
|
||||
clearInterval(diskCheckInterval);
|
||||
diskCheckInterval = null;
|
||||
}
|
||||
lastDiskWarning.clear();
|
||||
console.log('Metrics collector stopped');
|
||||
}
|
||||
+593
-120
@@ -5,8 +5,8 @@
|
||||
* All lifecycle operations use docker compose commands.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import {
|
||||
getEnvironment,
|
||||
getStackEnvVarsAsRecord,
|
||||
@@ -88,22 +88,6 @@ export interface DeployStackOptions {
|
||||
// ERRORS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Error for operations on external stacks without compose files
|
||||
*/
|
||||
export class ExternalStackError extends Error {
|
||||
public readonly stackName: string;
|
||||
|
||||
constructor(stackName: string) {
|
||||
super(
|
||||
`Stack "${stackName}" was created outside of Dockhand. ` +
|
||||
`To manage this stack, first import it by clicking the Import button in the stack menu.`
|
||||
);
|
||||
this.name = 'ExternalStackError';
|
||||
this.stackName = stackName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error when compose file is missing for a managed stack
|
||||
*/
|
||||
@@ -232,6 +216,65 @@ export function getStacksDir(): string {
|
||||
return _stacksDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stack directory path for a specific environment.
|
||||
* New stacks use: $DATA_DIR/stacks/<envName>/<stackName>/
|
||||
* Legacy stacks (no env): $DATA_DIR/stacks/<stackName>/
|
||||
*
|
||||
* Automatically looks up environment name from database.
|
||||
*/
|
||||
export async function getStackDir(stackName: string, envId?: number | null): Promise<string> {
|
||||
const stacksDir = getStacksDir();
|
||||
if (envId) {
|
||||
const env = await getEnvironment(envId);
|
||||
if (env) {
|
||||
return join(stacksDir, env.name, stackName);
|
||||
}
|
||||
}
|
||||
// Legacy path for stacks without environment
|
||||
return join(stacksDir, stackName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find stack directory, checking paths in order:
|
||||
* 1. New path (envName): $DATA_DIR/stacks/<envName>/<stackName>/
|
||||
* 2. ID-based path (envId): $DATA_DIR/stacks/<envId>/<stackName>/
|
||||
* 3. Legacy path: $DATA_DIR/stacks/<stackName>/
|
||||
*
|
||||
* Automatically looks up environment name from database.
|
||||
* Always checks legacy path for backwards compatibility with pre-env stacks.
|
||||
*/
|
||||
export async function findStackDir(stackName: string, envId?: number | null): Promise<string | null> {
|
||||
const stacksDir = getStacksDir();
|
||||
|
||||
// Look up environment name if we have an ID
|
||||
if (envId) {
|
||||
const env = await getEnvironment(envId);
|
||||
|
||||
// 1. Check new path (with envName)
|
||||
if (env) {
|
||||
const namePath = join(stacksDir, env.name, stackName);
|
||||
if (existsSync(namePath)) {
|
||||
return namePath;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check ID-based path
|
||||
const idPath = join(stacksDir, String(envId), stackName);
|
||||
if (existsSync(idPath)) {
|
||||
return idPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Always check legacy path (stacks created before env-scoping was added)
|
||||
const legacyPath = join(stacksDir, stackName);
|
||||
if (existsSync(legacyPath)) {
|
||||
return legacyPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List stacks that have compose files stored locally
|
||||
*/
|
||||
@@ -256,31 +299,116 @@ export function listManagedStacks(): string[] {
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get compose file content for a stack
|
||||
* Result type for getStackComposeFile
|
||||
*/
|
||||
export interface GetComposeFileResult {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
stackDir?: string;
|
||||
error?: string;
|
||||
needsFileLocation?: boolean;
|
||||
composePath?: string | null;
|
||||
envPath?: string | null;
|
||||
suggestedEnvPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get compose file content for a stack.
|
||||
*
|
||||
* Unified logic for all stacks:
|
||||
* - If composePath is set in DB → use custom path
|
||||
* - If composePath is NULL → use default location (data/stacks/{env}/{name}/)
|
||||
* - If no source record and no files found → return needsFileLocation: true
|
||||
*/
|
||||
export async function getStackComposeFile(
|
||||
stackName: string
|
||||
): Promise<{ success: boolean; content?: string; stackDir?: string; error?: string }> {
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<GetComposeFileResult> {
|
||||
const source = await getStackSource(stackName, envId);
|
||||
|
||||
// Check all common compose file names (Docker Compose v1 and v2 naming conventions)
|
||||
const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
||||
// Case 1: Stack not in database = untracked (discovered from Docker but not imported)
|
||||
// User must select the compose file location - don't guess from default location
|
||||
if (!source) {
|
||||
return {
|
||||
success: false,
|
||||
needsFileLocation: true,
|
||||
error: `Select the compose file location for stack "${stackName}"`
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Stack has custom composePath set - use it
|
||||
if (source.composePath) {
|
||||
try {
|
||||
if (!existsSync(source.composePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Compose file no longer accessible at ${source.composePath}. The file may have been moved or deleted.`,
|
||||
composePath: source.composePath,
|
||||
envPath: source.envPath
|
||||
};
|
||||
}
|
||||
|
||||
const content = await Bun.file(source.composePath).text();
|
||||
const stackDir = dirname(source.composePath);
|
||||
|
||||
// For custom paths, suggest .env next to compose if envPath not set
|
||||
let suggestedEnvPath: string | undefined;
|
||||
if (source.envPath === null) {
|
||||
suggestedEnvPath = source.composePath.replace(/\/[^/]+$/, '/.env');
|
||||
}
|
||||
|
||||
for (const fileName of composeFileNames) {
|
||||
const file = Bun.file(join(stackDir, fileName));
|
||||
if (await file.exists()) {
|
||||
return {
|
||||
success: true,
|
||||
content: await file.text(),
|
||||
stackDir
|
||||
content,
|
||||
stackDir,
|
||||
composePath: source.composePath,
|
||||
envPath: source.envPath,
|
||||
suggestedEnvPath
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to read compose file: ${message}`,
|
||||
composePath: source.composePath,
|
||||
envPath: source.envPath
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: Stack is in DB but no custom path - check default location
|
||||
// This is for stacks created in Dockhand using the default data directory
|
||||
const stackDir = await findStackDir(stackName, envId);
|
||||
|
||||
if (stackDir) {
|
||||
// Check all common compose file names
|
||||
const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
||||
|
||||
for (const fileName of composeFileNames) {
|
||||
const actualComposePath = join(stackDir, fileName);
|
||||
const file = Bun.file(actualComposePath);
|
||||
if (await file.exists()) {
|
||||
// Check for .env file in the same directory
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
const envExists = existsSync(envFilePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: await file.text(),
|
||||
stackDir,
|
||||
// Always return the actual resolved paths for display
|
||||
composePath: actualComposePath,
|
||||
envPath: envExists ? envFilePath : null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 4: Stack is in DB but compose file not found - need user to specify location
|
||||
return {
|
||||
success: false,
|
||||
error: `Compose file not found for stack "${stackName}". The stack may have been created outside of Dockhand.`
|
||||
needsFileLocation: true,
|
||||
error: `Select the compose file location for stack "${stackName}"`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -289,22 +417,206 @@ export async function getStackComposeFile(
|
||||
* @param name - Stack name
|
||||
* @param content - Compose file content
|
||||
* @param create - If true, creates a new stack (fails if exists). If false, updates existing (fails if not exists).
|
||||
* @param envId - Environment ID for path scoping
|
||||
*/
|
||||
export async function saveStackComposeFile(
|
||||
name: string,
|
||||
content: string,
|
||||
create = false
|
||||
create = false,
|
||||
envId?: number | null,
|
||||
options?: {
|
||||
composePath?: string; // Custom compose file path
|
||||
envPath?: string | null; // Custom env path (null = default, '' = none)
|
||||
moveFromDir?: string; // Old directory to move all files from when path changes
|
||||
oldComposePath?: string; // Old compose file path for renaming
|
||||
oldEnvPath?: string; // Old env file path for renaming
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Validate stack name
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
// Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores
|
||||
// Must also start with a letter or number
|
||||
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Stack name can only contain letters, numbers, hyphens, and underscores'
|
||||
error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores'
|
||||
};
|
||||
}
|
||||
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, name);
|
||||
// Check if this stack has a custom compose path configured, or if one was provided
|
||||
const source = await getStackSource(name, envId);
|
||||
const composePath = options?.composePath || source?.composePath;
|
||||
|
||||
// Handle compose file move/rename when path changes
|
||||
if (options?.oldComposePath && options?.composePath &&
|
||||
options.oldComposePath !== options.composePath &&
|
||||
existsSync(options.oldComposePath)) {
|
||||
const newDir = dirname(options.composePath);
|
||||
|
||||
// Ensure target directory exists
|
||||
if (!existsSync(newDir)) {
|
||||
try {
|
||||
mkdirSync(newDir, { recursive: true });
|
||||
} catch (err: any) {
|
||||
console.warn(`[Stack] Failed to create directory ${newDir}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Move/rename the compose file to new location
|
||||
try {
|
||||
renameSync(options.oldComposePath, options.composePath);
|
||||
console.log(`[Stack] Moved compose file: ${options.oldComposePath} -> ${options.composePath}`);
|
||||
} catch (renameErr: any) {
|
||||
// If rename fails (e.g., cross-filesystem), try copy+delete
|
||||
if (renameErr.code === 'EXDEV') {
|
||||
try {
|
||||
const data = readFileSync(options.oldComposePath);
|
||||
writeFileSync(options.composePath, data);
|
||||
unlinkSync(options.oldComposePath);
|
||||
console.log(`[Stack] Copied compose file (cross-fs): ${options.oldComposePath} -> ${options.composePath}`);
|
||||
} catch (err: any) {
|
||||
console.warn(`[Stack] Failed to copy compose file: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[Stack] Failed to move compose file: ${renameErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle env file move/rename when path changes
|
||||
if (options?.oldEnvPath && options?.envPath &&
|
||||
options.oldEnvPath !== options.envPath &&
|
||||
existsSync(options.oldEnvPath)) {
|
||||
const newDir = dirname(options.envPath);
|
||||
|
||||
// Ensure target directory exists
|
||||
if (!existsSync(newDir)) {
|
||||
try {
|
||||
mkdirSync(newDir, { recursive: true });
|
||||
} catch (err: any) {
|
||||
console.warn(`[Stack] Failed to create directory ${newDir}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Move/rename the env file to new location
|
||||
try {
|
||||
renameSync(options.oldEnvPath, options.envPath);
|
||||
console.log(`[Stack] Moved env file: ${options.oldEnvPath} -> ${options.envPath}`);
|
||||
} catch (renameErr: any) {
|
||||
// If rename fails (e.g., cross-filesystem), try copy+delete
|
||||
if (renameErr.code === 'EXDEV') {
|
||||
try {
|
||||
const data = readFileSync(options.oldEnvPath);
|
||||
writeFileSync(options.envPath, data);
|
||||
unlinkSync(options.oldEnvPath);
|
||||
console.log(`[Stack] Copied env file (cross-fs): ${options.oldEnvPath} -> ${options.envPath}`);
|
||||
} catch (err: any) {
|
||||
console.warn(`[Stack] Failed to copy env file: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[Stack] Failed to move env file: ${renameErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move all files from old directory to new directory when path changes
|
||||
// Get the new directory from composePath
|
||||
const newDir = options?.composePath ? dirname(options.composePath) : null;
|
||||
|
||||
if (options?.moveFromDir && newDir && options.moveFromDir !== newDir && existsSync(options.moveFromDir)) {
|
||||
try {
|
||||
// Ensure new directory exists
|
||||
if (!existsSync(newDir)) {
|
||||
mkdirSync(newDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Move all files from old directory to new directory
|
||||
const files = readdirSync(options.moveFromDir);
|
||||
for (const file of files) {
|
||||
const oldFilePath = join(options.moveFromDir, file);
|
||||
const newFilePath = join(newDir, file);
|
||||
|
||||
try {
|
||||
// Use rename for atomic move (same filesystem) or copy+delete for cross-filesystem
|
||||
renameSync(oldFilePath, newFilePath);
|
||||
console.log(`[Stack] Moved file: ${oldFilePath} -> ${newFilePath}`);
|
||||
} catch (renameErr: any) {
|
||||
// If rename fails (e.g., cross-filesystem), try copy+delete
|
||||
if (renameErr.code === 'EXDEV') {
|
||||
const stat = statSync(oldFilePath);
|
||||
if (stat.isDirectory()) {
|
||||
// For directories, use recursive copy
|
||||
cpSync(oldFilePath, newFilePath, { recursive: true });
|
||||
rmSync(oldFilePath, { recursive: true, force: true });
|
||||
} else {
|
||||
// For files, read and write
|
||||
const data = readFileSync(oldFilePath);
|
||||
writeFileSync(newFilePath, data);
|
||||
unlinkSync(oldFilePath);
|
||||
}
|
||||
console.log(`[Stack] Copied file (cross-fs): ${oldFilePath} -> ${newFilePath}`);
|
||||
} else {
|
||||
throw renameErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old directory if it's now empty
|
||||
try {
|
||||
const remaining = readdirSync(options.moveFromDir);
|
||||
if (remaining.length === 0) {
|
||||
rmSync(options.moveFromDir, { recursive: true, force: true });
|
||||
console.log(`[Stack] Removed empty old directory: ${options.moveFromDir}`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors when checking/removing old directory
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(`[Stack] Failed to move files from ${options.moveFromDir} to ${newDir}: ${err.message}`);
|
||||
// Continue with save even if move fails - new files will be written anyway
|
||||
}
|
||||
}
|
||||
|
||||
// If a custom composePath is being set (new or update), save it to the database
|
||||
if (options?.composePath || options?.envPath !== undefined) {
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
environmentId: envId ?? null,
|
||||
sourceType: 'internal',
|
||||
composePath: options?.composePath || source?.composePath || null,
|
||||
envPath: options?.envPath !== undefined ? options.envPath : (source?.envPath ?? null)
|
||||
});
|
||||
}
|
||||
|
||||
if (composePath) {
|
||||
// Write directly to the custom compose file path
|
||||
// Ensure parent directory exists for custom paths
|
||||
const parentDir = dirname(composePath);
|
||||
if (!existsSync(parentDir)) {
|
||||
try {
|
||||
mkdirSync(parentDir, { recursive: true });
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to create directory for compose file: ${err.message}` };
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Bun.write(composePath, content);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to save compose file: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// For creates, use new path; for updates, find existing path first
|
||||
let stackDir: string;
|
||||
if (create) {
|
||||
stackDir = await getStackDir(name, envId);
|
||||
} else {
|
||||
const existingDir = await findStackDir(name, envId);
|
||||
if (!existingDir) {
|
||||
return { success: false, error: `Stack "${name}" not found` };
|
||||
}
|
||||
stackDir = existingDir;
|
||||
}
|
||||
|
||||
const composeFile = join(stackDir, 'docker-compose.yml');
|
||||
const exists = existsSync(stackDir);
|
||||
|
||||
@@ -323,11 +635,6 @@ export async function saveStackComposeFile(
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to create stack directory: ${err.message}` };
|
||||
}
|
||||
} else {
|
||||
// Updating existing stack - must exist
|
||||
if (!exists) {
|
||||
return { success: false, error: `Stack "${name}" not found` };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -338,6 +645,68 @@ export async function saveStackComposeFile(
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// REGISTRY AUTHENTICATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Login to all configured Docker registries before running compose commands.
|
||||
* This ensures that `docker compose up` can pull images from private registries.
|
||||
*/
|
||||
async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Promise<void> {
|
||||
const { getRegistries } = await import('./db.js');
|
||||
const registries = await getRegistries();
|
||||
|
||||
if (registries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spawnEnv: Record<string, string> = { ...(process.env as Record<string, string>) };
|
||||
if (dockerHost) {
|
||||
spawnEnv.DOCKER_HOST = dockerHost;
|
||||
}
|
||||
|
||||
for (const reg of registries) {
|
||||
if (!reg.username || !reg.password) {
|
||||
continue; // Skip registries without credentials
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract registry host from URL
|
||||
const url = new URL(reg.url);
|
||||
const registryHost = url.host;
|
||||
|
||||
console.log(`${logPrefix} Logging into registry: ${registryHost}`);
|
||||
|
||||
const proc = Bun.spawn(
|
||||
['docker', 'login', '-u', reg.username, '--password-stdin', registryHost],
|
||||
{
|
||||
env: spawnEnv,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
}
|
||||
);
|
||||
|
||||
// Write password to stdin
|
||||
const writer = proc.stdin.getWriter();
|
||||
await writer.write(new TextEncoder().encode(reg.password));
|
||||
await writer.close();
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode === 0) {
|
||||
console.log(`${logPrefix} Successfully logged into ${registryHost}`);
|
||||
} else {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
console.error(`${logPrefix} Failed to login to ${registryHost}: ${stderr}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`${logPrefix} Error logging into registry ${reg.name}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPOSE COMMAND EXECUTION
|
||||
// =============================================================================
|
||||
@@ -364,11 +733,14 @@ async function executeLocalCompose(
|
||||
envVars?: Record<string, string>,
|
||||
secretVars?: Record<string, string>,
|
||||
forceRecreate?: boolean,
|
||||
removeVolumes?: boolean
|
||||
removeVolumes?: boolean,
|
||||
envId?: number | null
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
// For operations that write (up), use getStackDir; for others, try to find existing first
|
||||
const stackDir = operation === 'up'
|
||||
? await getStackDir(stackName, envId)
|
||||
: (await findStackDir(stackName, envId) || await getStackDir(stackName, envId));
|
||||
mkdirSync(stackDir, { recursive: true });
|
||||
|
||||
const composeFile = join(stackDir, 'docker-compose.yml');
|
||||
@@ -433,6 +805,11 @@ async function executeLocalCompose(
|
||||
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
|
||||
}
|
||||
|
||||
// Login to registries before pulling images
|
||||
if (operation === 'up' || operation === 'pull') {
|
||||
await loginToRegistries(dockerHost, logPrefix);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`${logPrefix} Spawning docker compose process...`);
|
||||
const proc = Bun.spawn(args, {
|
||||
@@ -577,6 +954,20 @@ async function executeComposeViaHawser(
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch registry credentials for Hawser to use for docker login
|
||||
const { getRegistries } = await import('./db.js');
|
||||
const allRegistries = await getRegistries();
|
||||
const registries = allRegistries
|
||||
.filter(r => r.username && r.password)
|
||||
.map(r => ({
|
||||
url: r.url,
|
||||
username: r.username!,
|
||||
password: r.password!
|
||||
}));
|
||||
if (registries.length > 0) {
|
||||
console.log(`${logPrefix} Sending ${registries.length} registry credentials to Hawser`);
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
operation,
|
||||
projectName: stackName,
|
||||
@@ -584,7 +975,8 @@ async function executeComposeViaHawser(
|
||||
envVars: allEnvVars, // All vars (including secrets) - Hawser injects via shell env
|
||||
files, // Files including .env (secrets NOT in .env file)
|
||||
forceRecreate: forceRecreate || false,
|
||||
removeVolumes: removeVolumes || false
|
||||
removeVolumes: removeVolumes || false,
|
||||
registries // Registry credentials for docker login
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} Sending request to Hawser agent...`);
|
||||
@@ -665,7 +1057,8 @@ async function executeComposeCommand(
|
||||
envVars,
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes
|
||||
removeVolumes,
|
||||
envId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -695,7 +1088,8 @@ async function executeComposeCommand(
|
||||
envVars,
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes
|
||||
removeVolumes,
|
||||
envId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -709,7 +1103,8 @@ async function executeComposeCommand(
|
||||
envVars,
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes
|
||||
removeVolumes,
|
||||
envId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -806,6 +1201,37 @@ async function getStackContainers(stackName: string, envId?: number | null): Pro
|
||||
return containers.filter((c) => c.labels['com.docker.compose.project'] === stackName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path hints from Docker container labels for a stack.
|
||||
* Docker Compose adds labels like:
|
||||
* - com.docker.compose.project.working_dir: /path/to/stack
|
||||
* - com.docker.compose.project.config_files: /path/to/docker-compose.yml[,...]
|
||||
*/
|
||||
export async function getStackPathHints(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<{
|
||||
workingDir: string | null;
|
||||
configFiles: string[] | null;
|
||||
}> {
|
||||
const containers = await getStackContainers(stackName, envId);
|
||||
|
||||
if (containers.length === 0) {
|
||||
return { workingDir: null, configFiles: null };
|
||||
}
|
||||
|
||||
// Get labels from first container (all containers in stack have same project labels)
|
||||
const labels = containers[0].labels || {};
|
||||
|
||||
const workingDir = labels['com.docker.compose.project.working_dir'] || null;
|
||||
const configFilesRaw = labels['com.docker.compose.project.config_files'] || null;
|
||||
|
||||
// Config files can be comma-separated if multiple compose files were used
|
||||
const configFiles = configFilesRaw ? configFilesRaw.split(',').map((f: string) => f.trim()) : null;
|
||||
|
||||
return { workingDir, configFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to perform container-based operations for external stacks
|
||||
* Used as fallback when no compose file exists.
|
||||
@@ -878,12 +1304,25 @@ async function withContainerFallback(
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Ensure we have a compose file for operations, throw appropriate error if not.
|
||||
* Result type for requireComposeFile - can indicate stack needs file location
|
||||
*/
|
||||
export interface RequireComposeResult {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
envVars?: Record<string, string>;
|
||||
secretVars?: Record<string, string>;
|
||||
needsFileLocation?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get compose file and env vars for stack operations.
|
||||
*
|
||||
* Returns:
|
||||
* - content: The compose file content
|
||||
* - envVars: Non-secret variables (from .env file, with DB fallback)
|
||||
* - secretVars: Secret variables (from DB only, for shell injection)
|
||||
* - needsFileLocation: true if stack needs user to specify file paths
|
||||
*
|
||||
* SECURITY: Secrets are NEVER written to .env files. They are stored in the database
|
||||
* and injected via shell environment variables at runtime.
|
||||
@@ -891,16 +1330,22 @@ async function withContainerFallback(
|
||||
async function requireComposeFile(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<{ content: string; envVars: Record<string, string>; secretVars: Record<string, string> }> {
|
||||
const composeResult = await getStackComposeFile(stackName);
|
||||
): Promise<RequireComposeResult> {
|
||||
const composeResult = await getStackComposeFile(stackName, envId);
|
||||
|
||||
// If compose file not found, return info about what's needed
|
||||
if (!composeResult.success) {
|
||||
// Check if this is an external stack
|
||||
const source = await getStackSource(stackName, envId);
|
||||
if (!source || source.sourceType === 'external') {
|
||||
throw new ExternalStackError(stackName);
|
||||
if (composeResult.needsFileLocation) {
|
||||
return {
|
||||
success: false,
|
||||
needsFileLocation: true,
|
||||
error: composeResult.error
|
||||
};
|
||||
}
|
||||
throw new ComposeFileNotFoundError(stackName);
|
||||
return {
|
||||
success: false,
|
||||
error: composeResult.error || `Compose file not found for stack "${stackName}"`
|
||||
};
|
||||
}
|
||||
|
||||
// Get SECRET variables from database (for shell injection at runtime)
|
||||
@@ -910,12 +1355,26 @@ async function requireComposeFile(
|
||||
// Get non-secret variables from database (for backward compatibility)
|
||||
const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
|
||||
|
||||
// Read non-secret vars from .env file (user can edit this file manually)
|
||||
const stackDir = join(getStacksDir(), stackName);
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
// Read non-secret vars from .env file
|
||||
// For stacks with custom path, use the env path if set (and not empty string which means "no env file")
|
||||
// Otherwise, use the .env file in the stack directory
|
||||
let envFilePath: string | null = null;
|
||||
|
||||
if (composeResult.composePath && composeResult.envPath) {
|
||||
// Custom compose path with explicit env path
|
||||
envFilePath = composeResult.envPath;
|
||||
} else if (composeResult.composePath && composeResult.envPath === '') {
|
||||
// Custom compose path with explicit "no env file" - don't read any file
|
||||
envFilePath = null;
|
||||
} else {
|
||||
// Default location - look for .env in stack directory
|
||||
const stackDir = composeResult.stackDir || await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
|
||||
envFilePath = join(stackDir, '.env');
|
||||
}
|
||||
|
||||
let fileEnvVars: Record<string, string> = {};
|
||||
|
||||
if (existsSync(envFilePath)) {
|
||||
if (envFilePath && existsSync(envFilePath)) {
|
||||
try {
|
||||
const content = await Bun.file(envFilePath).text();
|
||||
for (const line of content.split('\n')) {
|
||||
@@ -941,85 +1400,80 @@ async function requireComposeFile(
|
||||
// This ensures external edits to .env are respected during deployment
|
||||
const envVars = { ...dbNonSecretVars, ...fileEnvVars };
|
||||
|
||||
return { content: composeResult.content!, envVars, secretVars };
|
||||
return { success: true, content: composeResult.content!, envVars, secretVars };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a stack using docker compose up
|
||||
* Falls back to individual container start for external stacks
|
||||
* Falls back to individual container start for stacks without compose files
|
||||
*/
|
||||
export async function startStack(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('up', { stackName, envId }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
return withContainerFallback(stackName, envId, 'start');
|
||||
}
|
||||
throw err;
|
||||
const result = await requireComposeFile(stackName, envId);
|
||||
|
||||
if (!result.success) {
|
||||
// No compose file - fall back to container-based operations
|
||||
return withContainerFallback(stackName, envId, 'start');
|
||||
}
|
||||
|
||||
return executeComposeCommand('up', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a stack using docker compose stop
|
||||
* Falls back to individual container stop for external stacks
|
||||
* Falls back to individual container stop for stacks without compose files
|
||||
*/
|
||||
export async function stopStack(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('stop', { stackName, envId }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
throw err;
|
||||
const result = await requireComposeFile(stackName, envId);
|
||||
|
||||
if (!result.success) {
|
||||
// No compose file - fall back to container-based operations
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
|
||||
return executeComposeCommand('stop', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a stack using docker compose restart
|
||||
* Falls back to individual container restart for external stacks
|
||||
* Falls back to individual container restart for stacks without compose files
|
||||
*/
|
||||
export async function restartStack(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('restart', { stackName, envId }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
return withContainerFallback(stackName, envId, 'restart');
|
||||
}
|
||||
throw err;
|
||||
const result = await requireComposeFile(stackName, envId);
|
||||
|
||||
if (!result.success) {
|
||||
// No compose file - fall back to container-based operations
|
||||
return withContainerFallback(stackName, envId, 'restart');
|
||||
}
|
||||
|
||||
return executeComposeCommand('restart', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Down a stack using docker compose down (removes containers, keeps files)
|
||||
* For external stacks, this is equivalent to stop (no compose file to "down")
|
||||
* For stacks without compose files, this is equivalent to stop
|
||||
*/
|
||||
export async function downStack(
|
||||
stackName: string,
|
||||
envId?: number | null,
|
||||
removeVolumes = false
|
||||
): Promise<StackOperationResult> {
|
||||
try {
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
return executeComposeCommand('down', { stackName, envId, removeVolumes }, content, envVars, secretVars);
|
||||
} catch (err) {
|
||||
if (err instanceof ExternalStackError) {
|
||||
// For external stacks, down is the same as stop (no compose file to tear down)
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
throw err;
|
||||
const result = await requireComposeFile(stackName, envId);
|
||||
|
||||
if (!result.success) {
|
||||
// No compose file - down is the same as stop
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
|
||||
return executeComposeCommand('down', { stackName, envId, removeVolumes }, result.content!, result.envVars, result.secretVars);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1080,8 +1534,7 @@ export async function removeStack(
|
||||
const cleanupErrors: string[] = [];
|
||||
|
||||
// Delete compose file and directory
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
const stackDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
|
||||
if (existsSync(stackDir)) {
|
||||
try {
|
||||
rmSync(stackDir, { recursive: true, force: true });
|
||||
@@ -1166,19 +1619,19 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(envFileVars), null, 2));
|
||||
}
|
||||
|
||||
// Validate stack name
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
// Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores
|
||||
// Must also start with a letter or number
|
||||
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
|
||||
console.log(`${logPrefix} ERROR: Invalid stack name format`);
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: 'Stack name can only contain letters, numbers, hyphens, and underscores'
|
||||
error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores'
|
||||
};
|
||||
}
|
||||
|
||||
return withStackLock(name, async () => {
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, name);
|
||||
const stackDir = await getStackDir(name, envId);
|
||||
|
||||
// Read all files from source directory if provided (for Hawser deployments)
|
||||
let stackFiles: Record<string, string> | undefined;
|
||||
@@ -1248,9 +1701,16 @@ export async function pullStackImages(
|
||||
stackName: string,
|
||||
envId?: number | null
|
||||
): Promise<{ success: boolean; output?: string; error?: string }> {
|
||||
const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
|
||||
const result = await requireComposeFile(stackName, envId);
|
||||
|
||||
return executeComposeCommand('pull', { stackName, envId }, content, envVars, secretVars);
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Compose file not found'
|
||||
};
|
||||
}
|
||||
|
||||
return executeComposeCommand('pull', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -1279,10 +1739,19 @@ export async function saveStackEnvVarsToDb(
|
||||
*/
|
||||
export async function writeStackEnvFile(
|
||||
stackName: string,
|
||||
variables: { key: string; value: string; isSecret?: boolean }[]
|
||||
variables: { key: string; value: string; isSecret?: boolean }[],
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
const stacksDir = getStacksDir();
|
||||
const envFilePath = join(stacksDir, stackName, '.env');
|
||||
const envFilePath = customEnvPath
|
||||
? customEnvPath
|
||||
: join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
|
||||
|
||||
// Ensure parent directory exists
|
||||
const dir = dirname(envFilePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// SECURITY: Only write non-secret variables to .env file
|
||||
// Secrets are stored in DB and injected via shell environment at runtime
|
||||
@@ -1302,17 +1771,20 @@ export async function writeStackEnvFile(
|
||||
*/
|
||||
export async function writeRawStackEnvFile(
|
||||
stackName: string,
|
||||
rawContent: string
|
||||
rawContent: string,
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
const envFilePath = customEnvPath
|
||||
? customEnvPath
|
||||
: join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
|
||||
|
||||
// Ensure stack directory exists
|
||||
if (!existsSync(stackDir)) {
|
||||
mkdirSync(stackDir, { recursive: true });
|
||||
// Ensure parent directory exists
|
||||
const dir = dirname(envFilePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
await Bun.write(envFilePath, rawContent);
|
||||
}
|
||||
|
||||
@@ -1329,12 +1801,13 @@ export async function writeRawStackEnvFile(
|
||||
export async function saveStackEnvVars(
|
||||
stackName: string,
|
||||
variables: { key: string; value: string; isSecret?: boolean }[],
|
||||
envId?: number | null
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
// Save to database for secret tracking
|
||||
await saveStackEnvVarsToDb(stackName, variables, envId);
|
||||
// Write .env file to disk for Docker Compose
|
||||
await writeStackEnvFile(stackName, variables);
|
||||
await writeStackEnvFile(stackName, variables, envId, customEnvPath);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -102,7 +102,12 @@ export interface ShutdownCommand {
|
||||
type: 'shutdown';
|
||||
}
|
||||
|
||||
export type MainProcessCommand = RefreshEnvironmentsCommand | ShutdownCommand;
|
||||
export interface UpdateIntervalCommand {
|
||||
type: 'update_interval';
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
export type MainProcessCommand = RefreshEnvironmentsCommand | ShutdownCommand | UpdateIntervalCommand;
|
||||
|
||||
// Subprocess configuration
|
||||
interface SubprocessConfig {
|
||||
@@ -198,6 +203,20 @@ class SubprocessManager {
|
||||
this.sendToEvents({ type: 'refresh_environments' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to metrics subprocess
|
||||
*/
|
||||
sendToMetricsSubprocess(message: MainProcessCommand): void {
|
||||
this.sendToMetrics(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to events subprocess
|
||||
*/
|
||||
sendToEventsSubprocess(message: MainProcessCommand): void {
|
||||
this.sendToEvents(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the metrics collection subprocess
|
||||
*/
|
||||
@@ -591,3 +610,21 @@ export function refreshSubprocessEnvironments(): void {
|
||||
manager.refreshEnvironments();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to event subprocess
|
||||
*/
|
||||
export function sendToEventSubprocess(message: MainProcessCommand): void {
|
||||
if (manager) {
|
||||
manager.sendToEventsSubprocess(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to metrics subprocess
|
||||
*/
|
||||
export function sendToMetricsSubprocess(message: MainProcessCommand): void {
|
||||
if (manager) {
|
||||
manager.sendToMetricsSubprocess(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* Communication with main process via IPC (process.send).
|
||||
*/
|
||||
|
||||
import { getEnvironments, type ContainerEventAction } from '../db';
|
||||
import { getEnvironments, getEventCollectionMode, getEventPollInterval, type ContainerEventAction } from '../db';
|
||||
import { getDockerEvents } from '../docker';
|
||||
import type { MainProcessCommand } from '../subprocess-manager';
|
||||
|
||||
@@ -19,9 +19,15 @@ const MAX_RECONNECT_DELAY = 60000; // 1 minute max
|
||||
// Only send notifications on status CHANGES, not on every reconnect attempt
|
||||
const environmentOnlineStatus: Map<number, boolean> = new Map();
|
||||
|
||||
// Active collectors per environment
|
||||
// Active collectors per environment (for streaming mode)
|
||||
const collectors: Map<number, AbortController> = new Map();
|
||||
|
||||
// Poll intervals per environment (for polling mode)
|
||||
const pollIntervals: Map<number, ReturnType<typeof setInterval>> = new Map();
|
||||
|
||||
// Last poll timestamp per environment (for polling mode)
|
||||
const lastPollTime: Map<number, number> = new Map();
|
||||
|
||||
// Recent event cache for deduplication (key: timeNano-containerId-action)
|
||||
const recentEvents: Map<string, number> = new Map();
|
||||
const DEDUP_WINDOW_MS = 5000; // 5 second window for deduplication
|
||||
@@ -30,6 +36,10 @@ const CACHE_CLEANUP_INTERVAL_MS = 30000; // Clean up cache every 30 seconds
|
||||
let cacheCleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Track current settings to detect changes
|
||||
let currentPollInterval: number = 60000;
|
||||
let currentMode: 'stream' | 'poll' = 'stream';
|
||||
|
||||
// Actions we care about for container activity
|
||||
const CONTAINER_ACTIONS: ContainerEventAction[] = [
|
||||
'create',
|
||||
@@ -211,6 +221,76 @@ function processEvent(event: DockerEvent, envId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll events for a specific environment (polling mode)
|
||||
*/
|
||||
async function pollEnvironmentEvents(envId: number, envName: string) {
|
||||
try {
|
||||
// Calculate 'since' timestamp (use last poll time, or start from 30s ago if first poll)
|
||||
const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds
|
||||
const since = lastPollTime.get(envId) || (now - 30); // Default to 30s ago on first poll
|
||||
|
||||
// Fetch events since last check until now
|
||||
// IMPORTANT: 'until' is required for polling mode, otherwise Docker keeps the connection open
|
||||
const eventStream = await getDockerEvents(
|
||||
{ type: ['container'] },
|
||||
envId,
|
||||
{ since: since.toString(), until: now.toString() }
|
||||
);
|
||||
|
||||
if (!eventStream) {
|
||||
console.error(`[EventSubprocess] Failed to fetch events for ${envName}`);
|
||||
updateEnvironmentStatus(envId, envName, false, 'Failed to fetch Docker events');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark environment as online
|
||||
updateEnvironmentStatus(envId, envName, true);
|
||||
|
||||
// Read and process all events
|
||||
const reader = eventStream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
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) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(line) as DockerEvent;
|
||||
processEvent(event, envId);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Reader already released
|
||||
}
|
||||
}
|
||||
|
||||
// Update last poll time
|
||||
lastPollTime.set(envId, now);
|
||||
|
||||
} catch (error: any) {
|
||||
if (!isShuttingDown) {
|
||||
console.error(`[EventSubprocess] Poll error for ${envName}:`, error.message);
|
||||
updateEnvironmentStatus(envId, envName, false, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start collecting events for a specific environment
|
||||
*/
|
||||
@@ -332,7 +412,42 @@ async function startEnvironmentCollector(envId: number, envName: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop collecting events for a specific environment
|
||||
* Start polling mode for a specific environment
|
||||
*/
|
||||
async function startEnvironmentPoller(envId: number, envName: string, interval: number) {
|
||||
// Stop existing poller if any
|
||||
stopEnvironmentPoller(envId);
|
||||
|
||||
console.log(`[EventSubprocess] Starting poller for ${envName} (every ${interval / 1000}s)`);
|
||||
|
||||
// Initial poll immediately
|
||||
await pollEnvironmentEvents(envId, envName);
|
||||
|
||||
// Set up interval for subsequent polls
|
||||
const intervalId = setInterval(async () => {
|
||||
if (!isShuttingDown) {
|
||||
await pollEnvironmentEvents(envId, envName);
|
||||
}
|
||||
}, interval);
|
||||
|
||||
pollIntervals.set(envId, intervalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling for a specific environment
|
||||
*/
|
||||
function stopEnvironmentPoller(envId: number) {
|
||||
const intervalId = pollIntervals.get(envId);
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
pollIntervals.delete(envId);
|
||||
lastPollTime.delete(envId);
|
||||
environmentOnlineStatus.delete(envId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop collecting events for a specific environment (streaming mode)
|
||||
*/
|
||||
function stopEnvironmentCollector(envId: number) {
|
||||
const controller = collectors.get(envId);
|
||||
@@ -351,6 +466,21 @@ async function refreshEventCollectors() {
|
||||
|
||||
try {
|
||||
const environments = await getEnvironments();
|
||||
const mode = await getEventCollectionMode();
|
||||
const pollInterval = await getEventPollInterval();
|
||||
|
||||
// Detect if settings changed
|
||||
const modeChanged = mode !== currentMode;
|
||||
const intervalChanged = pollInterval !== currentPollInterval;
|
||||
|
||||
if (modeChanged) {
|
||||
console.log(`[EventSubprocess] Mode changed from ${currentMode} to ${mode}`);
|
||||
currentMode = mode;
|
||||
}
|
||||
if (intervalChanged) {
|
||||
console.log(`[EventSubprocess] Poll interval changed from ${currentPollInterval}ms to ${pollInterval}ms`);
|
||||
currentPollInterval = pollInterval;
|
||||
}
|
||||
|
||||
// Filter: only collect for environments with activity enabled AND not Hawser Edge
|
||||
const activeEnvIds = new Set(
|
||||
@@ -362,18 +492,55 @@ async function refreshEventCollectors() {
|
||||
// Stop collectors for removed environments or those with collection disabled
|
||||
for (const envId of collectors.keys()) {
|
||||
if (!activeEnvIds.has(envId)) {
|
||||
console.log(`[EventSubprocess] Stopping collector for environment ${envId}`);
|
||||
console.log(`[EventSubprocess] Stopping stream collector for environment ${envId}`);
|
||||
stopEnvironmentCollector(envId);
|
||||
}
|
||||
}
|
||||
|
||||
// Start collectors for environments with collection enabled
|
||||
// Stop pollers for removed environments or those with collection disabled
|
||||
// Also restart all pollers if interval changed
|
||||
for (const envId of pollIntervals.keys()) {
|
||||
if (!activeEnvIds.has(envId)) {
|
||||
console.log(`[EventSubprocess] Stopping poller for environment ${envId}`);
|
||||
stopEnvironmentPoller(envId);
|
||||
} else if (intervalChanged && mode === 'poll') {
|
||||
// Restart poller with new interval
|
||||
console.log(`[EventSubprocess] Restarting poller for environment ${envId} with new interval`);
|
||||
stopEnvironmentPoller(envId);
|
||||
}
|
||||
}
|
||||
|
||||
// Start collectors based on mode
|
||||
for (const env of environments) {
|
||||
// Skip Hawser Edge (handled by main process)
|
||||
if (env.connectionType === 'hawser-edge') continue;
|
||||
|
||||
if (env.collectActivity && !collectors.has(env.id)) {
|
||||
startEnvironmentCollector(env.id, env.name);
|
||||
// Skip if activity collection is disabled
|
||||
if (!env.collectActivity) continue;
|
||||
|
||||
const hasStreamCollector = collectors.has(env.id);
|
||||
const hasPoller = pollIntervals.has(env.id);
|
||||
|
||||
if (mode === 'stream') {
|
||||
// Switch from polling to streaming if needed
|
||||
if (hasPoller) {
|
||||
console.log(`[EventSubprocess] Switching ${env.name} from poll to stream`);
|
||||
stopEnvironmentPoller(env.id);
|
||||
}
|
||||
// Start stream if not already running
|
||||
if (!hasStreamCollector) {
|
||||
startEnvironmentCollector(env.id, env.name);
|
||||
}
|
||||
} else if (mode === 'poll') {
|
||||
// Switch from streaming to polling if needed
|
||||
if (hasStreamCollector) {
|
||||
console.log(`[EventSubprocess] Switching ${env.name} from stream to poll`);
|
||||
stopEnvironmentCollector(env.id);
|
||||
}
|
||||
// Start poller if not already running (will also restart after interval change above)
|
||||
if (!hasPoller) {
|
||||
startEnvironmentPoller(env.id, env.name, pollInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -392,6 +559,13 @@ function handleCommand(command: MainProcessCommand): void {
|
||||
refreshEventCollectors();
|
||||
break;
|
||||
|
||||
case 'update_interval':
|
||||
// This is used by metrics subprocess, but we handle it here too for consistency
|
||||
// Event subprocess re-reads interval from DB on refresh
|
||||
console.log('[EventSubprocess] Interval update - refreshing collectors...');
|
||||
refreshEventCollectors();
|
||||
break;
|
||||
|
||||
case 'shutdown':
|
||||
console.log('[EventSubprocess] Shutdown requested');
|
||||
shutdown();
|
||||
@@ -411,11 +585,16 @@ function shutdown(): void {
|
||||
cacheCleanupInterval = null;
|
||||
}
|
||||
|
||||
// Stop all environment collectors
|
||||
// Stop all environment stream collectors
|
||||
for (const envId of collectors.keys()) {
|
||||
stopEnvironmentCollector(envId);
|
||||
}
|
||||
|
||||
// Stop all environment pollers
|
||||
for (const envId of pollIntervals.keys()) {
|
||||
stopEnvironmentPoller(envId);
|
||||
}
|
||||
|
||||
// Clear the deduplication cache
|
||||
recentEvents.clear();
|
||||
|
||||
@@ -429,6 +608,15 @@ function shutdown(): void {
|
||||
async function start(): Promise<void> {
|
||||
console.log('[EventSubprocess] Starting container event collection...');
|
||||
|
||||
// Initialize current settings from database
|
||||
try {
|
||||
currentMode = await getEventCollectionMode();
|
||||
currentPollInterval = await getEventPollInterval();
|
||||
console.log(`[EventSubprocess] Initial mode: ${currentMode}, poll interval: ${currentPollInterval}ms`);
|
||||
} catch (error) {
|
||||
console.error('[EventSubprocess] Failed to load settings, using defaults:', error);
|
||||
}
|
||||
|
||||
// Start collectors for all environments
|
||||
await refreshEventCollectors();
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
* Communication with main process via IPC (process.send).
|
||||
*/
|
||||
|
||||
import { getEnvironments, getEnvSetting } from '../db';
|
||||
import { getEnvironments, getEnvSetting, getMetricsCollectionInterval } from '../db';
|
||||
import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from '../docker';
|
||||
import os from 'node:os';
|
||||
import type { MainProcessCommand } from '../subprocess-manager';
|
||||
|
||||
const COLLECT_INTERVAL = 10000; // 10 seconds
|
||||
let COLLECT_INTERVAL = 30000; // 30 seconds (default, will be loaded from settings)
|
||||
const DISK_CHECK_INTERVAL = 300000; // 5 minutes
|
||||
const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings
|
||||
const ENV_METRICS_TIMEOUT = 15000; // 15 seconds timeout per environment for metrics
|
||||
@@ -82,12 +82,16 @@ async function collectEnvMetrics(env: { id: number; name: string; host?: string;
|
||||
cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100;
|
||||
}
|
||||
|
||||
// Get container memory usage (subtract cache for actual usage)
|
||||
// Get container memory usage using the same formula as Docker CLI
|
||||
// Docker subtracts cache (inactive_file) from total usage
|
||||
// - cgroup v2: uses 'inactive_file'
|
||||
// - cgroup v1: uses 'total_inactive_file'
|
||||
const memUsage = stats.memory_stats?.usage || 0;
|
||||
const memCache = stats.memory_stats?.stats?.cache || 0;
|
||||
const actualMemUsed = memUsage - memCache;
|
||||
const memStats = stats.memory_stats?.stats || {};
|
||||
const memCache = memStats.inactive_file ?? memStats.total_inactive_file ?? 0;
|
||||
const actualMemUsed = memCache > 0 && memCache < memUsage ? memUsage - memCache : memUsage;
|
||||
|
||||
return { cpuPercent, memUsage: actualMemUsed > 0 ? actualMemUsed : memUsage };
|
||||
return { cpuPercent, memUsage: actualMemUsed };
|
||||
} catch {
|
||||
return { cpuPercent: 0, memUsage: 0 };
|
||||
}
|
||||
@@ -356,6 +360,16 @@ function handleCommand(command: MainProcessCommand): void {
|
||||
// The next collection cycle will pick up the new environments
|
||||
break;
|
||||
|
||||
case 'update_interval':
|
||||
console.log(`[MetricsSubprocess] Updating collection interval to ${command.intervalMs}ms`);
|
||||
COLLECT_INTERVAL = command.intervalMs;
|
||||
// Clear existing interval and restart with new timing
|
||||
if (collectInterval) {
|
||||
clearInterval(collectInterval);
|
||||
collectInterval = setInterval(collectMetrics, COLLECT_INTERVAL);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'shutdown':
|
||||
console.log('[MetricsSubprocess] Shutdown requested');
|
||||
shutdown();
|
||||
@@ -386,8 +400,15 @@ function shutdown(): void {
|
||||
/**
|
||||
* Start the metrics collector
|
||||
*/
|
||||
function start(): void {
|
||||
console.log('[MetricsSubprocess] Starting metrics collection (every 10s)...');
|
||||
async function start(): Promise<void> {
|
||||
// Load interval from settings
|
||||
try {
|
||||
COLLECT_INTERVAL = await getMetricsCollectionInterval();
|
||||
console.log(`[MetricsSubprocess] Starting metrics collection (every ${COLLECT_INTERVAL / 1000}s)...`);
|
||||
} catch (error) {
|
||||
console.error('[MetricsSubprocess] Failed to load interval from settings, using default 30s');
|
||||
COLLECT_INTERVAL = 30000;
|
||||
}
|
||||
|
||||
// Initial collection
|
||||
collectMetrics();
|
||||
|
||||
@@ -70,8 +70,21 @@ function createGridPreferencesStore() {
|
||||
return getDefaultColumnPreferences(gridId);
|
||||
}
|
||||
|
||||
// Return columns in saved order, filtering to visible ones
|
||||
return gridPrefs.columns.filter((col) => col.visible);
|
||||
// Merge with defaults to ensure new columns are included
|
||||
const defaults = getDefaultColumnPreferences(gridId);
|
||||
const savedIds = new Set(gridPrefs.columns.map((c) => c.id));
|
||||
|
||||
// Start with saved visible columns
|
||||
const result = gridPrefs.columns.filter((col) => col.visible);
|
||||
|
||||
// Add any new default columns that aren't in saved preferences
|
||||
for (const def of defaults) {
|
||||
if (!savedIds.has(def.id) && def.visible) {
|
||||
result.push(def);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
// Get all columns for a grid (visible and hidden, in order)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { browser } from '$app/environment';
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
export type DownloadFormat = 'tar' | 'tar.gz';
|
||||
export type EventCollectionMode = 'stream' | 'poll';
|
||||
|
||||
export interface AppSettings {
|
||||
confirmDestructive: boolean;
|
||||
@@ -22,6 +23,11 @@ export interface AppSettings {
|
||||
eventCleanupEnabled: boolean;
|
||||
logBufferSizeKb: number;
|
||||
defaultTimezone: string;
|
||||
eventCollectionMode: EventCollectionMode;
|
||||
eventPollInterval: number;
|
||||
metricsCollectionInterval: number;
|
||||
externalStackPaths: string[];
|
||||
primaryStackLocation: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -40,7 +46,12 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
scheduleCleanupEnabled: true,
|
||||
eventCleanupEnabled: true,
|
||||
logBufferSizeKb: 500,
|
||||
defaultTimezone: 'UTC'
|
||||
defaultTimezone: 'UTC',
|
||||
eventCollectionMode: 'stream',
|
||||
eventPollInterval: 60000,
|
||||
metricsCollectionInterval: 30000,
|
||||
externalStackPaths: [],
|
||||
primaryStackLocation: null
|
||||
};
|
||||
|
||||
// Create a writable store for app settings
|
||||
@@ -73,7 +84,12 @@ function createSettingsStore() {
|
||||
scheduleCleanupEnabled: settings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
|
||||
eventCleanupEnabled: settings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
|
||||
logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone
|
||||
defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||
eventCollectionMode: settings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
|
||||
eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
metricsCollectionInterval: settings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
||||
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -109,7 +125,12 @@ function createSettingsStore() {
|
||||
scheduleCleanupEnabled: updatedSettings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
|
||||
eventCleanupEnabled: updatedSettings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
|
||||
logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone
|
||||
defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||
eventCollectionMode: updatedSettings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
|
||||
eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
metricsCollectionInterval: updatedSettings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
||||
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -248,6 +269,41 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setEventCollectionMode: (value: EventCollectionMode) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, eventCollectionMode: value };
|
||||
saveSettings({ eventCollectionMode: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setEventPollInterval: (value: number) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, eventPollInterval: value };
|
||||
saveSettings({ eventPollInterval: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setMetricsCollectionInterval: (value: number) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, metricsCollectionInterval: value };
|
||||
saveSettings({ metricsCollectionInterval: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setExternalStackPaths: (value: string[]) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, externalStackPaths: value };
|
||||
saveSettings({ externalStackPaths: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setPrimaryStackLocation: (value: string | null) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, primaryStackLocation: value };
|
||||
saveSettings({ primaryStackLocation: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
// Manual refresh from database
|
||||
refresh: loadSettings
|
||||
};
|
||||
|
||||
+4
-1
@@ -34,6 +34,7 @@ export interface ImageInfo {
|
||||
size: number;
|
||||
virtualSize: number;
|
||||
labels: Record<string, string>;
|
||||
containers: number; // Number of containers using this image
|
||||
}
|
||||
|
||||
export interface VolumeUsage {
|
||||
@@ -90,7 +91,9 @@ export interface ContainerStats {
|
||||
id: string;
|
||||
name: string;
|
||||
cpuPercent: number;
|
||||
memoryUsage: number;
|
||||
memoryUsage: number; // Actual usage (total - cache), same as docker stats
|
||||
memoryRaw: number; // Raw total usage before cache subtraction
|
||||
memoryCache: number; // File cache (inactive_file)
|
||||
memoryLimit: number;
|
||||
memoryPercent: number;
|
||||
networkRx: number;
|
||||
|
||||
@@ -335,7 +335,7 @@
|
||||
connectionType: env.connectionType || 'socket',
|
||||
labels: env.labels || [],
|
||||
scannerEnabled: false,
|
||||
online: false,
|
||||
online: undefined, // undefined = connecting, false = offline, true = online
|
||||
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
|
||||
images: { total: 0, totalSize: 0 },
|
||||
volumes: { total: 0, totalSize: 0 },
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
Loader2,
|
||||
FileX,
|
||||
Heart,
|
||||
Search
|
||||
Search,
|
||||
Wifi,
|
||||
Radio
|
||||
} from 'lucide-svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import { currentEnvironment, environments as environmentsStore } from '$lib/stores/environment';
|
||||
@@ -38,7 +40,7 @@
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatDateTime } from '$lib/stores/settings';
|
||||
import { formatDateTime, appSettings } from '$lib/stores/settings';
|
||||
import { NoEnvironment } from '$lib/components/ui/empty-state';
|
||||
import { DataGrid } from '$lib/components/data-grid';
|
||||
|
||||
@@ -643,7 +645,20 @@
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
|
||||
<!-- Header with inline filters -->
|
||||
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
|
||||
<PageHeader icon={Activity} title="Activity" count={visibleEnd > 0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" />
|
||||
<div class="flex items-center gap-3">
|
||||
<PageHeader icon={Activity} title="Activity" count={visibleEnd > 0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" />
|
||||
<Badge variant="outline" class="gap-1.5 {($appSettings.eventCollectionMode || 'stream') === 'stream' ? 'text-green-500 border-green-500/50' : 'text-amber-500 border-amber-500/50'}">
|
||||
{#if ($appSettings.eventCollectionMode || 'stream') === 'stream'}
|
||||
<Wifi class="w-3 h-3" />
|
||||
<span>Stream</span>
|
||||
{:else if ($appSettings.eventCollectionMode || 'stream') === 'poll'}
|
||||
<Radio class="w-3 h-3" />
|
||||
<span>Poll</span><span class="text-[10px] opacity-70">({($appSettings.eventPollInterval || 60000) / 1000}s)</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">Off</span>
|
||||
{/if}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Container name search -->
|
||||
<div class="relative">
|
||||
|
||||
@@ -285,11 +285,19 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
inspectResponse = await fetch(inspectUrl, {
|
||||
headers: inspectHeaders,
|
||||
// @ts-ignore
|
||||
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
|
||||
});
|
||||
const fetchOpts: any = { headers: inspectHeaders };
|
||||
if (config.type === 'https') {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0, // Disable TLS session caching for mTLS
|
||||
servername: config.host,
|
||||
rejectUnauthorized: true
|
||||
};
|
||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||
if (config.key) fetchOpts.tls.key = config.key;
|
||||
fetchOpts.keepalive = false;
|
||||
}
|
||||
inspectResponse = await fetch(inspectUrl, fetchOpts);
|
||||
}
|
||||
|
||||
if (inspectResponse.ok) {
|
||||
@@ -341,12 +349,22 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
|
||||
const logsHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
response = await fetch(logsUrl, {
|
||||
const fetchOpts: any = {
|
||||
headers: logsHeaders,
|
||||
signal: abortController?.signal,
|
||||
// @ts-ignore
|
||||
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
|
||||
});
|
||||
signal: abortController?.signal
|
||||
};
|
||||
if (config.type === 'https') {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0, // Disable TLS session caching for mTLS
|
||||
servername: config.host,
|
||||
rejectUnauthorized: true
|
||||
};
|
||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||
if (config.key) fetchOpts.tls.key = config.key;
|
||||
fetchOpts.keepalive = false;
|
||||
}
|
||||
response = await fetch(logsUrl, fetchOpts);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getContainerStats } from '$lib/server/docker';
|
||||
import { getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
|
||||
@@ -47,6 +47,28 @@ function calculateBlockIO(stats: any): { read: number; write: number } {
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate memory usage the same way Docker CLI does.
|
||||
* Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
|
||||
* - cgroup v2: subtract inactive_file from stats
|
||||
* - cgroup v1: subtract total_inactive_file from stats
|
||||
* See: https://docs.docker.com/engine/containers/runmetrics/
|
||||
*
|
||||
* Returns: { usage: actual memory (minus cache), raw: total usage, cache: file cache }
|
||||
*/
|
||||
function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
|
||||
const raw = memoryStats?.usage || 0;
|
||||
const stats = memoryStats?.stats || {};
|
||||
|
||||
// cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
|
||||
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
|
||||
|
||||
// Only subtract cache if it's less than raw usage (sanity check)
|
||||
const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
|
||||
|
||||
return { usage, raw, cache };
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -67,15 +89,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const stats = await getContainerStats(params.id, envIdNum) as any;
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats);
|
||||
const memoryUsage = stats.memory_stats?.usage || 0;
|
||||
const memory = calculateMemoryUsage(stats.memory_stats);
|
||||
const memoryLimit = stats.memory_stats?.limit || 1;
|
||||
const memoryPercent = (memoryUsage / memoryLimit) * 100;
|
||||
const memoryPercent = (memory.usage / memoryLimit) * 100;
|
||||
const networkIO = calculateNetworkIO(stats);
|
||||
const blockIO = calculateBlockIO(stats);
|
||||
|
||||
return json({
|
||||
cpuPercent: Math.round(cpuPercent * 100) / 100,
|
||||
memoryUsage,
|
||||
memoryUsage: memory.usage,
|
||||
memoryRaw: memory.raw,
|
||||
memoryCache: memory.cache,
|
||||
memoryLimit,
|
||||
memoryPercent: Math.round(memoryPercent * 100) / 100,
|
||||
networkRx: networkIO.rx,
|
||||
@@ -85,6 +109,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Return 404 for deleted environments so client can clear stale cache
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Failed to get container stats:', error);
|
||||
return json({ error: error.message || 'Failed to get stats' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listContainers, getContainerStats } from '$lib/server/docker';
|
||||
import { listContainers, getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import type { ContainerStats } from '$lib/types';
|
||||
@@ -48,6 +48,28 @@ function calculateBlockIO(stats: any): { read: number; write: number } {
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate memory usage the same way Docker CLI does.
|
||||
* Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
|
||||
* - cgroup v2: subtract inactive_file from stats
|
||||
* - cgroup v1: subtract total_inactive_file from stats
|
||||
* See: https://docs.docker.com/engine/containers/runmetrics/
|
||||
*
|
||||
* Returns: { usage: actual memory (minus cache), raw: total usage, cache: file cache }
|
||||
*/
|
||||
function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
|
||||
const raw = memoryStats?.usage || 0;
|
||||
const stats = memoryStats?.stats || {};
|
||||
|
||||
// cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
|
||||
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
|
||||
|
||||
// Only subtract cache if it's less than raw usage (sanity check)
|
||||
const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
|
||||
|
||||
return { usage, raw, cache };
|
||||
}
|
||||
|
||||
// Helper to add timeout to promises
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return Promise.race([
|
||||
@@ -112,10 +134,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (!stats) return null;
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats);
|
||||
// Use raw memory usage (total memory attributed to container)
|
||||
const memoryUsage = stats.memory_stats?.usage || 0;
|
||||
// Calculate memory usage the same way Docker CLI does (excludes cache)
|
||||
const memory = calculateMemoryUsage(stats.memory_stats);
|
||||
const memoryLimit = stats.memory_stats?.limit || 1;
|
||||
const memoryPercent = (memoryUsage / memoryLimit) * 100;
|
||||
const memoryPercent = (memory.usage / memoryLimit) * 100;
|
||||
const networkIO = calculateNetworkIO(stats);
|
||||
const blockIO = calculateBlockIO(stats);
|
||||
|
||||
@@ -123,7 +145,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
id: container.id,
|
||||
name: container.name,
|
||||
cpuPercent: Math.round(cpuPercent * 100) / 100,
|
||||
memoryUsage,
|
||||
memoryUsage: memory.usage,
|
||||
memoryRaw: memory.raw,
|
||||
memoryCache: memory.cache,
|
||||
memoryLimit,
|
||||
memoryPercent: Math.round(memoryPercent * 100) / 100,
|
||||
networkRx: networkIO.rx,
|
||||
@@ -142,6 +166,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
|
||||
return json(validStats);
|
||||
} catch (error: any) {
|
||||
// Return 404 for deleted environments so client can clear stale cache
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Failed to get container stats:', error);
|
||||
return json([], { status: 200 }); // Return empty array instead of error
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface EnvironmentStats {
|
||||
updateCheckAutoUpdate: boolean;
|
||||
labels?: string[];
|
||||
connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
|
||||
online: boolean;
|
||||
online?: boolean; // undefined = connecting, false = offline, true = online
|
||||
error?: string;
|
||||
containers: {
|
||||
total: number;
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
listContainers,
|
||||
listImages,
|
||||
listNetworks,
|
||||
getDockerInfo,
|
||||
getContainerStats,
|
||||
getDiskUsage
|
||||
} from '$lib/server/docker';
|
||||
@@ -95,6 +94,28 @@ function calculateCpuPercent(stats: any): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate memory usage the same way Docker CLI does.
|
||||
* Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
|
||||
* - cgroup v2: subtract inactive_file from stats
|
||||
* - cgroup v1: subtract total_inactive_file from stats
|
||||
* See: https://docs.docker.com/engine/containers/runmetrics/
|
||||
*/
|
||||
function calculateMemoryUsage(memoryStats: any): number {
|
||||
const usage = memoryStats?.usage || 0;
|
||||
const stats = memoryStats?.stats || {};
|
||||
|
||||
// cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
|
||||
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
|
||||
|
||||
// Only subtract cache if it's less than usage (sanity check)
|
||||
if (cache > 0 && cache < usage) {
|
||||
return usage - cache;
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
// Progressive stats loading - returns stats object and emits partial updates via callback
|
||||
async function getEnvironmentStatsProgressive(
|
||||
env: any,
|
||||
@@ -150,23 +171,9 @@ async function getEnvironmentStatsProgressive(
|
||||
envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate;
|
||||
}
|
||||
|
||||
// Check if Docker is accessible (with 5 second timeout)
|
||||
const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null);
|
||||
if (!dockerInfo) {
|
||||
envStats.error = 'Connection timeout or Docker not accessible';
|
||||
envStats.loading = undefined; // Clear loading states on error
|
||||
// Send offline status to client
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
online: false,
|
||||
error: envStats.error,
|
||||
loading: undefined
|
||||
});
|
||||
return envStats;
|
||||
}
|
||||
envStats.online = true;
|
||||
|
||||
// Get all database stats in parallel for better performance
|
||||
// NOTE: We do NOT block on getDockerInfo() here - slow environments would block all others
|
||||
// Instead, we determine online status from whether listContainers succeeds
|
||||
const [latestMetrics, eventStats, recentEventsResult, metricsHistory] = await Promise.all([
|
||||
getLatestHostMetrics(env.id),
|
||||
getContainerEventStats(env.id),
|
||||
@@ -204,10 +211,9 @@ async function getEnvironmentStatsProgressive(
|
||||
}));
|
||||
}
|
||||
|
||||
// Send initial update with DB data and online status
|
||||
// Send initial update with DB data (online status determined later by Docker API success)
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
online: true,
|
||||
metrics: envStats.metrics,
|
||||
events: envStats.events,
|
||||
recentEvents: envStats.recentEvents,
|
||||
@@ -223,9 +229,22 @@ async function getEnvironmentStatsProgressive(
|
||||
return size && size > 0 ? size : 0;
|
||||
};
|
||||
|
||||
// PHASE 1: Containers (usually fast)
|
||||
const containersPromise = withTimeout(listContainers(true, env.id).catch(() => []), 10000, [])
|
||||
// Track if Docker API is accessible - determined by listContainers success
|
||||
let dockerApiAccessible = false;
|
||||
let dockerApiError: string | null = null;
|
||||
|
||||
// PHASE 1: Containers (usually fast) - this determines online status
|
||||
// Use 10s timeout - this is the critical path that determines if env is online
|
||||
const containersPromise = withTimeout(listContainers(true, env.id), 10000, null)
|
||||
.then(async (containers) => {
|
||||
// Timeout returns null
|
||||
if (containers === null) {
|
||||
throw new Error('Connection timeout');
|
||||
}
|
||||
// If we got here, Docker API is accessible
|
||||
dockerApiAccessible = true;
|
||||
envStats.online = true;
|
||||
|
||||
envStats.containers.total = containers.length;
|
||||
envStats.containers.running = containers.filter((c: any) => c.state === 'running').length;
|
||||
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length;
|
||||
@@ -236,11 +255,39 @@ async function getEnvironmentStatsProgressive(
|
||||
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
online: true,
|
||||
containers: { ...envStats.containers },
|
||||
loading: { ...envStats.loading! }
|
||||
});
|
||||
|
||||
return containers;
|
||||
})
|
||||
.catch((error) => {
|
||||
// Docker API failed - mark as offline
|
||||
dockerApiAccessible = false;
|
||||
const errorStr = String(error);
|
||||
if (errorStr.includes('not connected') || errorStr.includes('Edge agent')) {
|
||||
dockerApiError = 'Agent not connected';
|
||||
} else if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
|
||||
dockerApiError = 'Docker socket not accessible';
|
||||
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
|
||||
dockerApiError = 'Connection lost';
|
||||
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
|
||||
dockerApiError = 'Connection timeout';
|
||||
} else {
|
||||
dockerApiError = 'Connection error';
|
||||
}
|
||||
envStats.error = dockerApiError;
|
||||
envStats.loading!.containers = false;
|
||||
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
online: false,
|
||||
error: dockerApiError,
|
||||
loading: { ...envStats.loading! }
|
||||
});
|
||||
|
||||
return [] as any[];
|
||||
});
|
||||
|
||||
// PHASE 2: Images, Networks, Stacks (medium speed) - run in parallel
|
||||
@@ -339,7 +386,7 @@ async function getEnvironmentStatsProgressive(
|
||||
if (!stats) return null;
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats);
|
||||
const memoryUsage = stats.memory_stats?.usage || 0;
|
||||
const memoryUsage = calculateMemoryUsage(stats.memory_stats);
|
||||
const memoryLimit = stats.memory_stats?.limit || 1;
|
||||
const memoryPercent = (memoryUsage / memoryLimit) * 100;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getEnvironments, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
|
||||
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -69,6 +70,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
return json({ error: 'Name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if environment with this name already exists
|
||||
const existing = await getEnvironmentByName(data.name);
|
||||
if (existing) {
|
||||
return json({ error: 'An environment with this name already exists' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Host is required for direct and hawser-standard connections
|
||||
const connectionType = data.connectionType || 'socket';
|
||||
if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) {
|
||||
@@ -83,9 +90,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
host: data.host,
|
||||
port: data.port || 2375,
|
||||
protocol: data.protocol || 'http',
|
||||
tlsCa: data.tlsCa,
|
||||
tlsCert: data.tlsCert,
|
||||
tlsKey: data.tlsKey,
|
||||
tlsCa: cleanPem(data.tlsCa),
|
||||
tlsCert: cleanPem(data.tlsCert),
|
||||
tlsKey: cleanPem(data.tlsKey),
|
||||
tlsSkipVerify: data.tlsSkipVerify || false,
|
||||
icon: data.icon || 'globe',
|
||||
socketPath: data.socketPath || '/var/run/docker.sock',
|
||||
@@ -124,7 +131,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
return json(env);
|
||||
} catch (error) {
|
||||
console.error('Failed to create environment:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to create environment';
|
||||
return json({ error: message }, { status: 500 });
|
||||
return json({ error: 'Failed to create environment' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { deleteGitStackFiles } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { closeEdgeConnection } from '$lib/server/hawser';
|
||||
|
||||
@@ -62,9 +63,9 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
|
||||
host: data.host,
|
||||
port: data.port,
|
||||
protocol: data.protocol,
|
||||
tlsCa: data.tlsCa,
|
||||
tlsCert: data.tlsCert,
|
||||
tlsKey: data.tlsKey,
|
||||
tlsCa: cleanPem(data.tlsCa),
|
||||
tlsCert: cleanPem(data.tlsCert),
|
||||
tlsKey: cleanPem(data.tlsKey),
|
||||
tlsSkipVerify: data.tlsSkipVerify,
|
||||
icon: data.icon,
|
||||
socketPath: data.socketPath,
|
||||
|
||||
@@ -60,50 +60,54 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
headers['X-Hawser-Token'] = config.hawserToken;
|
||||
}
|
||||
|
||||
// For HTTPS with custom CA or skip verification, use subprocess to avoid Vite dev server TLS issues
|
||||
if (protocol === 'https' && (config.tlsCa || config.tlsSkipVerify)) {
|
||||
const fs = await import('node:fs');
|
||||
let tempCaPath = '';
|
||||
// For HTTPS with custom CA, client certs, or skip verification, use subprocess to avoid Vite dev server TLS issues
|
||||
if (protocol === 'https' && (config.tlsCa || config.tlsCert || config.tlsSkipVerify)) {
|
||||
// Clean PEM content (remove extra whitespace)
|
||||
const cleanPem = (pem: string) => pem
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n');
|
||||
|
||||
// Clean the certificate - remove leading/trailing whitespace from each line
|
||||
let cleanedCa = '';
|
||||
if (config.tlsCa && !config.tlsSkipVerify) {
|
||||
cleanedCa = config.tlsCa
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n');
|
||||
|
||||
tempCaPath = `/tmp/dockhand-ca-${Date.now()}.pem`;
|
||||
fs.writeFileSync(tempCaPath, cleanedCa);
|
||||
}
|
||||
|
||||
// Build Bun script that runs outside Vite's process (Vite interferes with TLS)
|
||||
const tlsConfig = config.tlsSkipVerify
|
||||
? `tls: { rejectUnauthorized: false }`
|
||||
: `tls: { ca: await Bun.file('${tempCaPath}').text() }`;
|
||||
// Pass config as base64-encoded JSON to avoid escaping issues
|
||||
const tlsConfig = {
|
||||
url: `https://${host}:${port}/info`,
|
||||
headers,
|
||||
tlsSkipVerify: config.tlsSkipVerify || false,
|
||||
ca: config.tlsCa && !config.tlsSkipVerify ? cleanPem(config.tlsCa) : null,
|
||||
cert: config.tlsCert ? cleanPem(config.tlsCert) : null,
|
||||
key: config.tlsKey ? cleanPem(config.tlsKey) : null,
|
||||
host
|
||||
};
|
||||
const configBase64 = Buffer.from(JSON.stringify(tlsConfig)).toString('base64');
|
||||
|
||||
// Inline script with config embedded (bun -e doesn't pass argv correctly)
|
||||
const scriptContent = `
|
||||
const response = await fetch('https://${host}:${port}/info', {
|
||||
headers: ${JSON.stringify(headers)},
|
||||
${tlsConfig}
|
||||
});
|
||||
const body = await response.text();
|
||||
console.log(JSON.stringify({ status: response.status, body }));
|
||||
const config = JSON.parse(Buffer.from('${configBase64}', 'base64').toString());
|
||||
try {
|
||||
const tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.tlsSkipVerify
|
||||
};
|
||||
if (config.ca) tls.ca = [config.ca];
|
||||
if (config.cert) tls.cert = [config.cert];
|
||||
if (config.key) tls.key = config.key;
|
||||
const response = await fetch(config.url, {
|
||||
headers: config.headers,
|
||||
tls,
|
||||
keepalive: false
|
||||
});
|
||||
const body = await response.text();
|
||||
console.log(JSON.stringify({ status: response.status, body }));
|
||||
} catch (e) {
|
||||
console.log(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
`;
|
||||
const scriptPath = `/tmp/dockhand-test-${Date.now()}.ts`;
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const proc = Bun.spawn(['bun', scriptPath], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const proc = Bun.spawn(['bun', '-e', scriptContent], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
// Cleanup temp files
|
||||
if (tempCaPath) {
|
||||
try { fs.unlinkSync(tempCaPath); } catch {}
|
||||
}
|
||||
try { fs.unlinkSync(scriptPath); } catch {}
|
||||
|
||||
if (!output.trim()) {
|
||||
throw new Error(stderr || 'Empty response from TLS test subprocess');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDockerEvents } from '$lib/server/docker';
|
||||
import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
@@ -118,8 +118,13 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
|
||||
processEvents();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to connect to Docker events:', error);
|
||||
sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
// Expected error when environment doesn't exist - don't spam logs
|
||||
sendEvent('error', { message: 'Environment not found' });
|
||||
} else {
|
||||
console.error('Failed to connect to Docker events:', error);
|
||||
sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
|
||||
}
|
||||
clearInterval(heartbeatInterval);
|
||||
controller.close();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { deployGitStack } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { registerSchedule } from '$lib/server/scheduler';
|
||||
import crypto from 'node:crypto';
|
||||
import { secureRandomBytes } from '$lib/server/crypto-fallback';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -94,7 +94,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
// Generate webhook secret if webhook is enabled
|
||||
let webhookSecret = data.webhookSecret;
|
||||
if (data.webhookEnabled && !webhookSecret) {
|
||||
webhookSecret = crypto.randomBytes(32).toString('hex');
|
||||
webhookSecret = secureRandomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
const gitStack = await createGitStack({
|
||||
|
||||
@@ -16,7 +16,16 @@ function isDockerHub(url: string): boolean {
|
||||
lower.includes('registry.hub.docker.com');
|
||||
}
|
||||
|
||||
async function fetchDockerHubTags(imageName: string): Promise<TagInfo[]> {
|
||||
interface PaginatedTags {
|
||||
tags: TagInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
}
|
||||
|
||||
async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize: number = 20): Promise<PaginatedTags> {
|
||||
// Docker Hub uses a different API
|
||||
// For official images: https://hub.docker.com/v2/repositories/library/<image>/tags
|
||||
// For user images: https://hub.docker.com/v2/repositories/<user>/<image>/tags
|
||||
@@ -27,7 +36,7 @@ async function fetchDockerHubTags(imageName: string): Promise<TagInfo[]> {
|
||||
repoPath = `library/${imageName}`;
|
||||
}
|
||||
|
||||
const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=100&ordering=last_updated`;
|
||||
const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=${pageSize}&page=${page}&ordering=last_updated`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
@@ -45,12 +54,21 @@ async function fetchDockerHubTags(imageName: string): Promise<TagInfo[]> {
|
||||
const data = await response.json();
|
||||
const results = data.results || [];
|
||||
|
||||
return results.map((tag: any) => ({
|
||||
const tags = results.map((tag: any) => ({
|
||||
name: tag.name,
|
||||
size: tag.full_size || tag.images?.[0]?.size,
|
||||
lastUpdated: tag.last_updated || tag.tag_last_pushed,
|
||||
digest: tag.images?.[0]?.digest
|
||||
}));
|
||||
|
||||
return {
|
||||
tags,
|
||||
total: data.count || 0,
|
||||
page,
|
||||
pageSize,
|
||||
hasNext: !!data.next,
|
||||
hasPrev: !!data.previous
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRegistryTags(registry: any, imageName: string): Promise<TagInfo[]> {
|
||||
@@ -104,16 +122,18 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const registryId = url.searchParams.get('registry');
|
||||
const imageName = url.searchParams.get('image');
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '20');
|
||||
|
||||
if (!imageName) {
|
||||
return json({ error: 'Image name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let tags: TagInfo[];
|
||||
let result: PaginatedTags;
|
||||
|
||||
if (!registryId) {
|
||||
// No registry specified, assume Docker Hub
|
||||
tags = await fetchDockerHubTags(imageName);
|
||||
result = await fetchDockerHubTags(imageName, page, pageSize);
|
||||
} else {
|
||||
const registry = await getRegistry(parseInt(registryId));
|
||||
if (!registry) {
|
||||
@@ -121,13 +141,22 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
|
||||
if (isDockerHub(registry.url)) {
|
||||
tags = await fetchDockerHubTags(imageName);
|
||||
result = await fetchDockerHubTags(imageName, page, pageSize);
|
||||
} else {
|
||||
tags = await fetchRegistryTags(registry, imageName);
|
||||
// V2 registries don't support pagination well, return all tags
|
||||
const tags = await fetchRegistryTags(registry, imageName);
|
||||
result = {
|
||||
tags,
|
||||
total: tags.length,
|
||||
page: 1,
|
||||
pageSize: tags.length,
|
||||
hasNext: false,
|
||||
hasPrev: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return json(tags);
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching tags:', error);
|
||||
|
||||
|
||||
@@ -15,14 +15,26 @@ import {
|
||||
getEventCleanupEnabled,
|
||||
setEventCleanupEnabled,
|
||||
getDefaultTimezone,
|
||||
setDefaultTimezone
|
||||
setDefaultTimezone,
|
||||
getEventCollectionMode,
|
||||
setEventCollectionMode,
|
||||
getEventPollInterval,
|
||||
setEventPollInterval,
|
||||
getMetricsCollectionInterval,
|
||||
setMetricsCollectionInterval,
|
||||
getExternalStackPaths,
|
||||
setExternalStackPaths,
|
||||
getPrimaryStackLocation,
|
||||
setPrimaryStackLocation
|
||||
} from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSystemJobs } from '$lib/server/scheduler';
|
||||
import { sendToEventSubprocess, sendToMetricsSubprocess, type UpdateIntervalCommand } from '$lib/server/subprocess-manager';
|
||||
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
export type DownloadFormat = 'tar' | 'tar.gz';
|
||||
export type EventCollectionMode = 'stream' | 'poll';
|
||||
|
||||
export interface GeneralSettings {
|
||||
confirmDestructive: boolean;
|
||||
@@ -41,6 +53,10 @@ export interface GeneralSettings {
|
||||
eventCleanupEnabled: boolean;
|
||||
logBufferSizeKb: number;
|
||||
defaultTimezone: string;
|
||||
// Background monitoring settings
|
||||
eventCollectionMode: EventCollectionMode;
|
||||
eventPollInterval: number;
|
||||
metricsCollectionInterval: number;
|
||||
// Theme settings (for when auth is disabled)
|
||||
lightTheme: string;
|
||||
darkTheme: string;
|
||||
@@ -48,6 +64,10 @@ export interface GeneralSettings {
|
||||
fontSize: string;
|
||||
gridFontSize: string;
|
||||
terminalFont: string;
|
||||
// External stack paths
|
||||
externalStackPaths: string[];
|
||||
// Primary stack location
|
||||
primaryStackLocation: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
|
||||
@@ -61,6 +81,9 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
||||
defaultTrivyArgs: 'image --format json {image}',
|
||||
logBufferSizeKb: 500,
|
||||
defaultTimezone: 'UTC',
|
||||
eventCollectionMode: 'stream',
|
||||
eventPollInterval: 60000,
|
||||
metricsCollectionInterval: 30000,
|
||||
lightTheme: 'default',
|
||||
darkTheme: 'default',
|
||||
font: 'system',
|
||||
@@ -104,12 +127,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
eventCleanupEnabled,
|
||||
logBufferSizeKb,
|
||||
defaultTimezone,
|
||||
eventCollectionMode,
|
||||
eventPollInterval,
|
||||
metricsCollectionInterval,
|
||||
lightTheme,
|
||||
darkTheme,
|
||||
font,
|
||||
fontSize,
|
||||
gridFontSize,
|
||||
terminalFont
|
||||
terminalFont,
|
||||
externalStackPaths,
|
||||
primaryStackLocation
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -127,12 +155,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getEventCleanupEnabled(),
|
||||
getSetting('log_buffer_size_kb'),
|
||||
getDefaultTimezone(),
|
||||
getEventCollectionMode(),
|
||||
getEventPollInterval(),
|
||||
getMetricsCollectionInterval(),
|
||||
getSetting('theme_light'),
|
||||
getSetting('theme_dark'),
|
||||
getSetting('theme_font'),
|
||||
getSetting('theme_font_size'),
|
||||
getSetting('theme_grid_font_size'),
|
||||
getSetting('theme_terminal_font')
|
||||
getSetting('theme_terminal_font'),
|
||||
getExternalStackPaths(),
|
||||
getPrimaryStackLocation()
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -152,12 +185,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
eventCleanupEnabled,
|
||||
logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
defaultTimezone: defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||
eventCollectionMode: (eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
|
||||
eventPollInterval: eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
metricsCollectionInterval: metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||
lightTheme: lightTheme ?? DEFAULT_SETTINGS.lightTheme,
|
||||
darkTheme: darkTheme ?? DEFAULT_SETTINGS.darkTheme,
|
||||
font: font ?? DEFAULT_SETTINGS.font,
|
||||
fontSize: fontSize ?? DEFAULT_SETTINGS.fontSize,
|
||||
gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize,
|
||||
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont
|
||||
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
|
||||
externalStackPaths,
|
||||
primaryStackLocation
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
@@ -175,7 +213,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont } = body;
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, externalStackPaths, primaryStackLocation } = body;
|
||||
|
||||
if (confirmDestructive !== undefined) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -228,6 +266,25 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
// Refresh system jobs to use the new timezone
|
||||
await refreshSystemJobs();
|
||||
}
|
||||
if (eventCollectionMode !== undefined && (eventCollectionMode === 'stream' || eventCollectionMode === 'poll')) {
|
||||
await setEventCollectionMode(eventCollectionMode);
|
||||
// Notify event subprocess to refresh collectors with new mode
|
||||
sendToEventSubprocess({ type: 'refresh_environments' });
|
||||
}
|
||||
if (eventPollInterval !== undefined && typeof eventPollInterval === 'number') {
|
||||
// Validate: 30s - 300s (30 seconds to 5 minutes)
|
||||
const validatedInterval = Math.max(30000, Math.min(300000, eventPollInterval));
|
||||
await setEventPollInterval(validatedInterval);
|
||||
// Notify event subprocess to refresh collectors with new interval
|
||||
sendToEventSubprocess({ type: 'refresh_environments' });
|
||||
}
|
||||
if (metricsCollectionInterval !== undefined && typeof metricsCollectionInterval === 'number') {
|
||||
// Validate: 10s - 300s (10 seconds to 5 minutes)
|
||||
const validatedInterval = Math.max(10000, Math.min(300000, metricsCollectionInterval));
|
||||
await setMetricsCollectionInterval(validatedInterval);
|
||||
// Notify metrics subprocess to update its collection interval
|
||||
sendToMetricsSubprocess({ type: 'update_interval', intervalMs: validatedInterval });
|
||||
}
|
||||
if (lightTheme !== undefined && VALID_LIGHT_THEMES.includes(lightTheme)) {
|
||||
await setSetting('theme_light', lightTheme);
|
||||
}
|
||||
@@ -246,6 +303,20 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (terminalFont !== undefined && VALID_TERMINAL_FONTS.includes(terminalFont)) {
|
||||
await setSetting('theme_terminal_font', terminalFont);
|
||||
}
|
||||
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
|
||||
// Filter to valid non-empty strings
|
||||
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
|
||||
await setExternalStackPaths(validPaths);
|
||||
}
|
||||
if (primaryStackLocation !== undefined) {
|
||||
// Accept string or null
|
||||
if (primaryStackLocation === null || (typeof primaryStackLocation === 'string' && primaryStackLocation.trim())) {
|
||||
await setPrimaryStackLocation(primaryStackLocation);
|
||||
} else if (primaryStackLocation === '') {
|
||||
// Empty string means clear the setting
|
||||
await setPrimaryStackLocation(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all settings in parallel for the response
|
||||
const [
|
||||
@@ -265,12 +336,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
eventCleanupEnabledVal,
|
||||
logBufferSizeKbVal,
|
||||
defaultTimezoneVal,
|
||||
eventCollectionModeVal,
|
||||
eventPollIntervalVal,
|
||||
metricsCollectionIntervalVal,
|
||||
lightThemeVal,
|
||||
darkThemeVal,
|
||||
fontVal,
|
||||
fontSizeVal,
|
||||
gridFontSizeVal,
|
||||
terminalFontVal
|
||||
terminalFontVal,
|
||||
externalStackPathsVal,
|
||||
primaryStackLocationVal
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -288,12 +364,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getEventCleanupEnabled(),
|
||||
getSetting('log_buffer_size_kb'),
|
||||
getDefaultTimezone(),
|
||||
getEventCollectionMode(),
|
||||
getEventPollInterval(),
|
||||
getMetricsCollectionInterval(),
|
||||
getSetting('theme_light'),
|
||||
getSetting('theme_dark'),
|
||||
getSetting('theme_font'),
|
||||
getSetting('theme_font_size'),
|
||||
getSetting('theme_grid_font_size'),
|
||||
getSetting('theme_terminal_font')
|
||||
getSetting('theme_terminal_font'),
|
||||
getExternalStackPaths(),
|
||||
getPrimaryStackLocation()
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -313,12 +394,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
eventCleanupEnabled: eventCleanupEnabledVal,
|
||||
logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
defaultTimezone: defaultTimezoneVal ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||
eventCollectionMode: (eventCollectionModeVal ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
|
||||
eventPollInterval: eventPollIntervalVal ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
metricsCollectionInterval: metricsCollectionIntervalVal ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||
lightTheme: lightThemeVal ?? DEFAULT_SETTINGS.lightTheme,
|
||||
darkTheme: darkThemeVal ?? DEFAULT_SETTINGS.darkTheme,
|
||||
font: fontVal ?? DEFAULT_SETTINGS.font,
|
||||
fontSize: fontSizeVal ?? DEFAULT_SETTINGS.fontSize,
|
||||
gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize,
|
||||
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont
|
||||
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
|
||||
externalStackPaths: externalStackPathsVal,
|
||||
primaryStackLocation: primaryStackLocationVal
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
|
||||
@@ -35,11 +35,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const existingNames = new Set(stacks.map((s) => s.name));
|
||||
|
||||
for (const source of stackSources) {
|
||||
// Only add internal/git stacks that aren't already in the list
|
||||
if (
|
||||
!existingNames.has(source.stackName) &&
|
||||
(source.sourceType === 'internal' || source.sourceType === 'git')
|
||||
) {
|
||||
// Add stacks from database that aren't already in the Docker list
|
||||
// This includes internal, git, and external (adopted) stacks that are currently down
|
||||
if (!existingNames.has(source.stackName)) {
|
||||
stacks.push({
|
||||
name: source.stackName,
|
||||
containers: [],
|
||||
@@ -78,7 +76,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, compose, start, envVars, rawEnvContent } = body;
|
||||
const { name, compose, start, envVars, rawEnvContent, composePath, envPath } = body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return json({ error: 'Stack name is required' }, { status: 400 });
|
||||
@@ -90,7 +88,10 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
|
||||
// If start is false, only create the compose file without deploying
|
||||
if (start === false) {
|
||||
const result = await saveStackComposeFile(name, compose, true);
|
||||
const result = await saveStackComposeFile(name, compose, true, envIdNum, {
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
if (!result.success) {
|
||||
return json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
@@ -100,7 +101,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
|
||||
if (rawEnvContent) {
|
||||
// Write raw content to .env file (should NOT contain secrets)
|
||||
await writeRawStackEnvFile(name, rawEnvContent);
|
||||
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
|
||||
}
|
||||
// Save ALL vars to DB (secrets for shell injection at runtime)
|
||||
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
@@ -108,14 +109,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
}
|
||||
// Fallback: if no rawEnvContent, generate .env from non-secret vars
|
||||
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
await saveStackEnvVars(name, envVars, envIdNum);
|
||||
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
|
||||
}
|
||||
|
||||
// Record the stack as internally created
|
||||
// Record the stack as internally created with custom paths if provided
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
environmentId: envIdNum,
|
||||
sourceType: 'internal'
|
||||
sourceType: 'internal',
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
return json({ success: true, started: false });
|
||||
@@ -124,13 +127,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
// Save environment variables BEFORE deploying so they're available during start
|
||||
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
|
||||
// First ensure the stack directory exists by saving compose file
|
||||
await saveStackComposeFile(name, compose, true);
|
||||
await saveStackComposeFile(name, compose, true, envIdNum, {
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
// - rawEnvContent: non-secret vars with comments → .env file
|
||||
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
|
||||
if (rawEnvContent) {
|
||||
// Write raw content to .env file (should NOT contain secrets)
|
||||
await writeRawStackEnvFile(name, rawEnvContent);
|
||||
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
|
||||
}
|
||||
// Save ALL vars to DB (secrets for shell injection at runtime)
|
||||
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
@@ -138,7 +144,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
}
|
||||
// Fallback: if no rawEnvContent, generate .env from non-secret vars
|
||||
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
await saveStackEnvVars(name, envVars, envIdNum);
|
||||
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,18 +152,22 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
const result = await deployStack({
|
||||
name,
|
||||
compose,
|
||||
envId: envIdNum
|
||||
envId: envIdNum,
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return json({ error: result.error, output: result.output }, { status: 400 });
|
||||
}
|
||||
|
||||
// Record the stack as internally created
|
||||
// Record the stack as internally created with custom paths if provided
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
environmentId: envIdNum,
|
||||
sourceType: 'internal'
|
||||
sourceType: 'internal',
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
return json({ success: true, started: true, output: result.output });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { removeStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { removeStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -34,9 +34,6 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
}
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalStackError) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -4,22 +4,36 @@ import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/ser
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
// GET /api/stacks/[name]/compose - Get compose file content
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('stacks', 'view'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = params;
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
try {
|
||||
const result = await getStackComposeFile(name);
|
||||
const result = await getStackComposeFile(name, envIdNum);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ error: result.error }, { status: 404 });
|
||||
// Return info about what's needed - unified response for all missing compose files
|
||||
return json({
|
||||
error: result.error,
|
||||
needsFileLocation: result.needsFileLocation || false,
|
||||
composePath: result.composePath,
|
||||
envPath: result.envPath
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
return json({ content: result.content, stackDir: result.stackDir });
|
||||
return json({
|
||||
content: result.content,
|
||||
stackDir: result.stackDir,
|
||||
composePath: result.composePath,
|
||||
envPath: result.envPath,
|
||||
suggestedEnvPath: result.suggestedEnvPath
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`Error getting compose file for stack ${name}:`, error);
|
||||
return json({ error: error.message || 'Failed to get compose file' }, { status: 500 });
|
||||
@@ -41,16 +55,29 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { content, restart = false } = body;
|
||||
const { content, restart = false, composePath, envPath, moveFromDir, oldComposePath, oldEnvPath } = body;
|
||||
|
||||
if (!content || typeof content !== 'string') {
|
||||
return json({ error: 'Compose file content is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build options object for custom paths, move operation, and file renames
|
||||
const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath)
|
||||
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath }
|
||||
: undefined;
|
||||
|
||||
let result;
|
||||
if (restart) {
|
||||
// Deploy with docker compose up -d --force-recreate
|
||||
// Force recreate ensures env var changes are applied
|
||||
// Note: deployStack uses requireComposeFile which will use saved paths
|
||||
// Save paths first if provided
|
||||
if (pathOptions) {
|
||||
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
|
||||
if (!saveResult.success) {
|
||||
return json({ error: saveResult.error }, { status: 500 });
|
||||
}
|
||||
}
|
||||
result = await deployStack({
|
||||
name,
|
||||
compose: content,
|
||||
@@ -58,8 +85,8 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
||||
forceRecreate: true
|
||||
});
|
||||
} else {
|
||||
// Just save the file without restarting
|
||||
result = await saveStackComposeFile(name, content);
|
||||
// Just save the file without restarting (update operation, not create)
|
||||
result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { downStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { downStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -42,9 +42,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalStackError) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
|
||||
+32
-7
@@ -1,9 +1,9 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getStackEnvVars, setStackEnvVars } from '$lib/server/db';
|
||||
import { getStacksDir } from '$lib/server/stacks';
|
||||
import { getStackEnvVars, setStackEnvVars, getStackSource } from '$lib/server/db';
|
||||
import { findStackDir } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
@@ -61,12 +61,37 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const dbVariables = await getStackEnvVars(stackName, envIdNum, true);
|
||||
const dbByKey = new Map(dbVariables.map(v => [v.key, v]));
|
||||
|
||||
// Try to read .env file from stack directory (only contains non-secrets)
|
||||
const stacksDir = getStacksDir();
|
||||
const envFilePath = join(stacksDir, stackName, '.env');
|
||||
// Check if this stack has a custom compose path configured
|
||||
const source = await getStackSource(stackName, envIdNum);
|
||||
|
||||
// Determine the env file path based on path resolution rules:
|
||||
// - envPath = '' (empty string) → explicitly no env file
|
||||
// - envPath = '/path/.env' → use custom path
|
||||
// - envPath = null with composePath → suggest .env next to compose (but don't auto-load)
|
||||
// - envPath = null without composePath → use default location
|
||||
let envFilePath: string | null = null;
|
||||
|
||||
if (source?.envPath === '') {
|
||||
// Empty string = explicitly no env file
|
||||
envFilePath = null;
|
||||
} else if (source?.envPath) {
|
||||
// Custom env path specified
|
||||
envFilePath = source.envPath;
|
||||
} else if (source?.composePath) {
|
||||
// Custom compose path but no env path - suggest .env next to compose
|
||||
// For loading, check if it exists (but don't fail if it doesn't)
|
||||
envFilePath = join(dirname(source.composePath), '.env');
|
||||
} else {
|
||||
// Default location - .env in stack directory
|
||||
const stackDir = await findStackDir(stackName, envIdNum);
|
||||
if (stackDir) {
|
||||
envFilePath = join(stackDir, '.env');
|
||||
}
|
||||
}
|
||||
|
||||
let fileVars: Record<string, string> = {};
|
||||
|
||||
if (existsSync(envFilePath)) {
|
||||
if (envFilePath && existsSync(envFilePath)) {
|
||||
try {
|
||||
const content = await Bun.file(envFilePath).text();
|
||||
fileVars = parseEnvFile(content);
|
||||
|
||||
+59
-10
@@ -1,8 +1,9 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getStacksDir } from '$lib/server/stacks';
|
||||
import { findStackDir, getStackDir } from '$lib/server/stacks';
|
||||
import { getStackSource } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
@@ -26,11 +27,36 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const stacksDir = getStacksDir();
|
||||
const envFilePath = join(stacksDir, stackName, '.env');
|
||||
|
||||
// Check if this stack has custom paths configured
|
||||
const source = await getStackSource(stackName, envIdNum);
|
||||
|
||||
// Determine the env file path based on path resolution rules:
|
||||
// - envPath = '' (empty string) → explicitly no env file
|
||||
// - envPath = '/path/.env' → use custom path
|
||||
// - envPath = null with composePath → suggest .env next to compose
|
||||
// - envPath = null without composePath → use default location
|
||||
let envFilePath: string | null = null;
|
||||
|
||||
if (source?.envPath === '') {
|
||||
// Empty string = explicitly no env file
|
||||
return json({ content: '', noEnvFile: true });
|
||||
} else if (source?.envPath) {
|
||||
// Custom env path specified
|
||||
envFilePath = source.envPath;
|
||||
} else if (source?.composePath) {
|
||||
// Custom compose path but no env path - suggest .env next to compose
|
||||
envFilePath = join(dirname(source.composePath), '.env');
|
||||
} else {
|
||||
// Default location - .env in stack directory
|
||||
const stackDir = await findStackDir(stackName, envIdNum);
|
||||
if (stackDir) {
|
||||
envFilePath = join(stackDir, '.env');
|
||||
}
|
||||
}
|
||||
|
||||
let content = '';
|
||||
if (existsSync(envFilePath)) {
|
||||
if (envFilePath && existsSync(envFilePath)) {
|
||||
try {
|
||||
content = await Bun.file(envFilePath).text();
|
||||
} catch {
|
||||
@@ -73,12 +99,35 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
|
||||
return json({ error: 'Invalid request body: content string required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
// Check if this stack has custom paths configured
|
||||
const source = await getStackSource(stackName, envIdNum);
|
||||
|
||||
// Only write if stack directory exists
|
||||
if (!existsSync(stackDir)) {
|
||||
// Determine the env file path based on path resolution rules:
|
||||
// - envPath = '' (empty string) → explicitly no env file, don't write
|
||||
// - envPath = '/path/.env' → use custom path
|
||||
// - envPath = null with composePath → suggest .env next to compose
|
||||
// - envPath = null without composePath → use default location
|
||||
let envFilePath: string | null = null;
|
||||
|
||||
if (source?.envPath === '') {
|
||||
// Empty string = explicitly no env file - don't allow writes
|
||||
return json({ success: true, noEnvFile: true });
|
||||
} else if (source?.envPath) {
|
||||
// Custom env path specified
|
||||
envFilePath = source.envPath;
|
||||
} else if (source?.composePath) {
|
||||
// Custom compose path but no env path - suggest .env next to compose
|
||||
envFilePath = join(dirname(source.composePath), '.env');
|
||||
} else {
|
||||
// Default location - .env in stack directory
|
||||
const stackDir = await findStackDir(stackName, envIdNum);
|
||||
if (stackDir) {
|
||||
envFilePath = join(stackDir, '.env');
|
||||
}
|
||||
}
|
||||
|
||||
// Only write if we have a valid path
|
||||
if (!envFilePath) {
|
||||
return json({ error: 'Stack directory not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { restartStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { restartStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalStackError) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { startStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { startStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalStackError) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { stopStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { stopStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalStackError) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const sources = await getStackSources(envIdNum);
|
||||
|
||||
// Convert to a map for easier lookup in the frontend
|
||||
const sourceMap: Record<string, { sourceType: string; repository?: any }> = {};
|
||||
const sourceMap: Record<string, { sourceType: string; composePath?: string | null; repository?: any }> = {};
|
||||
for (const source of sources) {
|
||||
sourceMap[source.stackName] = {
|
||||
sourceType: source.sourceType,
|
||||
composePath: source.composePath,
|
||||
repository: source.repository
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
nodeVersion: process.version,
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
kernel: os.release(),
|
||||
memory: bunInfo.memory,
|
||||
container: containerRuntime,
|
||||
ownContainer
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
@@ -1300,6 +1301,12 @@
|
||||
loadLayoutMode();
|
||||
loadStatusFilter();
|
||||
|
||||
// Check for image filter from URL params (from images page link)
|
||||
const imageParam = $page.url.searchParams.get('image');
|
||||
if (imageParam) {
|
||||
searchQuery = imageParam;
|
||||
}
|
||||
|
||||
// Load persisted pending updates from database
|
||||
loadPendingUpdates();
|
||||
|
||||
@@ -1687,7 +1694,10 @@
|
||||
<div class="{isFieldHighlighted(container.id, 'memory') ? 'stat-highlight' : ''} text-right">
|
||||
{#if containerStats.get(container.id)}
|
||||
{@const stats = containerStats.get(container.id)}
|
||||
<span class="text-xs font-mono {stats.memoryPercent > 80 ? 'text-red-500' : stats.memoryPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}" title="{formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)}">{formatBytes(stats.memoryUsage)}</span>
|
||||
{@const memoryTooltip = stats.memoryCache > 0
|
||||
? `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)} (Total: ${formatBytes(stats.memoryRaw)} | Cache: ${formatBytes(stats.memoryCache)})`
|
||||
: `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)}`}
|
||||
<span class="text-xs font-mono {stats.memoryPercent > 80 ? 'text-red-500' : stats.memoryPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}" title={memoryTooltip}>{formatBytes(stats.memoryUsage)}</span>
|
||||
{:else if container.state === 'running'}
|
||||
<span class="text-xs text-muted-foreground/50">...</span>
|
||||
{:else}
|
||||
|
||||
@@ -543,7 +543,7 @@
|
||||
<!-- Pull and Scan logs (collapsible) -->
|
||||
{#if item.showLogs && hasLogs}
|
||||
<div
|
||||
class="bg-muted/50 px-3 py-2 font-mono text-xs max-h-40 overflow-auto border-t overflow-x-hidden"
|
||||
class="bg-muted/50 px-3 py-2 font-mono text-xs border-t overflow-x-hidden"
|
||||
>
|
||||
{#each item.pullLogs as log}
|
||||
<div class="text-muted-foreground break-all">
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon } from 'lucide-svelte';
|
||||
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink } from 'lucide-svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import { currentEnvironment, appendEnvParam, environments } from '$lib/stores/environment';
|
||||
import ImageLayersView from '../images/ImageLayersView.svelte';
|
||||
import LogsPanel from '../logs/LogsPanel.svelte';
|
||||
import FileBrowserPanel from './FileBrowserPanel.svelte';
|
||||
@@ -42,6 +42,19 @@
|
||||
let showRawJson = $state(false);
|
||||
let jsonCopied = $state(false);
|
||||
|
||||
// Label copy state
|
||||
let copiedLabel = $state<string | null>(null);
|
||||
|
||||
async function copyLabel(key: string, value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${key}=${value}`);
|
||||
copiedLabel = key;
|
||||
setTimeout(() => copiedLabel = null, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Processes state
|
||||
interface ProcessesData {
|
||||
Titles: string[];
|
||||
@@ -75,6 +88,44 @@
|
||||
|
||||
let editInputRef: HTMLInputElement | null = null;
|
||||
|
||||
// Current environment details for port URL generation
|
||||
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
|
||||
|
||||
function extractHostFromUrl(urlString: string): string | null {
|
||||
if (!urlString) return null;
|
||||
// Handle tcp:// URLs (Docker remote)
|
||||
const tcpMatch = urlString.match(/^tcp:\/\/([^:\/]+)/);
|
||||
if (tcpMatch) return tcpMatch[1];
|
||||
// Handle http:// or https:// URLs
|
||||
const httpMatch = urlString.match(/^https?:\/\/([^:\/]+)/);
|
||||
if (httpMatch) return httpMatch[1];
|
||||
// Handle host:port format
|
||||
const hostPortMatch = urlString.match(/^([^:\/]+):\d+/);
|
||||
if (hostPortMatch) return hostPortMatch[1];
|
||||
// Just a hostname
|
||||
return urlString;
|
||||
}
|
||||
|
||||
function getPortUrl(publicPort: number): string | null {
|
||||
const env = currentEnvDetails;
|
||||
if (!env) return null;
|
||||
// Priority 1: Use publicIp if configured
|
||||
if (env.publicIp) {
|
||||
return `http://${env.publicIp}:${publicPort}`;
|
||||
}
|
||||
// Priority 2: Extract from host for direct/hawser-standard
|
||||
const connectionType = env.connectionType || 'socket';
|
||||
if (connectionType === 'direct' && env.host) {
|
||||
const host = extractHostFromUrl(env.host);
|
||||
if (host) return `http://${host}:${publicPort}`;
|
||||
} else if (connectionType === 'hawser-standard' && env.host) {
|
||||
const host = extractHostFromUrl(env.host);
|
||||
if (host) return `http://${host}:${publicPort}`;
|
||||
}
|
||||
// No public IP available for socket or hawser-edge
|
||||
return null;
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
editName = displayName;
|
||||
isEditing = true;
|
||||
@@ -379,7 +430,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-6xl h-[90vh] flex flex-col">
|
||||
<Dialog.Content class="max-w-6xl w-full h-[calc(100vh-2rem)] flex flex-col">
|
||||
<Dialog.Header class="shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Box class="w-5 h-5" />
|
||||
@@ -450,7 +501,7 @@
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<div class="flex-1 flex flex-col min-h-[400px]">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
@@ -461,7 +512,7 @@
|
||||
</div>
|
||||
{:else if containerData}
|
||||
<Tabs.Root bind:value={activeTab} class="w-full h-full flex flex-col">
|
||||
<Tabs.List class="w-full justify-start shrink-0 flex-wrap">
|
||||
<Tabs.List class="w-full justify-start shrink-0 flex-wrap h-auto min-h-10 bg-muted rounded-lg">
|
||||
<Tabs.Trigger value="overview" onclick={() => showLogs = false}>Overview</Tabs.Trigger>
|
||||
<Tabs.Trigger value="logs" onclick={() => showLogs = true}>Logs</Tabs.Trigger>
|
||||
<Tabs.Trigger value="layers" onclick={() => showLogs = false}>Layers</Tabs.Trigger>
|
||||
@@ -470,6 +521,7 @@
|
||||
<Tabs.Trigger value="mounts" onclick={() => showLogs = false}>Mounts</Tabs.Trigger>
|
||||
<Tabs.Trigger value="files" onclick={() => showLogs = false}>Files</Tabs.Trigger>
|
||||
<Tabs.Trigger value="env" onclick={() => showLogs = false}>Environment</Tabs.Trigger>
|
||||
<Tabs.Trigger value="labels" onclick={() => showLogs = false}>Labels</Tabs.Trigger>
|
||||
<Tabs.Trigger value="security" onclick={() => showLogs = false}>Security</Tabs.Trigger>
|
||||
<Tabs.Trigger value="resources" onclick={() => showLogs = false}>Resources</Tabs.Trigger>
|
||||
<Tabs.Trigger value="health" onclick={() => showLogs = false}>Health</Tabs.Trigger>
|
||||
@@ -642,23 +694,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Labels (collapsible) -->
|
||||
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
|
||||
<details class="group">
|
||||
<summary class="text-sm font-semibold cursor-pointer hover:text-primary">
|
||||
Labels ({Object.keys(containerData.Config.Labels).length})
|
||||
</summary>
|
||||
<div class="space-y-1 mt-2 max-h-32 overflow-y-auto">
|
||||
{#each Object.entries(containerData.Config.Labels) as [key, value]}
|
||||
<div class="text-xs p-2 bg-muted rounded">
|
||||
<code class="text-muted-foreground">{key}</code>
|
||||
<code class="text-muted-foreground">=</code>
|
||||
<code class="break-all">{value}</code>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Processes Tab -->
|
||||
@@ -845,8 +880,22 @@
|
||||
{#each Object.entries(containerData.NetworkSettings.Ports) as [containerPort, hostBindings]}
|
||||
{#if hostBindings && hostBindings.length > 0}
|
||||
{#each hostBindings as binding}
|
||||
{@const url = getPortUrl(parseInt(binding.HostPort))}
|
||||
<div class="flex items-center gap-2 text-xs p-2 bg-muted rounded">
|
||||
<code>{binding.HostIp || '0.0.0.0'}:{binding.HostPort}</code>
|
||||
{#if url}
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
title="Open {url}"
|
||||
>
|
||||
<code>{binding.HostIp || '0.0.0.0'}:{binding.HostPort}</code>
|
||||
<ExternalLink class="w-3 h-3" />
|
||||
</a>
|
||||
{:else}
|
||||
<code>{binding.HostIp || '0.0.0.0'}:{binding.HostPort}</code>
|
||||
{/if}
|
||||
<span class="text-muted-foreground">→</span>
|
||||
<code>{containerPort}</code>
|
||||
</div>
|
||||
@@ -944,6 +993,37 @@
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Labels Tab -->
|
||||
<Tabs.Content value="labels" class="space-y-4 overflow-auto">
|
||||
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
|
||||
<div class="space-y-1">
|
||||
{#each Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0])) as [key, value]}
|
||||
<div class="text-xs p-2 bg-muted rounded flex items-start gap-2 group">
|
||||
<div class="flex-1 min-w-0">
|
||||
<code class="text-muted-foreground font-medium">{key}</code>
|
||||
<code class="text-muted-foreground">=</code>
|
||||
<code class="break-all">{value}</code>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => copyLabel(key, value)}
|
||||
class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
|
||||
title={copiedLabel === key ? 'Copied!' : 'Copy label'}
|
||||
>
|
||||
{#if copiedLabel === key}
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-3 h-3 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">No labels</p>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Security Tab -->
|
||||
<Tabs.Content value="security" class="space-y-4 overflow-auto">
|
||||
<!-- Privileged & User -->
|
||||
@@ -1187,7 +1267,7 @@
|
||||
|
||||
<!-- Raw JSON Modal -->
|
||||
<Dialog.Root bind:open={showRawJson}>
|
||||
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col">
|
||||
<Dialog.Content class="max-w-4xl max-h-[90vh] sm:max-h-[80vh] flex flex-col">
|
||||
<Dialog.Header class="shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Code class="w-5 h-5" />
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
env_vars?: { key: string; value: string }[];
|
||||
envVars?: { key: string; value: string }[];
|
||||
labels?: { key: string; value: string }[];
|
||||
ports?: { hostPort: string; containerPort: string; protocol: string }[];
|
||||
volumes?: { hostPath: string; containerPath: string; mode: string }[];
|
||||
network_mode: string;
|
||||
restart_policy: string;
|
||||
networkMode: string;
|
||||
restartPolicy: string;
|
||||
}
|
||||
|
||||
interface DockerNetwork {
|
||||
@@ -315,15 +315,15 @@
|
||||
const configSet = configSets.find((c) => c.id === parseInt(configSetId));
|
||||
if (!configSet) return;
|
||||
|
||||
if (configSet.env_vars && configSet.env_vars.length > 0) {
|
||||
if (configSet.envVars && configSet.envVars.length > 0) {
|
||||
if (mode === 'edit') {
|
||||
// Merge mode for edit
|
||||
const existingKeys = new Set(envVars.map(e => e.key).filter(k => k));
|
||||
const newEnvVars = configSet.env_vars.filter(e => !existingKeys.has(e.key));
|
||||
const newEnvVars = configSet.envVars.filter(e => !existingKeys.has(e.key));
|
||||
envVars = [...envVars.filter(e => e.key), ...newEnvVars.map(e => ({ ...e }))];
|
||||
if (envVars.length === 0) envVars = [{ key: '', value: '' }];
|
||||
} else {
|
||||
envVars = configSet.env_vars.map((e) => ({ ...e }));
|
||||
envVars = configSet.envVars.map((e) => ({ ...e }));
|
||||
}
|
||||
}
|
||||
if (configSet.labels && configSet.labels.length > 0) {
|
||||
@@ -356,11 +356,11 @@
|
||||
volumeMappings = configSet.volumes.map((v) => ({ ...v }));
|
||||
}
|
||||
}
|
||||
if (configSet.network_mode) {
|
||||
networkMode = configSet.network_mode;
|
||||
if (configSet.networkMode) {
|
||||
networkMode = configSet.networkMode;
|
||||
}
|
||||
if (configSet.restart_policy) {
|
||||
restartPolicy = configSet.restart_policy;
|
||||
if (configSet.restartPolicy) {
|
||||
restartPolicy = configSet.restartPolicy;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,12 +49,12 @@
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
env_vars?: { key: string; value: string }[];
|
||||
envVars?: { key: string; value: string }[];
|
||||
labels?: { key: string; value: string }[];
|
||||
ports?: { hostPort: string; containerPort: string; protocol: string }[];
|
||||
volumes?: { hostPath: string; containerPath: string; mode: string }[];
|
||||
network_mode: string;
|
||||
restart_policy: string;
|
||||
networkMode: string;
|
||||
restartPolicy: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -564,7 +564,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
|
||||
<Dialog.Content class="max-w-4xl w-full h-[85vh] p-0 flex flex-col overflow-hidden !zoom-in-0 !zoom-out-0" showCloseButton={false}>
|
||||
<Dialog.Content class="max-w-4xl w-full max-h-[90vh] sm:max-h-[85vh] p-0 flex flex-col overflow-hidden !zoom-in-0 !zoom-out-0" showCloseButton={false}>
|
||||
<Dialog.Header class="px-5 py-4 border-b bg-muted/30 shrink-0 sticky top-0 z-10">
|
||||
<Dialog.Title class="text-base font-semibold">Create new container</Dialog.Title>
|
||||
<button
|
||||
|
||||
@@ -44,12 +44,12 @@
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
env_vars?: { key: string; value: string }[];
|
||||
envVars?: { key: string; value: string }[];
|
||||
labels?: { key: string; value: string }[];
|
||||
ports?: { hostPort: string; containerPort: string; protocol: string }[];
|
||||
volumes?: { hostPath: string; containerPath: string; mode: string }[];
|
||||
network_mode: string;
|
||||
restart_policy: string;
|
||||
networkMode: string;
|
||||
restartPolicy: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -897,7 +897,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
|
||||
<Dialog.Content class="max-w-4xl w-[56rem] max-h-[90vh] p-0 flex flex-col overflow-hidden" style="min-height: 85vh; height: 85vh;">
|
||||
<Dialog.Content class="max-w-4xl w-full max-h-[90vh] p-0 flex flex-col overflow-hidden sm:max-h-[85vh]">
|
||||
<Dialog.Header class="px-5 py-4 border-b bg-muted/30 shrink-0 sticky top-0 z-10">
|
||||
<Dialog.Title class="text-base font-semibold flex items-center gap-1">
|
||||
Edit container
|
||||
@@ -944,7 +944,7 @@
|
||||
</Dialog.Header>
|
||||
|
||||
{#if loadingData}
|
||||
<div class="flex-1 flex items-center justify-center text-muted-foreground text-sm" style="min-height: calc(85vh - 120px);">
|
||||
<div class="flex-1 flex items-center justify-center text-muted-foreground text-sm min-h-[200px]">
|
||||
<Loader2 class="w-5 h-5 animate-spin mr-2" />
|
||||
Loading container data...
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col">
|
||||
<Dialog.Content class="max-w-4xl h-[90vh] sm:h-[80vh] flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<FolderOpen class="w-5 h-5" />
|
||||
|
||||
@@ -69,9 +69,25 @@
|
||||
initialPath?: string;
|
||||
canEdit?: boolean;
|
||||
onUsageChange?: (usage: VolumeUsageInfo[], isInUse: boolean) => void;
|
||||
// File selection mode - when true, clicking a file selects it instead of opening viewer
|
||||
selectMode?: boolean;
|
||||
// Regex to filter which files can be selected (used with selectMode)
|
||||
selectFilter?: RegExp;
|
||||
// Callback when a file is selected (in selectMode)
|
||||
onFileSelect?: (path: string, name: string) => void;
|
||||
}
|
||||
|
||||
let { containerId, volumeName, envId, initialPath = '/', canEdit = true, onUsageChange }: Props = $props();
|
||||
let {
|
||||
containerId,
|
||||
volumeName,
|
||||
envId,
|
||||
initialPath = '/',
|
||||
canEdit = true,
|
||||
onUsageChange,
|
||||
selectMode = false,
|
||||
selectFilter,
|
||||
onFileSelect
|
||||
}: Props = $props();
|
||||
|
||||
// For volume mode, track whether volume is in use (controls editing ability)
|
||||
let volumeIsInUse = $state(false);
|
||||
@@ -110,6 +126,15 @@
|
||||
// Track if this container uses busybox (doesn't support --time-style=iso)
|
||||
let useSimpleLs = $state(false);
|
||||
|
||||
// Selection mode state
|
||||
let selectedFilePath = $state<string | null>(null);
|
||||
|
||||
// Check if a file matches the select filter (for highlighting)
|
||||
function matchesSelectFilter(name: string): boolean {
|
||||
if (!selectMode || !selectFilter) return false;
|
||||
return selectFilter.test(name);
|
||||
}
|
||||
|
||||
// Sort state
|
||||
let sortField = $state<SortField>('name');
|
||||
let sortDirection = $state<SortDirection>('asc');
|
||||
@@ -696,6 +721,11 @@
|
||||
if (entry.type === 'directory') {
|
||||
const newPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
|
||||
navigateTo(newPath);
|
||||
} else if (selectMode && entry.type === 'file') {
|
||||
// In select mode, clicking a file selects it
|
||||
const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
|
||||
selectedFilePath = filePath;
|
||||
onFileSelect?.(filePath, entry.name);
|
||||
}
|
||||
// Symlinks are not navigable - target path is displayed for reference
|
||||
}
|
||||
@@ -924,8 +954,11 @@
|
||||
<Table.Body>
|
||||
{#each displayEntries() as entry (entry.name)}
|
||||
{@const Icon = getIcon(entry)}
|
||||
{@const isClickable = entry.type === 'directory'}
|
||||
<Table.Row class="{isClickable ? 'cursor-pointer' : ''} hover:bg-muted/50 group">
|
||||
{@const isClickable = entry.type === 'directory' || (selectMode && entry.type === 'file')}
|
||||
{@const isSelectable = selectMode && entry.type === 'file' && matchesSelectFilter(entry.name)}
|
||||
{@const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`}
|
||||
{@const isSelected = selectMode && selectedFilePath === filePath}
|
||||
<Table.Row class="{isClickable ? 'cursor-pointer' : ''} hover:bg-muted/50 group {isSelected ? 'bg-primary/10 hover:bg-primary/15' : ''} {isSelectable ? 'border-l-2 border-l-primary' : ''}">
|
||||
<Table.Cell class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Wifi, WifiOff, ShieldCheck, Activity, Cpu, Settings, Unplug, Icon, Route, UndoDot, CircleArrowUp, CircleFadingArrowUp } from 'lucide-svelte';
|
||||
import { Wifi, WifiOff, ShieldCheck, Activity, Cpu, Settings, Unplug, Icon, Route, UndoDot, CircleArrowUp, CircleFadingArrowUp, Loader2 } from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import { getIconComponent } from '$lib/utils/icons';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -45,10 +45,11 @@
|
||||
// Helper flags
|
||||
const isMini = $derived(is1x1 || is2x1);
|
||||
const isWide = $derived(width >= 2);
|
||||
// Show offline when online is explicitly false
|
||||
// Only delay showing offline if online is undefined (truly unknown state during initial load)
|
||||
// Show offline only when online is explicitly false (confirmed offline)
|
||||
// Show connecting when online is undefined (initial load, not yet determined)
|
||||
const isStillLoading = $derived(stats.loading && Object.values(stats.loading).some(v => v === true));
|
||||
const showOffline = $derived(stats.online === false || (!stats.online && !isStillLoading));
|
||||
const showOffline = $derived(stats.online === false);
|
||||
const showConnecting = $derived(stats.online === undefined);
|
||||
</script>
|
||||
|
||||
<Card.Root
|
||||
@@ -84,10 +85,12 @@
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-sm truncate">{stats.name}</span>
|
||||
{#if !showOffline}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{:else}
|
||||
{#if showConnecting}
|
||||
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
|
||||
{:else if showOffline}
|
||||
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
|
||||
{:else}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
|
||||
@@ -138,13 +141,13 @@
|
||||
</Card.Header>
|
||||
<DashboardLabels labels={stats.labels} />
|
||||
<Card.Content class="overflow-hidden">
|
||||
{#if !showOffline}
|
||||
{#if showOffline}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
|
||||
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
|
||||
</div>
|
||||
{:else}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
|
||||
@@ -178,10 +181,12 @@
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-sm truncate">{stats.name}</span>
|
||||
{#if !showOffline}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{:else}
|
||||
{#if showConnecting}
|
||||
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
|
||||
{:else if showOffline}
|
||||
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
|
||||
{:else}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
|
||||
@@ -232,10 +237,12 @@
|
||||
</Card.Header>
|
||||
<DashboardLabels labels={stats.labels} />
|
||||
<Card.Content class="overflow-hidden">
|
||||
{#if !showOffline}
|
||||
{#if showOffline}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{:else}
|
||||
<div class="flex gap-4">
|
||||
<div class="space-y-2">
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
|
||||
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
|
||||
</div>
|
||||
{#if stats.recentEvents}
|
||||
@@ -244,8 +251,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
|
||||
@@ -279,10 +284,12 @@
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-sm truncate">{stats.name}</span>
|
||||
{#if !showOffline}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{:else}
|
||||
{#if showConnecting}
|
||||
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
|
||||
{:else if showOffline}
|
||||
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
|
||||
{:else}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
|
||||
@@ -333,9 +340,11 @@
|
||||
</Card.Header>
|
||||
<DashboardLabels labels={stats.labels} />
|
||||
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
|
||||
{#if !showOffline}
|
||||
{#if showOffline}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
|
||||
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
|
||||
{#if stats.collectMetrics && stats.metrics}
|
||||
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
|
||||
@@ -343,8 +352,6 @@
|
||||
<DashboardResourceStats images={stats.images} volumes={stats.volumes} networks={stats.networks} stacks={stats.stacks} loading={stats.loading} />
|
||||
<DashboardEventsSummary today={stats.events.today} total={stats.events.total} />
|
||||
</div>
|
||||
{:else}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
|
||||
@@ -378,10 +385,12 @@
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-sm truncate">{stats.name}</span>
|
||||
{#if !showOffline}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{:else}
|
||||
{#if showConnecting}
|
||||
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
|
||||
{:else if showOffline}
|
||||
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
|
||||
{:else}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
|
||||
@@ -432,9 +441,11 @@
|
||||
</Card.Header>
|
||||
<DashboardLabels labels={stats.labels} />
|
||||
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
|
||||
{#if !showOffline}
|
||||
{#if showOffline}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
|
||||
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
|
||||
{#if stats.collectMetrics && stats.metrics}
|
||||
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
|
||||
@@ -445,8 +456,6 @@
|
||||
<DashboardRecentEvents events={stats.recentEvents} limit={8} onclick={oneventsclick} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
|
||||
@@ -480,10 +489,12 @@
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-sm truncate">{stats.name}</span>
|
||||
{#if !showOffline}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{:else}
|
||||
{#if showConnecting}
|
||||
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
|
||||
{:else if showOffline}
|
||||
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
|
||||
{:else}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
|
||||
@@ -534,9 +545,11 @@
|
||||
</Card.Header>
|
||||
<DashboardLabels labels={stats.labels} />
|
||||
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
|
||||
{#if !showOffline}
|
||||
{#if showOffline}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
|
||||
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
|
||||
{#if stats.collectMetrics && stats.metrics}
|
||||
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
|
||||
@@ -546,10 +559,8 @@
|
||||
{#if stats.recentEvents}
|
||||
<DashboardRecentEvents events={stats.recentEvents} limit={8} onclick={oneventsclick} />
|
||||
{/if}
|
||||
<DashboardTopContainers containers={stats.topContainers} limit={8} loading={stats.loading?.topContainers} />
|
||||
<DashboardTopContainers containers={stats.topContainers} limit={8} loading={stats.loading?.topContainers || showConnecting} />
|
||||
</div>
|
||||
{:else}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
|
||||
@@ -577,11 +588,13 @@
|
||||
</Card.Header>
|
||||
<DashboardLabels labels={stats.labels} />
|
||||
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
|
||||
{#if !showOffline}
|
||||
{#if showOffline}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Left column -->
|
||||
<div class="space-y-3">
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
|
||||
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
|
||||
{#if stats.metrics}
|
||||
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
|
||||
@@ -591,11 +604,9 @@
|
||||
</div>
|
||||
<!-- Right column -->
|
||||
<div class="space-y-3 border-l border-border/50 pl-4">
|
||||
<DashboardTopContainers containers={stats.topContainers} limit={8} loading={stats.loading?.topContainers} />
|
||||
<DashboardTopContainers containers={stats.topContainers} limit={8} loading={stats.loading?.topContainers || showConnecting} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
|
||||
@@ -623,11 +634,13 @@
|
||||
</Card.Header>
|
||||
<DashboardLabels labels={stats.labels} />
|
||||
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
|
||||
{#if !showOffline}
|
||||
{#if showOffline}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Left column -->
|
||||
<div class="space-y-3">
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
|
||||
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
|
||||
{#if stats.metrics}
|
||||
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
|
||||
@@ -640,14 +653,12 @@
|
||||
</div>
|
||||
<!-- Right column -->
|
||||
<div class="space-y-3 border-l border-border/50 pl-4">
|
||||
<DashboardTopContainers containers={stats.topContainers} limit={10} loading={stats.loading?.topContainers} />
|
||||
<DashboardTopContainers containers={stats.topContainers} limit={10} loading={stats.loading?.topContainers || showConnecting} />
|
||||
{#if stats.collectMetrics && stats.metrics && stats.metricsHistory}
|
||||
<DashboardCpuMemoryCharts metricsHistory={stats.metricsHistory} metrics={stats.metrics} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
|
||||
@@ -675,11 +686,13 @@
|
||||
</Card.Header>
|
||||
<DashboardLabels labels={stats.labels} />
|
||||
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
|
||||
{#if !showOffline}
|
||||
{#if showOffline}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Left column -->
|
||||
<div class="space-y-3">
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
|
||||
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers || showConnecting} />
|
||||
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
|
||||
{#if stats.metrics}
|
||||
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
|
||||
@@ -689,18 +702,16 @@
|
||||
{#if stats.recentEvents}
|
||||
<DashboardRecentEvents events={stats.recentEvents} limit={10} onclick={oneventsclick} />
|
||||
{/if}
|
||||
<DashboardTopContainers containers={stats.topContainers} limit={10} loading={stats.loading?.topContainers} />
|
||||
<DashboardTopContainers containers={stats.topContainers} limit={10} loading={stats.loading?.topContainers || showConnecting} />
|
||||
</div>
|
||||
<!-- Right column -->
|
||||
<div class="space-y-3 border-l border-border/50 pl-4">
|
||||
{#if stats.collectMetrics && stats.metrics && stats.metricsHistory}
|
||||
<DashboardCpuMemoryCharts metricsHistory={stats.metricsHistory} metrics={stats.metrics} />
|
||||
{/if}
|
||||
<DashboardDiskUsage imagesSize={stats.images.totalSize} volumesSize={stats.volumes.totalSize} containersSize={stats.containersSize} buildCacheSize={stats.buildCacheSize} showPieChart={true} loading={stats.loading?.diskUsage} />
|
||||
<DashboardDiskUsage imagesSize={stats.images.totalSize} volumesSize={stats.volumes.totalSize} containersSize={stats.containersSize} buildCacheSize={stats.buildCacheSize} showPieChart={true} loading={stats.loading?.diskUsage || showConnecting} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<DashboardOfflineState error={stats.error} compact={isMini} />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
{/if}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
Unplug,
|
||||
Icon,
|
||||
CircleArrowUp,
|
||||
CircleFadingArrowUp
|
||||
CircleFadingArrowUp,
|
||||
Loader2
|
||||
} from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import { getIconComponent } from '$lib/utils/icons';
|
||||
@@ -27,7 +28,7 @@
|
||||
port?: number | null;
|
||||
icon: string;
|
||||
socketPath?: string;
|
||||
online: boolean;
|
||||
online?: boolean; // undefined = connecting, false = offline, true = online
|
||||
scannerEnabled: boolean;
|
||||
collectActivity: boolean;
|
||||
collectMetrics: boolean;
|
||||
@@ -40,6 +41,10 @@
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// Derived states for connecting/offline
|
||||
const showConnecting = $derived(online === undefined);
|
||||
const showOffline = $derived(online === false);
|
||||
|
||||
let {
|
||||
name,
|
||||
host,
|
||||
@@ -88,10 +93,12 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-sm truncate">{name}</span>
|
||||
{#if online}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{:else}
|
||||
{#if showConnecting}
|
||||
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
|
||||
{:else if showOffline}
|
||||
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
|
||||
{:else}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground truncate block">{hostDisplay}</span>
|
||||
@@ -124,10 +131,12 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-sm truncate">{name}</span>
|
||||
{#if online}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{:else}
|
||||
{#if showConnecting}
|
||||
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
|
||||
{:else if showOffline}
|
||||
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
|
||||
{:else}
|
||||
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground truncate block">{hostDisplay}</span>
|
||||
|
||||
@@ -11,4 +11,5 @@ export { default as DashboardTopContainers } from './dashboard-top-containers.sv
|
||||
export { default as DashboardDiskUsage } from './dashboard-disk-usage.svelte';
|
||||
export { default as DashboardCpuMemoryCharts } from './dashboard-cpu-memory-charts.svelte';
|
||||
export { default as DashboardOfflineState } from './dashboard-offline-state.svelte';
|
||||
export { default as DashboardConnectingState } from './dashboard-connecting-state.svelte';
|
||||
export { default as DashboardStatusIcons } from './dashboard-status-icons.svelte';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-svelte';
|
||||
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown, CircleDashed } from 'lucide-svelte';
|
||||
import { broom, whale } from '@lucide/lab';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
@@ -46,10 +46,12 @@
|
||||
imageId: string;
|
||||
size: number;
|
||||
created: number;
|
||||
containers: number;
|
||||
}>;
|
||||
totalSize: number;
|
||||
latestCreated: number;
|
||||
imageIds: Set<string>;
|
||||
containers: number;
|
||||
}
|
||||
|
||||
// Check if a registry is Docker Hub
|
||||
@@ -115,6 +117,8 @@
|
||||
// Prune state
|
||||
let confirmPrune = $state(false);
|
||||
let pruneStatus = $state<'idle' | 'pruning' | 'success' | 'error'>('idle');
|
||||
let confirmPruneUnused = $state(false);
|
||||
let pruneUnusedStatus = $state<'idle' | 'pruning' | 'success' | 'error'>('idle');
|
||||
|
||||
// Multi-select state
|
||||
let selectedImages = $state<Set<string>>(new Set());
|
||||
@@ -179,7 +183,8 @@
|
||||
tags: [],
|
||||
totalSize: 0,
|
||||
latestCreated: 0,
|
||||
imageIds: new Set()
|
||||
imageIds: new Set(),
|
||||
containers: 0
|
||||
});
|
||||
}
|
||||
const group = groups.get(key)!;
|
||||
@@ -188,11 +193,13 @@
|
||||
fullRef: image.id,
|
||||
imageId: image.id,
|
||||
size: image.size,
|
||||
created: image.created
|
||||
created: image.created,
|
||||
containers: image.containers
|
||||
});
|
||||
group.totalSize = Math.max(group.totalSize, image.size);
|
||||
group.latestCreated = Math.max(group.latestCreated, image.created);
|
||||
group.imageIds.add(image.id);
|
||||
group.containers += image.containers;
|
||||
} else {
|
||||
for (const fullTag of image.tags) {
|
||||
const colonIndex = fullTag.lastIndexOf(':');
|
||||
@@ -205,7 +212,8 @@
|
||||
tags: [],
|
||||
totalSize: 0,
|
||||
latestCreated: 0,
|
||||
imageIds: new Set()
|
||||
imageIds: new Set(),
|
||||
containers: 0
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,11 +225,16 @@
|
||||
fullRef: fullTag,
|
||||
imageId: image.id,
|
||||
size: image.size,
|
||||
created: image.created
|
||||
created: image.created,
|
||||
containers: image.containers
|
||||
});
|
||||
}
|
||||
group.totalSize = Math.max(group.totalSize, image.size);
|
||||
group.latestCreated = Math.max(group.latestCreated, image.created);
|
||||
// Only add containers count once per unique image ID
|
||||
if (!group.imageIds.has(image.id)) {
|
||||
group.containers += image.containers;
|
||||
}
|
||||
group.imageIds.add(image.id);
|
||||
}
|
||||
}
|
||||
@@ -432,7 +445,7 @@
|
||||
const response = await fetch(appendEnvParam('/api/prune/images', envId), { method: 'POST' });
|
||||
if (response.ok) {
|
||||
pruneStatus = 'success';
|
||||
toast.success('Unused images pruned');
|
||||
toast.success('Dangling images pruned');
|
||||
await fetchImages();
|
||||
} else {
|
||||
pruneStatus = 'error';
|
||||
@@ -445,6 +458,26 @@
|
||||
pendingTimeouts.push(setTimeout(() => { pruneStatus = 'idle'; }, 3000));
|
||||
}
|
||||
|
||||
async function pruneUnusedImages() {
|
||||
pruneUnusedStatus = 'pruning';
|
||||
confirmPruneUnused = false;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/prune/images?dangling=false', envId), { method: 'POST' });
|
||||
if (response.ok) {
|
||||
pruneUnusedStatus = 'success';
|
||||
toast.success('Unused images pruned');
|
||||
await fetchImages();
|
||||
} else {
|
||||
pruneUnusedStatus = 'error';
|
||||
toast.error('Failed to prune unused images');
|
||||
}
|
||||
} catch (error) {
|
||||
pruneUnusedStatus = 'error';
|
||||
toast.error('Failed to prune unused images');
|
||||
}
|
||||
pendingTimeouts.push(setTimeout(() => { pruneUnusedStatus = 'idle'; }, 3000));
|
||||
}
|
||||
|
||||
async function removeImage(id: string, tagName: string) {
|
||||
deleteError = null;
|
||||
try {
|
||||
@@ -618,13 +651,16 @@
|
||||
open={confirmPrune}
|
||||
action="Prune"
|
||||
itemType="dangling images"
|
||||
title="Prune images"
|
||||
title="Prune dangling images"
|
||||
position="left"
|
||||
onConfirm={pruneImages}
|
||||
onOpenChange={(open) => confirmPrune = open}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<span class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm bg-background shadow-xs border hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 {pruneStatus === 'pruning' ? 'opacity-50 pointer-events-none' : ''}">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm bg-background shadow-xs border hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 {pruneStatus === 'pruning' ? 'opacity-50 pointer-events-none' : ''}"
|
||||
title="Remove untagged intermediate layers (dangling images)"
|
||||
>
|
||||
{#if pruneStatus === 'pruning'}
|
||||
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
|
||||
{:else if pruneStatus === 'success'}
|
||||
@@ -638,6 +674,33 @@
|
||||
</span>
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
<ConfirmPopover
|
||||
open={confirmPruneUnused}
|
||||
action="Prune"
|
||||
itemType="all unused images"
|
||||
title="Prune unused images"
|
||||
position="left"
|
||||
onConfirm={pruneUnusedImages}
|
||||
onOpenChange={(open) => confirmPruneUnused = open}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm bg-background shadow-xs border hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 {pruneUnusedStatus === 'pruning' ? 'opacity-50 pointer-events-none' : ''}"
|
||||
title="Remove ALL images not used by any container (including tagged images)"
|
||||
>
|
||||
{#if pruneUnusedStatus === 'pruning'}
|
||||
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
|
||||
{:else if pruneUnusedStatus === 'success'}
|
||||
<Check class="w-3.5 h-3.5 text-green-600" />
|
||||
{:else if pruneUnusedStatus === 'error'}
|
||||
<XCircle class="w-3.5 h-3.5 text-destructive" />
|
||||
{:else}
|
||||
<Icon iconNode={broom} class="w-3.5 h-3.5 text-amber-600" />
|
||||
{/if}
|
||||
Prune unused
|
||||
</span>
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
<Button size="sm" variant="outline" onclick={fetchImages}>Refresh</Button>
|
||||
</div>
|
||||
@@ -785,6 +848,16 @@
|
||||
{group.tags[0].tag}
|
||||
</span>
|
||||
{/if}
|
||||
{#if group.containers === 0}
|
||||
<Badge variant="outline" class="text-2xs px-1.5 py-0 border-amber-500/50 text-amber-600 dark:text-amber-400 shadow-[0_0_4px_rgba(245,158,11,0.4)]">
|
||||
Unused
|
||||
</Badge>
|
||||
{:else if group.tags.length > 1 && group.tags.some(t => t.containers === 0)}
|
||||
<Badge variant="outline" class="text-2xs px-1.5 py-0 border-amber-500/30 text-amber-600/70 dark:text-amber-400/70 shadow-[0_0_3px_rgba(245,158,11,0.25)]" title="Some tags are unused">
|
||||
<CircleDashed class="w-2.5 h-2.5 mr-0.5" />
|
||||
Some unused
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if column.id === 'tags'}
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
@@ -867,6 +940,22 @@
|
||||
<span class="text-muted-foreground">{formatSize(tagInfo.size)}</span>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-muted-foreground">{formatImageDate(tagInfo.created)}</span>
|
||||
{:else if column.id === 'used'}
|
||||
{#if tagInfo.containers > 0}
|
||||
<a
|
||||
href="/containers?image={encodeURIComponent(tagInfo.fullRef)}"
|
||||
class="text-muted-foreground hover:text-foreground hover:underline"
|
||||
title="View containers using this image"
|
||||
>
|
||||
{tagInfo.containers} container{tagInfo.containers === 1 ? '' : 's'}
|
||||
</a>
|
||||
{:else if tagInfo.containers === 0}
|
||||
<Badge variant="outline" class="text-2xs px-1.5 py-0 border-amber-500/50 text-amber-600 dark:text-amber-400 shadow-[0_0_4px_rgba(245,158,11,0.4)]">
|
||||
Unused
|
||||
</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground/50">—</span>
|
||||
{/if}
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex items-center gap-1">
|
||||
{#if $canAccess('images', 'inspect')}
|
||||
@@ -930,7 +1019,7 @@
|
||||
<Tag class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if $canAccess('images', 'remove')}
|
||||
{#if $canAccess('images', 'remove') && tagInfo.containers === 0}
|
||||
<div class="relative">
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteId === tagInfo.fullRef}
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
<Popover.Trigger asChild>
|
||||
{@render children()}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-[28rem] p-0 overflow-hidden flex flex-col max-h-96 z-[150]" align="end" sideOffset={8}>
|
||||
<Popover.Content class="w-[28rem] p-0 overflow-hidden flex flex-col max-h-96 z-[200]" align="end" sideOffset={8}>
|
||||
<!-- Sticky Header -->
|
||||
<div class="p-3 border-b shrink-0 space-y-2">
|
||||
<div class="flex items-center gap-2 text-sm font-medium min-w-0">
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<div class="min-h-[400px] mt-4">
|
||||
<div class="min-h-[200px] sm:min-h-[300px] mt-4">
|
||||
<!-- Basic Tab -->
|
||||
<Tabs.Content value="basic" class="space-y-4 h-full overflow-y-auto">
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-4xl h-[90vh] flex flex-col">
|
||||
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<Dialog.Header class="shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Network class="w-5 h-5" />
|
||||
|
||||
@@ -43,8 +43,13 @@
|
||||
|
||||
interface ExpandedImageState {
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
error: string;
|
||||
tags: TagInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
let registries = $state<Registry[]>([]);
|
||||
@@ -226,38 +231,100 @@
|
||||
const { [imageName]: _, ...rest } = expandedImages;
|
||||
expandedImages = rest;
|
||||
} else {
|
||||
// Expand and fetch tags
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: { loading: true, error: '', tags: [] }
|
||||
};
|
||||
// Expand and fetch first page
|
||||
await fetchTagsPage(imageName, 1, true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let url = `/api/registry/tags?image=${encodeURIComponent(imageName)}`;
|
||||
if (selectedRegistryId) {
|
||||
url += `®istry=${selectedRegistryId}`;
|
||||
}
|
||||
async function loadMoreTags(imageName: string) {
|
||||
const state = expandedImages[imageName];
|
||||
if (!state || state.loading || state.loadingMore || !state.hasNext) return;
|
||||
await fetchTagsPage(imageName, state.page + 1, false);
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const tags = await response.json();
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: { loading: false, error: '', tags }
|
||||
};
|
||||
} else {
|
||||
const data = await response.json();
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: { loading: false, error: data.error || 'Failed to fetch tags', tags: [] }
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
async function fetchTagsPage(imageName: string, page: number, isFirstLoad: boolean) {
|
||||
const currentState = expandedImages[imageName];
|
||||
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: {
|
||||
loading: isFirstLoad,
|
||||
loadingMore: !isFirstLoad,
|
||||
error: '',
|
||||
tags: currentState?.tags || [],
|
||||
total: currentState?.total || 0,
|
||||
page: currentState?.page || 0,
|
||||
pageSize: 20,
|
||||
hasNext: currentState?.hasNext || false
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
let url = `/api/registry/tags?image=${encodeURIComponent(imageName)}&page=${page}&pageSize=20`;
|
||||
if (selectedRegistryId) {
|
||||
url += `®istry=${selectedRegistryId}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const prevState = expandedImages[imageName];
|
||||
const existingTags = isFirstLoad ? [] : (prevState?.tags || []);
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: { loading: false, error: error.message || 'Failed to fetch tags', tags: [] }
|
||||
[imageName]: {
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
error: '',
|
||||
tags: [...existingTags, ...data.tags],
|
||||
total: data.total,
|
||||
page: data.page,
|
||||
pageSize: data.pageSize,
|
||||
hasNext: data.hasNext
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const data = await response.json();
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: {
|
||||
...expandedImages[imageName],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
error: data.error || 'Failed to fetch tags'
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
expandedImages = {
|
||||
...expandedImages,
|
||||
[imageName]: {
|
||||
...expandedImages[imageName],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
error: error.message || 'Failed to fetch tags'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function handleTagsWheel(event: WheelEvent, imageName: string) {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
|
||||
// Prevent page scroll when at top/bottom of tags list
|
||||
const atTop = target.scrollTop === 0;
|
||||
const atBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 1;
|
||||
|
||||
if ((atTop && event.deltaY < 0) || (atBottom && event.deltaY > 0)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Load more when near bottom
|
||||
const state = expandedImages[imageName];
|
||||
if (!state || !state.hasNext || state.loading || state.loadingMore) return;
|
||||
|
||||
if (target.scrollHeight - target.scrollTop - target.clientHeight < 50) {
|
||||
loadMoreTags(imageName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +395,8 @@
|
||||
...expandedImages,
|
||||
[imageName]: {
|
||||
...state,
|
||||
tags: state.tags.filter(t => t.name !== tag)
|
||||
tags: state.tags.filter(t => t.name !== tag),
|
||||
total: Math.max(0, state.total - 1)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -514,9 +582,9 @@
|
||||
{expandState.error}
|
||||
</div>
|
||||
{:else if expandState?.tags && expandState.tags.length > 0}
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<div class="max-h-64 overflow-y-auto overscroll-contain" onwheel={(e) => handleTagsWheel(e, result.name)}>
|
||||
<table class="text-xs">
|
||||
<thead class="text-muted-foreground sticky top-0 bg-muted/50">
|
||||
<thead class="text-muted-foreground sticky top-0 bg-background z-10">
|
||||
<tr>
|
||||
<th class="text-left py-1 px-2 pr-4 font-medium">Tag</th>
|
||||
<th class="text-left py-1 px-2 pr-4 font-medium">Size</th>
|
||||
@@ -590,7 +658,20 @@
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Loading more indicator -->
|
||||
{#if expandState.loadingMore}
|
||||
<div class="flex items-center justify-center py-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="w-3 h-3 animate-spin mr-2" />
|
||||
Loading more...
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Tags count -->
|
||||
{#if expandState.total > 0}
|
||||
<div class="text-xs text-muted-foreground pt-1">
|
||||
{expandState.tags.length} of {expandState.total} tags loaded
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-xs text-muted-foreground py-2">
|
||||
No tags found
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee } from 'lucide-svelte';
|
||||
import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee, Monitor, Cog, MemoryStick, Database } from 'lucide-svelte';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
@@ -198,6 +198,7 @@
|
||||
nodeVersion: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
kernel: string;
|
||||
memory: {
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
@@ -438,7 +439,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if serverUptime !== null}
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-1 min-w-[8.5rem]">
|
||||
<Clock class="w-3 h-3 shrink-0" />
|
||||
<span class="tabular-nums">Uptime {formatUptime(serverUptime)}</span>
|
||||
</div>
|
||||
@@ -558,10 +559,13 @@
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-muted-foreground/50">|</span>
|
||||
<span class="text-muted-foreground">Platform</span>
|
||||
<Server class="w-3 h-3 text-muted-foreground" />
|
||||
<span>{systemInfo.runtime.platform}/{systemInfo.runtime.arch}</span>
|
||||
<span class="text-muted-foreground/50">|</span>
|
||||
<span class="text-muted-foreground">Memory</span>
|
||||
<Cpu class="w-3 h-3 text-muted-foreground" />
|
||||
<span>{systemInfo.runtime.kernel}</span>
|
||||
<span class="text-muted-foreground/50">|</span>
|
||||
<MemoryStick class="w-3 h-3 text-muted-foreground" />
|
||||
<span>{formatBytes(systemInfo.runtime.memory.rss)}</span>
|
||||
</div>
|
||||
{#if systemInfo.runtime.container.inContainer}
|
||||
@@ -585,7 +589,7 @@
|
||||
<!-- Database Info -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<HardDrive class="w-3.5 h-3.5" />
|
||||
<Database class="w-3.5 h-3.5" />
|
||||
Database
|
||||
</div>
|
||||
<div class="text-sm pl-5 space-y-1">
|
||||
|
||||
@@ -415,6 +415,10 @@
|
||||
newLabelInput = '';
|
||||
formPublicIp = environment.publicIp || '';
|
||||
modalTab = 'general';
|
||||
// Reset token state for this environment (important when switching between envs)
|
||||
hawserToken = null;
|
||||
generatedToken = null;
|
||||
pendingToken = null;
|
||||
// Load scanner settings, notifications, update check settings, and timezone
|
||||
loadScannerSettings(environment.id);
|
||||
loadEnvNotifications(environment.id);
|
||||
@@ -825,6 +829,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Reload only availability/versions without overwriting user's unsaved settings changes
|
||||
async function reloadScannerAvailability(envId?: number) {
|
||||
await loadScannerVersionsAsync(envId);
|
||||
}
|
||||
|
||||
async function saveScannerSettings(envId?: number) {
|
||||
try {
|
||||
const response = await fetch('/api/settings/scanner', {
|
||||
@@ -930,7 +939,8 @@
|
||||
checkingGrypeUpdate = true;
|
||||
grypeUpdateStatus = 'idle';
|
||||
try {
|
||||
const response = await fetch('/api/settings/scanner?checkUpdates=true');
|
||||
const envParam = environment?.id ? `&env=${environment.id}` : '';
|
||||
const response = await fetch(`/api/settings/scanner?checkUpdates=true${envParam}`);
|
||||
const data = await response.json();
|
||||
if (data.updates) {
|
||||
grypeUpdateStatus = data.updates.grype?.hasUpdate ? 'update-available' : 'up-to-date';
|
||||
@@ -947,7 +957,8 @@
|
||||
checkingTrivyUpdate = true;
|
||||
trivyUpdateStatus = 'idle';
|
||||
try {
|
||||
const response = await fetch('/api/settings/scanner?checkUpdates=true');
|
||||
const envParam = environment?.id ? `&env=${environment.id}` : '';
|
||||
const response = await fetch(`/api/settings/scanner?checkUpdates=true${envParam}`);
|
||||
const data = await response.json();
|
||||
if (data.updates) {
|
||||
trivyUpdateStatus = data.updates.trivy?.hasUpdate ? 'update-available' : 'up-to-date';
|
||||
@@ -1198,7 +1209,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(o) => { if (o) focusFirstInput(); else onClose(); }}>
|
||||
<Dialog.Content class="max-w-2xl min-h-[800px] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<Dialog.Content class="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<Dialog.Header class="flex-shrink-0 border-b pb-4">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
{#if !isEditing}
|
||||
@@ -1362,7 +1373,7 @@
|
||||
<HelpCircle class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 text-sm" side="right">
|
||||
<Popover.Content class="w-80 text-sm z-[200]" side="right">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<Unplug class="w-4 h-4 mt-0.5 text-cyan-500 shrink-0" />
|
||||
@@ -2112,7 +2123,7 @@
|
||||
{/if}
|
||||
{#if !loadingScannerVersions}
|
||||
{#if !scannerAvailability.grype}
|
||||
<ImagePullProgressPopover imageName="anchore/grype:latest" envId={environment?.id} onComplete={() => loadScannerSettings(environment?.id)}>
|
||||
<ImagePullProgressPopover imageName="anchore/grype:latest" envId={environment?.id} onComplete={() => reloadScannerAvailability(environment?.id)}>
|
||||
<button class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Download class="w-2.5 h-2.5 mr-0.5" />
|
||||
Pull
|
||||
@@ -2189,7 +2200,7 @@
|
||||
{/if}
|
||||
{#if !loadingScannerVersions}
|
||||
{#if !scannerAvailability.trivy}
|
||||
<ImagePullProgressPopover imageName="aquasec/trivy:latest" envId={environment?.id} onComplete={() => loadScannerSettings(environment?.id)}>
|
||||
<ImagePullProgressPopover imageName="aquasec/trivy:latest" envId={environment?.id} onComplete={() => reloadScannerAvailability(environment?.id)}>
|
||||
<button class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Download class="w-2.5 h-2.5 mr-0.5" />
|
||||
Pull
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe } from 'lucide-svelte';
|
||||
import { appSettings, type DateFormat, type DownloadFormat } from '$lib/stores/settings';
|
||||
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock } from 'lucide-svelte';
|
||||
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode } from '$lib/stores/settings';
|
||||
import { canAccess, authStore } from '$lib/stores/auth';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
|
||||
@@ -32,6 +32,9 @@
|
||||
let eventCleanupEnabled = $derived($appSettings.eventCleanupEnabled);
|
||||
let logBufferSizeKb = $derived($appSettings.logBufferSizeKb);
|
||||
let defaultTimezone = $derived($appSettings.defaultTimezone);
|
||||
let eventCollectionMode = $derived($appSettings.eventCollectionMode);
|
||||
let eventPollInterval = $derived($appSettings.eventPollInterval);
|
||||
let metricsCollectionInterval = $derived($appSettings.metricsCollectionInterval);
|
||||
|
||||
const dateFormatOptions: { value: DateFormat; label: string; example: string }[] = [
|
||||
{ value: 'DD.MM.YYYY', label: 'DD.MM.YYYY', example: '31.12.2024' },
|
||||
@@ -93,6 +96,27 @@
|
||||
appSettings.setLogBufferSizeKb(value);
|
||||
toast.success('Log buffer size updated');
|
||||
}
|
||||
|
||||
function handleEventCollectionModeChange(value: string | undefined) {
|
||||
if (value === 'stream' || value === 'poll') {
|
||||
appSettings.setEventCollectionMode(value);
|
||||
toast.success(`Event collection mode: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEventPollIntervalChange(selected: { value: number } | undefined) {
|
||||
if (selected?.value) {
|
||||
appSettings.setEventPollInterval(selected.value);
|
||||
toast.success(`Event poll interval: ${selected.value / 1000}s`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMetricsIntervalChange(selected: { value: number } | undefined) {
|
||||
if (selected?.value) {
|
||||
appSettings.setMetricsCollectionInterval(selected.value);
|
||||
toast.success(`Metrics interval: ${selected.value / 1000}s`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
@@ -362,7 +386,107 @@
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label>Activity event collection mode</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="w-80">
|
||||
<p class="text-xs">
|
||||
<strong>Stream:</strong> Continuous event stream from Docker, instant notifications, higher CPU usage<br />
|
||||
<strong>Poll:</strong> Periodic checks for new events, slight notification delay, lower CPU usage
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="eventCollectionMode"
|
||||
value="stream"
|
||||
checked={(eventCollectionMode || 'stream') === 'stream'}
|
||||
onchange={() => handleEventCollectionModeChange('stream')}
|
||||
disabled={!$canAccess('settings', 'edit')}
|
||||
class="accent-primary w-4 h-4"
|
||||
/>
|
||||
<Activity class="w-3.5 h-3.5" />
|
||||
<span class="text-sm">Stream</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="eventCollectionMode"
|
||||
value="poll"
|
||||
checked={(eventCollectionMode || 'stream') === 'poll'}
|
||||
onchange={() => handleEventCollectionModeChange('poll')}
|
||||
disabled={!$canAccess('settings', 'edit')}
|
||||
class="accent-primary w-4 h-4"
|
||||
/>
|
||||
<Clock class="w-3.5 h-3.5" />
|
||||
<span class="text-sm">Poll</span>
|
||||
</label>
|
||||
|
||||
<span class="text-xs text-muted-foreground {(eventCollectionMode || 'stream') === 'poll' ? '' : 'invisible'}">every</span>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={String(eventPollInterval || 60000)}
|
||||
onValueChange={(v) => v && handleEventPollIntervalChange({ value: parseInt(v) })}
|
||||
disabled={!$canAccess('settings', 'edit') || (eventCollectionMode || 'stream') !== 'poll'}
|
||||
>
|
||||
<Select.Trigger class="w-24 h-8 {(eventCollectionMode || 'stream') === 'poll' ? '' : 'invisible'}">
|
||||
{(eventPollInterval || 60000) === 30000 ? '30s' : (eventPollInterval || 60000) === 60000 ? '60s' : (eventPollInterval || 60000) === 120000 ? '120s' : '300s'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="30000">30s</Select.Item>
|
||||
<Select.Item value="60000">60s</Select.Item>
|
||||
<Select.Item value="120000">120s</Select.Item>
|
||||
<Select.Item value="300000">300s</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 pt-2 border-t">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="metrics-interval">Metrics collection interval</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="w-80">
|
||||
<p class="text-xs">
|
||||
How often to collect CPU/memory metrics from running containers. Lower intervals
|
||||
provide more frequent updates but increase CPU usage.
|
||||
</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={String(metricsCollectionInterval || 30000)}
|
||||
onValueChange={(v) => v && handleMetricsIntervalChange({ value: parseInt(v) })}
|
||||
disabled={!$canAccess('settings', 'edit')}
|
||||
>
|
||||
<Select.Trigger class="w-24 h-8">
|
||||
{(metricsCollectionInterval || 30000) === 10000 ? '10s' : (metricsCollectionInterval || 30000) === 30000 ? '30s' : (metricsCollectionInterval || 30000) === 60000 ? '60s' : '120s'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="10000">10s</Select.Item>
|
||||
<Select.Item value="30000">30s</Select.Item>
|
||||
<Select.Item value="60000">60s</Select.Item>
|
||||
<Select.Item value="120000">120s</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 pt-2 border-t">
|
||||
<div class="flex items-center gap-3">
|
||||
<Label for="schedule-retention">Schedule execution cleanup</Label>
|
||||
<TogglePill
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
||||
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, GitBranch, RefreshCw, Loader2, FileCode, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, ExternalLink, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw } from 'lucide-svelte';
|
||||
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import } from 'lucide-svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
|
||||
import StackModal from './StackModal.svelte';
|
||||
import GitStackModal from './GitStackModal.svelte';
|
||||
import ImportStackModal from './ImportStackModal.svelte';
|
||||
import GitDeployProgressPopover from './GitDeployProgressPopover.svelte';
|
||||
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
|
||||
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
|
||||
@@ -30,7 +31,7 @@
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
let stacks = $state<ComposeStackInfo[]>([]);
|
||||
let stackSources = $state<Record<string, { sourceType: string; repository?: any; gitStack?: any }>>({});
|
||||
let stackSources = $state<Record<string, { sourceType: string; composePath?: string | null; repository?: any; gitStack?: any }>>({});
|
||||
let stackEnvVarCounts = $state<Record<string, number>>({});
|
||||
let gitStacks = $state<any[]>([]);
|
||||
let gitRepositories = $state<any[]>([]);
|
||||
@@ -43,6 +44,7 @@
|
||||
let showCreateModal = $state(false);
|
||||
let showEditModal = $state(false);
|
||||
let showGitModal = $state(false);
|
||||
let showImportModal = $state(false);
|
||||
let editingStackName = $state('');
|
||||
let editingGitStack = $state<any>(null);
|
||||
let envId = $state<number | null>(null);
|
||||
@@ -576,37 +578,24 @@
|
||||
stackSources = sourcesData && !sourcesData.error ? sourcesData : {};
|
||||
gitStacks = Array.isArray(gitStacksData) ? gitStacksData : [];
|
||||
|
||||
// Create a set of docker stack names for quick lookup
|
||||
const dockerStackNames = new Set(dockerStacks.map((s: ComposeStackInfo) => s.name));
|
||||
|
||||
// Add undeployed git stacks as placeholder entries
|
||||
const undeployedGitStacks: ComposeStackInfo[] = gitStacks
|
||||
.filter((gs: any) => !dockerStackNames.has(gs.stackName))
|
||||
.map((gs: any) => ({
|
||||
name: gs.stackName,
|
||||
status: 'not deployed',
|
||||
containers: [],
|
||||
containerDetails: [],
|
||||
configFile: gs.composePath,
|
||||
workingDir: ''
|
||||
}));
|
||||
|
||||
// Add gitStack to all git-based stacks (both deployed and undeployed)
|
||||
// Add gitStack details to all git-based stacks
|
||||
// Note: The API already includes undeployed stacks from the database,
|
||||
// so we just need to attach the gitStack object for additional metadata
|
||||
for (const gs of gitStacks) {
|
||||
if (!stackSources[gs.stackName]) {
|
||||
// Undeployed git stack - create new source entry
|
||||
// Git stack not in sources yet - create source entry
|
||||
stackSources[gs.stackName] = {
|
||||
sourceType: 'git',
|
||||
repository: gs.repository,
|
||||
gitStack: gs
|
||||
};
|
||||
} else if (stackSources[gs.stackName].sourceType === 'git') {
|
||||
// Deployed git stack - add gitStack to existing source
|
||||
// Git stack already in sources - add gitStack object
|
||||
stackSources[gs.stackName].gitStack = gs;
|
||||
}
|
||||
}
|
||||
|
||||
stacks = [...dockerStacks, ...undeployedGitStacks];
|
||||
stacks = dockerStacks;
|
||||
|
||||
// Fetch env var counts for internal and git stacks (in background, don't block UI)
|
||||
const allStackNames = stacks.map(s => s.name);
|
||||
@@ -1130,6 +1119,10 @@
|
||||
<Plus class="w-3.5 h-3.5 mr-1" />
|
||||
Create
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onclick={() => showImportModal = true}>
|
||||
<Import class="w-3.5 h-3.5 mr-1" />
|
||||
Adopt
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1281,7 +1274,8 @@
|
||||
{#snippet cell(column, stack, rowState)}
|
||||
{@const source = getStackSource(stack.name)}
|
||||
{#if column.id === 'name'}
|
||||
{#if source.sourceType === 'internal'}
|
||||
{#if source.sourceType !== 'git'}
|
||||
<!-- Internal stacks (including those needing file location) are clickable -->
|
||||
<button
|
||||
type="button"
|
||||
class="font-medium text-xs hover:text-primary hover:underline cursor-pointer text-left"
|
||||
@@ -1290,6 +1284,7 @@
|
||||
{stack.name}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Git stacks open in GitStackModal instead -->
|
||||
<span class="font-medium text-xs">{stack.name}</span>
|
||||
{/if}
|
||||
{#if stackEnvVarCounts[stack.name]}
|
||||
@@ -1307,41 +1302,45 @@
|
||||
{/if}
|
||||
{:else if column.id === 'source'}
|
||||
{#if source.sourceType === 'git'}
|
||||
<span
|
||||
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 shadow-sm min-w-[5.5rem]"
|
||||
title={source.repository ? `${source.repository.url} (${source.repository.branch})` : 'Deployed from Git repository'}
|
||||
>
|
||||
<GitBranch class="w-3 h-3" />
|
||||
Git
|
||||
</span>
|
||||
{:else if source.sourceType === 'internal'}
|
||||
<span
|
||||
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 shadow-sm min-w-[5.5rem]"
|
||||
title="Managed by Dockhand"
|
||||
>
|
||||
<FileCode class="w-3 h-3" />
|
||||
Internal
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200 shadow-sm min-w-[5.5rem]"
|
||||
title="File location unknown"
|
||||
>
|
||||
<ExternalLink class="w-3 h-3" />
|
||||
Untracked
|
||||
</span>
|
||||
{/if}
|
||||
{:else if column.id === 'location'}
|
||||
{#if source.composePath}
|
||||
{@const dirPath = source.composePath.replace(/\/[^/]+$/, '')}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<span class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 shadow-sm">
|
||||
<GitBranch class="w-3 h-3" />
|
||||
Git
|
||||
<Tooltip.Trigger class="w-full text-left">
|
||||
<span class="text-xs text-muted-foreground block truncate">
|
||||
{dirPath}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{#if source.repository}
|
||||
{source.repository.url} ({source.repository.branch})
|
||||
{:else}
|
||||
Deployed from Git repository
|
||||
{/if}
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<code class="text-xs">{source.composePath}</code>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if source.sourceType === 'internal'}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<span class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 shadow-sm">
|
||||
<FileCode class="w-3 h-3" />
|
||||
Internal
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="whitespace-nowrap">Created in Dockhand</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<span class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200 shadow-sm">
|
||||
<ExternalLink class="w-3 h-3" />
|
||||
External
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="whitespace-nowrap">Created outside Dockhand</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<span class="text-xs text-muted-foreground/50">—</span>
|
||||
{/if}
|
||||
{:else if column.id === 'containers'}
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -1501,16 +1500,7 @@
|
||||
</GitDeployProgressPopover>
|
||||
{/if}
|
||||
{#if $canAccess('stacks', 'edit')}
|
||||
{#if source.sourceType === 'internal'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => editStack(stack.name)}
|
||||
title="Edit"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Pencil class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
|
||||
</button>
|
||||
{:else if source.sourceType === 'git' && source.gitStack}
|
||||
{#if source.sourceType === 'git' && source.gitStack}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openGitModal(source.gitStack)}
|
||||
@@ -1519,6 +1509,16 @@
|
||||
>
|
||||
<Pencil class="w-3 h-3 text-muted-foreground hover:text-purple-500" />
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Internal stacks (including those needing file location) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => editStack(stack.name)}
|
||||
title="Edit"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Pencil class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if stack.containers && stack.containers.length > 0}
|
||||
@@ -1947,6 +1947,12 @@
|
||||
onSaved={fetchStacks}
|
||||
/>
|
||||
|
||||
<ImportStackModal
|
||||
bind:open={showImportModal}
|
||||
onClose={() => showImportModal = false}
|
||||
onAdopted={fetchStacks}
|
||||
/>
|
||||
|
||||
<ContainerInspectModal
|
||||
bind:open={showInspectModal}
|
||||
containerId={inspectContainerId}
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
{@render children()}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="w-80 p-0 overflow-hidden flex flex-col"
|
||||
class="w-80 p-0 overflow-hidden flex flex-col z-[200]"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
interactOutsideBehavior={overallStatus !== 'idle' ? 'ignore' : 'close'}
|
||||
|
||||
+945
-138
File diff suppressed because it is too large
Load Diff
@@ -50,7 +50,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col">
|
||||
<Dialog.Content class="max-w-4xl h-[90vh] sm:h-[80vh] flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<HardDrive class="w-5 h-5" />
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-4xl h-[90vh] flex flex-col">
|
||||
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<Dialog.Header class="shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<HardDrive class="w-5 h-5" />
|
||||
|
||||
Reference in New Issue
Block a user