This commit is contained in:
jarek committed 2026-05-09 09:55:18 +02:00
1 parent 91ef3e3c9b
commit 002d969a5d
46 files changed
+7202 -233

No files matched your search

+20 -1
View File
@@ -473,7 +473,7 @@
// Unlock button width
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate && !r.systemContainer);
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate && !r.systemContainer && !r.updateDisabled && !r.isLocalImage);
const failed = data.results.filter((r: any) => r.error && !r.hasUpdate);
failedUpdateChecks = failed.map((r: any) => ({
containerName: r.containerName,
@@ -542,6 +542,19 @@
showBatchUpdateModal = true;
}
async function dismissPendingUpdates() {
const envId = $currentEnvironment?.id;
if (!envId) return;
try {
const response = await fetch(`/api/containers/pending-updates?env=${envId}`, { method: 'DELETE' });
if (response.ok) {
containerStore.setPendingUpdates([], new Map());
}
} catch {
toast.error('Failed to clear update indicators');
}
}
function updateAllContainers() {
if (batchUpdateContainerIds.length === 0) return;
@@ -1442,6 +1455,12 @@
>
<CircleArrowUp class="w-3.5 h-3.5" />
Update all ({updatableContainersCount})
<button
type="button"
onclick={(e) => { e.stopPropagation(); dismissPendingUpdates(); }}
class="-mr-1 text-[12px] leading-none rounded-full hover:bg-destructive/20 hover:text-destructive transition-colors opacity-40 hover:opacity-100"
title="Dismiss all update indicators"
>×</button>
</Button>
{/if}
{#if $canAccess('containers', 'remove')}
@@ -87,8 +87,12 @@
let forceUpdating = $state<Set<string>>(new Set()); // Track containers being force-updated
let filterMode = $state<'updated' | 'failed'>('updated');
let showFilterButtons = $derived(
summary && (summary.failed > 0 || summary.blocked > 0) && summary.success > 0
);
let filteredProgress = $derived(
!summary
!summary || !showFilterButtons
? progress
: filterMode === 'failed'
? progress.filter(p => p.step === 'failed' || p.step === 'blocked')
@@ -475,7 +479,7 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
<!-- Filter toggle + Container list with status - scrollable area -->
{#if progress.length > 0}
{#if summary && (summary.failed > 0 || summary.blocked > 0) && summary.success > 0}
{#if showFilterButtons}
<div class="flex items-center gap-1 shrink-0">
<Button
variant={filterMode === 'updated' ? 'default' : 'outline'}
+150 -14
View File
@@ -60,6 +60,12 @@
driver: string;
}
interface NetworkEndpointConfig {
ipv4Address: string;
ipv6Address: string;
aliases: string;
}
interface Props {
mode: 'create' | 'edit';
// Basic settings
@@ -82,6 +88,8 @@
// Networks
availableNetworks: DockerNetwork[];
selectedNetworks: string[];
networkConfigs: Record<string, NetworkEndpointConfig>;
macAddress: string;
// User/Group
containerUser: string;
// Privileged mode
@@ -157,6 +165,8 @@
labels = $bindable(),
availableNetworks,
selectedNetworks = $bindable(),
networkConfigs = $bindable(),
macAddress = $bindable(),
containerUser = $bindable(),
privilegedMode = $bindable(),
healthcheckEnabled = $bindable(),
@@ -195,6 +205,60 @@
imageSummary
}: Props = $props();
// Expanded network config rows
let expandedNetworks = $state<Set<string>>(new Set());
function toggleNetworkExpand(networkName: string) {
const next = new Set(expandedNetworks);
if (next.has(networkName)) {
next.delete(networkName);
} else {
next.add(networkName);
}
expandedNetworks = next;
}
function ensureNetworkConfig(networkName: string) {
if (!networkConfigs[networkName]) {
networkConfigs[networkName] = { ipv4Address: '', ipv6Address: '', aliases: '' };
networkConfigs = { ...networkConfigs };
}
}
function hasNetworkConfig(networkName: string): boolean {
const cfg = networkConfigs[networkName];
return !!cfg && !!(cfg.ipv4Address || cfg.ipv6Address || cfg.aliases);
}
// Validation helpers
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
const ipv6Regex = /^[0-9a-fA-F:]+$/;
const macRegex = /^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/;
function validateIpv4(value: string): string | null {
if (!value) return null;
return ipv4Regex.test(value) ? null : 'Invalid IPv4 address';
}
function validateIpv6(value: string): string | null {
if (!value) return null;
return ipv6Regex.test(value) ? null : 'Invalid IPv6 address';
}
function validateMac(value: string): string | null {
if (!value) return null;
return macRegex.test(value) ? null : 'Invalid MAC address (e.g., 02:42:ac:11:00:02)';
}
// Auto-expand networks that have config
$effect(() => {
for (const net of selectedNetworks) {
if (hasNetworkConfig(net) && !expandedNetworks.has(net)) {
expandedNetworks = new Set([...expandedNetworks, net]);
}
}
});
// Collapsible sections state
let showResources = $state(false);
let showSecurity = $state(false);
@@ -257,6 +321,11 @@
function removeNetwork(networkId: string) {
selectedNetworks = selectedNetworks.filter((n) => n !== networkId);
const { [networkId]: _, ...rest } = networkConfigs;
networkConfigs = rest;
const next = new Set(expandedNetworks);
next.delete(networkId);
expandedNetworks = next;
}
function addDeviceMapping() {
@@ -681,25 +750,92 @@
</Select.Root>
{#if selectedNetworks.length > 0}
<div class="flex flex-wrap gap-2 pt-1">
<div class="space-y-1 pt-1">
{#each selectedNetworks as networkName}
{@const network = availableNetworks.find(n => n.name === networkName)}
<Badge variant="secondary" class="flex items-center gap-1.5 pr-1">
{networkName}
{#if network}
<span class={getDriverBadgeClasses(network.driver)}>{network.driver}</span>
{@const isExpanded = expandedNetworks.has(networkName)}
<div class="border rounded-md">
<div class="flex items-center justify-between px-2.5 py-1.5">
<button
type="button"
onclick={() => { ensureNetworkConfig(networkName); toggleNetworkExpand(networkName); }}
class="flex items-center gap-1.5 text-sm hover:text-foreground transition-colors"
>
{#if isExpanded}
<ChevronDown class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<ChevronRight class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
<span>{networkName}</span>
{#if network}
<span class={getDriverBadgeClasses(network.driver)}>{network.driver}</span>
{/if}
{#if hasNetworkConfig(networkName)}
<Badge variant="secondary" class="text-2xs">configured</Badge>
{/if}
</button>
<button
type="button"
onclick={() => removeNetwork(networkName)}
class="p-0.5 hover:bg-destructive/20 rounded text-muted-foreground hover:text-destructive"
>
<X class="w-3.5 h-3.5" />
</button>
</div>
{#if isExpanded && networkConfigs[networkName]}
<div class="px-2.5 pb-2.5 pt-1 border-t space-y-2">
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label class="text-2xs font-medium text-muted-foreground">IPv4 address</Label>
<Input
bind:value={networkConfigs[networkName].ipv4Address}
placeholder="e.g., 172.28.0.100"
class="h-8 text-xs"
/>
{#if validateIpv4(networkConfigs[networkName].ipv4Address)}
<p class="text-2xs text-destructive">{validateIpv4(networkConfigs[networkName].ipv4Address)}</p>
{/if}
</div>
<div class="space-y-1">
<Label class="text-2xs font-medium text-muted-foreground">IPv6 address</Label>
<Input
bind:value={networkConfigs[networkName].ipv6Address}
placeholder="e.g., fd00::100"
class="h-8 text-xs"
/>
{#if validateIpv6(networkConfigs[networkName].ipv6Address)}
<p class="text-2xs text-destructive">{validateIpv6(networkConfigs[networkName].ipv6Address)}</p>
{/if}
</div>
</div>
<div class="space-y-1">
<Label class="text-2xs font-medium text-muted-foreground">Aliases (comma-separated)</Label>
<Input
bind:value={networkConfigs[networkName].aliases}
placeholder="e.g., myalias, web"
class="h-8 text-xs"
/>
</div>
</div>
{/if}
<button
type="button"
onclick={() => removeNetwork(networkName)}
class="ml-0.5 hover:bg-destructive/20 rounded p-0.5"
>
<X class="w-3 h-3" />
</button>
</Badge>
</div>
{/each}
</div>
{/if}
<!-- MAC Address -->
<div class="space-y-1 pt-1">
<Label class="text-xs font-medium">MAC address</Label>
<Input
bind:value={macAddress}
placeholder="e.g., 02:42:ac:11:00:02"
class="h-9"
/>
{#if validateMac(macAddress)}
<p class="text-xs text-destructive">{validateMac(macAddress)}</p>
{/if}
</div>
{#if mode === 'edit'}
<p class="text-xs text-muted-foreground">Container will be connected to selected networks in addition to the network mode above</p>
{/if}
@@ -851,7 +987,7 @@
size="icon"
variant="ghost"
onclick={() => removeLabel(index)}
disabled={labels.length === 1}
disabled={labels.length <= 1 && !labels[0]?.key}
class="h-9 w-9 text-muted-foreground hover:text-destructive"
>
<Trash2 class="w-4 h-4" />
@@ -112,6 +112,8 @@
}
let availableNetworks = $state<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
let macAddress = $state('');
// Auto-update settings
let autoUpdateEnabled = $state(false);
@@ -431,6 +433,17 @@
deviceRequests = [dr];
}
// Build per-network configs for the API
const netConfigs: Record<string, { ipv4Address?: string; ipv6Address?: string; aliases?: string[] }> = {};
for (const [netName, cfg] of Object.entries(networkConfigs)) {
if (!selectedNetworks.includes(netName)) continue;
const entry: { ipv4Address?: string; ipv6Address?: string; aliases?: string[] } = {};
if (cfg.ipv4Address) entry.ipv4Address = cfg.ipv4Address;
if (cfg.ipv6Address) entry.ipv6Address = cfg.ipv6Address;
if (cfg.aliases) entry.aliases = cfg.aliases.split(',').map(a => a.trim()).filter(Boolean);
if (Object.keys(entry).length > 0) netConfigs[netName] = entry;
}
const payload = {
name: name.trim(),
image: image.trim(),
@@ -443,6 +456,8 @@
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined,
networkMode,
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined,
macAddress: macAddress.trim() || undefined,
startAfterCreate,
user: containerUser.trim() || undefined,
privileged: privilegedMode || undefined,
@@ -530,6 +545,8 @@
envVars = [{ key: '', value: '' }];
labels = [{ key: '', value: '' }];
selectedNetworks = [];
networkConfigs = {};
macAddress = '';
autoUpdateEnabled = false;
autoUpdateCronExpression = '0 3 * * *';
vulnerabilityCriteria = 'never';
@@ -723,6 +740,8 @@
bind:labels
{availableNetworks}
bind:selectedNetworks
bind:networkConfigs
bind:macAddress
bind:containerUser
bind:privilegedMode
bind:healthcheckEnabled
@@ -113,6 +113,8 @@
}
let availableNetworks = $state<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
let macAddress = $state('');
// User/Group
let containerUser = $state('');
@@ -182,6 +184,8 @@
envVars: typeof envVars;
labels: typeof labels;
selectedNetworks: string[];
networkConfigs: Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>;
macAddress: string;
// Advanced options
containerUser: string;
privilegedMode: boolean;
@@ -428,6 +432,24 @@
const networks = data.NetworkSettings?.Networks || {};
selectedNetworks = Object.keys(networks);
// Parse per-network IP/alias config from NetworkSettings
const parsedNetConfigs: Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }> = {};
for (const [netName, netData] of Object.entries(networks) as [string, any][]) {
const ipam = netData.IPAMConfig || {};
const aliases = netData.Aliases || [];
if (ipam.IPv4Address || ipam.IPv6Address || aliases.length > 0) {
parsedNetConfigs[netName] = {
ipv4Address: ipam.IPv4Address || '',
ipv6Address: ipam.IPv6Address || '',
aliases: aliases.join(', ')
};
}
}
networkConfigs = parsedNetConfigs;
// Parse MAC address
macAddress = data.Config?.MacAddress || '';
// Parse advanced options - User
containerUser = data.Config.User || '';
@@ -561,6 +583,8 @@
envVars: JSON.parse(JSON.stringify(envVars)),
labels: JSON.parse(JSON.stringify(labels)),
selectedNetworks: [...selectedNetworks],
networkConfigs: JSON.parse(JSON.stringify(networkConfigs)),
macAddress,
// Advanced options
containerUser,
privilegedMode,
@@ -631,6 +655,8 @@
if (JSON.stringify(currentLabels) !== JSON.stringify(originalLabels)) return true;
if (JSON.stringify([...selectedNetworks].sort()) !== JSON.stringify([...originalConfig.selectedNetworks].sort())) return true;
if (JSON.stringify(networkConfigs) !== JSON.stringify(originalConfig.networkConfigs)) return true;
if (macAddress !== originalConfig.macAddress) return true;
// Advanced options - User & Security
if (containerUser !== originalConfig.containerUser) return true;
@@ -701,7 +727,9 @@
volumeMappings: volumeMappings.filter(v => v.hostPath && v.containerPath),
envVars: envVars.filter(e => e.key),
labels: labels.filter(l => l.key),
selectedNetworks: [...selectedNetworks].sort()
selectedNetworks: [...selectedNetworks].sort(),
networkConfigs,
macAddress
});
}
@@ -716,7 +744,9 @@
volumeMappings: originalConfig.volumeMappings.filter(v => v.hostPath && v.containerPath),
envVars: originalConfig.envVars.filter(e => e.key),
labels: originalConfig.labels.filter(l => l.key),
selectedNetworks: [...originalConfig.selectedNetworks].sort()
selectedNetworks: [...originalConfig.selectedNetworks].sort(),
networkConfigs: originalConfig.networkConfigs,
macAddress: originalConfig.macAddress
});
}
@@ -901,18 +931,31 @@
deviceRequests = [dr];
}
// Build per-network configs for the API
const netConfigs: Record<string, { ipv4Address?: string; ipv6Address?: string; aliases?: string[] }> = {};
for (const [netName, cfg] of Object.entries(networkConfigs)) {
if (!selectedNetworks.includes(netName)) continue;
const entry: { ipv4Address?: string; ipv6Address?: string; aliases?: string[] } = {};
if (cfg.ipv4Address) entry.ipv4Address = cfg.ipv4Address;
if (cfg.ipv6Address) entry.ipv6Address = cfg.ipv6Address;
if (cfg.aliases) entry.aliases = cfg.aliases.split(',').map(a => a.trim()).filter(Boolean);
if (Object.keys(entry).length > 0) netConfigs[netName] = entry;
}
const payload = {
name: name.trim(),
image: image.trim(),
ports: Object.keys(ports).length > 0 ? ports : null,
volumeBinds: volumeBinds.length > 0 ? volumeBinds : null,
env: env.length > 0 ? env : undefined,
labels: Object.keys(labelsObj).length > 0 ? labelsObj : undefined,
labels: labelsObj,
cmd,
restartPolicy,
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined,
networkMode,
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined,
macAddress: macAddress.trim() || undefined,
startAfterUpdate,
repullImage,
user: containerUser.trim() || null,
@@ -1091,6 +1134,8 @@
bind:labels
{availableNetworks}
bind:selectedNetworks
bind:networkConfigs
bind:macAddress
bind:containerUser
bind:privilegedMode
bind:healthcheckEnabled