This commit is contained in:
jarek committed 2026-03-02 07:59:58 +01:00
1 parent 4b430340db
commit 9c451aedf9
120 files changed
+7777 -6046

No files matched your search

+142 -241
View File
@@ -73,9 +73,10 @@
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 type { ContainerInfo } from '$lib/types';
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import { containerStore } from '$lib/stores/containers';
import { onDockerEvent, isContainerListChange } from '$lib/stores/events';
import { appSettings } from '$lib/stores/settings';
import { canAccess } from '$lib/stores/auth';
@@ -87,8 +88,7 @@
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());
// Track change detection for stat highlighting (UI-only, stays in component)
let changedFields = $state<Map<string, Set<string>>>(new Map());
// Format bytes to human readable
@@ -100,21 +100,26 @@
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
}
type SortField = 'name' | 'image' | 'state' | 'health' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory';
type SortField = 'name' | 'image' | 'state' | 'health' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory' | 'ports';
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());
// Data from persistent store (survives page navigation)
const containers = $derived($containerStore.data);
const containerStats = $derived($containerStore.stats);
const autoUpdateSettings = $derived($containerStore.autoUpdateSettings);
const envHasScanning = $derived($containerStore.envHasScanning);
const envVulnerabilityCriteria = $derived($containerStore.envVulnerabilityCriteria);
const loading = $derived($containerStore.loading);
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('');
// Search and sort state - initialize from URL for persistence across navigation
const initialSearch = $page.url.searchParams.get('search')
?? $page.url.searchParams.get('image') ?? '';
let searchQuery = $state(initialSearch);
let sortField = $state<SortField>('name');
let sortDirection = $state<SortDirection>('asc');
@@ -167,6 +172,18 @@
saveStatusFilter();
});
// Sync search query to URL for persistence across navigation
$effect(() => {
const q = searchQuery;
const url = new URL($page.url);
if (q) url.searchParams.set('search', q);
else url.searchParams.delete('search');
url.searchParams.delete('image'); // clean up legacy param
if (url.toString() !== $page.url.toString()) {
goto(url.toString(), { replaceState: true, noScroll: true, keepFocus: true });
}
});
// Track if initial fetch has been done
let initialFetchDone = $state(false);
@@ -177,29 +194,28 @@
// Only fetch if environment actually changed or this is initial load
if (env && (newEnvId !== envId || !initialFetchDone)) {
const isEnvSwitch = envId !== null && newEnvId !== envId;
envId = newEnvId;
initialFetchDone = true;
// Clear update state from previous environment
batchUpdateContainerIds = [];
batchUpdateContainerNames = new Map();
updateCheckStatus = 'idle';
// Clear shell detection cache for new environment
shellDetectionCache = {};
fetchContainers();
fetchStats();
loadPendingUpdates();
if (isEnvSwitch) {
// Full env switch — invalidate cache, show spinner
containerStore.invalidate();
}
// Refresh data (store handles loading state internally)
containerStore.refresh(newEnvId);
} else if (!env) {
// No environment - clear data and stop loading
envId = null;
containers = [];
loading = false;
batchUpdateContainerIds = [];
batchUpdateContainerNames = new Map();
updateCheckStatus = 'idle';
shellDetectionCache = {};
containerStore.clear();
}
});
let loading = $state(true);
let showCreateModal = $state(false);
let showEditModal = $state(false);
let editContainerId = $state('');
@@ -247,8 +263,8 @@
// 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());
const batchUpdateContainerIds = $derived($containerStore.pendingUpdateIds);
const batchUpdateContainerNames = $derived($containerStore.pendingUpdateNames);
// Single container update mode (doesn't overwrite batch list)
let singleUpdateContainerId = $state<string | null>(null);
@@ -332,7 +348,7 @@
function handleBatchOpComplete() {
selectedContainers = new Set();
fetchContainers();
containerStore.refreshContainers(envId);
}
function bulkStart() {
@@ -353,9 +369,9 @@
function bulkRestart() {
startBatchOperation(
`Restarting ${selectedInFilter.length} container${selectedInFilter.length !== 1 ? 's' : ''}`,
`Restarting ${selectedNonSystem.length} container${selectedNonSystem.length !== 1 ? 's' : ''}`,
'restart',
selectedInFilter
selectedNonSystem
);
}
@@ -377,9 +393,9 @@
function bulkRemove() {
startBatchOperation(
`Removing ${selectedInFilter.length} container${selectedInFilter.length !== 1 ? 's' : ''}`,
`Removing ${selectedNonSystem.length} container${selectedNonSystem.length !== 1 ? 's' : ''}`,
'remove',
selectedInFilter,
selectedNonSystem,
{ force: true }
);
}
@@ -393,7 +409,7 @@
});
if (response.ok) {
pruneStatus = 'success';
await fetchContainers();
await containerStore.refreshContainers(envId);
} else {
pruneStatus = 'error';
}
@@ -418,23 +434,28 @@
}
const data = await response.json();
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate);
const failedChecks = data.results.filter((r: any) => r.error && !r.hasUpdate).length;
const failedSuffix = failedChecks > 0 ? ` (${failedChecks} failed to check)` : '';
if (containersWithUpdates.length === 0) {
updateCheckStatus = 'none';
batchUpdateContainerIds = [];
batchUpdateContainerNames.clear();
toast.success('All containers are up to date');
containerStore.setPendingUpdates([], new Map());
if (failedChecks > 0) {
toast.warning(`All containers are up to date${failedSuffix}`);
} else {
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])
containerStore.setPendingUpdates(
containersWithUpdates.map((r: any) => r.containerId),
new Map(containersWithUpdates.map((r: any) => [r.containerId, r.containerName]))
);
updateCheckStatus = 'found';
toast.info(`${containersWithUpdates.length} update(s) available`);
toast.info(`${containersWithUpdates.length} update(s) available${failedSuffix}`);
} catch (error) {
updateCheckStatus = 'error';
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
@@ -444,19 +465,10 @@
// 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
await containerStore.loadPendingUpdates(envId);
// Update local UI status if there are pending updates
if ($containerStore.pendingUpdateIds.length > 0) {
updateCheckStatus = 'found';
}
}
@@ -475,8 +487,7 @@
selectedNames.set(id, container.name);
}
}
batchUpdateContainerIds = selectedWithUpdates;
batchUpdateContainerNames = selectedNames;
containerStore.setPendingUpdates(selectedWithUpdates, selectedNames);
showBatchUpdateModal = true;
}
@@ -492,7 +503,7 @@
}
}
if (allNames.size === 0) return;
batchUpdateContainerNames = allNames;
containerStore.patch({ pendingUpdateNames: allNames });
showBatchUpdateModal = true;
}
@@ -529,7 +540,7 @@
// Reload pending updates from database to restore highlighting for remaining containers
loadPendingUpdates();
fetchContainers();
containerStore.refreshContainers(envId);
}
// Action in progress state (for animations)
@@ -717,6 +728,15 @@
const stackB = b.labels?.['com.docker.compose.project'] || '';
cmp = stackA.localeCompare(stackB);
break;
case 'ports':
const getLowestPort = (c: ContainerInfo) => {
const publicPorts = (c.ports || [])
.filter((p: any) => p.PublicPort)
.map((p: any) => p.PublicPort!);
return publicPorts.length > 0 ? Math.min(...publicPorts) : Infinity;
};
cmp = getLowestPort(a) - getLowestPort(b);
break;
case 'ip':
const ipA = getContainerIp(a.networks);
const ipB = getContainerIp(b.networks);
@@ -758,122 +778,15 @@
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'));
// Count by state for selected containers (exclude system containers from destructive actions)
const selectedNonSystem = $derived(selectedInFilter.filter(c => !c.systemContainer));
const selectedRunning = $derived(selectedNonSystem.filter(c => c.state === 'running'));
const selectedStopped = $derived(selectedNonSystem.filter(c => c.state !== 'running' && c.state !== 'paused'));
const selectedPaused = $derived(selectedNonSystem.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 };
// Thin wrappers — delegate to persistent store
function fetchContainers() {
return containerStore.refreshContainers(envId);
}
// Check if highlightChanges is enabled for current environment
@@ -896,49 +809,37 @@
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>>();
// Detect stat changes for highlighting when store stats update
$effect(() => {
const currentStats = $containerStore.stats;
const prevStats = $containerStore.previousStats;
for (const stat of stats) {
statsMap.set(stat.id, stat);
if (!highlightChangesEnabled || prevStats.size === 0) return;
// 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);
}
}
const newChangedFields = new Map<string, Set<string>>();
for (const [id, stat] of currentStats) {
const prev = prevStats.get(id);
if (prev) {
const changes = new Set<string>();
if (hasFieldChanged(id, 'cpu', prev.cpuPercent, stat.cpuPercent)) changes.add('cpu');
if (hasFieldChanged(id, 'memory', prev.memoryUsage, stat.memoryUsage)) changes.add('memory');
if (hasFieldChanged(id, 'networkRx', prev.networkRx, stat.networkRx)) changes.add('network');
if (hasFieldChanged(id, 'networkTx', prev.networkTx, stat.networkTx)) changes.add('network');
if (hasFieldChanged(id, 'blockRead', prev.blockRead, stat.blockRead)) changes.add('disk');
if (hasFieldChanged(id, 'blockWrite', prev.blockWrite, stat.blockWrite)) changes.add('disk');
if (changes.size > 0) {
newChangedFields.set(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);
}
}
changedFields = newChangedFields;
if (newChangedFields.size > 0) {
pendingTimeouts.push(setTimeout(() => {
changedFields = new Map();
}, 1500));
}
});
async function startContainer(id: string) {
operationError = null;
@@ -954,7 +855,7 @@
return;
}
toast.success(`Started ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to start container:', error);
operationError = { id, message: 'Failed to start container' };
@@ -978,7 +879,7 @@
return;
}
toast.success(`Stopped ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to stop container:', error);
operationError = { id, message: 'Failed to stop container' };
@@ -1003,7 +904,7 @@
return;
}
toast.success(`Paused ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to pause container:', error);
operationError = { id, message: 'Failed to pause container' };
@@ -1026,7 +927,7 @@
return;
}
toast.success(`Resumed ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to unpause container:', error);
operationError = { id, message: 'Failed to unpause container' };
@@ -1050,7 +951,7 @@
return;
}
toast.success(`Restarted ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to restart container:', error);
operationError = { id, message: 'Failed to restart container' };
@@ -1075,7 +976,7 @@
return;
}
toast.success(`Removed ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to remove container:', error);
operationError = { id, message: 'Failed to remove container' };
@@ -1379,8 +1280,8 @@
function handleVisibilityChange() {
if (document.visibilityState === 'visible' && envId) {
// Tab became visible - refresh data immediately
fetchContainers();
fetchStats();
containerStore.refreshContainers(envId);
containerStore.refreshStats(envId);
}
}
@@ -1388,12 +1289,6 @@
loadLayoutMode();
loadStatusFilter();
// Check for image filter from URL params (from images page link)
const imageParam = $page.url.searchParams.get('image');
if (imageParam) {
searchQuery = imageParam;
}
// Load persisted pending updates from database
loadPendingUpdates();
@@ -1405,14 +1300,14 @@
// Set up interval to refresh stats every 5 seconds (use module-scope var for cleanup)
statsInterval = setInterval(() => {
if (envId) fetchStats();
if (envId) containerStore.refreshStats(envId);
}, 5000);
// Subscribe to container events (SSE connection is global in layout)
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isContainerListChange(event)) {
fetchContainers();
fetchStats();
containerStore.refreshContainers(envId);
containerStore.refreshStats(envId);
}
});
@@ -1634,12 +1529,12 @@
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('containers', 'restart')}
{#if selectedNonSystem.length > 0 && $canAccess('containers', 'restart')}
<ConfirmPopover
open={confirmBulkRestart}
action="Restart"
itemType="{selectedInFilter.length} container{selectedInFilter.length !== 1 ? 's' : ''}"
title="Restart {selectedInFilter.length}"
itemType="{selectedNonSystem.length} container{selectedNonSystem.length !== 1 ? 's' : ''}"
title="Restart {selectedNonSystem.length}"
variant="secondary"
unstyled
onConfirm={bulkRestart}
@@ -1653,12 +1548,12 @@
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('containers', 'remove')}
{#if selectedNonSystem.length > 0 && $canAccess('containers', 'remove')}
<ConfirmPopover
open={confirmBulkRemove}
action="Remove"
itemType="{selectedInFilter.length} container{selectedInFilter.length !== 1 ? 's' : ''}"
title="Remove {selectedInFilter.length}"
itemType="{selectedNonSystem.length} container{selectedNonSystem.length !== 1 ? 's' : ''}"
title="Remove {selectedNonSystem.length}"
unstyled
onConfirm={bulkRemove}
onOpenChange={(open) => confirmBulkRemove = open}
@@ -1897,7 +1792,7 @@
{:else if column.id === 'ports'}
{#if ports.length > 0}
<div class="flex flex-wrap gap-1">
{#each ports.slice(0, 2) as port}
{#each ports as port}
{@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null}
{#if url}
<a
@@ -1915,9 +1810,6 @@
<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>
@@ -1943,13 +1835,20 @@
{/if}
{:else if column.id === 'stack'}
{#if stack}
<button
type="button"
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/stacks?search=${encodeURIComponent(stack)}`, envId)); }}
class="cursor-pointer"
>
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors">{stack}</Badge>
</button>
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/stacks?search=${encodeURIComponent(stack)}`, envId)); }}
class="cursor-pointer"
>
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors truncate max-w-full">{stack}</Badge>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-sm whitespace-nowrap">{stack}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
{/if}
@@ -1965,6 +1864,7 @@
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
</button>
{/if}
{#if !container.systemContainer}
{#if container.state === 'running' || container.state === 'restarting'}
{#if $canAccess('containers', 'stop')}
<ConfirmPopover
@@ -2030,6 +1930,7 @@
{/snippet}
</ConfirmPopover>
{/if}
{/if}
<button
type="button"
onclick={() => inspectContainer(container)}
@@ -2171,7 +2072,7 @@
</Popover.Root>
{/if}
{/if}
{#if $canAccess('containers', 'remove')}
{#if !container.systemContainer && $canAccess('containers', 'remove')}
<ConfirmPopover
open={confirmDeleteId === container.id}
action="Delete"
+133 -171
View File
@@ -12,6 +12,7 @@
import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte';
import UpdateSummaryStats from '$lib/components/UpdateSummaryStats.svelte';
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
import { watchJob } from '$lib/utils/sse-fetch';
interface Props {
open: boolean;
@@ -131,140 +132,123 @@
throw new Error(data.error || 'Failed to start update');
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
const { jobId } = await response.json();
const successIds: string[] = [];
const failedIds: string[] = [];
const blockedIds: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
await watchJob(jobId, (line) => {
try {
const data = line.data as any;
scrollTick++;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
if (data.type === 'start') {
totalCount = data.total;
} else if (data.type === 'progress') {
currentIndex = data.current;
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue;
// Update or add progress entry
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
if (existingIndex >= 0) {
progress[existingIndex].step = data.step;
progress[existingIndex].success = data.success;
progress[existingIndex].error = data.error;
progress = [...progress]; // Trigger reactivity
} else {
progress = [...progress, {
containerId: data.containerId,
containerName: data.containerName,
step: data.step,
success: data.success,
error: data.error,
pullLogs: [],
scanLogs: [],
showLogs: true,
}];
}
try {
const data = JSON.parse(line.slice(6));
scrollTick++;
if (data.type === 'start') {
totalCount = data.total;
} else if (data.type === 'progress') {
currentIndex = data.current;
// Update or add progress entry
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
if (existingIndex >= 0) {
progress[existingIndex].step = data.step;
progress[existingIndex].success = data.success;
progress[existingIndex].error = data.error;
progress = [...progress]; // Trigger reactivity
} else {
progress = [...progress, {
containerId: data.containerId,
containerName: data.containerName,
step: data.step,
success: data.success,
error: data.error,
pullLogs: [],
scanLogs: [],
showLogs: true,
}];
}
// Track success/failed for onComplete callback
if (data.success === true) {
successIds.push(data.containerId);
} else if (data.success === false && data.step === 'failed') {
failedIds.push(data.containerId);
}
} else if (data.type === 'pull_log') {
// Add pull log to the container's log list
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
// For layer progress, update existing entry or add new
if (data.pullId) {
const existingLog = containerProgress.pullLogs.find(l => l.id === data.pullId);
if (existingLog) {
existingLog.status = data.pullStatus;
existingLog.progress = data.pullProgress;
} else {
containerProgress.pullLogs.push({
status: data.pullStatus,
id: data.pullId,
progress: data.pullProgress
});
}
// Track success/failed for onComplete callback
if (data.success === true) {
successIds.push(data.containerId);
} else if (data.success === false && data.step === 'failed') {
failedIds.push(data.containerId);
}
} else if (data.type === 'pull_log') {
// Add pull log to the container's log list
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
// For layer progress, update existing entry or add new
if (data.pullId) {
const existingLog = containerProgress.pullLogs.find((l: any) => l.id === data.pullId);
if (existingLog) {
existingLog.status = data.pullStatus;
existingLog.progress = data.pullProgress;
} else {
// General status message (no layer ID)
containerProgress.pullLogs.push({
status: data.pullStatus
status: data.pullStatus,
id: data.pullId,
progress: data.pullProgress
});
}
progress = [...progress]; // Trigger reactivity
}
} else if (data.type === 'scan_start') {
// Update step to scanning
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.step = 'scanning';
progress = [...progress];
}
} else if (data.type === 'scan_log') {
// Add scan log to the container's log list
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.scanLogs.push({
scanner: data.scanner,
message: data.message
} else {
// General status message (no layer ID)
containerProgress.pullLogs.push({
status: data.pullStatus
});
progress = [...progress];
}
} else if (data.type === 'scan_complete') {
// Store scan result, individual scanner results, and vulnerabilities
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.scanResult = data.scanResult;
containerProgress.scannerResults = data.scannerResults;
containerProgress.vulnerabilities = data.vulnerabilities;
progress = [...progress];
}
} else if (data.type === 'blocked') {
// Mark container as blocked
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
if (existingIndex >= 0) {
progress[existingIndex].step = 'blocked';
progress[existingIndex].success = false;
progress[existingIndex].scanResult = data.scanResult;
progress[existingIndex].scannerResults = data.scannerResults;
progress[existingIndex].blockReason = data.blockReason;
progress = [...progress];
}
blockedIds.push(data.containerId);
currentIndex = data.current;
} else if (data.type === 'complete') {
status = 'complete';
summary = data.summary;
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
} else if (data.type === 'error') {
status = 'error';
errorMessage = data.error || 'Unknown error occurred';
progress = [...progress]; // Trigger reactivity
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
} else if (data.type === 'scan_start') {
// Update step to scanning
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.step = 'scanning';
progress = [...progress];
}
} else if (data.type === 'scan_log') {
// Add scan log to the container's log list
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.scanLogs.push({
scanner: data.scanner,
message: data.message
});
progress = [...progress];
}
} else if (data.type === 'scan_complete') {
// Store scan result, individual scanner results, and vulnerabilities
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.scanResult = data.scanResult;
containerProgress.scannerResults = data.scannerResults;
containerProgress.vulnerabilities = data.vulnerabilities;
progress = [...progress];
}
} else if (data.type === 'blocked') {
// Mark container as blocked
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
if (existingIndex >= 0) {
progress[existingIndex].step = 'blocked';
progress[existingIndex].success = false;
progress[existingIndex].scanResult = data.scanResult;
progress[existingIndex].scannerResults = data.scannerResults;
progress[existingIndex].blockReason = data.blockReason;
progress = [...progress];
}
blockedIds.push(data.containerId);
currentIndex = data.current;
} else if (data.type === 'complete') {
status = 'complete';
summary = data.summary;
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
} else if (data.type === 'error') {
status = 'error';
errorMessage = data.error || 'Unknown error occurred';
}
} catch (e) {
console.error('Failed to process job line:', e);
}
}
});
} catch (error: any) {
console.error('Failed to update containers:', error);
status = 'error';
@@ -343,63 +327,41 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
throw new Error(data.error || 'Failed to start update');
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const { jobId } = await response.json();
await watchJob(jobId, (line) => {
const data = line.data as any;
const decoder = new TextDecoder();
let buffer = '';
if (data.type === 'progress') {
item.step = data.step;
item.success = data.success;
item.error = data.error;
progress = [...progress];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'progress') {
item.step = data.step;
item.success = data.success;
item.error = data.error;
progress = [...progress];
// Update summary if container succeeded
if (data.success === true && summary) {
summary.blocked--;
summary.success++;
summary = { ...summary };
}
} else if (data.type === 'pull_log') {
if (data.pullId) {
const existingLog = item.pullLogs.find(l => l.id === data.pullId);
if (existingLog) {
existingLog.status = data.pullStatus;
existingLog.progress = data.pullProgress;
} else {
item.pullLogs.push({
status: data.pullStatus,
id: data.pullId,
progress: data.pullProgress
});
}
} else {
item.pullLogs.push({ status: data.pullStatus });
}
progress = [...progress];
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
// Update summary if container succeeded
if (data.success === true && summary) {
summary.blocked--;
summary.success++;
summary = { ...summary };
}
} else if (data.type === 'pull_log') {
if (data.pullId) {
const existingLog = item.pullLogs.find(l => l.id === data.pullId);
if (existingLog) {
existingLog.status = data.pullStatus;
existingLog.progress = data.pullProgress;
} else {
item.pullLogs.push({
status: data.pullStatus,
id: data.pullId,
progress: data.pullProgress
});
}
} else {
item.pullLogs.push({ status: data.pullStatus });
}
progress = [...progress];
}
}
});
} catch (error: any) {
console.error('Failed to force update container:', error);
item.step = 'failed';
@@ -452,9 +452,22 @@
healthcheckTimeout = Math.floor((healthcheck.Timeout || 30e9) / 1e9);
healthcheckRetries = healthcheck.Retries || 3;
healthcheckStartPeriod = Math.floor((healthcheck.StartPeriod || 0) / 1e9);
} else {
healthcheckEnabled = false;
healthcheckCommand = '';
healthcheckInterval = 30;
healthcheckTimeout = 30;
healthcheckRetries = 3;
healthcheckStartPeriod = 0;
}
// Parse advanced options - Resources
// Parse advanced options - Resources (reset first to avoid stale values)
memoryLimit = '';
memoryReservation = '';
cpuShares = '';
nanoCpus = '';
cpuQuota = '';
cpuPeriod = '';
if (data.HostConfig.Memory) {
memoryLimit = formatBytes(data.HostConfig.Memory);
}