This commit is contained in:
jarek
2026-01-03 09:10:38 +01:00
parent f2102003e3
commit 80c000c601
7 changed files with 170 additions and 49 deletions
+27 -12
View File
@@ -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
+4
View File
@@ -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 + '/'));
}
-1
View File
@@ -61,7 +61,6 @@
});
function handleConfirm() {
console.log('[ConfirmPopover] handleConfirm called, onConfirm:', typeof onConfirm);
onConfirm();
open = false;
onOpenChange(false);
+82 -12
View File
@@ -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<object, DataGridRowState>();
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}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {totalHeight - offsetY - (visibleData.length * rowHeight)}px; padding: 0; border: none;"></td></tr>
{/if}
<!-- Footer (rendered at the bottom of virtual scroll) -->
{#if footer}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} class="p-0 border-none">{@render footer()}</td></tr>
{/if}
</tbody>
</table>
{:else}
+4
View File
@@ -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" },
+28 -19
View File
@@ -68,6 +68,7 @@
// State
let events = $state<ContainerEvent[]>([]);
let eventIds = $state<Set<number>>(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...
</div>
{/snippet}
{#snippet footer()}
{#if loadingMore}
<div class="flex items-center justify-center py-2 text-muted-foreground">
<Loader2 class="w-4 h-4 animate-spin mr-2" />
Loading more...
</div>
{:else if !hasMore && events.length > 0}
<div class="text-center py-2 text-sm text-muted-foreground">
End of results ({total.toLocaleString()} events)
</div>
{/if}
{/snippet}
</DataGrid>
<!-- Loading more indicator -->
{#if loadingMore}
<div class="flex items-center justify-center py-2 text-muted-foreground border-t">
<Loader2 class="w-4 h-4 animate-spin mr-2" />
Loading more...
</div>
{/if}
<!-- End of results -->
{#if !hasMore && events.length > 0}
<div class="text-center py-2 text-sm text-muted-foreground border-t">
End of results ({total.toLocaleString()} events)
</div>
{/if}
{/if}
</div>
+25 -5
View File
@@ -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;
}