1.0.9
This commit is contained in:
1 parent
62636426bf
commit
c185d00dc3
60 files changed
+2830
-918
No files matched your search
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in new issue
Block a user