mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 21:19:05 +00:00
2174 lines
76 KiB
Svelte
2174 lines
76 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { toast } from 'svelte-sonner';
|
|
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 ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
|
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import { Badge } from '$lib/components/ui/badge';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Switch } from '$lib/components/ui/switch';
|
|
import { Label } from '$lib/components/ui/label';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import {
|
|
Play,
|
|
Square,
|
|
RotateCw,
|
|
Trash2,
|
|
Plus,
|
|
FileText,
|
|
Pencil,
|
|
RefreshCw,
|
|
CircleArrowUp,
|
|
X,
|
|
Terminal,
|
|
ArrowUpDown,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
Search,
|
|
ExternalLink,
|
|
LayoutPanelLeft,
|
|
Rows3,
|
|
GripVertical,
|
|
Skull,
|
|
Pause,
|
|
Eye,
|
|
Shell,
|
|
User,
|
|
CheckSquare,
|
|
Square as SquareIcon,
|
|
Check,
|
|
XCircle,
|
|
Icon,
|
|
AlertTriangle,
|
|
FolderOpen,
|
|
ShieldOff,
|
|
ShieldAlert,
|
|
ShieldX,
|
|
Shield,
|
|
ShieldCheck,
|
|
Box
|
|
} from 'lucide-svelte';
|
|
import { broom } from '@lucide/lab';
|
|
import CreateContainerModal from './CreateContainerModal.svelte';
|
|
import EditContainerModal from './EditContainerModal.svelte';
|
|
import TerminalPanel from '../terminal/TerminalPanel.svelte';
|
|
import LogsPanel from '../logs/LogsPanel.svelte';
|
|
import ContainerInspectModal from './ContainerInspectModal.svelte';
|
|
import FileBrowserModal from './FileBrowserModal.svelte';
|
|
import BatchUpdateModal from './BatchUpdateModal.svelte';
|
|
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
|
import type { ContainerInfo, ContainerStats } from '$lib/types';
|
|
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
|
|
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
|
|
import { onDockerEvent, isContainerListChange } from '$lib/stores/events';
|
|
import { appSettings } from '$lib/stores/settings';
|
|
import { canAccess } from '$lib/stores/auth';
|
|
import { vulnerabilityCriteriaIcons } from '$lib/utils/update-steps';
|
|
import { ipToNumber } from '$lib/utils/ip';
|
|
import { DataGrid } from '$lib/components/data-grid';
|
|
import type { ColumnConfig } from '$lib/types';
|
|
import type { DataGridRowState } from '$lib/components/data-grid/types';
|
|
|
|
// Track previous stats for change detection
|
|
let previousStats = $state<Map<string, ContainerStats>>(new Map());
|
|
let changedFields = $state<Map<string, Set<string>>>(new Map());
|
|
|
|
// Format bytes to human readable
|
|
function formatBytes(bytes: number, decimals = 1): string {
|
|
if (bytes === 0) return '0B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'K', 'M', 'G', 'T'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
|
|
}
|
|
|
|
type SortField = 'name' | 'image' | 'state' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory';
|
|
type SortDirection = 'asc' | 'desc';
|
|
|
|
let containers = $state<ContainerInfo[]>([]);
|
|
let containerStats = $state<Map<string, ContainerStats>>(new Map());
|
|
let autoUpdateSettings = $state<Map<string, { enabled: boolean; label: string; tooltip: string; vulnerabilityCriteria?: string }>>(new Map());
|
|
let envId = $state<number | null>(null);
|
|
let envHasScanning = $state(false);
|
|
let envVulnerabilityCriteria = $state<'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current'>('never');
|
|
|
|
// Derived: current environment details for reactive port URL generation
|
|
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
|
|
|
|
// Search and sort state
|
|
let searchQuery = $state('');
|
|
let sortField = $state<SortField>('name');
|
|
let sortDirection = $state<SortDirection>('asc');
|
|
|
|
// Status filter state
|
|
const STATUS_FILTER_STORAGE_KEY = 'dockhand-containers-status-filter';
|
|
let statusFilter = $state<string[]>([]);
|
|
|
|
// Status types with icons for filter and table
|
|
const statusTypes = [
|
|
{ value: 'running', label: 'Running', icon: Play, color: 'text-emerald-500' },
|
|
{ value: 'paused', label: 'Paused', icon: Pause, color: 'text-amber-500' },
|
|
{ value: 'restarting', label: 'Restarting', icon: RotateCw, color: 'text-red-500' },
|
|
{ value: 'exited', label: 'Exited', icon: Square, color: 'text-rose-500' },
|
|
{ value: 'created', label: 'Created', icon: Plus, color: 'text-sky-500' },
|
|
{ value: 'dead', label: 'Dead', icon: Skull, color: 'text-gray-500' }
|
|
];
|
|
|
|
function getStatusIcon(state: string) {
|
|
const status = statusTypes.find(s => s.value === state.toLowerCase());
|
|
return status?.icon || Square;
|
|
}
|
|
|
|
function getStatusIconColor(state: string): string {
|
|
const status = statusTypes.find(s => s.value === state.toLowerCase());
|
|
return status?.color || 'text-muted-foreground';
|
|
}
|
|
|
|
function loadStatusFilter() {
|
|
if (typeof window !== 'undefined') {
|
|
const saved = localStorage.getItem(STATUS_FILTER_STORAGE_KEY);
|
|
if (saved) {
|
|
try {
|
|
statusFilter = JSON.parse(saved);
|
|
} catch {
|
|
statusFilter = [];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function saveStatusFilter() {
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem(STATUS_FILTER_STORAGE_KEY, JSON.stringify(statusFilter));
|
|
}
|
|
}
|
|
|
|
// Save status filter when it changes
|
|
$effect(() => {
|
|
const _s = statusFilter;
|
|
saveStatusFilter();
|
|
});
|
|
|
|
// Track if initial fetch has been done
|
|
let initialFetchDone = $state(false);
|
|
|
|
// Subscribe to environment changes using $effect
|
|
$effect(() => {
|
|
const env = $currentEnvironment;
|
|
const newEnvId = env?.id ?? null;
|
|
|
|
// Only fetch if environment actually changed or this is initial load
|
|
if (env && (newEnvId !== envId || !initialFetchDone)) {
|
|
envId = newEnvId;
|
|
initialFetchDone = true;
|
|
// Clear update state from previous environment
|
|
batchUpdateContainerIds = [];
|
|
batchUpdateContainerNames = new Map();
|
|
updateCheckStatus = 'idle';
|
|
fetchContainers();
|
|
fetchStats();
|
|
loadPendingUpdates();
|
|
} else if (!env) {
|
|
// No environment - clear data and stop loading
|
|
envId = null;
|
|
containers = [];
|
|
loading = false;
|
|
batchUpdateContainerIds = [];
|
|
batchUpdateContainerNames = new Map();
|
|
updateCheckStatus = 'idle';
|
|
}
|
|
});
|
|
let loading = $state(true);
|
|
let showCreateModal = $state(false);
|
|
let showEditModal = $state(false);
|
|
let editContainerId = $state('');
|
|
|
|
// Inspect modal state
|
|
let showInspectModal = $state(false);
|
|
let inspectContainerId = $state('');
|
|
let inspectContainerName = $state('');
|
|
|
|
// File browser modal state
|
|
let showFileBrowserModal = $state(false);
|
|
let fileBrowserContainerId = $state('');
|
|
let fileBrowserContainerName = $state('');
|
|
|
|
// Terminal state - track active terminals per container
|
|
interface ActiveTerminal {
|
|
containerId: string;
|
|
containerName: string;
|
|
shell: string;
|
|
user: string;
|
|
}
|
|
let activeTerminals = $state<ActiveTerminal[]>([]);
|
|
let currentTerminalContainerId = $state<string | null>(null);
|
|
let terminalPopoverStates = $state<Record<string, boolean>>({});
|
|
let terminalShell = $state('/bin/bash');
|
|
let terminalUser = $state('root');
|
|
|
|
// Confirmation popover state
|
|
let confirmStopId = $state<string | null>(null);
|
|
let confirmRestartId = $state<string | null>(null);
|
|
let confirmDeleteId = $state<string | null>(null);
|
|
|
|
// Bulk action confirmation state
|
|
let confirmBulkStop = $state(false);
|
|
let confirmBulkStart = $state(false);
|
|
let confirmBulkRestart = $state(false);
|
|
let confirmBulkPause = $state(false);
|
|
let confirmBulkUnpause = $state(false);
|
|
let confirmBulkRemove = $state(false);
|
|
|
|
// Prune state
|
|
let confirmPrune = $state(false);
|
|
let pruneStatus = $state<'idle' | 'pruning' | 'success' | 'error'>('idle');
|
|
|
|
// Update check state
|
|
let updateCheckStatus = $state<'idle' | 'checking' | 'found' | 'none' | 'error'>('idle');
|
|
let showBatchUpdateModal = $state(false);
|
|
let batchUpdateContainerIds = $state<string[]>([]);
|
|
let batchUpdateContainerNames = $state<Map<string, string>>(new Map());
|
|
|
|
// Operation error state
|
|
let operationError = $state<{ id: string; message: string } | null>(null);
|
|
|
|
// Timeout tracking for cleanup
|
|
let pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
|
|
|
|
function clearErrorAfterDelay(id: string) {
|
|
pendingTimeouts.push(setTimeout(() => {
|
|
if (operationError?.id === id) operationError = null;
|
|
}, 5000));
|
|
}
|
|
|
|
// Multi-select state (for bulk actions via checkbox)
|
|
let selectedContainers = $state<Set<string>>(new Set());
|
|
let bulkActionInProgress = $state(false);
|
|
|
|
// Row highlight state (visual only, for row click)
|
|
let highlightedRowId = $state<string | null>(null);
|
|
|
|
// Batch operation modal state
|
|
let showBatchOpModal = $state(false);
|
|
let batchOpTitle = $state('');
|
|
let batchOpOperation = $state('');
|
|
let batchOpItems = $state<Array<{ id: string; name: string }>>([]);
|
|
let batchOpOptions = $state<Record<string, any>>({});
|
|
|
|
// 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 selected containers with updates
|
|
const selectedWithUpdatesCount = $derived(
|
|
Array.from(selectedContainers).filter(id => containersWithUpdatesSet.has(id)).length
|
|
);
|
|
|
|
// Selection helpers
|
|
function selectNone() {
|
|
selectedContainers = new Set();
|
|
}
|
|
|
|
// Bulk actions - now use BatchOperationModal
|
|
function startBatchOperation(
|
|
opTitle: string,
|
|
operation: string,
|
|
targetContainers: ContainerInfo[],
|
|
options: Record<string, any> = {}
|
|
) {
|
|
batchOpTitle = opTitle;
|
|
batchOpOperation = operation;
|
|
batchOpItems = targetContainers.map(c => ({ id: c.id, name: c.name }));
|
|
batchOpOptions = options;
|
|
showBatchOpModal = true;
|
|
}
|
|
|
|
function handleBatchOpClose() {
|
|
showBatchOpModal = false;
|
|
}
|
|
|
|
function handleBatchOpComplete() {
|
|
selectedContainers = new Set();
|
|
fetchContainers();
|
|
}
|
|
|
|
function bulkStart() {
|
|
startBatchOperation(
|
|
`Starting ${selectedStopped.length} container${selectedStopped.length !== 1 ? 's' : ''}`,
|
|
'start',
|
|
selectedStopped
|
|
);
|
|
}
|
|
|
|
function bulkStop() {
|
|
startBatchOperation(
|
|
`Stopping ${selectedRunning.length} container${selectedRunning.length !== 1 ? 's' : ''}`,
|
|
'stop',
|
|
selectedRunning
|
|
);
|
|
}
|
|
|
|
function bulkRestart() {
|
|
startBatchOperation(
|
|
`Restarting ${selectedInFilter.length} container${selectedInFilter.length !== 1 ? 's' : ''}`,
|
|
'restart',
|
|
selectedInFilter
|
|
);
|
|
}
|
|
|
|
function bulkPause() {
|
|
startBatchOperation(
|
|
`Pausing ${selectedRunning.length} container${selectedRunning.length !== 1 ? 's' : ''}`,
|
|
'pause',
|
|
selectedRunning
|
|
);
|
|
}
|
|
|
|
function bulkUnpause() {
|
|
startBatchOperation(
|
|
`Unpausing ${selectedPaused.length} container${selectedPaused.length !== 1 ? 's' : ''}`,
|
|
'unpause',
|
|
selectedPaused
|
|
);
|
|
}
|
|
|
|
function bulkRemove() {
|
|
startBatchOperation(
|
|
`Removing ${selectedInFilter.length} container${selectedInFilter.length !== 1 ? 's' : ''}`,
|
|
'remove',
|
|
selectedInFilter,
|
|
{ force: true }
|
|
);
|
|
}
|
|
|
|
async function pruneContainers() {
|
|
pruneStatus = 'pruning';
|
|
confirmPrune = false;
|
|
try {
|
|
const response = await fetch(appendEnvParam('/api/prune/containers', envId), {
|
|
method: 'POST'
|
|
});
|
|
if (response.ok) {
|
|
pruneStatus = 'success';
|
|
await fetchContainers();
|
|
} else {
|
|
pruneStatus = 'error';
|
|
}
|
|
} catch (error) {
|
|
pruneStatus = 'error';
|
|
}
|
|
pendingTimeouts.push(setTimeout(() => {
|
|
pruneStatus = 'idle';
|
|
}, 3000));
|
|
}
|
|
|
|
async function checkForUpdates() {
|
|
updateCheckStatus = 'checking';
|
|
try {
|
|
const response = await fetch(appendEnvParam('/api/containers/check-updates', envId), {
|
|
method: 'POST'
|
|
});
|
|
if (!response.ok) {
|
|
updateCheckStatus = 'error';
|
|
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate);
|
|
|
|
if (containersWithUpdates.length === 0) {
|
|
updateCheckStatus = 'none';
|
|
batchUpdateContainerIds = [];
|
|
batchUpdateContainerNames.clear();
|
|
toast.success('All containers are up to date');
|
|
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
|
return;
|
|
}
|
|
|
|
// Prepare data for batch update modal (but don't open it yet)
|
|
batchUpdateContainerIds = containersWithUpdates.map((r: any) => r.containerId);
|
|
batchUpdateContainerNames = new Map(
|
|
containersWithUpdates.map((r: any) => [r.containerId, r.containerName])
|
|
);
|
|
updateCheckStatus = 'found';
|
|
toast.info(`${containersWithUpdates.length} update(s) available`);
|
|
} catch (error) {
|
|
updateCheckStatus = 'error';
|
|
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
|
}
|
|
}
|
|
|
|
// Load pending updates from database (persisted from check-updates or scheduled jobs)
|
|
async function loadPendingUpdates() {
|
|
if (!envId) return;
|
|
try {
|
|
const response = await fetch(appendEnvParam('/api/containers/pending-updates', envId));
|
|
if (!response.ok) return;
|
|
const data = await response.json();
|
|
if (data.pendingUpdates && data.pendingUpdates.length > 0) {
|
|
batchUpdateContainerIds = data.pendingUpdates.map((u: any) => u.containerId);
|
|
batchUpdateContainerNames = new Map(
|
|
data.pendingUpdates.map((u: any) => [u.containerId, u.containerName])
|
|
);
|
|
updateCheckStatus = 'found';
|
|
}
|
|
} catch {
|
|
// Ignore errors - this is a background load
|
|
}
|
|
}
|
|
|
|
function updateSelectedContainers() {
|
|
// Only include selected containers that have updates available
|
|
const selectedWithUpdates = Array.from(selectedContainers).filter(id => containersWithUpdatesSet.has(id));
|
|
if (selectedWithUpdates.length === 0) return;
|
|
|
|
const selectedNames = new Map<string, string>();
|
|
for (const id of selectedWithUpdates) {
|
|
const container = containers.find(c => c.id === id);
|
|
if (container) {
|
|
selectedNames.set(id, container.name);
|
|
}
|
|
}
|
|
batchUpdateContainerIds = selectedWithUpdates;
|
|
batchUpdateContainerNames = selectedNames;
|
|
showBatchUpdateModal = true;
|
|
}
|
|
|
|
function updateAllContainers() {
|
|
if (batchUpdateContainerIds.length === 0) return;
|
|
|
|
// Build names map from all containers with updates
|
|
const allNames = new Map<string, string>();
|
|
for (const id of batchUpdateContainerIds) {
|
|
const container = containers.find(c => c.id === id);
|
|
if (container) {
|
|
allNames.set(id, container.name);
|
|
}
|
|
}
|
|
batchUpdateContainerNames = allNames;
|
|
showBatchUpdateModal = true;
|
|
}
|
|
|
|
function updateSingleContainer(containerId: string, containerName: string) {
|
|
batchUpdateContainerIds = [containerId];
|
|
batchUpdateContainerNames = new Map([[containerId, containerName]]);
|
|
showBatchUpdateModal = true;
|
|
}
|
|
|
|
function handleBatchUpdateClose() {
|
|
showBatchUpdateModal = false;
|
|
updateCheckStatus = 'idle';
|
|
}
|
|
|
|
function handleBatchUpdateComplete(results: { success: string[]; failed: string[]; blocked: string[] }) {
|
|
if (results.success.length > 0) {
|
|
toast.success(`Updated ${results.success.length} container(s)`);
|
|
}
|
|
if (results.failed.length > 0) {
|
|
toast.error(`Failed to update ${results.failed.length} container(s)`);
|
|
}
|
|
if (results.blocked.length > 0) {
|
|
toast.warning(`${results.blocked.length} update(s) blocked by vulnerability policy`);
|
|
}
|
|
selectedContainers = new Set();
|
|
|
|
// Keep blocked containers in the update list - they still have updates available
|
|
// Only remove successfully updated containers
|
|
const successSet = new Set(results.success);
|
|
batchUpdateContainerIds = batchUpdateContainerIds.filter(id => !successSet.has(id));
|
|
for (const id of results.success) {
|
|
batchUpdateContainerNames.delete(id);
|
|
}
|
|
|
|
fetchContainers();
|
|
}
|
|
|
|
// Action in progress state (for animations)
|
|
let stoppingId = $state<string | null>(null);
|
|
let restartingId = $state<string | null>(null);
|
|
|
|
// Helper to check if container has active terminal
|
|
function hasActiveTerminal(containerId: string): boolean {
|
|
return activeTerminals.some(t => t.containerId === containerId);
|
|
}
|
|
|
|
// Helper to get active terminal
|
|
function getActiveTerminal(containerId: string): ActiveTerminal | undefined {
|
|
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' }
|
|
];
|
|
// Logs state - track active logs per container (like terminals)
|
|
interface ActiveLogs {
|
|
containerId: string;
|
|
containerName: string;
|
|
}
|
|
let activeLogs = $state<ActiveLogs[]>([]);
|
|
let currentLogsContainerId = $state<string | null>(null);
|
|
|
|
// Helper to check if container has active logs
|
|
function hasActiveLogs(containerId: string): boolean {
|
|
return activeLogs.some(l => l.containerId === containerId);
|
|
}
|
|
|
|
// Helper to get active logs
|
|
function getActiveLogs(containerId: string): ActiveLogs | undefined {
|
|
return activeLogs.find(l => l.containerId === containerId);
|
|
}
|
|
|
|
// Layout state - horizontal (panels at bottom) or vertical (panels on right)
|
|
type LayoutMode = 'horizontal' | 'vertical';
|
|
const LAYOUT_STORAGE_KEY = 'dockhand-containers-layout';
|
|
const PANEL_WIDTH_STORAGE_KEY = 'dockhand-containers-panel-width';
|
|
const DEFAULT_PANEL_WIDTH = 400;
|
|
const MIN_PANEL_WIDTH = 250;
|
|
const MAX_PANEL_WIDTH = 800;
|
|
|
|
let layoutMode = $state<LayoutMode>('horizontal');
|
|
let panelWidth = $state(DEFAULT_PANEL_WIDTH);
|
|
let isResizingWidth = $state(false);
|
|
let mainContentRef: HTMLDivElement | undefined;
|
|
|
|
function loadLayoutMode() {
|
|
if (typeof window !== 'undefined') {
|
|
const saved = localStorage.getItem(LAYOUT_STORAGE_KEY) as LayoutMode;
|
|
if (saved === 'horizontal' || saved === 'vertical') {
|
|
layoutMode = saved;
|
|
}
|
|
const savedWidth = localStorage.getItem(PANEL_WIDTH_STORAGE_KEY);
|
|
if (savedWidth) {
|
|
const w = parseInt(savedWidth);
|
|
if (!isNaN(w) && w >= MIN_PANEL_WIDTH && w <= MAX_PANEL_WIDTH) {
|
|
panelWidth = w;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleLayoutMode() {
|
|
layoutMode = layoutMode === 'horizontal' ? 'vertical' : 'horizontal';
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem(LAYOUT_STORAGE_KEY, layoutMode);
|
|
}
|
|
}
|
|
|
|
function startWidthResize(e: MouseEvent) {
|
|
e.preventDefault();
|
|
isResizingWidth = true;
|
|
document.addEventListener('mousemove', handleWidthResize);
|
|
document.addEventListener('mouseup', stopWidthResize);
|
|
}
|
|
|
|
function handleWidthResize(e: MouseEvent) {
|
|
if (!isResizingWidth || !mainContentRef) return;
|
|
const containerRect = mainContentRef.getBoundingClientRect();
|
|
const newWidth = containerRect.right - e.clientX;
|
|
panelWidth = Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, newWidth));
|
|
}
|
|
|
|
function stopWidthResize() {
|
|
isResizingWidth = false;
|
|
document.removeEventListener('mousemove', handleWidthResize);
|
|
document.removeEventListener('mouseup', stopWidthResize);
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(panelWidth));
|
|
}
|
|
}
|
|
|
|
let scrollContainer: HTMLDivElement | undefined;
|
|
|
|
// Filtered and sorted containers - use $derived.by for complex logic
|
|
const filteredContainers = $derived.by(() => {
|
|
let result = containers;
|
|
|
|
// Filter out stopped/exited containers if setting is disabled
|
|
if (!$appSettings.showStoppedContainers) {
|
|
result = result.filter(c => c.state.toLowerCase() !== 'exited');
|
|
}
|
|
|
|
// Filter by status if any are selected
|
|
if (statusFilter.length > 0) {
|
|
result = result.filter(c => statusFilter.includes(c.state.toLowerCase()));
|
|
}
|
|
|
|
// Filter by search query
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase();
|
|
result = result.filter(c =>
|
|
c.name.toLowerCase().includes(query) ||
|
|
c.image.toLowerCase().includes(query) ||
|
|
(c.labels?.['com.docker.compose.project'] || '').toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
// Sort
|
|
result = [...result].sort((a, b) => {
|
|
let cmp = 0;
|
|
switch (sortField) {
|
|
case 'name':
|
|
cmp = a.name.localeCompare(b.name);
|
|
break;
|
|
case 'image':
|
|
cmp = a.image.localeCompare(b.image);
|
|
break;
|
|
case 'state':
|
|
// Running first, then others alphabetically
|
|
const stateOrder = { running: 0, paused: 1, created: 2, exited: 3 };
|
|
cmp = (stateOrder[a.state.toLowerCase() as keyof typeof stateOrder] ?? 4) -
|
|
(stateOrder[b.state.toLowerCase() as keyof typeof stateOrder] ?? 4);
|
|
break;
|
|
case 'uptime':
|
|
cmp = parseUptimeToSeconds(a.status) - parseUptimeToSeconds(b.status);
|
|
break;
|
|
case 'stack':
|
|
const stackA = a.labels?.['com.docker.compose.project'] || '';
|
|
const stackB = b.labels?.['com.docker.compose.project'] || '';
|
|
cmp = stackA.localeCompare(stackB);
|
|
break;
|
|
case 'ip':
|
|
const ipA = getContainerIp(a.networks);
|
|
const ipB = getContainerIp(b.networks);
|
|
cmp = ipToNumber(ipA) - ipToNumber(ipB);
|
|
break;
|
|
case 'cpu':
|
|
const cpuA = containerStats.get(a.id)?.cpuPercent ?? -1;
|
|
const cpuB = containerStats.get(b.id)?.cpuPercent ?? -1;
|
|
cmp = cpuA - cpuB;
|
|
break;
|
|
case 'memory':
|
|
const memA = containerStats.get(a.id)?.memoryUsage ?? -1;
|
|
const memB = containerStats.get(b.id)?.memoryUsage ?? -1;
|
|
cmp = memA - memB;
|
|
break;
|
|
}
|
|
// Secondary sort by name for stability when primary values are equal
|
|
if (cmp === 0 && sortField !== 'name') {
|
|
cmp = a.name.localeCompare(b.name);
|
|
}
|
|
return sortDirection === 'asc' ? cmp : -cmp;
|
|
});
|
|
|
|
return result;
|
|
});
|
|
|
|
// Check if all filtered containers are selected
|
|
const allFilteredSelected = $derived(
|
|
filteredContainers.length > 0 && filteredContainers.every(c => selectedContainers.has(c.id))
|
|
);
|
|
|
|
// Check if some (but not all) filtered containers are selected
|
|
const someFilteredSelected = $derived(
|
|
filteredContainers.some(c => selectedContainers.has(c.id)) && !allFilteredSelected
|
|
);
|
|
|
|
// Get selected containers that are in current filter
|
|
const selectedInFilter = $derived(
|
|
filteredContainers.filter(c => selectedContainers.has(c.id))
|
|
);
|
|
|
|
// Count by state for selected containers
|
|
const selectedRunning = $derived(selectedInFilter.filter(c => c.state === 'running'));
|
|
const selectedStopped = $derived(selectedInFilter.filter(c => c.state !== 'running' && c.state !== 'paused'));
|
|
const selectedPaused = $derived(selectedInFilter.filter(c => c.state === 'paused'));
|
|
|
|
async function fetchContainers() {
|
|
loading = true;
|
|
try {
|
|
const response = await fetch(appendEnvParam('/api/containers', envId));
|
|
if (!response.ok) {
|
|
// Handle stale environment ID (e.g., after database reset)
|
|
if (response.status === 404 && envId) {
|
|
clearStaleEnvironment(envId);
|
|
environments.refresh();
|
|
return;
|
|
}
|
|
toast.error('Failed to load containers');
|
|
return;
|
|
}
|
|
containers = await response.json();
|
|
// Fetch auto-update settings for all containers
|
|
await fetchAutoUpdateSettings();
|
|
} catch (error) {
|
|
console.error('Failed to fetch containers:', error);
|
|
toast.error('Failed to load containers');
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function checkScannerSettings() {
|
|
if (!envId) {
|
|
envHasScanning = false;
|
|
envVulnerabilityCriteria = 'never';
|
|
return;
|
|
}
|
|
try {
|
|
// Fetch scanner settings and environment update-check settings in parallel
|
|
const [scannerResponse, updateCheckResponse] = await Promise.all([
|
|
fetch(`/api/settings/scanner?env=${envId}&settingsOnly=true`),
|
|
fetch(`/api/environments/${envId}/update-check`)
|
|
]);
|
|
|
|
if (scannerResponse.ok) {
|
|
const data = await scannerResponse.json();
|
|
const settings = data.settings || data;
|
|
envHasScanning = settings.scanner !== 'none';
|
|
}
|
|
|
|
if (updateCheckResponse.ok) {
|
|
const data = await updateCheckResponse.json();
|
|
envVulnerabilityCriteria = data.settings?.vulnerabilityCriteria || 'never';
|
|
}
|
|
} catch {
|
|
envHasScanning = false;
|
|
envVulnerabilityCriteria = 'never';
|
|
}
|
|
}
|
|
|
|
async function fetchAutoUpdateSettings() {
|
|
const settings = new Map<string, { enabled: boolean; label: string; tooltip: string; vulnerabilityCriteria?: string }>();
|
|
const envParam = envId ? `?env=${envId}` : '';
|
|
|
|
// Check scanner settings first
|
|
await checkScannerSettings();
|
|
|
|
// Fetch all auto-update settings in a single request
|
|
try {
|
|
const response = await fetch(`/api/auto-update${envParam}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
// data is a map of containerName -> settings
|
|
for (const [containerName, setting] of Object.entries(data)) {
|
|
if (setting && typeof setting === 'object' && 'enabled' in setting && setting.enabled) {
|
|
const s = setting as { enabled: boolean; scheduleType: string; cronExpression: string | null; vulnerabilityCriteria: string };
|
|
const { label, tooltip } = formatSchedule(s.scheduleType, s.cronExpression || '');
|
|
settings.set(containerName, {
|
|
enabled: true,
|
|
label,
|
|
tooltip,
|
|
vulnerabilityCriteria: s.vulnerabilityCriteria || 'never'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch auto-update settings:', err);
|
|
}
|
|
|
|
autoUpdateSettings = settings;
|
|
}
|
|
|
|
function formatSchedule(scheduleType: string, cronExpression: string): { label: string; tooltip: string } {
|
|
if (!cronExpression) return { label: 'on', tooltip: 'Auto-update enabled' };
|
|
|
|
const parts = cronExpression.split(' ');
|
|
if (parts.length < 5) return { label: 'cron', tooltip: cronExpression };
|
|
|
|
const [min, hr, , , dow] = parts;
|
|
const hourNum = parseInt(hr);
|
|
const minNum = parseInt(min);
|
|
const ampm = hourNum >= 12 ? 'PM' : 'AM';
|
|
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
|
|
const timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
|
|
|
|
if (scheduleType === 'daily' || dow === '*') {
|
|
return { label: 'daily', tooltip: `Daily at ${timeStr}` };
|
|
}
|
|
|
|
if (scheduleType === 'weekly') {
|
|
const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
const dayName = days[parseInt(dow)] || dow;
|
|
return { label: dayName, tooltip: `Every ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][parseInt(dow)] || dow} at ${timeStr}` };
|
|
}
|
|
|
|
return { label: 'cron', tooltip: cronExpression };
|
|
}
|
|
|
|
// Check if highlightChanges is enabled for current environment
|
|
const highlightChangesEnabled = $derived($currentEnvironment?.highlightChanges ?? true);
|
|
|
|
// Helper to check if a stat field changed significantly
|
|
function hasFieldChanged(containerId: string, field: string, oldVal: number | undefined, newVal: number | undefined): boolean {
|
|
if (oldVal === undefined || newVal === undefined) return false;
|
|
// For CPU, consider significant if changed by more than 0.5%
|
|
if (field === 'cpu') return Math.abs(newVal - oldVal) > 0.5;
|
|
// For memory, consider significant if changed by more than 1MB
|
|
if (field === 'memory') return Math.abs(newVal - oldVal) > 1024 * 1024;
|
|
// For network/disk, consider significant if changed by more than 10KB
|
|
return Math.abs(newVal - oldVal) > 10 * 1024;
|
|
}
|
|
|
|
// Helper to check if a field is currently highlighted
|
|
function isFieldHighlighted(containerId: string, field: string): boolean {
|
|
if (!highlightChangesEnabled) return false;
|
|
return changedFields.get(containerId)?.has(field) ?? false;
|
|
}
|
|
|
|
async function fetchStats() {
|
|
try {
|
|
const response = await fetch(appendEnvParam('/api/containers/stats', envId));
|
|
const stats: ContainerStats[] = await response.json();
|
|
const statsMap = new Map<string, ContainerStats>();
|
|
const newChangedFields = new Map<string, Set<string>>();
|
|
|
|
for (const stat of stats) {
|
|
statsMap.set(stat.id, stat);
|
|
|
|
// Track changes if highlighting is enabled
|
|
if (highlightChangesEnabled) {
|
|
const prev = previousStats.get(stat.id);
|
|
if (prev) {
|
|
const changes = new Set<string>();
|
|
if (hasFieldChanged(stat.id, 'cpu', prev.cpuPercent, stat.cpuPercent)) changes.add('cpu');
|
|
if (hasFieldChanged(stat.id, 'memory', prev.memoryUsage, stat.memoryUsage)) changes.add('memory');
|
|
if (hasFieldChanged(stat.id, 'networkRx', prev.networkRx, stat.networkRx)) changes.add('network');
|
|
if (hasFieldChanged(stat.id, 'networkTx', prev.networkTx, stat.networkTx)) changes.add('network');
|
|
if (hasFieldChanged(stat.id, 'blockRead', prev.blockRead, stat.blockRead)) changes.add('disk');
|
|
if (hasFieldChanged(stat.id, 'blockWrite', prev.blockWrite, stat.blockWrite)) changes.add('disk');
|
|
if (changes.size > 0) {
|
|
newChangedFields.set(stat.id, changes);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update changed fields and clear after animation duration
|
|
changedFields = newChangedFields;
|
|
if (newChangedFields.size > 0) {
|
|
pendingTimeouts.push(setTimeout(() => {
|
|
changedFields = new Map();
|
|
}, 1500));
|
|
}
|
|
|
|
// Store current stats as previous for next comparison
|
|
previousStats = new Map(statsMap);
|
|
containerStats = statsMap;
|
|
} catch (error) {
|
|
console.error('Failed to fetch container stats:', error);
|
|
}
|
|
}
|
|
|
|
async function startContainer(id: string) {
|
|
operationError = null;
|
|
const container = containers.find(c => c.id === id);
|
|
const name = container?.name || id.slice(0, 12);
|
|
try {
|
|
const response = await fetch(appendEnvParam(`/api/containers/${id}/start`, envId), { method: 'POST' });
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
operationError = { id, message: data.error || 'Failed to start container' };
|
|
toast.error(`Failed to start ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
return;
|
|
}
|
|
toast.success(`Started ${name}`);
|
|
await fetchContainers();
|
|
} catch (error) {
|
|
console.error('Failed to start container:', error);
|
|
operationError = { id, message: 'Failed to start container' };
|
|
toast.error(`Failed to start ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
}
|
|
}
|
|
|
|
async function stopContainer(id: string) {
|
|
operationError = null;
|
|
stoppingId = id;
|
|
const container = containers.find(c => c.id === id);
|
|
const name = container?.name || id.slice(0, 12);
|
|
try {
|
|
const response = await fetch(appendEnvParam(`/api/containers/${id}/stop`, envId), { method: 'POST' });
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
operationError = { id, message: data.error || 'Failed to stop container' };
|
|
toast.error(`Failed to stop ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
return;
|
|
}
|
|
toast.success(`Stopped ${name}`);
|
|
await fetchContainers();
|
|
} catch (error) {
|
|
console.error('Failed to stop container:', error);
|
|
operationError = { id, message: 'Failed to stop container' };
|
|
toast.error(`Failed to stop ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
} finally {
|
|
stoppingId = null;
|
|
}
|
|
}
|
|
|
|
async function pauseContainer(id: string) {
|
|
operationError = null;
|
|
const container = containers.find(c => c.id === id);
|
|
const name = container?.name || id.slice(0, 12);
|
|
try {
|
|
const response = await fetch(appendEnvParam(`/api/containers/${id}/pause`, envId), { method: 'POST' });
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
operationError = { id, message: data.error || 'Failed to pause container' };
|
|
toast.error(`Failed to pause ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
return;
|
|
}
|
|
toast.success(`Paused ${name}`);
|
|
await fetchContainers();
|
|
} catch (error) {
|
|
console.error('Failed to pause container:', error);
|
|
operationError = { id, message: 'Failed to pause container' };
|
|
toast.error(`Failed to pause ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
}
|
|
}
|
|
|
|
async function unpauseContainer(id: string) {
|
|
operationError = null;
|
|
const container = containers.find(c => c.id === id);
|
|
const name = container?.name || id.slice(0, 12);
|
|
try {
|
|
const response = await fetch(appendEnvParam(`/api/containers/${id}/unpause`, envId), { method: 'POST' });
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
operationError = { id, message: data.error || 'Failed to unpause container' };
|
|
toast.error(`Failed to unpause ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
return;
|
|
}
|
|
toast.success(`Resumed ${name}`);
|
|
await fetchContainers();
|
|
} catch (error) {
|
|
console.error('Failed to unpause container:', error);
|
|
operationError = { id, message: 'Failed to unpause container' };
|
|
toast.error(`Failed to unpause ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
}
|
|
}
|
|
|
|
async function restartContainer(id: string) {
|
|
operationError = null;
|
|
restartingId = id;
|
|
const container = containers.find(c => c.id === id);
|
|
const name = container?.name || id.slice(0, 12);
|
|
try {
|
|
const response = await fetch(appendEnvParam(`/api/containers/${id}/restart`, envId), { method: 'POST' });
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
operationError = { id, message: data.error || 'Failed to restart container' };
|
|
toast.error(`Failed to restart ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
return;
|
|
}
|
|
toast.success(`Restarted ${name}`);
|
|
await fetchContainers();
|
|
} catch (error) {
|
|
console.error('Failed to restart container:', error);
|
|
operationError = { id, message: 'Failed to restart container' };
|
|
toast.error(`Failed to restart ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
} finally {
|
|
restartingId = null;
|
|
}
|
|
}
|
|
|
|
async function removeContainer(id: string) {
|
|
operationError = null;
|
|
const container = containers.find(c => c.id === id);
|
|
const name = container?.name || id.slice(0, 12);
|
|
try {
|
|
const response = await fetch(appendEnvParam(`/api/containers/${id}?force=true`, envId), { method: 'DELETE' });
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
operationError = { id, message: data.error || 'Failed to remove container' };
|
|
toast.error(`Failed to remove ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
return;
|
|
}
|
|
toast.success(`Removed ${name}`);
|
|
await fetchContainers();
|
|
} catch (error) {
|
|
console.error('Failed to remove container:', error);
|
|
operationError = { id, message: 'Failed to remove container' };
|
|
toast.error(`Failed to remove ${name}`);
|
|
clearErrorAfterDelay(id);
|
|
}
|
|
}
|
|
|
|
function openTerminal(container: ContainerInfo) {
|
|
// Check if there's already an active terminal for this container
|
|
const existingTerminal = getActiveTerminal(container.id);
|
|
if (existingTerminal) {
|
|
// Just show the existing terminal
|
|
currentTerminalContainerId = container.id;
|
|
terminalPopoverStates[container.id] = false;
|
|
} else {
|
|
// Show popover to configure new terminal
|
|
terminalPopoverStates[container.id] = true;
|
|
}
|
|
}
|
|
|
|
function startTerminal(container: ContainerInfo) {
|
|
// Create new terminal session
|
|
const terminal: ActiveTerminal = {
|
|
containerId: container.id,
|
|
containerName: container.name,
|
|
shell: terminalShell,
|
|
user: terminalUser
|
|
};
|
|
activeTerminals = [...activeTerminals, terminal];
|
|
currentTerminalContainerId = container.id;
|
|
terminalPopoverStates[container.id] = false;
|
|
}
|
|
|
|
function closeTerminal(containerId: string) {
|
|
activeTerminals = activeTerminals.filter(t => t.containerId !== containerId);
|
|
if (currentTerminalContainerId === containerId) {
|
|
currentTerminalContainerId = null;
|
|
}
|
|
}
|
|
|
|
function showLogs(container: ContainerInfo) {
|
|
// Check if there's already active logs for this container
|
|
if (hasActiveLogs(container.id)) {
|
|
// Just show the existing logs
|
|
currentLogsContainerId = container.id;
|
|
} else {
|
|
// Create new logs session
|
|
const logs: ActiveLogs = {
|
|
containerId: container.id,
|
|
containerName: container.name
|
|
};
|
|
activeLogs = [...activeLogs, logs];
|
|
currentLogsContainerId = container.id;
|
|
}
|
|
}
|
|
|
|
function closeLogs(containerId: string) {
|
|
activeLogs = activeLogs.filter(l => l.containerId !== containerId);
|
|
if (currentLogsContainerId === containerId) {
|
|
currentLogsContainerId = null;
|
|
}
|
|
}
|
|
|
|
function selectContainer(container: ContainerInfo) {
|
|
// Handle logs - show if container has active logs, hide otherwise
|
|
if (hasActiveLogs(container.id)) {
|
|
currentLogsContainerId = container.id;
|
|
} else if (currentLogsContainerId) {
|
|
// Hide current logs but keep the session active
|
|
currentLogsContainerId = null;
|
|
}
|
|
|
|
// Handle terminal - show if container has active terminal, hide otherwise
|
|
if (hasActiveTerminal(container.id)) {
|
|
currentTerminalContainerId = container.id;
|
|
} else if (currentTerminalContainerId) {
|
|
// Hide current terminal but keep the session active
|
|
currentTerminalContainerId = null;
|
|
}
|
|
}
|
|
|
|
function editContainer(id: string) {
|
|
editContainerId = id;
|
|
showEditModal = true;
|
|
}
|
|
|
|
function inspectContainer(container: ContainerInfo) {
|
|
inspectContainerId = container.id;
|
|
inspectContainerName = container.name;
|
|
showInspectModal = true;
|
|
}
|
|
|
|
function browseFiles(container: ContainerInfo) {
|
|
fileBrowserContainerId = container.id;
|
|
fileBrowserContainerName = container.name;
|
|
showFileBrowserModal = true;
|
|
}
|
|
|
|
function getStatusClasses(state: string): string {
|
|
const base = 'state-badge px-1.5 py-0.5 rounded-sm text-xs font-medium text-black dark:text-white shadow-sm whitespace-nowrap justify-center';
|
|
switch (state.toLowerCase()) {
|
|
case 'running':
|
|
return `${base} bg-emerald-200 dark:bg-emerald-800`;
|
|
case 'exited':
|
|
return `${base} bg-rose-200 dark:bg-rose-800`;
|
|
case 'restarting':
|
|
return `${base} bg-red-300 dark:bg-red-700`;
|
|
case 'paused':
|
|
return `${base} bg-amber-200 dark:bg-amber-800`;
|
|
case 'created':
|
|
return `${base} bg-sky-200 dark:bg-sky-800`;
|
|
default:
|
|
return `${base} bg-slate-200 dark:bg-slate-700`;
|
|
}
|
|
}
|
|
|
|
interface PortMapping {
|
|
publicPort: number;
|
|
privatePort: number;
|
|
display: string;
|
|
}
|
|
|
|
function formatPorts(ports: ContainerInfo['ports']): PortMapping[] {
|
|
if (!ports || ports.length === 0) return [];
|
|
const seen = new Set<string>();
|
|
return ports
|
|
.filter(p => p.PublicPort)
|
|
.map(p => ({
|
|
publicPort: p.PublicPort,
|
|
privatePort: p.PrivatePort,
|
|
display: `${p.PublicPort}:${p.PrivatePort}`
|
|
}))
|
|
.filter(p => {
|
|
const key = p.display;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function extractHostFromUrl(urlString: string): string | null {
|
|
if (!urlString) return null;
|
|
|
|
// Handle tcp://, http://, https:// URLs
|
|
const protocolMatch = urlString.match(/^(?:tcp|http|https):\/\/([^:/]+)/i);
|
|
if (protocolMatch) {
|
|
return protocolMatch[1];
|
|
}
|
|
|
|
// Handle hostname:port format (no protocol)
|
|
const hostPortMatch = urlString.match(/^([^:/]+):\d+$/);
|
|
if (hostPortMatch) {
|
|
return hostPortMatch[1];
|
|
}
|
|
|
|
// Just a hostname
|
|
return urlString;
|
|
}
|
|
|
|
function getPortUrl(publicPort: number): string | null {
|
|
const env = currentEnvDetails;
|
|
if (!env) return null;
|
|
|
|
// Priority 1: Use publicIp if configured
|
|
if (env.publicIp) {
|
|
return `http://${env.publicIp}:${publicPort}`;
|
|
}
|
|
|
|
// Priority 2: Extract from host for direct/hawser-standard
|
|
const connectionType = env.connectionType || 'socket';
|
|
|
|
if (connectionType === 'direct' && env.host) {
|
|
// Remote Docker via TCP - extract host from URL (e.g., tcp://192.168.1.4:2376)
|
|
const host = extractHostFromUrl(env.host);
|
|
if (host) return `http://${host}:${publicPort}`;
|
|
} else if (connectionType === 'hawser-standard' && env.host) {
|
|
// Hawser standard mode - extract host from URL
|
|
const host = extractHostFromUrl(env.host);
|
|
if (host) return `http://${host}:${publicPort}`;
|
|
}
|
|
|
|
// No public IP available for socket or hawser-edge
|
|
return null;
|
|
}
|
|
|
|
function getContainerIp(networks: ContainerInfo['networks']): string {
|
|
if (!networks) return '-';
|
|
const entries = Object.entries(networks);
|
|
if (entries.length === 0) return '-';
|
|
const ip = entries[0][1]?.ipAddress;
|
|
return ip || '-';
|
|
}
|
|
|
|
function formatUptime(status: string): string {
|
|
// Extract uptime from status like "Up 2 hours" or "Exited (0) 3 days ago"
|
|
if (!status) return '-';
|
|
const upMatch = status.match(/Up\s+(.+?)(?:\s+\(|$)/i);
|
|
if (upMatch) return upMatch[1].trim();
|
|
const exitMatch = status.match(/Exited.+?(\d+\s+\w+)\s+ago/i);
|
|
if (exitMatch) return exitMatch[1] + ' ago';
|
|
return '-';
|
|
}
|
|
|
|
function parseUptimeToSeconds(status: string): number {
|
|
// Parse uptime from status to seconds for sorting
|
|
// Running containers have positive values (higher = longer uptime)
|
|
// Stopped containers have negative values (sorted after running)
|
|
if (!status) return -Infinity;
|
|
|
|
const upMatch = status.match(/Up\s+(.+?)(?:\s+\(|$)/i);
|
|
if (upMatch) {
|
|
return parseTimeStringToSeconds(upMatch[1].trim());
|
|
}
|
|
|
|
// Exited containers - use negative values so they sort after running
|
|
const exitMatch = status.match(/Exited.+?(\d+\s+\w+)\s+ago/i);
|
|
if (exitMatch) {
|
|
return -parseTimeStringToSeconds(exitMatch[1]);
|
|
}
|
|
|
|
return -Infinity;
|
|
}
|
|
|
|
function parseTimeStringToSeconds(timeStr: string): number {
|
|
// Parse strings like "2 hours", "3 days", "About a minute", "Less than a second"
|
|
const str = timeStr.toLowerCase();
|
|
|
|
if (str.includes('second')) return 1;
|
|
if (str.includes('less than a minute') || str.includes('about a minute')) return 60;
|
|
|
|
const match = str.match(/(\d+)\s*(second|minute|hour|day|week|month|year)/);
|
|
if (!match) return 0;
|
|
|
|
const value = parseInt(match[1], 10);
|
|
const unit = match[2];
|
|
|
|
switch (unit) {
|
|
case 'second': return value;
|
|
case 'minute': return value * 60;
|
|
case 'hour': return value * 3600;
|
|
case 'day': return value * 86400;
|
|
case 'week': return value * 604800;
|
|
case 'month': return value * 2592000;
|
|
case 'year': return value * 31536000;
|
|
default: return 0;
|
|
}
|
|
}
|
|
|
|
function getHealthVariant(health?: string): 'default' | 'destructive' | 'secondary' | 'outline' {
|
|
switch (health) {
|
|
case 'healthy':
|
|
return 'default';
|
|
case 'unhealthy':
|
|
return 'destructive';
|
|
case 'starting':
|
|
return 'secondary';
|
|
default:
|
|
return 'outline';
|
|
}
|
|
}
|
|
|
|
function formatMounts(mounts: ContainerInfo['mounts']): string[] {
|
|
if (!mounts || mounts.length === 0) return [];
|
|
return mounts.map(m => {
|
|
const src = m.source.length > 20 ? '...' + m.source.slice(-17) : m.source;
|
|
return `${src}:${m.destination}`;
|
|
});
|
|
}
|
|
|
|
function getComposeProject(labels: ContainerInfo['labels']): string | null {
|
|
return labels?.['com.docker.compose.project'] || null;
|
|
}
|
|
|
|
function toggleSort(field: SortField) {
|
|
if (sortField === field) {
|
|
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
sortField = field;
|
|
sortDirection = field === 'state' ? 'asc' : 'asc';
|
|
}
|
|
}
|
|
|
|
|
|
// Handle tab visibility changes (e.g., user switches back from another tab)
|
|
function handleVisibilityChange() {
|
|
if (document.visibilityState === 'visible' && envId) {
|
|
// Tab became visible - refresh data immediately
|
|
fetchContainers();
|
|
fetchStats();
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
loadLayoutMode();
|
|
loadStatusFilter();
|
|
|
|
// Load persisted pending updates from database
|
|
loadPendingUpdates();
|
|
|
|
// Listen for tab visibility changes to refresh when user returns
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
document.addEventListener('resume', handleVisibilityChange);
|
|
|
|
// Initial fetch is handled by $effect - no need to duplicate here
|
|
|
|
// Set up interval to refresh stats every 5 seconds
|
|
const statsInterval = setInterval(() => {
|
|
if (envId) fetchStats();
|
|
}, 5000);
|
|
|
|
// Subscribe to container events (SSE connection is global in layout)
|
|
const unsubscribe = onDockerEvent((event) => {
|
|
if (envId && isContainerListChange(event)) {
|
|
fetchContainers();
|
|
fetchStats();
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
unsubscribe();
|
|
clearInterval(statsInterval);
|
|
};
|
|
});
|
|
|
|
// Cleanup resize event listeners and pending timeouts on component destroy
|
|
onDestroy(() => {
|
|
document.removeEventListener('mousemove', handleWidthResize);
|
|
document.removeEventListener('mouseup', stopWidthResize);
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
document.removeEventListener('resume', handleVisibilityChange);
|
|
pendingTimeouts.forEach(id => clearTimeout(id));
|
|
pendingTimeouts = [];
|
|
});
|
|
</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">
|
|
<PageHeader icon={Box} title="Containers" count={containers.length} />
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<div class="relative">
|
|
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search containers..."
|
|
bind:value={searchQuery}
|
|
onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')}
|
|
class="pl-8 h-8 w-48 text-sm"
|
|
/>
|
|
</div>
|
|
<!-- Status filter (multi-select) -->
|
|
<MultiSelectFilter
|
|
bind:value={statusFilter}
|
|
options={statusTypes}
|
|
placeholder="All statuses"
|
|
pluralLabel="statuses"
|
|
width="w-44"
|
|
defaultIcon={Box}
|
|
/>
|
|
<div class="flex gap-2">
|
|
{#if $canAccess('containers', 'create')}
|
|
<Button size="sm" variant="secondary" onclick={() => (showCreateModal = true)}>
|
|
<Plus class="w-3.5 h-3.5 mr-1" />
|
|
Create
|
|
</Button>
|
|
{/if}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onclick={checkForUpdates}
|
|
disabled={updateCheckStatus === 'checking'}
|
|
title="Check for available updates"
|
|
>
|
|
{#if updateCheckStatus === 'checking'}
|
|
<CircleArrowUp class="w-3.5 h-3.5 mr-1 animate-spin" />
|
|
{:else if updateCheckStatus === 'none' || updateCheckStatus === 'found'}
|
|
<Check class="w-3.5 h-3.5 mr-1 text-green-600" />
|
|
{:else if updateCheckStatus === 'error'}
|
|
<XCircle class="w-3.5 h-3.5 mr-1 text-destructive" />
|
|
{:else}
|
|
<CircleArrowUp class="w-3.5 h-3.5 mr-1" />
|
|
{/if}
|
|
Check for updates
|
|
</Button>
|
|
{#if batchUpdateContainerIds.length > 0}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onclick={updateAllContainers}
|
|
class="border-amber-500/40 text-amber-600 hover:bg-amber-500/10 hover:border-amber-500"
|
|
title="Update all containers with available updates"
|
|
>
|
|
<CircleArrowUp class="w-3.5 h-3.5 mr-1" />
|
|
Update all ({batchUpdateContainerIds.length})
|
|
</Button>
|
|
{/if}
|
|
{#if $canAccess('containers', 'remove')}
|
|
<ConfirmPopover
|
|
open={confirmPrune}
|
|
action="Prune"
|
|
itemType="stopped containers"
|
|
title="Prune containers"
|
|
position="left"
|
|
onConfirm={pruneContainers}
|
|
onOpenChange={(open) => confirmPrune = open}
|
|
unstyled
|
|
>
|
|
{#snippet children({ open })}
|
|
<Button size="sm" variant="outline" disabled={pruneStatus === 'pruning'}>
|
|
{#if pruneStatus === 'pruning'}
|
|
<RefreshCw class="w-3.5 h-3.5 mr-1 animate-spin" />
|
|
{:else if pruneStatus === 'success'}
|
|
<Check class="w-3.5 h-3.5 mr-1 text-green-600" />
|
|
{:else if pruneStatus === 'error'}
|
|
<XCircle class="w-3.5 h-3.5 mr-1 text-destructive" />
|
|
{:else}
|
|
<Icon iconNode={broom} class="w-3.5 h-3.5 mr-1" />
|
|
{/if}
|
|
Prune
|
|
</Button>
|
|
{/snippet}
|
|
</ConfirmPopover>
|
|
{/if}
|
|
<Button size="sm" variant="outline" onclick={fetchContainers}>Refresh</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onclick={toggleLayoutMode}
|
|
class="h-8 w-8 p-0"
|
|
title={layoutMode === 'horizontal' ? 'Switch to vertical layout (logs/terminal on side)' : 'Switch to horizontal layout (logs/terminal below)'}
|
|
>
|
|
{#if layoutMode === 'horizontal'}
|
|
<LayoutPanelLeft class="w-4 h-4" />
|
|
{:else}
|
|
<Rows3 class="w-4 h-4" />
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Selection bar -->
|
|
{#if selectedContainers.size > 0}
|
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<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"
|
|
onclick={selectNone}
|
|
disabled={bulkActionInProgress}
|
|
>
|
|
Clear
|
|
</button>
|
|
{#if selectedStopped.length > 0 && $canAccess('containers', 'start')}
|
|
<ConfirmPopover
|
|
open={confirmBulkStart}
|
|
action="Start"
|
|
itemType="{selectedStopped.length} stopped container{selectedStopped.length !== 1 ? 's' : ''}"
|
|
title="Start {selectedStopped.length}"
|
|
variant="secondary"
|
|
unstyled
|
|
onConfirm={bulkStart}
|
|
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' : ''}">
|
|
<Play class="w-3 h-3" />
|
|
Start
|
|
</span>
|
|
{/snippet}
|
|
</ConfirmPopover>
|
|
{/if}
|
|
{#if selectedRunning.length > 0 && $canAccess('containers', 'stop')}
|
|
<ConfirmPopover
|
|
open={confirmBulkStop}
|
|
action="Stop"
|
|
itemType="{selectedRunning.length} running container{selectedRunning.length !== 1 ? 's' : ''}"
|
|
title="Stop {selectedRunning.length}"
|
|
unstyled
|
|
onConfirm={bulkStop}
|
|
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' : ''}">
|
|
<Square class="w-3 h-3" />
|
|
Stop
|
|
</span>
|
|
{/snippet}
|
|
</ConfirmPopover>
|
|
<ConfirmPopover
|
|
open={confirmBulkPause}
|
|
action="Pause"
|
|
itemType="{selectedRunning.length} running container{selectedRunning.length !== 1 ? 's' : ''}"
|
|
title="Pause {selectedRunning.length}"
|
|
variant="secondary"
|
|
unstyled
|
|
onConfirm={bulkPause}
|
|
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' : ''}">
|
|
<Pause class="w-3 h-3" />
|
|
Pause
|
|
</span>
|
|
{/snippet}
|
|
</ConfirmPopover>
|
|
{/if}
|
|
{#if selectedPaused.length > 0 && $canAccess('containers', 'start')}
|
|
<ConfirmPopover
|
|
open={confirmBulkUnpause}
|
|
action="Unpause"
|
|
itemType="{selectedPaused.length} paused container{selectedPaused.length !== 1 ? 's' : ''}"
|
|
title="Unpause {selectedPaused.length}"
|
|
variant="secondary"
|
|
unstyled
|
|
onConfirm={bulkUnpause}
|
|
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' : ''}">
|
|
<Play class="w-3 h-3" />
|
|
Unpause
|
|
</span>
|
|
{/snippet}
|
|
</ConfirmPopover>
|
|
{/if}
|
|
{#if $canAccess('containers', 'restart')}
|
|
<ConfirmPopover
|
|
open={confirmBulkRestart}
|
|
action="Restart"
|
|
itemType="{selectedInFilter.length} container{selectedInFilter.length !== 1 ? 's' : ''}"
|
|
title="Restart {selectedInFilter.length}"
|
|
variant="secondary"
|
|
unstyled
|
|
onConfirm={bulkRestart}
|
|
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' : ''}">
|
|
<RotateCw class="w-3 h-3" />
|
|
Restart
|
|
</span>
|
|
{/snippet}
|
|
</ConfirmPopover>
|
|
{/if}
|
|
{#if $canAccess('containers', 'remove')}
|
|
<ConfirmPopover
|
|
open={confirmBulkRemove}
|
|
action="Remove"
|
|
itemType="{selectedInFilter.length} container{selectedInFilter.length !== 1 ? 's' : ''}"
|
|
title="Remove {selectedInFilter.length}"
|
|
unstyled
|
|
onConfirm={bulkRemove}
|
|
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' : ''}">
|
|
<Trash2 class="w-3 h-3" />
|
|
Remove
|
|
</span>
|
|
{/snippet}
|
|
</ConfirmPopover>
|
|
{/if}
|
|
{#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' : ''}"
|
|
onclick={updateSelectedContainers}
|
|
disabled={bulkActionInProgress}
|
|
title="Update selected containers to latest image"
|
|
>
|
|
<CircleArrowUp class="w-3 h-3" />
|
|
Update {selectedWithUpdatesCount}
|
|
</button>
|
|
{/if}
|
|
{#if bulkActionInProgress}
|
|
<CircleArrowUp class="w-3 h-3 animate-spin ml-1" />
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if $environments.length === 0 || !$currentEnvironment}
|
|
<NoEnvironment />
|
|
{:else if !loading && containers.length === 0}
|
|
<EmptyState
|
|
icon={Box}
|
|
title="No containers found"
|
|
description="Create a new container to get started"
|
|
/>
|
|
{:else}
|
|
<!-- Main content area - changes based on layout mode -->
|
|
<div
|
|
bind:this={mainContentRef}
|
|
class="flex-1 min-h-0 {layoutMode === 'vertical' ? 'flex gap-3' : 'flex flex-col gap-3'}"
|
|
>
|
|
<!-- Table section -->
|
|
<DataGrid
|
|
data={filteredContainers}
|
|
keyField="id"
|
|
gridId="containers"
|
|
loading={loading}
|
|
selectable
|
|
bind:selectedKeys={selectedContainers}
|
|
sortState={{ field: sortField, direction: sortDirection }}
|
|
onSortChange={(state) => { sortField = state.field as SortField; sortDirection = state.direction; }}
|
|
highlightedKey={highlightedRowId}
|
|
rowClass={(container) => {
|
|
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 ';
|
|
return classes;
|
|
}}
|
|
onRowClick={(container, e) => {
|
|
if (activeLogs.length > 0 || activeTerminals.length > 0) {
|
|
selectContainer(container);
|
|
}
|
|
highlightedRowId = highlightedRowId === container.id ? null : container.id;
|
|
}}
|
|
>
|
|
{#snippet cell(column, container, rowState)}
|
|
{@const ports = formatPorts(container.ports)}
|
|
{@const stack = getComposeProject(container.labels)}
|
|
{#if column.id === 'name'}
|
|
<span class="text-xs font-medium truncate block" title={container.name}>{container.name}</span>
|
|
{: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)}
|
|
<span title="Update available">
|
|
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
|
|
</span>
|
|
{/if}
|
|
<span class="text-xs text-muted-foreground truncate" title={container.image}>{container.image}</span>
|
|
</div>
|
|
{:else if column.id === 'state'}
|
|
{@const StateIcon = getStatusIcon(container.state)}
|
|
<span class="{getStatusClasses(container.state)} inline-flex items-center gap-1">
|
|
<StateIcon class="w-[1em] h-[1em] {container.state.toLowerCase() === 'restarting' ? 'animate-spin' : ''}" />
|
|
{container.state}
|
|
</span>
|
|
{:else if column.id === 'health'}
|
|
{#if container.health}
|
|
<div class="flex items-center justify-center" title={container.health}>
|
|
{#if container.health === 'healthy'}
|
|
<span class="h-2.5 w-2.5 rounded-full bg-green-500 animate-pulse"></span>
|
|
{:else if container.health === 'unhealthy'}
|
|
<span class="h-2.5 w-2.5 rounded-full bg-red-500"></span>
|
|
{:else}
|
|
<span class="h-2.5 w-2.5 rounded-full bg-yellow-500 animate-pulse"></span>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="flex items-center justify-center">
|
|
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
|
|
</div>
|
|
{/if}
|
|
{:else if column.id === 'uptime'}
|
|
<span class="text-xs text-muted-foreground whitespace-nowrap">{formatUptime(container.status)}</span>
|
|
{:else if column.id === 'restartCount'}
|
|
{#if container.restartCount > 0}
|
|
<span class="text-xs text-red-500 text-center block" title="{container.restartCount} restarts">{container.restartCount}</span>
|
|
{:else}
|
|
<span class="text-gray-400 dark:text-gray-600 text-xs text-center block">-</span>
|
|
{/if}
|
|
{:else if column.id === 'cpu'}
|
|
<div class="{isFieldHighlighted(container.id, 'cpu') ? 'stat-highlight' : ''} text-right">
|
|
{#if containerStats.get(container.id)}
|
|
{@const stats = containerStats.get(container.id)}
|
|
<span class="text-xs font-mono {stats.cpuPercent > 80 ? 'text-red-500' : stats.cpuPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}">{stats.cpuPercent.toFixed(1)}%</span>
|
|
{:else if container.state === 'running'}
|
|
<span class="text-xs text-muted-foreground/50">...</span>
|
|
{:else}
|
|
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
|
|
{/if}
|
|
</div>
|
|
{:else if column.id === 'memory'}
|
|
<div class="{isFieldHighlighted(container.id, 'memory') ? 'stat-highlight' : ''} text-right">
|
|
{#if containerStats.get(container.id)}
|
|
{@const stats = containerStats.get(container.id)}
|
|
<span class="text-xs font-mono {stats.memoryPercent > 80 ? 'text-red-500' : stats.memoryPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}" title="{formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)}">{formatBytes(stats.memoryUsage)}</span>
|
|
{:else if container.state === 'running'}
|
|
<span class="text-xs text-muted-foreground/50">...</span>
|
|
{:else}
|
|
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
|
|
{/if}
|
|
</div>
|
|
{:else if column.id === 'networkIO'}
|
|
<div class="{isFieldHighlighted(container.id, 'network') ? 'stat-highlight' : ''} text-right whitespace-nowrap">
|
|
{#if containerStats.get(container.id)}
|
|
{@const stats = containerStats.get(container.id)}
|
|
<span class="text-xs font-mono text-muted-foreground" title="↓{formatBytes(stats.networkRx)} received / ↑{formatBytes(stats.networkTx)} sent">
|
|
<span class="text-2xs text-blue-400">↓</span>{formatBytes(stats.networkRx, 0)} <span class="text-2xs text-orange-400">↑</span>{formatBytes(stats.networkTx, 0)}
|
|
</span>
|
|
{:else if container.state === 'running'}
|
|
<span class="text-xs text-muted-foreground/50">...</span>
|
|
{:else}
|
|
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
|
|
{/if}
|
|
</div>
|
|
{:else if column.id === 'diskIO'}
|
|
<div class="{isFieldHighlighted(container.id, 'disk') ? 'stat-highlight' : ''} text-right whitespace-nowrap">
|
|
{#if containerStats.get(container.id)}
|
|
{@const stats = containerStats.get(container.id)}
|
|
<span class="text-xs font-mono text-muted-foreground" title="↓{formatBytes(stats.blockRead)} read / ↑{formatBytes(stats.blockWrite)} written">
|
|
<span class="text-2xs text-green-400">r</span>{formatBytes(stats.blockRead, 0)} <span class="text-2xs text-yellow-400">w</span>{formatBytes(stats.blockWrite, 0)}
|
|
</span>
|
|
{:else if container.state === 'running'}
|
|
<span class="text-xs text-muted-foreground/50">...</span>
|
|
{:else}
|
|
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
|
|
{/if}
|
|
</div>
|
|
{:else if column.id === 'ip'}
|
|
<code class="text-xs">{getContainerIp(container.networks)}</code>
|
|
{:else if column.id === 'ports'}
|
|
{#if ports.length > 0}
|
|
<div class="flex flex-wrap gap-1">
|
|
{#each ports.slice(0, 2) as port}
|
|
{@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null}
|
|
{#if url}
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onclick={(e) => e.stopPropagation()}
|
|
class="inline-flex items-center gap-0.5 text-xs bg-muted hover:bg-blue-500/20 hover:text-blue-500 px-1 py-0.5 rounded transition-colors"
|
|
title="Open {url} in new tab"
|
|
>
|
|
<code>{port.display}</code>
|
|
<ExternalLink class="w-2.5 h-2.5 text-muted-foreground" />
|
|
</a>
|
|
{:else}
|
|
<code class="text-xs bg-muted px-1 py-0.5 rounded">{port.display}</code>
|
|
{/if}
|
|
{/each}
|
|
{#if ports.length > 2}
|
|
<span class="text-xs text-muted-foreground">+{ports.length - 2}</span>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
|
|
{/if}
|
|
{:else if column.id === 'autoUpdate'}
|
|
{#if autoUpdateSettings.get(container.name)?.enabled}
|
|
{@const settings = autoUpdateSettings.get(container.name)}
|
|
<div class="flex items-center justify-center gap-1">
|
|
<span class="text-xs text-green-500 cursor-default" title={settings?.tooltip}>{settings?.label}</span>
|
|
{#if envHasScanning}
|
|
{@const criteria = settings?.vulnerabilityCriteria || 'never'}
|
|
{@const icon = vulnerabilityCriteriaIcons[criteria]}
|
|
{#if icon}
|
|
{@const IconComponent = icon.component}
|
|
<span class="cursor-default" title={icon.title}>
|
|
<IconComponent class={icon.class} />
|
|
</span>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<span class="text-gray-400 dark:text-gray-600 text-xs text-center block">-</span>
|
|
{/if}
|
|
{:else if column.id === 'stack'}
|
|
{#if stack}
|
|
<Badge variant="outline" class="text-xs py-0 px-1.5">{stack}</Badge>
|
|
{:else}
|
|
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
|
|
{/if}
|
|
{:else if column.id === 'actions'}
|
|
<div class="relative flex gap-0.5 justify-end">
|
|
{#if containersWithUpdatesSet.has(container.id)}
|
|
<button
|
|
type="button"
|
|
onclick={() => updateSingleContainer(container.id, container.name)}
|
|
title="Update available - click to update"
|
|
class="p-0.5 rounded hover:bg-muted transition-colors cursor-pointer"
|
|
>
|
|
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
|
|
</button>
|
|
{/if}
|
|
{#if container.state === 'running' || container.state === 'restarting'}
|
|
{#if $canAccess('containers', 'stop')}
|
|
<ConfirmPopover
|
|
open={confirmStopId === container.id}
|
|
action="Stop"
|
|
itemType="container"
|
|
itemName={container.name}
|
|
title="Stop"
|
|
onConfirm={() => stopContainer(container.id)}
|
|
onOpenChange={(open) => confirmStopId = open ? container.id : null}
|
|
>
|
|
{#snippet children({ open })}
|
|
<Square class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'} {stoppingId === container.id ? 'animate-pulse text-destructive' : ''}" />
|
|
{/snippet}
|
|
</ConfirmPopover>
|
|
{#if container.state === 'running'}
|
|
<button
|
|
type="button"
|
|
onclick={() => pauseContainer(container.id)}
|
|
title="Pause"
|
|
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
|
>
|
|
<Pause class="w-3 h-3 text-muted-foreground hover:text-yellow-500" />
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
{:else if container.state === 'paused'}
|
|
{#if $canAccess('containers', 'start')}
|
|
<button
|
|
type="button"
|
|
onclick={() => unpauseContainer(container.id)}
|
|
title="Unpause"
|
|
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
|
>
|
|
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
|
</button>
|
|
{/if}
|
|
{:else}
|
|
{#if $canAccess('containers', 'start')}
|
|
<button
|
|
type="button"
|
|
onclick={() => startContainer(container.id)}
|
|
title="Start"
|
|
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
|
>
|
|
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
{#if $canAccess('containers', 'restart')}
|
|
<ConfirmPopover
|
|
open={confirmRestartId === container.id}
|
|
action="Restart"
|
|
itemType="container"
|
|
itemName={container.name}
|
|
title="Restart"
|
|
variant="secondary"
|
|
onConfirm={() => restartContainer(container.id)}
|
|
onOpenChange={(open) => confirmRestartId = open ? container.id : null}
|
|
>
|
|
{#snippet children({ open })}
|
|
<RotateCw class="w-3 h-3 {open ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'} {restartingId === container.id ? 'animate-spin text-foreground' : ''}" />
|
|
{/snippet}
|
|
</ConfirmPopover>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
onclick={() => inspectContainer(container)}
|
|
title="View details"
|
|
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
|
>
|
|
<Eye class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
|
</button>
|
|
{#if container.state === 'running' && $canAccess('containers', 'exec')}
|
|
<button
|
|
type="button"
|
|
onclick={() => browseFiles(container)}
|
|
title="Browse files"
|
|
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
|
>
|
|
<FolderOpen class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
|
</button>
|
|
{/if}
|
|
{#if $canAccess('containers', 'create')}
|
|
<button
|
|
type="button"
|
|
onclick={() => editContainer(container.id)}
|
|
title="Edit"
|
|
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
|
>
|
|
<Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
|
</button>
|
|
{/if}
|
|
{#if $canAccess('containers', 'logs')}
|
|
{#if hasActiveLogs(container.id)}
|
|
<button
|
|
type="button"
|
|
onclick={(e) => { e.stopPropagation(); currentLogsContainerId = container.id; }}
|
|
title="Show logs"
|
|
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
|
>
|
|
<FileText class="w-4 h-4 text-blue-400" style="filter: drop-shadow(0 0 4px rgba(96,165,250,0.9)) drop-shadow(0 0 8px rgba(96,165,250,0.6));" strokeWidth={2.5} />
|
|
</button>
|
|
{:else}
|
|
<button
|
|
type="button"
|
|
onclick={(e) => { e.stopPropagation(); showLogs(container); }}
|
|
title="Open logs"
|
|
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
|
>
|
|
<FileText class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
{#if container.state === 'running' && $canAccess('containers', 'exec')}
|
|
{#if hasActiveTerminal(container.id)}
|
|
<button
|
|
type="button"
|
|
onclick={(e) => { e.stopPropagation(); currentTerminalContainerId = container.id; }}
|
|
title="Show terminal"
|
|
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
|
>
|
|
<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.Trigger
|
|
onclick={(e: MouseEvent) => e.stopPropagation()}
|
|
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
|
>
|
|
<Terminal class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
|
</Popover.Trigger>
|
|
<Popover.Content class="w-56 p-0" align="end" sideOffset={5}>
|
|
<div class="px-3 py-2 border-b bg-muted/50">
|
|
<div class="flex items-center gap-2">
|
|
<Terminal class="w-3.5 h-3.5 text-muted-foreground" />
|
|
<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>
|
|
</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>
|
|
</Popover.Content>
|
|
</Popover.Root>
|
|
{/if}
|
|
{/if}
|
|
{#if $canAccess('containers', 'remove')}
|
|
<ConfirmPopover
|
|
open={confirmDeleteId === container.id}
|
|
action="Delete"
|
|
itemType="container"
|
|
itemName={container.name}
|
|
title="Remove"
|
|
onConfirm={() => removeContainer(container.id)}
|
|
onOpenChange={(open) => confirmDeleteId = open ? container.id : null}
|
|
>
|
|
{#snippet children({ open })}
|
|
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
|
{/snippet}
|
|
</ConfirmPopover>
|
|
{/if}
|
|
{#if operationError?.id === container.id}
|
|
<div class="absolute bottom-full right-0 mb-1 z-50 bg-destructive text-destructive-foreground rounded-md shadow-lg p-2 text-xs whitespace-nowrap flex items-center gap-2 max-w-xs">
|
|
<AlertTriangle class="w-3 h-3 flex-shrink-0" />
|
|
<span class="truncate">{operationError.message}</span>
|
|
<button onclick={() => operationError = null} class="flex-shrink-0 hover:bg-white/20 rounded p-0.5">
|
|
<X class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
</DataGrid>
|
|
|
|
<!-- Panels section - in vertical mode this is a column on the right with resize handle -->
|
|
{#if layoutMode === 'vertical' && (currentLogsContainerId || currentTerminalContainerId)}
|
|
<!-- Vertical resize handle -->
|
|
<div
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
class="w-2 cursor-ew-resize flex items-center justify-center hover:bg-muted transition-colors {isResizingWidth ? 'bg-muted' : ''}"
|
|
onmousedown={startWidthResize}
|
|
>
|
|
<GripVertical class="w-4 h-8 text-zinc-600" />
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-3 h-full overflow-hidden" style="width: {panelWidth}px; flex-shrink: 0;">
|
|
<!-- Current Logs Panel -->
|
|
{#if currentLogsContainerId}
|
|
{@const activeLog = activeLogs.find(l => l.containerId === currentLogsContainerId)}
|
|
{#if activeLog}
|
|
<div class="flex-1 min-h-0">
|
|
<LogsPanel
|
|
containerId={activeLog.containerId}
|
|
containerName={activeLog.containerName}
|
|
visible={true}
|
|
envId={envId}
|
|
fillHeight={true}
|
|
onClose={() => closeLogs(activeLog.containerId)}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Current Terminal Panel -->
|
|
{#if currentTerminalContainerId}
|
|
{@const activeTerminal = activeTerminals.find(t => t.containerId === currentTerminalContainerId)}
|
|
{#if activeTerminal}
|
|
<div class="flex-1 min-h-0">
|
|
<TerminalPanel
|
|
containerId={activeTerminal.containerId}
|
|
containerName={activeTerminal.containerName}
|
|
shell={activeTerminal.shell}
|
|
user={activeTerminal.user}
|
|
visible={true}
|
|
envId={envId}
|
|
fillHeight={true}
|
|
onClose={() => closeTerminal(activeTerminal.containerId)}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Panels for horizontal mode - below the table, full width -->
|
|
{#if layoutMode === 'horizontal'}
|
|
<!-- Show current logs panel -->
|
|
{#if currentLogsContainerId}
|
|
{@const activeLog = activeLogs.find(l => l.containerId === currentLogsContainerId)}
|
|
{#if activeLog}
|
|
<LogsPanel
|
|
containerId={activeLog.containerId}
|
|
containerName={activeLog.containerName}
|
|
visible={true}
|
|
envId={envId}
|
|
onClose={() => closeLogs(activeLog.containerId)}
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Show current terminal panel -->
|
|
{#if currentTerminalContainerId}
|
|
{@const activeTerminal = activeTerminals.find(t => t.containerId === currentTerminalContainerId)}
|
|
{#if activeTerminal}
|
|
<TerminalPanel
|
|
containerId={activeTerminal.containerId}
|
|
containerName={activeTerminal.containerName}
|
|
shell={activeTerminal.shell}
|
|
user={activeTerminal.user}
|
|
visible={true}
|
|
envId={envId}
|
|
onClose={() => closeTerminal(activeTerminal.containerId)}
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<CreateContainerModal
|
|
bind:open={showCreateModal}
|
|
onClose={() => (showCreateModal = false)}
|
|
onSuccess={fetchContainers}
|
|
/>
|
|
|
|
<EditContainerModal
|
|
bind:open={showEditModal}
|
|
containerId={editContainerId}
|
|
onClose={() => (showEditModal = false)}
|
|
onSuccess={fetchContainers}
|
|
/>
|
|
|
|
<ContainerInspectModal
|
|
bind:open={showInspectModal}
|
|
containerId={inspectContainerId}
|
|
containerName={inspectContainerName}
|
|
onRename={(newName) => {
|
|
// Update the container name in the local state
|
|
inspectContainerName = newName;
|
|
// Refresh the container list
|
|
fetchContainers();
|
|
}}
|
|
/>
|
|
|
|
<FileBrowserModal
|
|
bind:open={showFileBrowserModal}
|
|
containerId={fileBrowserContainerId}
|
|
containerName={fileBrowserContainerName}
|
|
envId={envId ?? undefined}
|
|
onclose={() => showFileBrowserModal = false}
|
|
/>
|
|
|
|
<BatchUpdateModal
|
|
bind:open={showBatchUpdateModal}
|
|
containerIds={batchUpdateContainerIds}
|
|
containerNames={batchUpdateContainerNames}
|
|
{envId}
|
|
vulnerabilityCriteria={envHasScanning ? envVulnerabilityCriteria : 'never'}
|
|
onClose={handleBatchUpdateClose}
|
|
onComplete={handleBatchUpdateComplete}
|
|
/>
|
|
|
|
<BatchOperationModal
|
|
bind:open={showBatchOpModal}
|
|
title={batchOpTitle}
|
|
operation={batchOpOperation}
|
|
entityType="containers"
|
|
items={batchOpItems}
|
|
envId={envId ?? undefined}
|
|
options={batchOpOptions}
|
|
onClose={handleBatchOpClose}
|
|
onComplete={handleBatchOpComplete}
|
|
/>
|
|
|
|
<style>
|
|
@keyframes amber-glow {
|
|
0% {
|
|
background-color: rgb(245 158 11 / 0.3);
|
|
box-shadow: inset 0 0 8px rgb(245 158 11 / 0.5);
|
|
}
|
|
100% {
|
|
background-color: transparent;
|
|
box-shadow: none;
|
|
}
|
|
}
|
|
|
|
:global(.stat-highlight) {
|
|
animation: amber-glow 1.5s ease-out;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
/* Update available row styling */
|
|
:global(tr.has-update) {
|
|
background-color: rgb(245 158 11 / 0.15) !important;
|
|
}
|
|
|
|
:global(tr.has-update:hover) {
|
|
background-color: rgb(245 158 11 / 0.25) !important;
|
|
}
|
|
|
|
:global(.update-border) {
|
|
position: relative;
|
|
margin: -4px -8px -4px -8px;
|
|
padding: 4px 8px 4px 8px;
|
|
border: 2px solid rgb(245 158 11);
|
|
border-radius: 4px;
|
|
box-shadow: 0 0 8px rgb(245 158 11 / 0.4);
|
|
}
|
|
</style>
|
|
|