mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.5
This commit is contained in:
+27
-12
@@ -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
|
||||
|
||||
@@ -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 + '/'));
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
console.log('[ConfirmPopover] handleConfirm called, onConfirm:', typeof onConfirm);
|
||||
onConfirm();
|
||||
open = false;
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user