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