diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 6f750f5..82ab463 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -85,10 +85,19 @@ else delgroup dockhand 2>/dev/null || true # Check for UID conflicts - warn but don't delete other users + SKIP_USER_CREATE=false if getent passwd "$PUID" >/dev/null 2>&1; then EXISTING=$(getent passwd "$PUID" | cut -d: -f1) - echo "WARNING: UID $PUID already in use by '$EXISTING'. Using default UID 1001." - PUID=1001 + if [ "$EXISTING" = "bun" ]; then + echo "Note: UID $PUID is used by the 'bun' runtime user - reusing it for dockhand" + echo "If upgrading from a previous version, you may need to fix data permissions:" + echo " chown -R $PUID:$PGID /path/to/your/data" + RUN_USER="bun" + SKIP_USER_CREATE=true + else + echo "WARNING: UID $PUID already in use by '$EXISTING'. Using default UID 1001." + PUID=1001 + fi fi # Handle GID - reuse existing group or create new @@ -99,21 +108,26 @@ else TARGET_GROUP="dockhand" fi - adduser -u "$PUID" -G "$TARGET_GROUP" -h /home/dockhand -D dockhand + if [ "$SKIP_USER_CREATE" = "false" ]; then + adduser -u "$PUID" -G "$TARGET_GROUP" -h /home/dockhand -D dockhand + fi fi # === Directory Ownership === - chown -R dockhand:dockhand /app/data /home/dockhand 2>/dev/null || true + chown -R "$RUN_USER":"$RUN_USER" /app/data 2>/dev/null || true + if [ "$RUN_USER" = "dockhand" ]; then + chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true + fi if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then mkdir -p "$DATA_DIR" - chown -R dockhand:dockhand "$DATA_DIR" 2>/dev/null || true + chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true fi fi # === Docker Socket Access (Optional) === # Check if Docker socket is mounted and accessible -# Socket path can be configured via environment-specific settings in the app +# Note: DOCKER_HOST with tcp:// requires configuring an environment via the web UI SOCKET_PATH="/var/run/docker.sock" if [ -S "$SOCKET_PATH" ]; then @@ -121,7 +135,7 @@ if [ -S "$SOCKET_PATH" ]; then if [ "$RUN_USER" != "root" ]; then if ! su-exec "$RUN_USER" test -r "$SOCKET_PATH" 2>/dev/null; then SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "unknown") - echo "WARNING: Docker socket at $SOCKET_PATH is not readable by dockhand user" + echo "WARNING: Docker socket at $SOCKET_PATH is not readable by $RUN_USER user" echo "" echo "To use local Docker, fix with one of these options:" echo "" @@ -154,8 +168,8 @@ if [ -S "$SOCKET_PATH" ]; then echo "Using configured hostname: $DOCKHAND_HOSTNAME" fi else - echo "No Docker socket found at $SOCKET_PATH" - echo "Configure Docker environments via the web UI (Settings > Environments)" + echo "No local Docker socket mounted (this is normal when using socket-proxy or remote Docker)" + echo "Configure your Docker environment via the web UI: Settings > Environments" fi # === Run Application === @@ -167,10 +181,11 @@ if [ "$RUN_USER" = "root" ]; then exec "$@" fi else - # Running as dockhand user + # Running as non-root user + echo "Running as user: $RUN_USER" if [ "$1" = "" ]; then - exec su-exec dockhand bun run ./build/index.js + exec su-exec "$RUN_USER" bun run ./build/index.js else - exec su-exec dockhand "$@" + exec su-exec "$RUN_USER" "$@" fi fi diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0e07423..10be1d1 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -73,6 +73,10 @@ const PUBLIC_PATHS = [ // Check if path is public function isPublicPath(pathname: string): boolean { + // Webhook endpoints have their own auth (signature/secret verification) + if (pathname.match(/^\/api\/git\/stacks\/\d+\/webhook$/)) return true; + if (pathname.match(/^\/api\/git\/webhook\/\d+$/)) return true; + return PUBLIC_PATHS.some(path => pathname === path || pathname.startsWith(path + '/')); } diff --git a/src/lib/components/ConfirmPopover.svelte b/src/lib/components/ConfirmPopover.svelte index ace900f..9fe0c28 100644 --- a/src/lib/components/ConfirmPopover.svelte +++ b/src/lib/components/ConfirmPopover.svelte @@ -61,7 +61,6 @@ }); function handleConfirm() { - console.log('[ConfirmPopover] handleConfirm called, onConfirm:', typeof onConfirm); onConfirm(); open = false; onOpenChange(false); diff --git a/src/lib/components/data-grid/DataGrid.svelte b/src/lib/components/data-grid/DataGrid.svelte index f6ca09a..d1f5051 100644 --- a/src/lib/components/data-grid/DataGrid.svelte +++ b/src/lib/components/data-grid/DataGrid.svelte @@ -67,6 +67,7 @@ cell?: Snippet<[ColumnConfig, T, DataGridRowState]>; emptyState?: Snippet; loadingState?: Snippet; + footer?: Snippet; } let { @@ -100,7 +101,8 @@ headerCell, cell, emptyState, - loadingState + loadingState, + footer }: Props = $props(); // Column configuration @@ -112,14 +114,16 @@ // Grid preferences (reactive) const gridPrefs = $derived($gridPreferencesStore); - // Get ordered visible columns from preferences + // Get ordered visible columns from preferences (excluding fixed columns) const orderedColumns = $derived.by(() => { const prefs = gridPrefs[gridId]; if (!prefs?.columns?.length) { // Default: all configurable columns visible return columnConfigs.filter((c) => !c.fixed).map((c) => c.id); } - return prefs.columns.filter((c) => c.visible).map((c) => c.id); + // Filter out fixed columns - they're rendered separately via fixedStartCols/fixedEndCols + const fixedIds = new Set([...fixedStartCols, ...fixedEndCols]); + return prefs.columns.filter((c) => c.visible && !fixedIds.has(c.id)).map((c) => c.id); }); // Identify visible grow columns (columns with grow: true that are currently visible) @@ -153,6 +157,7 @@ let resizeRAF: number | null = null; let scrollRAF: number | null = null; let visibleRangeRAF: number | null = null; + let containerResizeRAF: number | null = null; let loadMorePending = false; // Helper to get base width for a column (without grow calculation) @@ -348,11 +353,38 @@ // Virtual scroll calculations const totalHeight = $derived(virtualScroll ? data.length * rowHeight : 0); + + // Memoization state for visibleData to prevent creating new arrays on every scroll + let prevStartIndex = -1; + let prevEndIndex = -1; + let prevDataRef: T[] | null = null; + let cachedVisibleData: T[] = []; + + // Memoized startIndex/endIndex/visibleData calculation const startIndex = $derived(virtualScroll ? Math.max(0, Math.floor(scrollTop / rowHeight) - bufferRows) : 0); const endIndex = $derived( virtualScroll ? Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + bufferRows) : data.length ); - const visibleData = $derived(virtualScroll ? data.slice(startIndex, endIndex) : data); + + // Memoized visibleData - only create new array when bounds or data actually change + const visibleData = $derived.by(() => { + if (!virtualScroll) return data; + + // If data reference changed, we must reslice + const dataChanged = data !== prevDataRef; + + // Only create new array if bounds or data actually changed + if (!dataChanged && startIndex === prevStartIndex && endIndex === prevEndIndex && cachedVisibleData.length > 0) { + return cachedVisibleData; + } + + prevStartIndex = startIndex; + prevEndIndex = endIndex; + prevDataRef = data; + cachedVisibleData = data.slice(startIndex, endIndex); + return cachedVisibleData; + }); + const offsetY = $derived(virtualScroll ? startIndex * rowHeight : 0); // Notify parent of visible range changes (throttled via RAF) @@ -414,12 +446,17 @@ } const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - scrollContainerWidth = entry.contentRect.width; - if (virtualScroll) { - containerHeight = entry.contentRect.height; + // Throttle with RAF to prevent "ResizeObserver loop" warnings + if (containerResizeRAF) return; + containerResizeRAF = requestAnimationFrame(() => { + containerResizeRAF = null; + for (const entry of entries) { + scrollContainerWidth = entry.contentRect.width; + if (virtualScroll) { + containerHeight = entry.contentRect.height; + } } - } + }); }); resizeObserver.observe(scrollContainer); @@ -434,6 +471,7 @@ if (resizeRAF) cancelAnimationFrame(resizeRAF); if (scrollRAF) cancelAnimationFrame(scrollRAF); if (visibleRangeRAF) cancelAnimationFrame(visibleRangeRAF); + if (containerResizeRAF) cancelAnimationFrame(containerResizeRAF); }); // Set context for child components @@ -457,15 +495,43 @@ highlightedKey }); - // Helper to get row state + // Row state cache to prevent creating new objects on every scroll + let rowStateCache = new WeakMap(); + let rowStateCacheDataRef: T[] | null = null; + + // Clear row state cache when data reference changes + $effect(() => { + if (data !== rowStateCacheDataRef) { + rowStateCache = new WeakMap(); + rowStateCacheDataRef = data; + } + }); + + // Helper to get row state (memoized via WeakMap) function getRowState(item: T, index: number): DataGridRowState { - return { + const actualIndex = virtualScroll ? startIndex + index : index; + + // 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; + } + + // Create new state object and cache it + const state: DataGridRowState = { isSelected: isSelected(item[keyField]), isHighlighted: highlightedKey === item[keyField], isSelectable: isItemSelectable(item), isExpanded: isExpanded(item[keyField]), - index: virtualScroll ? startIndex + index : index + index: actualIndex }; + + rowStateCache.set(item as object, state); + return state; } // Helper to check if column is resizable @@ -858,6 +924,10 @@ {#if totalHeight - offsetY - (visibleData.length * rowHeight) > 0} {/if} + + {#if footer} + {@render footer()} + {/if} {:else} diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 4bc6471..7954abb 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -9,6 +9,10 @@ { "type": "feature", "text": "Stack env editor now supports freestyle text entry for pasting env contents" }, { "type": "feature", "text": "Stack env vars saved as .env file next to compose, respecting external edits" }, { "type": "feature", "text": "Additional container options: ulimits, security options, DNS settings" }, + { "type": "fix", "text": "DataGrid performance and memory leak on Activity page with thousands of rows" }, + { "type": "fix", "text": "Webhook endpoints bypass session authentication when auth is enabled" }, + { "type": "fix", "text": "PUID 1000 conflict with existing dockhand user in container" }, + { "type": "fix", "text": "Gmail SMTP notification errors" }, { "type": "fix", "text": "More detailed error messages when stack fails to start" }, { "type": "fix", "text": "Container startup with user: directive in compose" }, { "type": "fix", "text": "Stack editor flickering when typing fast" }, diff --git a/src/routes/activity/+page.svelte b/src/routes/activity/+page.svelte index 132d58f..008d4e2 100644 --- a/src/routes/activity/+page.svelte +++ b/src/routes/activity/+page.svelte @@ -68,6 +68,7 @@ // State let events = $state([]); + let eventIds = $state>(new Set()); // Fast duplicate check let total = $state(0); let loading = $state(false); let loadingMore = $state(false); @@ -246,6 +247,7 @@ toast.success('Activity log cleared'); // Reset and reload events = []; + eventIds = new Set(); total = 0; hasMore = true; fetchEvents(false); @@ -301,13 +303,22 @@ } const data = await response.json(); + // Update total first so hasMore calculation is correct + total = data.total; + if (append) { events = [...events, ...data.events]; + hasMore = events.length < total; + // Update eventIds Set with new events + for (const evt of data.events) { + eventIds.add(evt.id); + } } else { events = data.events; + hasMore = events.length < total; + // Reset eventIds Set + eventIds = new Set(data.events.map((evt: ContainerEvent) => evt.id)); } - total = data.total; - hasMore = events.length < total; dataFetched = true; loading = false; @@ -503,8 +514,9 @@ if (eventDate > filterToDate) return; } - // Add to beginning of events (prepend new events) - if (!events.some(event => event.id === newEvent.id)) { + // Add to beginning of events (prepend new events) - use Set for fast duplicate check + if (!eventIds.has(newEvent.id)) { + eventIds.add(newEvent.id); events = [newEvent, ...events]; total = total + 1; @@ -839,22 +851,19 @@ Loading... {/snippet} + {#snippet footer()} + {#if loadingMore} +
+ + Loading more... +
+ {:else if !hasMore && events.length > 0} +
+ End of results ({total.toLocaleString()} events) +
+ {/if} + {/snippet} - - - {#if loadingMore} -
- - Loading more... -
- {/if} - - - {#if !hasMore && events.length > 0} -
- End of results ({total.toLocaleString()} events) -
- {/if} {/if} diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index d0fdf8e..fe95bbe 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -553,9 +553,19 @@ return; } - const dockerStacks = await stacksRes.json(); - const sourcesData = await sourcesRes.json(); - const gitStacksData = await gitStacksRes.json(); + // Safe JSON parsing - handle potential non-JSON responses + const safeJson = async (res: Response, fallback: any) => { + try { + return await res.json(); + } catch { + console.warn(`[Stacks] Failed to parse response from ${res.url}`); + return fallback; + } + }; + + const dockerStacks = await safeJson(stacksRes, []); + const sourcesData = await safeJson(sourcesRes, {}); + const gitStacksData = await safeJson(gitStacksRes, []); // Debug logging if (gitStacksData?.error) { @@ -736,9 +746,19 @@ stackActionLoading = name; try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' }); + // Log raw response for debugging + const rawText = await response.text(); + console.log(`[downStack] Response status: ${response.status}, raw body:`, rawText); + if (!response.ok) { - const data = await response.json(); - const errorMsg = data.error || 'Failed to bring down stack'; + let errorMsg = 'Failed to bring down stack'; + try { + const data = JSON.parse(rawText); + errorMsg = data.error || errorMsg; + } catch { + // Response may not be valid JSON + errorMsg = rawText || errorMsg; + } showErrorDialog(`Failed to bring down ${name}`, errorMsg); return; }