v1.0.19
This commit is contained in:
1 parent
4b430340db
commit
9c451aedf9
120 files changed
+7777
-6046
No files matched your search
+142
-241
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user