1.0.28
This commit is contained in:
46 files changed
+7202
-233
No files matched your search
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user