This commit is contained in:
jarek committed 2026-01-17 15:06:14 +01:00
1 parent 62636426bf
commit c185d00dc3
60 files changed
+2830 -918

No files matched your search

-5
View File
@@ -10,7 +10,6 @@
import CommandPalette from '$lib/components/CommandPalette.svelte';
import WhatsNewModal from '$lib/components/WhatsNewModal.svelte';
import { SidebarProvider, SidebarTrigger } from '$lib/components/ui/sidebar';
import { startStatsCollection, stopStatsCollection } from '$lib/stores/stats';
import { connectSSE, disconnectSSE } from '$lib/stores/events';
import { currentEnvironment, environments } from '$lib/stores/environment';
import { licenseStore, daysUntilExpiry } from '$lib/stores/license';
@@ -70,9 +69,6 @@
// Initialize grid preferences
gridPreferencesStore.init();
// Start global stats collection for CPU/Memory graphs
startStatsCollection();
// Connect to SSE for real-time Docker events (global)
connectSSE(envId);
@@ -86,7 +82,6 @@
checkWhatsNew();
return () => {
stopStatsCollection();
disconnectSSE();
};
});
+83 -10
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Dashboard - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
@@ -11,7 +15,7 @@
import EnvironmentTileSkeleton from './dashboard/EnvironmentTileSkeleton.svelte';
import DraggableGrid, { type GridItemLayout } from './dashboard/DraggableGrid.svelte';
import { dashboardPreferences, dashboardData, GRID_COLS, GRID_ROW_HEIGHT, type TileItem } from '$lib/stores/dashboard';
import { currentEnvironment } from '$lib/stores/environment';
import { currentEnvironment, environments } from '$lib/stores/environment';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import type { EnvironmentStats } from './api/dashboard/stats/+server';
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
@@ -42,11 +46,69 @@
let tiles = $state<TileItem[]>([]);
let gridItems = $state<GridItemLayout[]>([]);
let initialLoading = $state(true);
let environmentsLoaded = $state(false); // Tracks if environments were ever received (prevents false "no environments" message)
let refreshing = $state(false);
let prefsLoaded = $state(false);
const mobileWatcher = new IsMobile();
const isMobile = $derived.by(() => mobileWatcher.current);
// Subscribe to environments store's loaded flag for quick "loaded" detection
// When loaded, immediately create skeleton tiles so the UI shows something useful
// The SSE stream will then update these tiles with real stats
$effect(() => {
const unsubscribe = environments.loaded.subscribe(loaded => {
if (loaded) {
environmentsLoaded = true;
// Create skeleton tiles immediately from the fast environments store
// This avoids waiting for the slower SSE stream to show initial UI
const envList = $environments;
if (tiles.length === 0 && envList.length > 0) {
const skeletonTiles = envList.map(env => ({
id: env.id,
stats: {
id: env.id,
name: env.name,
host: env.host,
port: env.port,
icon: env.icon || 'globe',
socketPath: env.socketPath,
collectActivity: false,
collectMetrics: true,
connectionType: env.connectionType || 'socket',
labels: [],
scannerEnabled: false,
online: undefined,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0, pendingUpdates: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
buildCacheSize: 0,
networks: { total: 0 },
stacks: { total: 0, running: 0, partial: 0, stopped: 0 },
metrics: null,
events: { total: 0, today: 0 },
topContainers: [],
loading: { containers: true, images: true, volumes: true, networks: true, stacks: true, diskUsage: true, topContainers: true }
} as EnvironmentStats,
info: { id: env.id, name: env.name, host: env.host, port: env.port, icon: env.icon || 'globe', socketPath: env.socketPath, collectActivity: false, collectMetrics: true, connectionType: env.connectionType || 'socket' },
loading: true
}));
tiles = skeletonTiles;
// Generate grid layout for these tiles
const savedLayout = $dashboardPreferences.gridLayout;
const tileIds = envList.map(env => env.id);
const newGridItems = savedLayout.length > 0
? mergeLayout(tileIds, savedLayout)
: generateDefaultLayout(tileIds);
gridItems = newGridItems;
}
}
});
return unsubscribe;
});
// Label filtering - load from localStorage
let filterLabels = $state<string[]>([]);
let labelFilterLoaded = $state(false);
@@ -321,6 +383,8 @@
const data = JSON.parse(eventData);
if (eventType === 'environments') {
// Mark that we've received environment data (prevents false "no environments" message)
environmentsLoaded = true;
// Create tiles for each environment with initial loading state
const envList = data as (EnvironmentInfo & { loading?: EnvironmentStats['loading'] })[];
const cachedData = dashboardData.getData();
@@ -342,7 +406,7 @@
labels: env.labels || [],
scannerEnabled: false,
online: undefined, // undefined = connecting, false = offline, true = online
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0, pendingUpdates: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
@@ -394,18 +458,26 @@
}
} else if (eventType === 'partial') {
// Progressive update - merge partial data into existing stats
// Only apply defined values to avoid overwriting with undefined
// Use deep merge for nested objects to preserve existing values
const partialStats = data as Partial<EnvironmentStats> & { id: number };
const tile = tiles.find(t => t.id === partialStats.id);
if (tile?.stats) {
// Use direct mutation for Svelte 5 reactivity
// Deep merge for nested objects like containers, images, etc.
for (const [key, value] of Object.entries(partialStats)) {
if (value !== undefined && key !== 'id') {
(tile.stats as any)[key] = value;
const existing = (tile.stats as any)[key];
// Deep merge for plain objects (not arrays or null)
if (existing && typeof existing === 'object' && !Array.isArray(existing) &&
value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(existing, value);
} else {
(tile.stats as any)[key] = value;
}
}
}
}
// Also update the store
// Also update the store with deep merge
const definedStats: Partial<EnvironmentStats> = {};
for (const [key, value] of Object.entries(partialStats)) {
if (value !== undefined) {
@@ -799,6 +871,7 @@
tiles = cachedData.tiles;
gridItems = cachedData.gridItems;
initialLoading = false;
environmentsLoaded = true;
// Then refresh in background
fetchStatsStreaming(true);
@@ -852,7 +925,7 @@
<div class="flex flex-col gap-4 h-full overflow-auto pb-4">
<!-- Header -->
<div class="flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<div class="flex items-center gap-4">
<PageHeader icon={LayoutGrid} title="Environments" />
@@ -940,14 +1013,14 @@
</div>
</div>
<!-- Initial loading state before any tiles -->
{#if initialLoading && tiles.length === 0}
<!-- Initial loading state before any tiles - show until we know whether environments exist -->
{#if !environmentsLoaded && tiles.length === 0}
<div class="flex items-center justify-center gap-2 text-muted-foreground py-8">
<Loader2 class="w-5 h-5 animate-spin text-primary" />
<span class="text-sm">Loading environments...</span>
</div>
{:else if tiles.length === 0}
<!-- No environments -->
{:else if tiles.length === 0 && environmentsLoaded && $environments.length === 0}
<!-- No environments - only shown after we've confirmed there are none -->
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div class="w-16 h-16 mb-4 rounded-2xl border-2 border-dashed border-muted-foreground/30 flex items-center justify-center">
<Server class="w-8 h-8 opacity-40" />
+3 -5
View File
@@ -625,10 +625,7 @@
connectSSE();
initialLoadDone = true;
});
return () => {
disconnectSSE();
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
onDestroy(() => {
@@ -644,7 +641,7 @@
<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">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<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'}">
@@ -782,6 +779,7 @@
variant="destructive"
disabled={clearingActivity}
onOpenChange={(open) => showClearConfirm = open}
unstyled
>
{#snippet children({ open })}
<Button variant="outline" size="sm" disabled={clearingActivity || total === 0}>
@@ -0,0 +1,99 @@
import { json } from '@sveltejs/kit';
import { execInContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// Shell paths to check
const SHELLS_TO_CHECK = [
{ path: '/bin/bash', label: 'Bash' },
{ path: '/bin/sh', label: 'Shell (sh)' },
{ path: '/bin/zsh', label: 'Zsh' },
{ path: '/bin/ash', label: 'Ash (Alpine)' }
];
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check - need exec permission to detect shells
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const containerId = params.id;
const availableShells: string[] = [];
// Check each shell by testing if the file exists and is executable
// Use a single command to check all shells at once for efficiency
const checkCommand = SHELLS_TO_CHECK.map(s =>
`test -x ${s.path} && echo "${s.path}"`
).join('; ');
try {
const output = await execInContainer(
containerId,
['sh', '-c', checkCommand],
envIdNum
);
// Parse output - each line is an available shell path
const lines = output.trim().split('\n').filter(Boolean);
availableShells.push(...lines);
} catch {
// If even sh fails, try checking with test commands individually
// This handles edge cases where sh might not be available
for (const shell of SHELLS_TO_CHECK) {
try {
await execInContainer(
containerId,
['test', '-x', shell.path],
envIdNum
);
availableShells.push(shell.path);
} catch {
// Shell not available, continue to next
}
}
}
// Determine default shell - prefer bash, then sh, then first available
let defaultShell: string | null = null;
if (availableShells.includes('/bin/bash')) {
defaultShell = '/bin/bash';
} else if (availableShells.includes('/bin/sh')) {
defaultShell = '/bin/sh';
} else if (availableShells.length > 0) {
defaultShell = availableShells[0];
}
return json({
shells: availableShells,
defaultShell,
allShells: SHELLS_TO_CHECK.map(s => ({
path: s.path,
label: s.label,
available: availableShells.includes(s.path)
}))
});
} catch (error) {
console.error('Error detecting shells:', error);
return json({
error: 'Failed to detect shells',
shells: [],
defaultShell: null,
allShells: SHELLS_TO_CHECK.map(s => ({
path: s.path,
label: s.label,
available: false
}))
}, { status: 200 }); // Return 200 with empty results rather than 500
}
};
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
export interface UpdateCheckResult {
containerId: string;
@@ -36,7 +37,10 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
await clearPendingContainerUpdates(envIdNum);
}
const containers = await listContainers(true, envIdNum);
const allContainers = await listContainers(true, envIdNum);
// Filter out system containers (Dockhand, Hawser) - they cannot be updated from within Dockhand
const containers = allContainers.filter(c => !isSystemContainer(c.image));
// Check container for updates
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
+10 -5
View File
@@ -5,7 +5,8 @@ import {
getContainerEventStats,
getEnvSetting,
hasEnvironments,
getEnvUpdateCheckSettings
getEnvUpdateCheckSettings,
getPendingContainerUpdates
} from '$lib/server/db';
import {
listContainers,
@@ -61,6 +62,7 @@ export interface EnvironmentStats {
paused: number;
restarting: number;
unhealthy: number;
pendingUpdates: number;
};
images: {
total: number;
@@ -165,7 +167,7 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
online: false,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0, pendingUpdates: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
@@ -252,10 +254,11 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
// Get latest metrics and event stats in parallel
const [latestMetrics, eventStats] = await Promise.all([
// Get latest metrics, event stats, and pending updates in parallel
const [latestMetrics, eventStats, pendingUpdates] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id)
getContainerEventStats(env.id),
getPendingContainerUpdates(env.id)
]);
if (latestMetrics) {
@@ -272,6 +275,8 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
today: eventStats.today
};
envStats.containers.pendingUpdates = pendingUpdates.length;
} catch (error) {
// Convert technical error messages to user-friendly ones
const errorStr = String(error);
@@ -6,7 +6,8 @@ import {
getContainerEventStats,
getContainerEvents,
getEnvSetting,
getEnvUpdateCheckSettings
getEnvUpdateCheckSettings,
getPendingContainerUpdates
} from '$lib/server/db';
import {
listContainers,
@@ -136,7 +137,7 @@ async function getEnvironmentStatsProgressive(
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
online: false,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0, pendingUpdates: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
@@ -174,11 +175,12 @@ async function getEnvironmentStatsProgressive(
// 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([
const [latestMetrics, eventStats, recentEventsResult, metricsHistory, pendingUpdates] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
getContainerEvents({ environmentId: env.id, limit: 10 }),
getHostMetrics(30, env.id)
getHostMetrics(30, env.id),
getPendingContainerUpdates(env.id)
]);
if (latestMetrics) {
@@ -195,6 +197,8 @@ async function getEnvironmentStatsProgressive(
today: eventStats.today
};
envStats.containers.pendingUpdates = pendingUpdates.length;
if (recentEventsResult.events.length > 0) {
envStats.recentEvents = recentEventsResult.events.map(e => ({
container_name: e.containerName || 'unknown',
@@ -221,6 +225,7 @@ async function getEnvironmentStatsProgressive(
scannerEnabled: envStats.scannerEnabled,
updateCheckEnabled: envStats.updateCheckEnabled,
updateCheckAutoUpdate: envStats.updateCheckAutoUpdate,
containers: { ...envStats.containers },
loading: { ...envStats.loading }
});
@@ -251,6 +256,7 @@ async function getEnvironmentStatsProgressive(
envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length;
envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length;
envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length;
// Note: pendingUpdates is already set from DB query, preserve it
envStats.loading!.containers = false;
onPartialUpdate({
+1 -1
View File
@@ -109,7 +109,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
stackName: trimmedStackName,
environmentId: data.environmentId || null,
repositoryId: repositoryId,
composePath: data.composePath || 'docker-compose.yml',
composePath: data.composePath || 'compose.yaml',
envFilePath: data.envFilePath || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
+32 -12
View File
@@ -1,10 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
const PAGE_SIZE = 100;
export const GET: RequestHandler = async ({ url }) => {
try {
const registryId = url.searchParams.get('registry');
const lastParam = url.searchParams.get('last'); // For pagination
if (!registryId) {
return json({ error: 'Registry ID is required' }, { status: 400 });
@@ -20,22 +24,20 @@ export const GET: RequestHandler = async ({ url }) => {
return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 });
}
// Build the catalog URL
let catalogUrl = registry.url;
if (!catalogUrl.endsWith('/')) {
catalogUrl += '/';
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
// Build catalog URL with pagination
let catalogUrl = `${baseUrl}/v2/_catalog?n=${PAGE_SIZE}`;
if (lastParam) {
catalogUrl += `&last=${encodeURIComponent(lastParam)}`;
}
catalogUrl += 'v2/_catalog';
// Prepare headers
const headers: HeadersInit = {
'Accept': 'application/json'
};
// Add auth if credentials are present
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
if (authHeader) {
headers['Authorization'] = authHeader;
}
const response = await fetch(catalogUrl, {
@@ -56,7 +58,18 @@ export const GET: RequestHandler = async ({ url }) => {
const data = await response.json();
// The V2 API returns { repositories: [...] }
const repositories = data.repositories || [];
const repositories: string[] = data.repositories || [];
// Parse Link header for pagination
// Format: </v2/_catalog?last=xxx&n=100>; rel="next"
let nextLast: string | null = null;
const linkHeader = response.headers.get('Link');
if (linkHeader) {
const nextMatch = linkHeader.match(/<[^>]*[?&]last=([^&>]+)[^>]*>;\s*rel="next"/);
if (nextMatch) {
nextLast = decodeURIComponent(nextMatch[1]);
}
}
// For each repository, we could fetch tags, but that's expensive
// Just return the repository names for now
@@ -68,7 +81,14 @@ export const GET: RequestHandler = async ({ url }) => {
is_automated: false
}));
return json(results);
return json({
repositories: results,
pagination: {
pageSize: PAGE_SIZE,
hasMore: !!nextLast,
nextLast: nextLast
}
});
} catch (error: any) {
console.error('Error fetching registry catalog:', error);
+6 -9
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
function isDockerHub(url: string): boolean {
const lower = url.toLowerCase();
@@ -37,22 +38,18 @@ export const DELETE: RequestHandler = async ({ url }) => {
return json({ error: 'Docker Hub does not support image deletion via API. Please use the Docker Hub web interface.' }, { status: 400 });
}
let baseUrl = registry.url;
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull,push,delete`);
const headers: HeadersInit = {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
};
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
if (authHeader) {
headers['Authorization'] = authHeader;
}
// Step 1: Get the manifest digest
const manifestUrl = `${baseUrl}v2/${imageName}/manifests/${tag}`;
const manifestUrl = `${baseUrl}/v2/${imageName}/manifests/${tag}`;
const headResponse = await fetch(manifestUrl, {
method: 'HEAD',
headers
@@ -74,7 +71,7 @@ export const DELETE: RequestHandler = async ({ url }) => {
}
// Step 2: Delete the manifest by digest
const deleteUrl = `${baseUrl}v2/${imageName}/manifests/${digest}`;
const deleteUrl = `${baseUrl}/v2/${imageName}/manifests/${digest}`;
const deleteResponse = await fetch(deleteUrl, {
method: 'DELETE',
headers
+116 -34
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
interface SearchResult {
name: string;
@@ -44,46 +45,30 @@ async function searchDockerHub(term: string, limit: number): Promise<SearchResul
}
async function searchPrivateRegistry(registry: any, term: string, limit: number): Promise<SearchResult[]> {
// Private registries use the V2 catalog API
let baseUrl = registry.url;
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
const results: string[] = [];
const catalogUrl = `${baseUrl}v2/_catalog?n=1000`;
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
}
const response = await fetch(catalogUrl, {
method: 'GET',
headers
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
// Strategy 1: If term looks like an image name (contains /), try direct lookup first
// This is much faster than iterating through catalog for large registries like ghcr.io
if (term.includes('/')) {
const directResult = await tryDirectImageLookup(registry, term);
if (directResult) {
results.push(term);
}
throw new Error(`Registry returned error: ${response.status}`);
}
const data = await response.json();
const repositories = data.repositories || [];
// Filter repositories by search term (case-insensitive)
const termLower = term.toLowerCase();
const filtered = repositories
.filter((name: string) => name.toLowerCase().includes(termLower))
.slice(0, limit);
// Strategy 2: Fall back to catalog search for partial matches or if direct lookup failed
if (results.length < limit) {
const catalogResults = await searchCatalog(registry, term, limit - results.length);
// Add catalog results, avoiding duplicates
for (const name of catalogResults) {
if (!results.includes(name)) {
results.push(name);
}
}
}
// Return results in the same format as Docker Hub
return filtered.map((name: string) => ({
return results.map((name: string) => ({
name,
description: '',
star_count: 0,
@@ -92,6 +77,103 @@ async function searchPrivateRegistry(registry: any, term: string, limit: number)
}));
}
// Try to directly check if an image exists by querying its tags endpoint
async function tryDirectImageLookup(registry: any, imageName: string): Promise<boolean> {
try {
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
const response = await fetch(`${baseUrl}/v2/${imageName}/tags/list`, {
method: 'GET',
headers
});
// 200 = image exists, 404 = doesn't exist
return response.ok;
} catch {
return false;
}
}
// Search through catalog (slow for large registries, limited to first few pages)
async function searchCatalog(registry: any, term: string, limit: number): Promise<string[]> {
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
const termLower = term.toLowerCase();
const results: string[] = [];
const PAGE_SIZE = 200;
const MAX_PAGES = 3; // Limit pages to avoid long waits on huge registries
let lastRepo: string | null = null;
let pagesSearched = 0;
while (results.length < limit && pagesSearched < MAX_PAGES) {
let catalogUrl = `${baseUrl}/v2/_catalog?n=${PAGE_SIZE}`;
if (lastRepo) {
catalogUrl += `&last=${encodeURIComponent(lastRepo)}`;
}
const response = await fetch(catalogUrl, {
method: 'GET',
headers
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
}
throw new Error(`Registry returned error: ${response.status}`);
}
const data = await response.json();
const repositories: string[] = data.repositories || [];
if (repositories.length === 0) {
break;
}
// Filter and add matching repos
for (const name of repositories) {
if (name.toLowerCase().includes(termLower)) {
results.push(name);
if (results.length >= limit) {
break;
}
}
}
// Get last repo for next page
lastRepo = repositories[repositories.length - 1];
// Check if there are more pages
const linkHeader = response.headers.get('Link');
if (!linkHeader || !linkHeader.includes('rel="next"')) {
if (repositories.length < PAGE_SIZE) {
break;
}
}
pagesSearched++;
}
return results;
}
export const GET: RequestHandler = async ({ url }) => {
const term = url.searchParams.get('term');
const limit = parseInt(url.searchParams.get('limit') || '25', 10);
+5 -10
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
interface TagInfo {
name: string;
@@ -72,21 +73,15 @@ async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize:
}
async function fetchRegistryTags(registry: any, imageName: string): Promise<TagInfo[]> {
// Standard V2 registry API
let baseUrl = registry.url;
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
const tagsUrl = `${baseUrl}v2/${imageName}/tags/list`;
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
const tagsUrl = `${baseUrl}/v2/${imageName}/tags/list`;
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
if (authHeader) {
headers['Authorization'] = authHeader;
}
const response = await fetch(tagsUrl, {
@@ -37,13 +37,17 @@ export const POST: RequestHandler = async ({ params, request, url, cookies }) =>
currentComposePath = source.composePath;
currentDir = dirname(source.composePath);
} else {
// Stack uses default directory structure
// Stack uses default directory structure - check all valid compose filenames
const stackDir = await findStackDir(name, envIdNum);
if (stackDir) {
const defaultComposePath = join(stackDir, 'docker-compose.yml');
if (existsSync(defaultComposePath)) {
currentComposePath = defaultComposePath;
currentDir = stackDir;
const composeNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
for (const fileName of composeNames) {
const composePath = join(stackDir, fileName);
if (existsSync(composePath)) {
currentComposePath = composePath;
currentDir = stackDir;
break;
}
}
}
}
@@ -47,7 +47,7 @@ export const GET: RequestHandler = async ({ url }) => {
return json({
stackDir,
composePath: `${stackDir}/docker-compose.yml`,
composePath: `${stackDir}/compose.yaml`,
envPath: `${stackDir}/.env`,
source: location ? 'custom' : 'default'
});
+19 -11
View File
@@ -581,6 +581,9 @@
}
}
// Resize handler - stored at module scope for cleanup in onDestroy
let resizeHandler: (() => void) | null = null;
onMount(async () => {
// Load saved filters from localStorage first
loadFiltersFromStorage();
@@ -608,23 +611,28 @@
initialLoadDone = true;
// Update container height on resize
const updateHeight = () => {
resizeHandler = () => {
if (scrollContainer) {
containerHeight = scrollContainer.clientHeight;
}
};
updateHeight();
window.addEventListener('resize', updateHeight);
resizeHandler();
window.addEventListener('resize', resizeHandler);
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
return () => {
window.removeEventListener('resize', updateHeight);
// Disconnect SSE when component unmounts
disconnectAuditSSE();
if (unsubscribeSSE) {
unsubscribeSSE();
}
};
onDestroy(() => {
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler);
resizeHandler = null;
}
// Disconnect SSE when component unmounts
disconnectAuditSSE();
if (unsubscribeSSE) {
unsubscribeSSE();
unsubscribeSSE = null;
}
});
// Refetch when license changes (only after initial mount)
+285 -96
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Containers - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
@@ -6,6 +10,7 @@
import * as Dialog from '$lib/components/ui/dialog';
import * as Popover from '$lib/components/ui/popover';
import * as Select from '$lib/components/ui/select';
import * as Tooltip from '$lib/components/ui/tooltip';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -51,7 +56,12 @@
ShieldX,
Shield,
ShieldCheck,
Box
Box,
Ship,
Cable,
Copy,
Loader2,
AlertCircle
} from 'lucide-svelte';
import { broom } from '@lucide/lab';
import CreateContainerModal from './CreateContainerModal.svelte';
@@ -70,6 +80,7 @@
import { canAccess } from '$lib/stores/auth';
import { vulnerabilityCriteriaIcons } from '$lib/utils/update-steps';
import { ipToNumber } from '$lib/utils/ip';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellDetectionResult } from '$lib/utils/shell-detection';
import { DataGrid } from '$lib/components/data-grid';
import type { ColumnConfig } from '$lib/types';
import type { DataGridRowState } from '$lib/components/data-grid/types';
@@ -170,6 +181,8 @@
batchUpdateContainerIds = [];
batchUpdateContainerNames = new Map();
updateCheckStatus = 'idle';
// Clear shell detection cache for new environment
shellDetectionCache = {};
fetchContainers();
fetchStats();
loadPendingUpdates();
@@ -181,6 +194,7 @@
batchUpdateContainerIds = [];
batchUpdateContainerNames = new Map();
updateCheckStatus = 'idle';
shellDetectionCache = {};
}
});
let loading = $state(true);
@@ -267,14 +281,28 @@
// Set of container IDs with updates available (for O(1) lookup)
const containersWithUpdatesSet = $derived(new Set(batchUpdateContainerIds));
// Check if any selected container has an update available
const selectedHaveUpdates = $derived(
Array.from(selectedContainers).some(id => containersWithUpdatesSet.has(id))
// Count of updatable containers (excluding system containers like Dockhand/Hawser)
const updatableContainersCount = $derived(
batchUpdateContainerIds.filter(id => {
const container = containers.find(c => c.id === id);
return container && !container.systemContainer;
}).length
);
// Count selected containers with updates
// Check if any selected container has an update available (excluding system containers)
const selectedHaveUpdates = $derived(
Array.from(selectedContainers).some(id => {
const container = containers.find(c => c.id === id);
return container && containersWithUpdatesSet.has(id) && !container.systemContainer;
})
);
// Count selected containers with updates (excluding system containers)
const selectedWithUpdatesCount = $derived(
Array.from(selectedContainers).filter(id => containersWithUpdatesSet.has(id)).length
Array.from(selectedContainers).filter(id => {
const container = containers.find(c => c.id === id);
return container && containersWithUpdatesSet.has(id) && !container.systemContainer;
}).length
);
// Selection helpers
@@ -431,8 +459,11 @@
}
function updateSelectedContainers() {
// Only include selected containers that have updates available
const selectedWithUpdates = Array.from(selectedContainers).filter(id => containersWithUpdatesSet.has(id));
// Only include selected containers that have updates available (excluding system containers)
const selectedWithUpdates = Array.from(selectedContainers).filter(id => {
const container = containers.find(c => c.id === id);
return container && containersWithUpdatesSet.has(id) && !container.systemContainer;
});
if (selectedWithUpdates.length === 0) return;
const selectedNames = new Map<string, string>();
@@ -450,14 +481,15 @@
function updateAllContainers() {
if (batchUpdateContainerIds.length === 0) return;
// Build names map from all containers with updates
// Build names map from all containers with updates (excluding system containers)
const allNames = new Map<string, string>();
for (const id of batchUpdateContainerIds) {
const container = containers.find(c => c.id === id);
if (container) {
if (container && !container.systemContainer) {
allNames.set(id, container.name);
}
}
if (allNames.size === 0) return;
batchUpdateContainerNames = allNames;
showBatchUpdateModal = true;
}
@@ -512,18 +544,44 @@
return activeTerminals.find(t => t.containerId === containerId);
}
// Shell and user options
const shellOptions = [
{ value: '/bin/bash', label: 'Bash' },
{ value: '/bin/sh', label: 'Shell (sh)' },
{ value: '/bin/zsh', label: 'Zsh' },
{ value: '/bin/ash', label: 'Ash (Alpine)' }
];
const userOptions = [
{ value: 'root', label: 'root' },
{ value: 'nobody', label: 'nobody' },
{ value: '', label: 'Container default' }
];
// Shell detection state per container
let shellDetectionCache = $state<Record<string, ShellDetectionResult>>({});
let detectingShellsFor = $state<string | null>(null);
// Check if any shell is available for a container
function anyShellAvailableFor(containerId: string): boolean {
const detection = shellDetectionCache[containerId];
return !detection || hasAvailableShell(detection);
}
// Detect shells when popover opens
async function detectContainerShells(containerId: string) {
if (shellDetectionCache[containerId] || detectingShellsFor === containerId) return;
detectingShellsFor = containerId;
try {
const result = await detectShells(containerId, $currentEnvironment?.id ?? null);
shellDetectionCache[containerId] = result;
// Auto-select best available shell if current is not available
const bestShell = getBestShell(result, terminalShell);
if (bestShell && bestShell !== terminalShell) {
terminalShell = bestShell;
}
} catch (error) {
console.error('Failed to detect shells:', error);
} finally {
detectingShellsFor = null;
}
}
// User options from shared utilities
const userOptions = USER_OPTIONS;
// Stats polling interval - module scope for cleanup in onDestroy
let statsInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribeDockerEvent: (() => void) | null = null;
// Logs state - track active logs per container (like terminals)
interface ActiveLogs {
containerId: string;
@@ -1214,6 +1272,18 @@
return '-';
}
let copiedCommand = $state<string | null>(null);
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(() => {
copiedCommand = text;
toast.success('Copied to clipboard');
setTimeout(() => { copiedCommand = null; }, 2000);
}).catch(() => {
toast.error('Failed to copy to clipboard');
});
}
function parseUptimeToSeconds(status: string): number {
// Parse uptime from status to seconds for sorting
// Running containers have positive values (higher = longer uptime)
@@ -1322,27 +1392,37 @@
// Initial fetch is handled by $effect - no need to duplicate here
// Set up interval to refresh stats every 5 seconds
const statsInterval = setInterval(() => {
// Set up interval to refresh stats every 5 seconds (use module-scope var for cleanup)
statsInterval = setInterval(() => {
if (envId) fetchStats();
}, 5000);
// Subscribe to container events (SSE connection is global in layout)
const unsubscribe = onDockerEvent((event) => {
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isContainerListChange(event)) {
fetchContainers();
fetchStats();
}
});
return () => {
unsubscribe();
clearInterval(statsInterval);
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
// Cleanup resize event listeners and pending timeouts on component destroy
// Cleanup on component destroy
onDestroy(() => {
// Clear stats polling interval
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
// Unsubscribe from Docker events
if (unsubscribeDockerEvent) {
unsubscribeDockerEvent();
unsubscribeDockerEvent = null;
}
// Cleanup resize event listeners and pending timeouts
document.removeEventListener('mousemove', handleWidthResize);
document.removeEventListener('mouseup', stopWidthResize);
document.removeEventListener('visibilitychange', handleVisibilityChange);
@@ -1353,7 +1433,7 @@
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Box} title="Containers" count={containers.length} />
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
@@ -1400,7 +1480,7 @@
{/if}
Check for updates
</Button>
{#if batchUpdateContainerIds.length > 0}
{#if updatableContainersCount > 0}
<Button
size="sm"
variant="outline"
@@ -1409,7 +1489,7 @@
title="Update all containers with available updates"
>
<CircleArrowUp class="w-3.5 h-3.5 mr-1" />
Update all ({batchUpdateContainerIds.length})
Update all ({updatableContainersCount})
</Button>
{/if}
{#if $canAccess('containers', 'remove')}
@@ -1457,13 +1537,14 @@
</div>
</div>
<!-- Selection bar -->
{#if selectedContainers.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<!-- Selection bar - always reserve space to prevent layout shift -->
<div class="h-4 shrink-0">
{#if selectedContainers.size > 0}
<div class="flex items-center gap-1 text-xs text-muted-foreground h-full">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
disabled={bulkActionInProgress}
>
@@ -1481,7 +1562,7 @@
onOpenChange={(open) => confirmBulkStart = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-green-600 hover:border-green-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-green-600 hover:border-green-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<Play class="w-3 h-3" />
Start
</span>
@@ -1499,7 +1580,7 @@
onOpenChange={(open) => confirmBulkStop = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-red-600 hover:border-red-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-red-600 hover:border-red-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<Square class="w-3 h-3" />
Stop
</span>
@@ -1516,7 +1597,7 @@
onOpenChange={(open) => confirmBulkPause = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-yellow-600 hover:border-yellow-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-yellow-600 hover:border-yellow-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<Pause class="w-3 h-3" />
Pause
</span>
@@ -1535,7 +1616,7 @@
onOpenChange={(open) => confirmBulkUnpause = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-blue-600 hover:border-blue-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-blue-600 hover:border-blue-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<Play class="w-3 h-3" />
Unpause
</span>
@@ -1554,7 +1635,7 @@
onOpenChange={(open) => confirmBulkRestart = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<RotateCw class="w-3 h-3" />
Restart
</span>
@@ -1572,7 +1653,7 @@
onOpenChange={(open) => confirmBulkRemove = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<Trash2 class="w-3 h-3" />
Remove
</span>
@@ -1582,7 +1663,7 @@
{#if selectedHaveUpdates}
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-amber-500/40 shadow-sm text-amber-600 hover:border-amber-500 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-amber-500/40 text-amber-600 hover:border-amber-500 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}"
onclick={updateSelectedContainers}
disabled={bulkActionInProgress}
title="Update selected containers to latest image"
@@ -1594,10 +1675,11 @@
{#if bulkActionInProgress}
<CircleArrowUp class="w-3 h-3 animate-spin ml-1" />
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#if $environments.length === 0 || !$currentEnvironment}
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />
{:else if !loading && containers.length === 0}
<EmptyState
@@ -1626,7 +1708,7 @@
let classes = '';
if (currentLogsContainerId === container.id) classes += 'bg-blue-500/10 hover:bg-blue-500/15 ';
if (currentTerminalContainerId === container.id) classes += 'bg-green-500/10 hover:bg-green-500/15 ';
if ($appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id)) classes += 'has-update ';
if ($appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) && !container.systemContainer) classes += 'has-update ';
return classes;
}}
onRowClick={(container, e) => {
@@ -1640,15 +1722,98 @@
{@const ports = formatPorts(container.ports)}
{@const stack = getComposeProject(container.labels)}
{#if column.id === 'name'}
<button
type="button"
class="text-xs font-medium truncate block text-left hover:text-primary hover:underline cursor-pointer"
title={container.name}
onclick={(e) => { e.stopPropagation(); inspectContainer(container); }}
>{container.name}</button>
<div class="flex items-center gap-1.5 min-w-0">
<button
type="button"
class="text-xs font-medium truncate text-left hover:text-primary hover:underline cursor-pointer"
title={container.name}
onclick={(e) => { e.stopPropagation(); inspectContainer(container); }}
>{container.name}</button>
{#if container.systemContainer}
{@const hasUpdate = containersWithUpdatesSet.has(container.id)}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="secondary" class="text-2xs py-0 px-1 shrink-0 {hasUpdate ? 'bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20' : 'bg-blue-500/10 text-blue-600 dark:text-blue-400 hover:bg-blue-500/20'} cursor-help flex items-center gap-0.5">
{#if container.systemContainer === 'dockhand'}
<Ship class="w-2.5 h-2.5" />
{:else}
<Cable class="w-2.5 h-2.5" />
{/if}
{container.systemContainer === 'dockhand' ? 'Dockhand' : 'Hawser'}
{#if hasUpdate}
<CircleArrowUp class="w-2.5 h-2.5" />
{/if}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side="right" class="w-auto p-3">
{#if container.systemContainer === 'dockhand'}
{#if hasUpdate}
{@const composeCmd = 'docker compose pull && docker compose up -d'}
{@const dockerCmd = `docker stop ${container.name} && docker pull fnsys/dockhand:latest && docker start ${container.name}`}
<div class="space-y-2">
<p class="font-medium text-sm flex items-center gap-1.5">
<CircleArrowUp class="w-4 h-4 text-amber-500" />
Update available
</p>
<p class="text-muted-foreground text-xs">Cannot be updated from within Dockhand. Update manually:</p>
<div class="space-y-1.5">
<p class="text-muted-foreground text-2xs">Using Compose:</p>
<div class="flex items-center gap-2 bg-muted rounded p-2">
<code class="text-2xs font-mono whitespace-nowrap">{composeCmd}</code>
<Button size="icon" variant="ghost" class="h-5 w-5 shrink-0" onclick={(e) => { e.stopPropagation(); copyToClipboard(composeCmd); }}>
{#if copiedCommand === composeCmd}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</Button>
</div>
<p class="text-muted-foreground text-2xs">Using Docker CLI:</p>
<div class="flex items-center gap-2 bg-muted rounded p-2">
<code class="text-2xs font-mono whitespace-nowrap">{dockerCmd}</code>
<Button size="icon" variant="ghost" class="h-5 w-5 shrink-0" onclick={(e) => { e.stopPropagation(); copyToClipboard(dockerCmd); }}>
{#if copiedCommand === dockerCmd}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</Button>
</div>
</div>
</div>
{:else}
<p class="text-sm whitespace-nowrap">Dockhand management container</p>
{/if}
{:else}
{#if hasUpdate}
<div class="space-y-2">
<p class="font-medium text-sm flex items-center gap-1.5 whitespace-nowrap">
<CircleArrowUp class="w-4 h-4 text-amber-500" />
Update available
</p>
<p class="text-muted-foreground text-xs whitespace-nowrap">Update on the remote host where Hawser runs.</p>
<a
href="https://github.com/Finsys/hawser"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline text-xs flex items-center gap-1 whitespace-nowrap"
onclick={(e) => e.stopPropagation()}
>
<ExternalLink class="w-3 h-3" />
Update instructions on GitHub
</a>
</div>
{:else}
<p class="text-sm whitespace-nowrap">Hawser remote agent</p>
{/if}
{/if}
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{:else if column.id === 'image'}
<div class="flex items-center gap-1.5 {$appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) ? 'update-border' : ''}">
{#if containersWithUpdatesSet.has(container.id)}
<div class="flex items-center gap-1.5 {$appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) && !container.systemContainer ? 'update-border' : ''}">
{#if containersWithUpdatesSet.has(container.id) && !container.systemContainer}
<span title="Update available">
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
</span>
@@ -1799,7 +1964,7 @@
{/if}
{:else if column.id === 'actions'}
<div class="relative flex gap-0.5 justify-end">
{#if containersWithUpdatesSet.has(container.id)}
{#if containersWithUpdatesSet.has(container.id) && !container.systemContainer}
<button
type="button"
onclick={() => updateSingleContainer(container.id, container.name)}
@@ -1934,7 +2099,10 @@
<Terminal class="w-4 h-4 text-green-400" style="filter: drop-shadow(0 0 4px rgba(74,222,128,0.9)) drop-shadow(0 0 8px rgba(74,222,128,0.6));" strokeWidth={2.5} />
</button>
{:else}
<Popover.Root open={terminalPopoverStates[container.id] ?? false} onOpenChange={(open) => { terminalPopoverStates[container.id] = open; }}>
<Popover.Root open={terminalPopoverStates[container.id] ?? false} onOpenChange={(open) => {
terminalPopoverStates[container.id] = open;
if (open) detectContainerShells(container.id);
}}>
<Popover.Trigger
onclick={(e: MouseEvent) => e.stopPropagation()}
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
@@ -1948,46 +2116,66 @@
<span class="text-xs font-medium truncate" title={container.name}>{container.name}</span>
</div>
</div>
<div class="p-3 space-y-3">
<div class="space-y-1.5">
<Label class="text-xs">Shell</Label>
<Select.Root type="single" bind:value={terminalShell}>
<Select.Trigger class="w-full h-8 text-xs">
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{shellOptions.find(o => o.value === terminalShell)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each shellOptions as option}
<Select.Item value={option.value} label={option.label}>
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if detectingShellsFor === container.id}
<div class="p-4 text-center">
<Loader2 class="w-5 h-5 mx-auto mb-2 text-muted-foreground animate-spin" />
<p class="text-xs text-muted-foreground">Detecting shells...</p>
</div>
<div class="space-y-1.5">
<Label class="text-xs">User</Label>
<Select.Root type="single" bind:value={terminalUser}>
<Select.Trigger class="w-full h-8 text-xs">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{userOptions.find(o => o.value === terminalUser)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{:else if !anyShellAvailableFor(container.id)}
<div class="p-4 text-center">
<AlertCircle class="w-5 h-5 mx-auto mb-2 text-amber-500" />
<p class="text-xs font-medium text-amber-500">No shell available</p>
<p class="text-xs text-muted-foreground mt-1">This container has no shell installed.</p>
</div>
<Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
<Terminal class="w-3 h-3 mr-1" />
Connect
</Button>
</div>
{:else}
<div class="p-3 space-y-3">
<div class="space-y-1.5">
<Label class="text-xs">Shell</Label>
<Select.Root type="single" bind:value={terminalShell}>
<Select.Trigger class="w-full h-8 text-xs">
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{shellDetectionCache[container.id]?.allShells.find(o => o.path === terminalShell)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#if shellDetectionCache[container.id]}
{#each shellDetectionCache[container.id].allShells as option}
<Select.Item value={option.path} label={option.label} disabled={!option.available}>
<Shell class="w-3 h-3 mr-1.5 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" />
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}>
{option.label}
{#if !option.available}
<span class="text-xs ml-1">(unavailable)</span>
{/if}
</span>
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label class="text-xs">User</Label>
<Select.Root type="single" bind:value={terminalUser}>
<Select.Trigger class="w-full h-8 text-xs">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{userOptions.find(o => o.value === terminalUser)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
<Terminal class="w-3 h-3 mr-1" />
Connect
</Button>
</div>
{/if}
</Popover.Content>
</Popover.Root>
{/if}
@@ -2197,5 +2385,6 @@
border-radius: 4px;
box-shadow: 0 0 8px rgb(245 158 11 / 0.4);
}
</style>
+68 -30
View File
@@ -4,11 +4,14 @@
import CronEditor from '$lib/components/cron-editor.svelte';
import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
import { currentEnvironment } from '$lib/stores/environment';
import { Ship, Cable, ExternalLink, AlertTriangle } from 'lucide-svelte';
import type { SystemContainerType } from '$lib/types';
interface Props {
enabled: boolean;
cronExpression: string;
vulnerabilityCriteria: VulnerabilityCriteria;
systemContainer?: SystemContainerType | null;
onenablechange?: (enabled: boolean) => void;
oncronchange?: (cron: string) => void;
oncriteriachange?: (criteria: VulnerabilityCriteria) => void;
@@ -18,6 +21,7 @@
enabled = $bindable(),
cronExpression = $bindable(),
vulnerabilityCriteria = $bindable(),
systemContainer = null,
onenablechange,
oncronchange,
oncriteriachange
@@ -47,35 +51,69 @@
}
</script>
<div class="space-y-3">
<div class="flex items-center gap-3">
<Label class="text-xs font-normal">Enable automatic image updates</Label>
<TogglePill
bind:checked={enabled}
onchange={(value) => onenablechange?.(value)}
/>
</div>
{#if enabled}
<CronEditor
value={cronExpression}
onchange={(cron) => {
cronExpression = cron;
oncronchange?.(cron);
}}
/>
{#if envHasScanning}
<div class="space-y-1.5">
<Label class="text-xs font-medium">Vulnerability criteria</Label>
<VulnerabilityCriteriaSelector
bind:value={vulnerabilityCriteria}
onchange={(v) => oncriteriachange?.(v)}
/>
<p class="text-xs text-muted-foreground">
Block auto-updates if new image has vulnerabilities matching this criteria
</p>
{#if systemContainer}
<!-- System container - show informational message instead of settings -->
<div class="rounded-lg border border-blue-500/30 bg-blue-500/5 p-3">
<div class="flex items-start gap-2">
<AlertTriangle class="w-4 h-4 text-blue-500 mt-0.5 shrink-0" />
<div class="space-y-2 text-xs">
{#if systemContainer === 'dockhand'}
<p class="font-medium text-blue-600 dark:text-blue-400">Auto-updates not available</p>
<p class="text-muted-foreground">
Dockhand cannot update itself. To update, run on the host:
</p>
<code class="block bg-muted rounded px-2 py-1 font-mono text-2xs">
docker compose pull && docker compose up -d
</code>
{:else}
<p class="font-medium text-blue-600 dark:text-blue-400">Auto-updates not available</p>
<p class="text-muted-foreground">
Hawser agents must be updated on their remote host.
</p>
<a
href="https://github.com/Finsys/hawser"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline flex items-center gap-1"
>
<ExternalLink class="w-3 h-3" />
View update instructions on GitHub
</a>
{/if}
</div>
</div>
</div>
{:else}
<div class="space-y-3">
<div class="flex items-center gap-3">
<Label class="text-xs font-normal">Enable automatic image updates</Label>
<TogglePill
bind:checked={enabled}
onchange={(value) => onenablechange?.(value)}
/>
</div>
{#if enabled}
<CronEditor
value={cronExpression}
onchange={(cron) => {
cronExpression = cron;
oncronchange?.(cron);
}}
/>
{#if envHasScanning}
<div class="space-y-1.5">
<Label class="text-xs font-medium">Vulnerability criteria</Label>
<VulnerabilityCriteriaSelector
bind:value={vulnerabilityCriteria}
onchange={(v) => oncriteriachange?.(v)}
/>
<p class="text-xs text-muted-foreground">
Block auto-updates if new image has vulnerabilities matching this criteria
</p>
</div>
{/if}
{/if}
{/if}
</div>
</div>
{/if}
@@ -978,7 +978,7 @@
<Tabs.Content value="env" class="space-y-4 overflow-auto">
{#if containerData.Config?.Env && containerData.Config.Env.length > 0}
<div class="space-y-1">
{#each containerData.Config.Env as envVar}
{#each [...containerData.Config.Env].sort((a, b) => a.split('=')[0].localeCompare(b.split('=')[0])) as envVar}
{@const [key, ...valueParts] = envVar.split('=')}
{@const value = valueParts.join('=')}
<div class="text-xs p-2 bg-muted rounded">
@@ -1214,10 +1214,10 @@
</Tabs.Content>
<!-- Health Tab -->
<Tabs.Content value="health" class="space-y-4 overflow-auto">
<Tabs.Content value="health" class="flex flex-col overflow-hidden">
{#if containerData.State?.Health}
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="flex flex-col flex-1 min-h-0 gap-3">
<div class="grid grid-cols-2 gap-3 text-sm shrink-0">
<div>
<p class="text-muted-foreground">Status</p>
<Badge variant={containerData.State.Health.Status === 'healthy' ? 'default' : 'destructive'}>
@@ -1231,9 +1231,9 @@
</div>
{#if containerData.State.Health.Log && containerData.State.Health.Log.length > 0}
<div class="space-y-2">
<h3 class="text-sm font-semibold">Health check log</h3>
<div class="space-y-1 max-h-64 overflow-y-auto">
<div class="flex flex-col flex-1 min-h-0">
<h3 class="text-sm font-semibold mb-2 shrink-0">Health check log</h3>
<div class="space-y-1 overflow-y-auto flex-1">
{#each containerData.State.Health.Log.slice(-5) as log}
<div class="p-2 border border-border rounded text-xs space-y-1">
<div class="flex justify-between items-center">
@@ -9,6 +9,15 @@
import { Badge } from '$lib/components/ui/badge';
import AutoUpdateSettings from './AutoUpdateSettings.svelte';
import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
import type { SystemContainerType } from '$lib/types';
// Detect system containers (must match server-side logic in update-utils.ts)
function detectSystemContainer(imageName: string): SystemContainerType | null {
const lower = imageName.toLowerCase();
if (lower.includes('fnsys/dockhand')) return 'dockhand';
if (lower.includes('finsys/hawser') || lower.includes('ghcr.io/finsys/hawser')) return 'hawser';
return null;
}
// Protocol options for ports
const protocolOptions = [
@@ -1263,6 +1272,7 @@
bind:enabled={autoUpdateEnabled}
bind:cronExpression={autoUpdateCronExpression}
bind:vulnerabilityCriteria={vulnerabilityCriteria}
systemContainer={detectSystemContainer(image)}
/>
</div>
</div>
+128 -72
View File
@@ -4,7 +4,8 @@
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import { Terminal as TerminalIcon, X, ExternalLink, Shell, User } from 'lucide-svelte';
import { Terminal as TerminalIcon, X, ExternalLink, Shell, User, Loader2, AlertCircle } from 'lucide-svelte';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellDetectionResult } from '$lib/utils/shell-detection';
// Dynamic imports for browser-only xterm
let Terminal: any;
@@ -16,10 +17,11 @@
open: boolean;
containerId: string;
containerName: string;
envId?: number | null;
onClose: () => void;
}
let { open = $bindable(), containerId, containerName, onClose }: Props = $props();
let { open = $bindable(), containerId, containerName, envId = null, onClose }: Props = $props();
let terminalRef: HTMLDivElement;
let terminal: Terminal | null = null;
@@ -28,19 +30,14 @@
let connected = $state(false);
let error = $state<string | null>(null);
// Shell options
const shellOptions = [
{ value: '/bin/bash', label: 'Bash' },
{ value: '/bin/sh', label: 'Shell (sh)' },
{ value: '/bin/zsh', label: 'Zsh' },
{ value: '/bin/ash', label: 'Ash (Alpine)' }
];
// Shell detection state
let shellDetection = $state<ShellDetectionResult | null>(null);
let detectingShells = $state(false);
const userOptions = [
{ value: 'root', label: 'root' },
{ value: 'nobody', label: 'nobody' },
{ value: '', label: 'Container default' }
];
// Derived: check if any shell is available
const anyShellAvailable = $derived(
!shellDetection || hasAvailableShell(shellDetection)
);
let selectedShell = $state('/bin/bash');
let selectedUser = $state('root');
@@ -108,7 +105,10 @@
error = null;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/containers/${containerId}/exec?shell=${encodeURIComponent(selectedShell)}&user=${encodeURIComponent(selectedUser)}`;
let wsUrl = `${protocol}//${window.location.host}/api/containers/${containerId}/exec?shell=${encodeURIComponent(selectedShell)}&user=${encodeURIComponent(selectedUser)}`;
if (envId) {
wsUrl += `&envId=${envId}`;
}
terminal.writeln(`\x1b[90mConnecting to ${containerName}...\x1b[0m`);
terminal.writeln(`\x1b[90mShell: ${selectedShell}, User: ${selectedUser || 'default'}\x1b[0m`);
@@ -162,7 +162,7 @@
}
function startSession() {
if (!xtermLoaded) return;
if (!xtermLoaded || !anyShellAvailable) return;
showConfig = false;
// Wait for DOM update then init terminal
setTimeout(() => {
@@ -176,7 +176,7 @@
user: selectedUser,
name: containerName
});
const url = `/terminal/${containerId}?${params.toString()}`;
const url = `/terminal?container=${containerId}`;
window.open(url, `terminal_${containerId}`, 'width=900,height=600,resizable=yes,scrollbars=no');
handleClose();
}
@@ -212,6 +212,27 @@
}
}
// Detect shells when dialog opens
async function detectContainerShells() {
if (!containerId) return;
detectingShells = true;
shellDetection = null;
try {
shellDetection = await detectShells(containerId, envId);
// Auto-select best available shell if current is not available
const bestShell = getBestShell(shellDetection, selectedShell);
if (bestShell && bestShell !== selectedShell) {
selectedShell = bestShell;
}
} catch (error) {
console.error('Failed to detect shells:', error);
} finally {
detectingShells = false;
}
}
onMount(async () => {
window.addEventListener('resize', handleResize);
@@ -235,10 +256,13 @@
cleanup();
});
// Reset when dialog closes
// Detect shells when dialog opens, reset when it closes
$effect(() => {
if (!open) {
if (open) {
detectContainerShells();
} else {
cleanup();
shellDetection = null;
}
});
</script>
@@ -268,63 +292,95 @@
{#if showConfig}
<div class="flex-1 flex items-center justify-center p-6">
<div class="w-full max-w-md space-y-6">
{#if detectingShells}
<div class="text-center">
<TerminalIcon class="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<h3 class="text-lg font-medium">Open terminal session</h3>
<p class="text-sm text-muted-foreground mt-1">
Configure the shell and user for this session
<Loader2 class="w-12 h-12 mx-auto mb-4 text-muted-foreground animate-spin" />
<h3 class="text-lg font-medium">Detecting available shells...</h3>
</div>
{:else if !anyShellAvailable}
<div class="text-center">
<AlertCircle class="w-12 h-12 mx-auto mb-4 text-amber-500" />
<h3 class="text-lg font-medium text-amber-500">No shell available</h3>
<p class="text-sm text-muted-foreground mt-2">
This container does not have any shell installed.
</p>
</div>
<div class="space-y-4">
<div class="space-y-2">
<Label>Shell</Label>
<Select.Root type="single" bind:value={selectedShell}>
<Select.Trigger class="w-full h-10">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{shellOptions.find(o => o.value === selectedShell)?.label || 'Select shell'}</span>
</Select.Trigger>
<Select.Content>
{#each shellOptions as option}
<Select.Item value={option.value} label={option.label}>
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>User</Label>
<Select.Root type="single" bind:value={selectedUser}>
<Select.Trigger class="w-full h-10">
<User class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{userOptions.find(o => o.value === selectedUser)?.label || 'Select user'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="flex gap-2">
<Button onclick={startSession} class="flex-1" disabled={!xtermLoaded}>
<TerminalIcon class="w-4 h-4 mr-2" />
{xtermLoaded ? 'Connect' : 'Loading...'}
</Button>
<Button onclick={openInNewWindow} variant="outline" disabled={!xtermLoaded} title="Open in new window">
<ExternalLink class="w-4 h-4" />
<p class="text-xs text-muted-foreground/70 mt-1">
Containers built from scratch or distroless images often don't include shells.
</p>
<Button onclick={handleClose} variant="outline" class="mt-6">
Close
</Button>
</div>
</div>
{:else}
<div class="w-full max-w-md space-y-6">
<div class="text-center">
<TerminalIcon class="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<h3 class="text-lg font-medium">Open terminal session</h3>
<p class="text-sm text-muted-foreground mt-1">
Configure the shell and user for this session
</p>
</div>
<div class="space-y-4">
<div class="space-y-2">
<Label>Shell</Label>
<Select.Root type="single" bind:value={selectedShell}>
<Select.Trigger class="w-full h-10">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{shellDetection?.allShells.find(o => o.path === selectedShell)?.label || 'Select shell'}</span>
</Select.Trigger>
<Select.Content>
{#if shellDetection}
{#each shellDetection.allShells as option}
<Select.Item
value={option.path}
label={option.label}
disabled={!option.available}
>
<Shell class="w-4 h-4 mr-2 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" />
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}>
{option.label}
{#if !option.available}
<span class="text-xs ml-1">(unavailable)</span>
{/if}
</span>
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>User</Label>
<Select.Root type="single" bind:value={selectedUser}>
<Select.Trigger class="w-full h-10">
<User class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{USER_OPTIONS.find(o => o.value === selectedUser)?.label || 'Select user'}</span>
</Select.Trigger>
<Select.Content>
{#each USER_OPTIONS as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="flex gap-2">
<Button onclick={startSession} class="flex-1" disabled={!xtermLoaded || !anyShellAvailable}>
<TerminalIcon class="w-4 h-4 mr-2" />
{xtermLoaded ? 'Connect' : 'Loading...'}
</Button>
<Button onclick={openInNewWindow} variant="outline" disabled={!xtermLoaded} title="Open in new window">
<ExternalLink class="w-4 h-4" />
</Button>
</div>
</div>
{/if}
</div>
{:else}
<div class="flex-1 bg-[#0c0c0c] p-2 overflow-hidden">
@@ -5,6 +5,7 @@
Pause,
RefreshCw,
AlertTriangle,
ArrowUpCircle,
Loader2
} from 'lucide-svelte';
@@ -14,6 +15,7 @@
paused: number;
restarting: number;
unhealthy: number;
pendingUpdates: number;
total: number;
}
@@ -54,10 +56,14 @@
<AlertTriangle class="w-3 h-3 text-muted-foreground/50" />
<div class="skeleton w-3 h-3 rounded"></div>
</div>
<div class="flex items-center gap-0.5">
<ArrowUpCircle class="w-3 h-3 text-muted-foreground/50" />
<div class="skeleton w-3 h-3 rounded"></div>
</div>
</div>
{:else if showSkeleton}
<!-- Full skeleton grid view -->
<div class="grid grid-cols-6 gap-1 min-h-5">
<div class="grid grid-cols-7 gap-1 min-h-5">
<div class="flex items-center gap-1">
<Play class="w-3.5 h-3.5 text-muted-foreground/50" />
<div class="skeleton w-4 h-4 rounded"></div>
@@ -78,6 +84,10 @@
<AlertTriangle class="w-3.5 h-3.5 text-muted-foreground/50" />
<div class="skeleton w-4 h-4 rounded"></div>
</div>
<div class="flex items-center gap-1">
<ArrowUpCircle class="w-3.5 h-3.5 text-muted-foreground/50" />
<div class="skeleton w-4 h-4 rounded"></div>
</div>
<div class="flex items-center gap-1">
<span class="text-xs text-muted-foreground/50">Total</span>
<div class="skeleton w-4 h-4 rounded"></div>
@@ -106,10 +116,14 @@
<AlertTriangle class="w-3 h-3 {containers.unhealthy > 0 ? 'text-red-500' : 'text-emerald-500'}" />
<span class="text-2xs font-medium">{containers.unhealthy}</span>
</div>
<div class="flex items-center gap-0.5 {containers.pendingUpdates > 0 ? 'pending-glow' : ''}" title="Pending updates">
<ArrowUpCircle class="w-3 h-3 {containers.pendingUpdates > 0 ? 'text-amber-400' : 'text-muted-foreground'}" />
<span class="text-2xs font-medium {containers.pendingUpdates > 0 ? 'text-amber-400' : ''}">{containers.pendingUpdates}</span>
</div>
</div>
{:else}
<!-- Full grid view -->
<div class="grid grid-cols-6 gap-1 min-h-5">
<div class="grid grid-cols-7 gap-1 min-h-5">
<div class="flex items-center gap-1" title="Running containers">
<Play class="w-3.5 h-3.5 text-emerald-500" />
<span class="text-sm font-medium">{containers.running}</span>
@@ -130,6 +144,10 @@
<AlertTriangle class="w-3.5 h-3.5 {containers.unhealthy > 0 ? 'text-red-500' : 'text-emerald-500'}" />
<span class="text-sm font-medium">{containers.unhealthy}</span>
</div>
<div class="flex items-center gap-1 {containers.pendingUpdates > 0 ? 'pending-glow' : ''}" title="Pending updates">
<ArrowUpCircle class="w-3.5 h-3.5 {containers.pendingUpdates > 0 ? 'text-amber-400' : 'text-muted-foreground'}" />
<span class="text-sm font-medium {containers.pendingUpdates > 0 ? 'text-amber-400' : ''}">{containers.pendingUpdates}</span>
</div>
<div class="flex items-center gap-1" title="Total containers">
<span class="text-xs text-muted-foreground">Total</span>
<span class="text-sm font-medium">{containers.total}</span>
@@ -147,4 +165,15 @@
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes pending-pulse {
0%, 100% {
filter: drop-shadow(0 0 2px rgba(251, 191, 36, 0.4));
}
50% {
filter: drop-shadow(0 0 3px rgba(251, 191, 36, 0.6)) drop-shadow(0 0 5px rgba(251, 191, 36, 0.3));
}
}
:global(.pending-glow) {
animation: pending-pulse 2s ease-in-out infinite;
}
</style>
+1 -1
View File
@@ -245,7 +245,7 @@
</script>
<div class="space-y-4">
<div class="flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Globe} title="Environments">
<Badge variant="secondary" class="text-xs">{environments.length} total</Badge>
</PageHeader>
+38 -15
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Images - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -66,6 +70,10 @@
let loading = $state(true);
let envId = $state<number | null>(null);
// Polling interval - module scope for cleanup in onDestroy
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribeDockerEvent: (() => void) | null = null;
// Registry state
let registries = $state<Registry[]>([]);
@@ -604,22 +612,33 @@
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('resume', handleVisibilityChange);
const unsubscribe = onDockerEvent((event) => {
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isImageListChange(event)) {
fetchImages();
}
});
const interval = setInterval(() => {
refreshInterval = setInterval(() => {
if (envId) fetchImages();
}, 30000);
return () => {
clearInterval(interval);
unsubscribe();
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
// Cleanup on component destroy
onDestroy(() => {
// Clear polling interval
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
// Unsubscribe from Docker events
if (unsubscribeDockerEvent) {
unsubscribeDockerEvent();
unsubscribeDockerEvent = null;
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
pendingTimeouts.forEach(id => clearTimeout(id));
@@ -628,7 +647,7 @@
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader
icon={Images}
title="Images"
@@ -655,6 +674,7 @@
position="left"
onConfirm={pruneImages}
onOpenChange={(open) => confirmPrune = open}
unstyled
>
{#snippet children({ open })}
<span
@@ -682,6 +702,7 @@
position="left"
onConfirm={pruneUnusedImages}
onOpenChange={(open) => confirmPruneUnused = open}
unstyled
>
{#snippet children({ open })}
<span
@@ -706,13 +727,14 @@
</div>
</div>
<!-- Selection bar -->
{#if selectedImages.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<!-- Selection bar - always reserve space to prevent layout shift -->
<div class="h-4 shrink-0">
{#if selectedImages.size > 0}
<div class="flex items-center gap-1 text-xs text-muted-foreground h-full">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
>
Clear
@@ -720,7 +742,7 @@
{#if $canAccess('images', 'remove')}
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all disabled:opacity-50 cursor-pointer"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-destructive hover:border-destructive/40 hover:shadow transition-all disabled:opacity-50 cursor-pointer"
onclick={bulkRemove}
disabled={selectedInFilter.length === 0}
>
@@ -728,8 +750,9 @@
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />
@@ -1027,7 +1050,7 @@
itemType="image"
itemName={tagInfo.fullRef}
title="Remove"
onConfirm={() => removeImage(tagInfo.fullRef, tagInfo.fullRef)}
onConfirm={() => removeImage(tagInfo.imageId, tagInfo.fullRef)}
onOpenChange={(open) => confirmDeleteId = open ? tagInfo.fullRef : null}
>
{#snippet children({ open })}
+13 -2
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Logs - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
@@ -227,6 +231,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
let envId = $state<number | null>(null);
let isInitialLoad = $state(true);
// Polling interval - module scope for cleanup in onDestroy
let containerInterval: ReturnType<typeof setInterval> | null = null;
// Searchable dropdown state
let searchQuery = $state('');
let dropdownOpen = $state(false);
@@ -1399,11 +1406,15 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
onMount(() => {
// All initialization is handled in currentEnvironment.subscribe
// This just sets up the refresh interval
const containerInterval = setInterval(fetchContainers, 10000);
return () => clearInterval(containerInterval);
containerInterval = setInterval(fetchContainers, 10000);
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
onDestroy(() => {
if (containerInterval) {
clearInterval(containerInterval);
containerInterval = null;
}
stopStreaming();
});
</script>
+36 -14
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Networks - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -28,6 +32,10 @@
let loading = $state(true);
let envId = $state<number | null>(null);
// Polling interval - module scope for cleanup in onDestroy
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribeDockerEvent: (() => void) | null = null;
// Search and sort state - with debounce
let searchInput = $state('');
let searchQuery = $state('');
@@ -452,22 +460,33 @@
document.addEventListener('resume', handleVisibilityChange);
// Subscribe to network events (SSE connection is global in layout)
const unsubscribe = onDockerEvent((event) => {
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isNetworkListChange(event)) {
fetchNetworks();
}
});
const interval = setInterval(() => {
refreshInterval = setInterval(() => {
if (envId) fetchNetworks();
}, 30000);
return () => {
clearInterval(interval);
unsubscribe();
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
// Cleanup on component destroy
onDestroy(() => {
// Clear polling interval
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
// Unsubscribe from Docker events
if (unsubscribeDockerEvent) {
unsubscribeDockerEvent();
unsubscribeDockerEvent = null;
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
pendingTimeouts.forEach(id => clearTimeout(id));
@@ -476,7 +495,7 @@
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Network} title="Networks" count={networks.length} />
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
@@ -512,6 +531,7 @@
position="left"
onConfirm={pruneNetworks}
onOpenChange={(open) => confirmPrune = open}
unstyled
>
{#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' : ''}">
@@ -542,13 +562,14 @@
</div>
</div>
<!-- Selection bar -->
{#if selectedNetworks.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<!-- Selection bar - always reserve space to prevent layout shift -->
<div class="h-4 shrink-0">
{#if selectedNetworks.size > 0}
<div class="flex items-center gap-1 text-xs text-muted-foreground h-full">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
>
Clear
@@ -564,15 +585,16 @@
onOpenChange={(open) => confirmBulkRemove = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<Trash2 class="w-3 h-3" />
Delete
</span>
{/snippet}
</ConfirmPopover>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />
+113 -18
View File
@@ -57,13 +57,26 @@
let selectedRegistryId = $state<number | null>(null);
let searchTerm = $state('');
let browseFilter = $state('');
let results = $state<SearchResult[]>([]);
let loading = $state(false);
let browsing = $state(false);
let loadingMore = $state(false);
let searched = $state(false);
let browseMode = $state(false);
let errorMessage = $state('');
// Pagination state for browse mode
let hasMoreResults = $state(false);
let nextPageCursor = $state<string | null>(null);
// Filtered results for browse mode
let filteredResults = $derived(
browseMode && browseFilter.trim()
? results.filter(r => r.name.toLowerCase().includes(browseFilter.toLowerCase()))
: results
);
// Copy to registry modal state
let showCopyModal = $state(false);
let copyImageName = $state('');
@@ -174,28 +187,60 @@
}
}
async function browse() {
async function browse(loadMore = false) {
if (!selectedRegistryId) return;
browsing = true;
searched = true;
browseMode = true;
if (loadMore) {
loadingMore = true;
} else {
browsing = true;
searched = true;
browseMode = true;
results = [];
hasMoreResults = false;
nextPageCursor = null;
}
errorMessage = '';
try {
const response = await fetch(`/api/registry/catalog?registry=${selectedRegistryId}`);
let url = `/api/registry/catalog?registry=${selectedRegistryId}`;
if (loadMore && nextPageCursor) {
url += `&last=${encodeURIComponent(nextPageCursor)}`;
}
const response = await fetch(url);
if (response.ok) {
results = await response.json();
const data = await response.json();
// Handle both old array format and new paginated format
if (Array.isArray(data)) {
// Old format (backwards compat)
results = loadMore ? [...results, ...data] : data;
hasMoreResults = false;
nextPageCursor = null;
} else {
// New paginated format
const newResults = data.repositories || [];
results = loadMore ? [...results, ...newResults] : newResults;
hasMoreResults = data.pagination?.hasMore || false;
nextPageCursor = data.pagination?.nextLast || null;
}
} else {
const data = await response.json();
errorMessage = data.error || 'Failed to browse registry';
results = [];
if (!loadMore) {
results = [];
}
}
} catch (error) {
console.error('Failed to browse registry:', error);
errorMessage = 'Failed to browse registry';
results = [];
if (!loadMore) {
results = [];
}
} finally {
browsing = false;
loadingMore = false;
}
}
@@ -221,8 +266,11 @@
results = [];
searched = false;
browseMode = false;
browseFilter = '';
errorMessage = '';
expandedImages = {};
hasMoreResults = false;
nextPageCursor = null;
}
async function toggleImageExpansion(imageName: string) {
@@ -433,7 +481,7 @@
</script>
<div class="h-full flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Download} title="Registry" showConnection={false} />
{#if $canAccess('registries', 'edit')}
<a href="/settings?tab=registries" class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs">
@@ -446,14 +494,17 @@
<!-- Registry Selector + Search Bar -->
<div class="shrink-0 flex gap-2">
<Select.Root type="single" value={selectedRegistryId ? String(selectedRegistryId) : undefined} onValueChange={(v) => { selectedRegistryId = Number(v); handleRegistryChange(); }}>
<Select.Trigger class="h-9 w-48">
<Select.Trigger class="h-9 min-w-48 max-w-64 shrink-0">
{@const selected = registries.find(r => r.id === selectedRegistryId)}
{#if selected && isDockerHub(selected)}
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground shrink-0" />
{:else}
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
<Server class="w-4 h-4 mr-2 text-muted-foreground shrink-0" />
{/if}
<span class="truncate">{selected ? selected.name : 'Select registry'}</span>
{#if selected?.hasCredentials}
<Badge variant="outline" class="ml-1.5 text-xs shrink-0">auth</Badge>
{/if}
<span>{selected ? `${selected.name}${selected.hasCredentials ? ' (auth)' : ''}` : 'Select registry'}</span>
</Select.Trigger>
<Select.Content>
{#each registries as registry}
@@ -490,7 +541,7 @@
Search
</Button>
{#if supportsBrowsing()}
<Button variant="outline" onclick={browse} disabled={loading || browsing}>
<Button variant="outline" onclick={() => browse()} disabled={loading || browsing}>
{#if browsing}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
@@ -507,10 +558,36 @@
{:else if errorMessage}
<p class="text-red-600 dark:text-red-400 text-sm">{errorMessage}</p>
{:else if searched && results.length === 0}
<p class="text-muted-foreground text-sm">
{browseMode ? 'No images found in this registry' : `No images found for "${searchTerm}"`}
</p>
<div class="text-sm">
<p class="text-muted-foreground">
{browseMode ? 'No images found in this registry' : `No images found for "${searchTerm}"`}
</p>
{#if !browseMode && supportsBrowsing()}
<p class="text-muted-foreground mt-2">
Tip: Large registries don't support search. Try <button class="text-primary underline" onclick={() => browse()}>Browse</button> and use the filter to find images.
</p>
{/if}
</div>
{:else if results.length > 0}
<!-- Browse mode filter -->
{#if browseMode}
<div class="shrink-0 flex items-center gap-2 text-sm">
<div class="relative flex-1 max-w-xs">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Filter results..."
bind:value={browseFilter}
class="h-8 pl-8 text-xs"
/>
</div>
<span class="text-muted-foreground text-xs">
{filteredResults.length === results.length
? `${results.length} images`
: `${filteredResults.length} of ${results.length} images`}
</span>
</div>
{/if}
<div
bind:this={scrollContainer}
class="flex-1 min-h-0 rounded-lg overflow-auto"
@@ -527,7 +604,7 @@
</tr>
</thead>
<tbody>
{#each results as result (result.name)}
{#each filteredResults as result (result.name)}
{@const isExpanded = !!expandedImages[result.name]}
{@const expandState = expandedImages[result.name]}
<!-- Main row -->
@@ -684,6 +761,24 @@
</tbody>
</table>
</div>
<!-- Load More button for pagination (outside scroll container so always visible) -->
{#if browseMode && hasMoreResults}
<div class="shrink-0 flex justify-center py-3 border-t border-muted">
<Button
variant="outline"
size="sm"
onclick={() => browse(true)}
disabled={loadingMore}
>
{#if loadingMore}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Loading...
{:else}
Load more images
{/if}
</Button>
</div>
{/if}
{:else}
<div class="text-center py-12 text-muted-foreground">
<Download class="w-12 h-12 mx-auto mb-4 opacity-50" />
+1 -1
View File
@@ -894,7 +894,7 @@
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<!-- Header with filters -->
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Timer} title="Schedules" count={filteredSchedules.length} />
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
+5 -1
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Settings - Dockhand</title>
</svelte:head>
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@@ -37,7 +41,7 @@
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Settings} title="Settings" showConnection={false} />
</div>
@@ -1002,7 +1002,7 @@
}
// Refresh scanner status after pull
await checkScannerImages();
await loadScannerVersionsAsync(environment?.id);
grypeUpdateStatus = 'up-to-date';
setTimeout(() => { grypeUpdateStatus = 'idle'; }, 3000);
} catch (error) {
@@ -1043,7 +1043,7 @@
}
// Refresh scanner status after pull
await checkScannerImages();
await loadScannerVersionsAsync(environment?.id);
trivyUpdateStatus = 'up-to-date';
setTimeout(() => { trivyUpdateStatus = 'idle'; }, 3000);
} catch (error) {
@@ -555,7 +555,7 @@
<div class="text-center py-8 text-muted-foreground">
<FolderOpen class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">No Docker Compose files found in the configured paths.</p>
<p class="text-xs mt-1">Make sure your paths contain docker-compose.yml, compose.yml, or similar files.</p>
<p class="text-xs mt-1">Make sure your paths contain compose.yaml, compose.yml, or similar files.</p>
</div>
{/if}
+45 -21
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Stacks - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
@@ -52,6 +56,11 @@
// Derived: current environment details for reactive port URL generation
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
// Polling intervals - module scope for cleanup in onDestroy
let stacksInterval: ReturnType<typeof setInterval> | null = null;
let statsInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribeDockerEvent: (() => void) | null = null;
// Helper: extract host from URL (e.g., tcp://192.168.1.4:2376 -> 192.168.1.4)
function extractHostFromUrl(urlString: string): string | null {
if (!urlString) return null;
@@ -1037,38 +1046,51 @@
document.addEventListener('resume', handleVisibilityChange);
// Subscribe to container events (stacks are identified by container labels)
const unsubscribe = onDockerEvent((event) => {
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isContainerListChange(event)) {
fetchStacks();
fetchStats();
}
});
// Refresh stacks every 30 seconds
const stacksInterval = setInterval(() => {
// Refresh stacks every 30 seconds (use module-scope vars for cleanup)
stacksInterval = setInterval(() => {
if (envId) fetchStacks();
}, 30000);
// Refresh stats every 5 seconds (faster for resource monitoring)
const statsInterval = setInterval(() => {
statsInterval = setInterval(() => {
if (envId) fetchStats();
}, 5000);
return () => {
clearInterval(stacksInterval);
clearInterval(statsInterval);
unsubscribe();
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
// Cleanup on component destroy
onDestroy(() => {
// Clear polling intervals
if (stacksInterval) {
clearInterval(stacksInterval);
stacksInterval = null;
}
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
// Unsubscribe from Docker events
if (unsubscribeDockerEvent) {
unsubscribeDockerEvent();
unsubscribeDockerEvent = null;
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
});
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Layers} title="Compose stacks" count={stacks.length}>
{#if stacks.length > 0}
<button
@@ -1127,13 +1149,14 @@
</div>
</div>
<!-- Selection bar -->
{#if selectedStacks.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<!-- Selection bar - always reserve space to prevent layout shift -->
<div class="h-4 shrink-0">
{#if selectedStacks.size > 0}
<div class="flex items-center gap-1 text-xs text-muted-foreground h-full">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
>
Clear
@@ -1151,7 +1174,7 @@
onOpenChange={(open) => confirmBulkStart = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-green-600 hover:border-green-500/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-green-600 hover:border-green-500/40 hover:shadow transition-all cursor-pointer">
<Play class="w-3 h-3" />
Start
</span>
@@ -1171,7 +1194,7 @@
onOpenChange={(open) => confirmBulkRestart = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-amber-600 hover:border-amber-500/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-amber-600 hover:border-amber-500/40 hover:shadow transition-all cursor-pointer">
<RotateCcw class="w-3 h-3" />
Restart
</span>
@@ -1190,7 +1213,7 @@
onOpenChange={(open) => confirmBulkStop = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-red-600 hover:border-red-500/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-red-600 hover:border-red-500/40 hover:shadow transition-all cursor-pointer">
<Square class="w-3 h-3" />
Stop
</span>
@@ -1209,7 +1232,7 @@
onOpenChange={(open) => confirmBulkDown = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-orange-600 hover:border-orange-500/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-orange-600 hover:border-orange-500/40 hover:shadow transition-all cursor-pointer">
<ArrowBigDown class="w-3 h-3" />
Down
</span>
@@ -1228,15 +1251,16 @@
onOpenChange={(open) => confirmBulkRemove = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<Trash2 class="w-3 h-3" />
Remove
</span>
{/snippet}
</ConfirmPopover>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />
+4 -4
View File
@@ -79,7 +79,7 @@
// Form state - stack deployment config
let formStackName = $state('');
let formStackNameUserModified = $state(false);
let formComposePath = $state('docker-compose.yml');
let formComposePath = $state('compose.yaml');
let formAutoUpdate = $state(false);
let formAutoUpdateCron = $state('0 3 * * *');
let formWebhookEnabled = $state(false);
@@ -290,7 +290,7 @@
formNewRepoCredentialId = null;
formStackName = '';
formStackNameUserModified = false;
formComposePath = 'docker-compose.yml';
formComposePath = 'compose.yaml';
formEnvFilePath = null;
formAutoUpdate = false;
formAutoUpdateCron = '0 3 * * *';
@@ -344,7 +344,7 @@
try {
let body: any = {
stackName: formStackName,
composePath: formComposePath || 'docker-compose.yml',
composePath: formComposePath || 'compose.yaml',
envFilePath: formEnvFilePath,
environmentId: environmentId,
autoUpdate: formAutoUpdate,
@@ -661,7 +661,7 @@
<div class="space-y-2">
<Label for="compose-path">Compose file path</Label>
<Input id="compose-path" bind:value={formComposePath} placeholder="docker-compose.yml" />
<Input id="compose-path" bind:value={formComposePath} placeholder="compose.yaml" />
<p class="text-xs text-muted-foreground">Path to the compose file within the repository</p>
</div>
+47 -6
View File
@@ -65,6 +65,9 @@
// Error dialog state
let operationError = $state<{ title: string; message: string; details?: string } | null>(null);
// Stack exists warning dialog state
let showExistsWarning = $state(false);
// ─── Path State (Simplified) ─────────────────────────────────────────────────
// Working paths: what we're currently editing (always strings, never null)
@@ -197,7 +200,7 @@
// Get the current compose filename
const currentComposePath = workingComposePath;
const composeFilename = currentComposePath ? currentComposePath.split('/').pop() : 'docker-compose.yml';
const composeFilename = currentComposePath ? currentComposePath.split('/').pop() : 'compose.yaml';
// Build new paths: create a subfolder with the stack name inside selected directory
const displayName = mode === 'edit' ? stackName : newStackName;
@@ -371,7 +374,7 @@
const stackName = newStackName.trim();
if (stackName) {
// If we have a stack name, include the subfolder
finalPath = `${baseDir}/${stackName}/docker-compose.yml`;
finalPath = `${baseDir}/${stackName}/compose.yaml`;
} else {
// No stack name yet - just show the selected directory
finalPath = `${baseDir}/`;
@@ -811,6 +814,26 @@ services:
if (hasErrors) return;
const envId = $currentEnvironment?.id ?? null;
// Check if stack already exists
try {
const stacksResponse = await fetch(appendEnvParam('/api/stacks', envId));
if (stacksResponse.ok) {
const stacks = await stacksResponse.json();
const existingStack = stacks.find((s: { name: string }) =>
s.name.toLowerCase() === newStackName.trim().toLowerCase()
);
if (existingStack) {
showExistsWarning = true;
return;
}
}
} catch (e) {
console.warn('Failed to check for existing stacks:', e);
// Continue with creation if check fails
}
saving = true;
error = null;
@@ -818,8 +841,6 @@ services:
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] };
try {
const envId = $currentEnvironment?.id ?? null;
// Build request body
const requestBody: Record<string, unknown> = {
name: newStackName.trim(),
@@ -1365,7 +1386,7 @@ services:
<PathBarItem
label="Compose file"
path={workingComposePath || null}
placeholder="/path/to/docker-compose.yml"
placeholder="/path/to/compose.yaml"
copied={composePathCopied}
onCopy={() => copyToClipboard(workingComposePath, (v) => composePathCopied = v)}
onBrowse={openComposeBrowser}
@@ -1457,7 +1478,7 @@ services:
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
onchange={() => { markDirty(); debouncedValidate(); }}
theme={editorTheme}
infoText="These variables will be written to a .env file in the stack directory."
infoText="These variables will be written to a .env file in the stack directory and passed to the compose command."
/>
</div>
</div>
@@ -1671,6 +1692,26 @@ services:
</Dialog.Content>
</Dialog.Root>
<!-- Stack already exists warning dialog -->
<Dialog.Root bind:open={showExistsWarning}>
<Dialog.Content class="max-w-sm">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<TriangleAlert class="w-5 h-5 text-amber-500" />
Stack already exists
</Dialog.Title>
<Dialog.Description>
A stack named "{newStackName}" already exists. Please choose a different name.
</Dialog.Description>
</Dialog.Header>
<div class="flex justify-end mt-4">
<Button size="sm" onclick={() => showExistsWarning = false}>
OK
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Error dialog for failed operations -->
{#if operationError}
{@const errorDialogOpen = true}
+157 -50
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Terminal - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
@@ -5,12 +9,13 @@
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User } from 'lucide-svelte';
import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User, Loader2, AlertCircle } from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import type { ContainerInfo } from '$lib/types';
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
import Terminal from './Terminal.svelte';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellInfo, type ShellDetectionResult } from '$lib/utils/shell-detection';
// Track if we've handled the initial container from URL
let initialContainerHandled = $state(false);
@@ -23,23 +28,18 @@
let terminalComponent: ReturnType<typeof Terminal> | undefined;
let connected = $state(false);
// Shell detection state
let shellDetection = $state<ShellDetectionResult | null>(null);
let detectingShells = $state(false);
// Shell/user options
let selectedShell = $state('/bin/bash');
let selectedUser = $state('root');
let terminalFontSize = $state(14);
const shellOptions = [
{ value: '/bin/bash', label: 'Bash' },
{ value: '/bin/sh', label: 'Shell (sh)' },
{ value: '/bin/zsh', label: 'Zsh' },
{ value: '/bin/ash', label: 'Ash (Alpine)' }
];
const userOptions = [
{ value: 'root', label: 'root' },
{ value: 'nobody', label: 'nobody' },
{ value: '', label: 'Container default' }
];
// Track previous shell/user for reconnection
let prevShell = $state('/bin/bash');
let prevUser = $state('root');
const fontSizeOptions = [10, 12, 14, 16, 18];
@@ -47,6 +47,20 @@
let searchQuery = $state('');
let dropdownOpen = $state(false);
// Derived: check if selected shell is available
const selectedShellAvailable = $derived(
!shellDetection || shellDetection.shells.includes(selectedShell)
);
// Derived: check if any shell is available
const anyShellAvailable = $derived(
!shellDetection || hasAvailableShell(shellDetection)
);
// Polling intervals - module scope for cleanup in onDestroy
let containerInterval: ReturnType<typeof setInterval> | null = null;
let connectedPollInterval: ReturnType<typeof setInterval> | null = null;
// Subscribe to environment changes
currentEnvironment.subscribe((env) => {
envId = env?.id ?? null;
@@ -81,7 +95,7 @@
}
}
function selectContainer(container: ContainerInfo) {
async function selectContainer(container: ContainerInfo) {
// Disconnect from previous container
if (selectedContainer && terminalComponent) {
terminalComponent.dispose();
@@ -89,6 +103,23 @@
selectedContainer = container;
searchQuery = '';
dropdownOpen = false;
// Detect available shells
detectingShells = true;
shellDetection = null;
try {
shellDetection = await detectShells(container.id, envId);
// Auto-select best available shell if current is not available
const bestShell = getBestShell(shellDetection, selectedShell);
if (bestShell && bestShell !== selectedShell) {
selectedShell = bestShell;
}
} catch (error) {
console.error('Failed to detect shells:', error);
} finally {
detectingShells = false;
}
}
function clearSelection() {
@@ -98,6 +129,7 @@
selectedContainer = null;
searchQuery = '';
connected = false;
shellDetection = null;
}
function handleInputFocus() {
@@ -119,6 +151,18 @@
}
}
// Watch for shell/user changes while connected and trigger reconnect
$effect(() => {
if (selectedContainer && connected && terminalComponent) {
if (selectedShell !== prevShell || selectedUser !== prevUser) {
// Reconnect with new shell/user
terminalComponent.reconnect();
}
}
prevShell = selectedShell;
prevUser = selectedUser;
});
// Change font size
function changeFontSize(newSize: number) {
terminalFontSize = newSize;
@@ -143,24 +187,28 @@
}
}
const containerInterval = setInterval(fetchContainers, 10000);
containerInterval = setInterval(fetchContainers, 10000);
// Poll connected state from terminal component
const connectedPollInterval = setInterval(() => {
connectedPollInterval = setInterval(() => {
if (terminalComponent) {
connected = terminalComponent.getConnected();
}
}, 500);
window.addEventListener('resize', handleResize);
return () => {
clearInterval(containerInterval);
clearInterval(connectedPollInterval);
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
onDestroy(() => {
if (containerInterval) {
clearInterval(containerInterval);
containerInterval = null;
}
if (connectedPollInterval) {
clearInterval(connectedPollInterval);
connectedPollInterval = null;
}
window.removeEventListener('resize', handleResize);
terminalComponent?.dispose();
});
@@ -225,42 +273,83 @@
</Button>
{/if}
{#if !selectedContainer}
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">Shell:</Label>
<!-- Shell selector - always visible -->
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">Shell:</Label>
{#if detectingShells}
<div class="h-9 w-36 flex items-center justify-center border rounded-md bg-muted/50">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
{:else}
<Select.Root type="single" bind:value={selectedShell}>
<Select.Trigger class="h-9 w-36">
<Select.Trigger class="h-9 w-44" disabled={!anyShellAvailable}>
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{shellOptions.find(o => o.value === selectedShell)?.label || 'Select'}</span>
<span class={!selectedShellAvailable ? 'text-muted-foreground line-through' : ''}>
{shellDetection?.allShells.find(o => o.path === selectedShell)?.label ||
(selectedShell === '/bin/bash' ? 'Bash' :
selectedShell === '/bin/sh' ? 'Shell (sh)' :
selectedShell === '/bin/zsh' ? 'Zsh' :
selectedShell === '/bin/ash' ? 'Ash (Alpine)' : 'Select')}
</span>
</Select.Trigger>
<Select.Content>
{#each shellOptions as option}
<Select.Item value={option.value} label={option.label}>
{#if shellDetection}
{#each shellDetection.allShells as option}
<Select.Item
value={option.path}
label={option.label}
disabled={!option.available}
>
<Shell class="w-4 h-4 mr-2 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" />
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}>
{option.label}
{#if !option.available}
<span class="text-xs ml-1">(unavailable)</span>
{/if}
</span>
</Select.Item>
{/each}
{:else}
<Select.Item value="/bin/bash" label="Bash">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
Bash
</Select.Item>
{/each}
<Select.Item value="/bin/sh" label="Shell (sh)">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
Shell (sh)
</Select.Item>
<Select.Item value="/bin/zsh" label="Zsh">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
Zsh
</Select.Item>
<Select.Item value="/bin/ash" label="Ash (Alpine)">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
Ash (Alpine)
</Select.Item>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">User:</Label>
<Select.Root type="single" bind:value={selectedUser}>
<Select.Trigger class="h-9 w-40">
<User class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{userOptions.find(o => o.value === selectedUser)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
{/if}
</div>
<!-- User selector - always visible -->
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">User:</Label>
<Select.Root type="single" bind:value={selectedUser}>
<Select.Trigger class="h-9 w-48">
<User class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{USER_OPTIONS.find(o => o.value === selectedUser)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each USER_OPTIONS as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<!-- Shell output - full height -->
@@ -272,6 +361,24 @@
<p>Select a container to open shell</p>
</div>
</div>
{:else if detectingShells}
<div class="flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<Loader2 class="w-12 h-12 mx-auto mb-3 opacity-50 animate-spin" />
<p>Detecting available shells...</p>
</div>
</div>
{:else if !anyShellAvailable}
<div class="flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<AlertCircle class="w-12 h-12 mx-auto mb-3 opacity-50 text-amber-500" />
<p class="font-medium text-amber-500">No shell available in this container</p>
<p class="text-sm mt-2">This container may not have a shell installed.</p>
<p class="text-xs mt-1 text-muted-foreground/70">
Containers built from scratch or distroless images often don't include shells.
</p>
</div>
</div>
{:else}
<!-- Header bar inside black area -->
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50 shrink-0">
@@ -320,7 +427,7 @@
</div>
</div>
<div class="flex-1 min-h-0 w-full">
{#key selectedContainer.id}
{#key `${selectedContainer.id}-${selectedShell}-${selectedUser}`}
<Terminal
bind:this={terminalComponent}
containerId={selectedContainer.id}
+36 -14
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Volumes - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -31,6 +35,10 @@
let loading = $state(true);
let envId = $state<number | null>(null);
// Polling interval - module scope for cleanup in onDestroy
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribeDockerEvent: (() => void) | null = null;
// Search and sort state - with debounce
let searchInput = $state('');
let searchQuery = $state('');
@@ -369,22 +377,33 @@
document.addEventListener('resume', handleVisibilityChange);
// Subscribe to volume events (SSE connection is global in layout)
const unsubscribe = onDockerEvent((event) => {
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isVolumeListChange(event)) {
fetchVolumes();
}
});
const interval = setInterval(() => {
refreshInterval = setInterval(() => {
if (envId) fetchVolumes();
}, 30000);
return () => {
clearInterval(interval);
unsubscribe();
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
// Cleanup on component destroy
onDestroy(() => {
// Clear polling interval
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
// Unsubscribe from Docker events
if (unsubscribeDockerEvent) {
unsubscribeDockerEvent();
unsubscribeDockerEvent = null;
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
pendingTimeouts.forEach(id => clearTimeout(id));
@@ -393,7 +412,7 @@
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={HardDrive} title="Volumes" count={volumes.length} />
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
@@ -431,6 +450,7 @@
position="left"
onConfirm={pruneVolumes}
onOpenChange={(open) => confirmPrune = open}
unstyled
>
{#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' : ''}">
@@ -458,13 +478,14 @@
</div>
</div>
<!-- Selection bar -->
{#if selectedVolumes.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<!-- Selection bar - always reserve space to prevent layout shift -->
<div class="h-4 shrink-0">
{#if selectedVolumes.size > 0}
<div class="flex items-center gap-1 text-xs text-muted-foreground h-full">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
>
Clear
@@ -480,15 +501,16 @@
onOpenChange={(open) => confirmBulkRemove = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<Trash2 class="w-3 h-3" />
Delete
</span>
{/snippet}
</ConfirmPopover>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />