mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-09 21:29:04 +00:00
Initial commit
This commit is contained in:
387
routes/settings/config-sets/ConfigSetModal.svelte
Normal file
387
routes/settings/config-sets/ConfigSetModal.svelte
Normal file
@@ -0,0 +1,387 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { ToggleGroup } from '$lib/components/ui/toggle-pill';
|
||||
import { Plus, Check, RefreshCw, Trash2 } from 'lucide-svelte';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
|
||||
// Protocol options for ports
|
||||
const protocolOptions = [
|
||||
{ value: 'tcp', label: 'TCP' },
|
||||
{ value: 'udp', label: 'UDP' }
|
||||
];
|
||||
|
||||
// Mode options for volumes
|
||||
const volumeModeOptions = [
|
||||
{ value: 'rw', label: 'RW' },
|
||||
{ value: 'ro', label: 'RO' }
|
||||
];
|
||||
|
||||
export interface ConfigSet {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
envVars?: { key: string; value: string }[];
|
||||
labels?: { key: string; value: string }[];
|
||||
ports?: { hostPort: string; containerPort: string; protocol: string }[];
|
||||
volumes?: { hostPath: string; containerPath: string; mode: string }[];
|
||||
networkMode?: string;
|
||||
restartPolicy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
configSet?: ConfigSet | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), configSet = null, onClose, onSaved }: Props = $props();
|
||||
|
||||
const isEditing = $derived(configSet !== null);
|
||||
|
||||
// Form state
|
||||
let formName = $state('');
|
||||
let formDescription = $state('');
|
||||
let formEnvVars = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]);
|
||||
let formLabels = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]);
|
||||
let formPorts = $state<{ hostPort: string; containerPort: string; protocol: string }[]>([{ hostPort: '', containerPort: '', protocol: 'tcp' }]);
|
||||
let formVolumes = $state<{ hostPath: string; containerPath: string; mode: string }[]>([{ hostPath: '', containerPath: '', mode: 'rw' }]);
|
||||
let formNetworkMode = $state('bridge');
|
||||
let formRestartPolicy = $state('no');
|
||||
let formError = $state('');
|
||||
let formErrors = $state<{ name?: string; ports?: string[] }>({});
|
||||
let formSaving = $state(false);
|
||||
|
||||
// Validate port number
|
||||
function isValidPort(value: string): boolean {
|
||||
if (!value.trim()) return true; // Empty is ok (will be filtered out)
|
||||
const num = parseInt(value, 10);
|
||||
return !isNaN(num) && num >= 1 && num <= 65535 && String(num) === value.trim();
|
||||
}
|
||||
|
||||
function validatePort(index: number, field: 'host' | 'container') {
|
||||
const port = formPorts[index];
|
||||
const value = field === 'host' ? port.hostPort : port.containerPort;
|
||||
|
||||
if (!formErrors.ports) formErrors.ports = [];
|
||||
const errorKey = `${index}-${field}`;
|
||||
|
||||
if (value.trim() && !isValidPort(value)) {
|
||||
if (!formErrors.ports.includes(errorKey)) {
|
||||
formErrors.ports = [...formErrors.ports, errorKey];
|
||||
}
|
||||
} else {
|
||||
formErrors.ports = formErrors.ports.filter(e => e !== errorKey);
|
||||
}
|
||||
}
|
||||
|
||||
function hasPortError(index: number, field: 'host' | 'container'): boolean {
|
||||
return formErrors.ports?.includes(`${index}-${field}`) ?? false;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formName = '';
|
||||
formDescription = '';
|
||||
formEnvVars = [{ key: '', value: '' }];
|
||||
formLabels = [{ key: '', value: '' }];
|
||||
formPorts = [{ hostPort: '', containerPort: '', protocol: 'tcp' }];
|
||||
formVolumes = [{ hostPath: '', containerPath: '', mode: 'rw' }];
|
||||
formNetworkMode = 'bridge';
|
||||
formRestartPolicy = 'no';
|
||||
formError = '';
|
||||
formErrors = {};
|
||||
formSaving = false;
|
||||
}
|
||||
|
||||
// Initialize form when configSet changes or modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (configSet) {
|
||||
formName = configSet.name;
|
||||
formDescription = configSet.description || '';
|
||||
formEnvVars = configSet.envVars?.length ? [...configSet.envVars] : [{ key: '', value: '' }];
|
||||
formLabels = configSet.labels?.length ? [...configSet.labels] : [{ key: '', value: '' }];
|
||||
formPorts = configSet.ports?.length ? [...configSet.ports] : [{ hostPort: '', containerPort: '', protocol: 'tcp' }];
|
||||
formVolumes = configSet.volumes?.length ? [...configSet.volumes] : [{ hostPath: '', containerPath: '', mode: 'rw' }];
|
||||
formNetworkMode = configSet.networkMode || 'bridge';
|
||||
formRestartPolicy = configSet.restartPolicy || 'no';
|
||||
formError = '';
|
||||
formErrors = {};
|
||||
formSaving = false;
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function addEnvVar() { formEnvVars = [...formEnvVars, { key: '', value: '' }]; }
|
||||
function removeEnvVar(i: number) { formEnvVars = formEnvVars.filter((_, idx) => idx !== i); }
|
||||
function addLabel() { formLabels = [...formLabels, { key: '', value: '' }]; }
|
||||
function removeLabel(i: number) { formLabels = formLabels.filter((_, idx) => idx !== i); }
|
||||
function addPort() { formPorts = [...formPorts, { hostPort: '', containerPort: '', protocol: 'tcp' }]; }
|
||||
function removePort(i: number) { formPorts = formPorts.filter((_, idx) => idx !== i); }
|
||||
function addVolume() { formVolumes = [...formVolumes, { hostPath: '', containerPath: '', mode: 'rw' }]; }
|
||||
function removeVolume(i: number) { formVolumes = formVolumes.filter((_, idx) => idx !== i); }
|
||||
|
||||
function getCleanedFormData() {
|
||||
return {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || undefined,
|
||||
envVars: formEnvVars.filter(e => e.key.trim()),
|
||||
labels: formLabels.filter(l => l.key.trim()),
|
||||
ports: formPorts.filter(p => p.containerPort.trim()),
|
||||
volumes: formVolumes.filter(v => v.hostPath.trim() && v.containerPath.trim()),
|
||||
networkMode: formNetworkMode,
|
||||
restartPolicy: formRestartPolicy
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
formErrors = {};
|
||||
|
||||
if (!formName.trim()) {
|
||||
formErrors.name = 'Name is required';
|
||||
}
|
||||
|
||||
// Validate all ports
|
||||
const portErrors: string[] = [];
|
||||
formPorts.forEach((port, i) => {
|
||||
if (port.hostPort.trim() && !isValidPort(port.hostPort)) {
|
||||
portErrors.push(`${i}-host`);
|
||||
}
|
||||
if (port.containerPort.trim() && !isValidPort(port.containerPort)) {
|
||||
portErrors.push(`${i}-container`);
|
||||
}
|
||||
});
|
||||
if (portErrors.length > 0) {
|
||||
formErrors.ports = portErrors;
|
||||
}
|
||||
|
||||
// Stop if there are any errors
|
||||
if (formErrors.name || (formErrors.ports && formErrors.ports.length > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
formSaving = true;
|
||||
formError = '';
|
||||
|
||||
try {
|
||||
const url = isEditing ? `/api/config-sets/${configSet!.id}` : '/api/config-sets';
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(getCleanedFormData())
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
open = false;
|
||||
onSaved();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
if (data.error?.includes('already exists')) {
|
||||
formErrors.name = 'Config set name already exists';
|
||||
} else {
|
||||
formError = data.error || `Failed to ${isEditing ? 'update' : 'create'} config set`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
formError = `Failed to ${isEditing ? 'update' : 'create'} config set`;
|
||||
} finally {
|
||||
formSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { formError = ''; formErrors = {}; focusFirstInput(); } }}>
|
||||
<Dialog.Content class="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{isEditing ? 'Edit' : 'Add'} config set</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
{#if formError}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">{formError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="cfg-name">Name *</Label>
|
||||
<Input
|
||||
id="cfg-name"
|
||||
bind:value={formName}
|
||||
placeholder="production-web"
|
||||
class={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
oninput={() => formErrors.name = undefined}
|
||||
/>
|
||||
{#if formErrors.name}
|
||||
<p class="text-xs text-destructive">{formErrors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="cfg-description">Description</Label>
|
||||
<Input id="cfg-description" bind:value={formDescription} placeholder="Common settings for web services" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="cfg-network">Network mode</Label>
|
||||
<Select.Root type="single" value={formNetworkMode} onValueChange={(v) => formNetworkMode = v}>
|
||||
<Select.Trigger class="w-full">
|
||||
<span>{formNetworkMode === 'bridge' ? 'Bridge' : formNetworkMode === 'host' ? 'Host' : 'None'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="bridge" label="Bridge" />
|
||||
<Select.Item value="host" label="Host" />
|
||||
<Select.Item value="none" label="None" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="cfg-restart">Restart policy</Label>
|
||||
<Select.Root type="single" value={formRestartPolicy} onValueChange={(v) => formRestartPolicy = v}>
|
||||
<Select.Trigger class="w-full">
|
||||
<span>{formRestartPolicy === 'no' ? 'No' : formRestartPolicy === 'always' ? 'Always' : formRestartPolicy === 'on-failure' ? 'On failure' : 'Unless stopped'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="no" label="No" />
|
||||
<Select.Item value="always" label="Always" />
|
||||
<Select.Item value="on-failure" label="On failure" />
|
||||
<Select.Item value="unless-stopped" label="Unless stopped" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Variables -->
|
||||
<div class="space-y-2 border-t pt-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<Label class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Environment variables</Label>
|
||||
<Button type="button" size="sm" variant="ghost" onclick={addEnvVar} class="h-7 text-xs">
|
||||
<Plus class="w-3.5 h-3.5 mr-1" />Add
|
||||
</Button>
|
||||
</div>
|
||||
{#each formEnvVars as envVar, i}
|
||||
<div class="flex gap-2 items-center">
|
||||
<Input bind:value={envVar.key} placeholder="KEY" class="flex-1 h-8" />
|
||||
<Input bind:value={envVar.value} placeholder="value" class="flex-1 h-8" />
|
||||
<Button type="button" size="icon" variant="ghost" onclick={() => removeEnvVar(i)} disabled={formEnvVars.length === 1} class="h-8 w-8">
|
||||
<Trash2 class="w-3 h-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Labels -->
|
||||
<div class="space-y-2 border-t pt-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<Label class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Labels</Label>
|
||||
<Button type="button" size="sm" variant="ghost" onclick={addLabel} class="h-7 text-xs">
|
||||
<Plus class="w-3.5 h-3.5 mr-1" />Add
|
||||
</Button>
|
||||
</div>
|
||||
{#each formLabels as label, i}
|
||||
<div class="flex gap-2 items-center">
|
||||
<Input bind:value={label.key} placeholder="label.key" class="flex-1 h-8" />
|
||||
<Input bind:value={label.value} placeholder="value" class="flex-1 h-8" />
|
||||
<Button type="button" size="icon" variant="ghost" onclick={() => removeLabel(i)} disabled={formLabels.length === 1} class="h-8 w-8">
|
||||
<Trash2 class="w-3 h-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Ports -->
|
||||
<div class="space-y-2 border-t pt-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<Label class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Port mappings</Label>
|
||||
<Button type="button" size="sm" variant="ghost" onclick={addPort} class="h-7 text-xs">
|
||||
<Plus class="w-3.5 h-3.5 mr-1" />Add
|
||||
</Button>
|
||||
</div>
|
||||
{#each formPorts as port, i}
|
||||
<div class="grid grid-cols-[1fr_1fr_5rem_auto] gap-2 items-start">
|
||||
<div>
|
||||
<Input
|
||||
bind:value={port.hostPort}
|
||||
placeholder="Host port"
|
||||
class="h-8 {hasPortError(i, 'host') ? 'border-destructive focus-visible:ring-destructive' : ''}"
|
||||
oninput={() => validatePort(i, 'host')}
|
||||
/>
|
||||
{#if hasPortError(i, 'host')}
|
||||
<p class="text-xs text-destructive mt-0.5">Invalid port (1-65535)</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
bind:value={port.containerPort}
|
||||
placeholder="Container port"
|
||||
class="h-8 {hasPortError(i, 'container') ? 'border-destructive focus-visible:ring-destructive' : ''}"
|
||||
oninput={() => validatePort(i, 'container')}
|
||||
/>
|
||||
{#if hasPortError(i, 'container')}
|
||||
<p class="text-xs text-destructive mt-0.5">Invalid port (1-65535)</p>
|
||||
{/if}
|
||||
</div>
|
||||
<ToggleGroup
|
||||
value={port.protocol}
|
||||
options={protocolOptions}
|
||||
onchange={(v) => { formPorts[i].protocol = v; formPorts = formPorts; }}
|
||||
/>
|
||||
<Button type="button" size="icon" variant="ghost" onclick={() => removePort(i)} disabled={formPorts.length === 1} class="h-8 w-8">
|
||||
<Trash2 class="w-3 h-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Volumes -->
|
||||
<div class="space-y-2 border-t pt-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<Label class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Volume mappings</Label>
|
||||
<Button type="button" size="sm" variant="ghost" onclick={addVolume} class="h-7 text-xs">
|
||||
<Plus class="w-3.5 h-3.5 mr-1" />Add
|
||||
</Button>
|
||||
</div>
|
||||
{#each formVolumes as vol, i}
|
||||
<div class="grid grid-cols-[1fr_1fr_5rem_auto] gap-2 items-center">
|
||||
<Input bind:value={vol.hostPath} placeholder="Host path" class="h-8" />
|
||||
<Input bind:value={vol.containerPath} placeholder="Container path" class="h-8" />
|
||||
<ToggleGroup
|
||||
value={vol.mode}
|
||||
options={volumeModeOptions}
|
||||
onchange={(v) => { formVolumes[i].mode = v; formVolumes = formVolumes; }}
|
||||
/>
|
||||
<Button type="button" size="icon" variant="ghost" onclick={() => removeVolume(i)} disabled={formVolumes.length === 1} class="h-8 w-8">
|
||||
<Trash2 class="w-3 h-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={handleClose}>Cancel</Button>
|
||||
<Button onclick={save} disabled={formSaving}>
|
||||
{#if formSaving}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else if isEditing}
|
||||
<Check class="w-4 h-4 mr-1" />
|
||||
{:else}
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
{isEditing ? 'Save' : 'Add'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
195
routes/settings/config-sets/ConfigSetsTab.svelte
Normal file
195
routes/settings/config-sets/ConfigSetsTab.svelte
Normal file
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Plus, Trash2, Pencil, Layers } from 'lucide-svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import ConfigSetModal from './ConfigSetModal.svelte';
|
||||
import { EmptyState } from '$lib/components/ui/empty-state';
|
||||
|
||||
// Config set types
|
||||
interface ConfigSet {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
envVars: { key: string; value: string }[];
|
||||
labels: { key: string; value: string }[];
|
||||
ports: { hostPort: string; containerPort: string; protocol: string }[];
|
||||
volumes: { hostPath: string; containerPath: string; mode: string }[];
|
||||
networkMode: string;
|
||||
restartPolicy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Config set state
|
||||
let configSets = $state<ConfigSet[]>([]);
|
||||
let cfgLoading = $state(true);
|
||||
let showCfgModal = $state(false);
|
||||
let editingCfg = $state<ConfigSet | null>(null);
|
||||
let confirmDeleteConfigSetId = $state<number | null>(null);
|
||||
|
||||
async function fetchConfigSets() {
|
||||
cfgLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/config-sets');
|
||||
configSets = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config sets:', error);
|
||||
toast.error('Failed to fetch config sets');
|
||||
} finally {
|
||||
cfgLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCfgModal(cfg?: ConfigSet) {
|
||||
editingCfg = cfg || null;
|
||||
showCfgModal = true;
|
||||
}
|
||||
|
||||
async function deleteConfigSet(id: number) {
|
||||
try {
|
||||
const response = await fetch(`/api/config-sets/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await fetchConfigSets();
|
||||
toast.success('Config set deleted');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
toast.error(data.error || 'Failed to delete config set');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete config set');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchConfigSets();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Card.Root class="border-dashed">
|
||||
<Card.Content class="pt-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Layers class="w-5 h-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p class="text-sm font-medium">What are config sets?</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
Config sets are reusable templates for container configuration. Define common environment variables, labels, ports, and volumes once, then apply them when creating or editing containers. Values from config sets can be overwritten during container creation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge variant="secondary" class="text-xs">{configSets.length} total</Badge>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if $canAccess('configsets', 'create')}
|
||||
<Button size="sm" onclick={() => openCfgModal()}>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
Add config set
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" variant="outline" onclick={fetchConfigSets}>Refresh</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if cfgLoading && configSets.length === 0}
|
||||
<p class="text-muted-foreground text-sm">Loading config sets...</p>
|
||||
{:else if configSets.length === 0}
|
||||
<EmptyState
|
||||
icon={Layers}
|
||||
title="No config sets found"
|
||||
description="Create a reusable config set to get started"
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each configSets as cfg (cfg.id)}
|
||||
<div out:fade={{ duration: 200 }}>
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-2">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Layers class="w-5 h-5 text-muted-foreground" />
|
||||
<Card.Title class="text-base">{cfg.name}</Card.Title>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-3">
|
||||
{#if cfg.description}
|
||||
<p class="text-sm text-muted-foreground">{cfg.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#if cfg.envVars && cfg.envVars.length > 0}
|
||||
<Badge variant="outline" class="text-xs">{cfg.envVars.length} env vars</Badge>
|
||||
{/if}
|
||||
{#if cfg.labels && cfg.labels.length > 0}
|
||||
<Badge variant="outline" class="text-xs">{cfg.labels.length} labels</Badge>
|
||||
{/if}
|
||||
{#if cfg.ports && cfg.ports.length > 0}
|
||||
<Badge variant="outline" class="text-xs">{cfg.ports.length} ports</Badge>
|
||||
{/if}
|
||||
{#if cfg.volumes && cfg.volumes.length > 0}
|
||||
<Badge variant="outline" class="text-xs">{cfg.volumes.length} volumes</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground">
|
||||
<span>Network: {cfg.networkMode}</span>
|
||||
<span class="mx-1">|</span>
|
||||
<span>Restart: {cfg.restartPolicy}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
{#if $canAccess('configsets', 'edit')}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openCfgModal(cfg)}
|
||||
>
|
||||
<Pencil class="w-3 h-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
{/if}
|
||||
{#if $canAccess('configsets', 'delete')}
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteConfigSetId === cfg.id}
|
||||
action="Delete"
|
||||
itemType="config set"
|
||||
itemName={cfg.name}
|
||||
title="Remove"
|
||||
position="left"
|
||||
onConfirm={() => deleteConfigSet(cfg.id)}
|
||||
onOpenChange={(open) => confirmDeleteConfigSetId = open ? cfg.id : null}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfigSetModal
|
||||
bind:open={showCfgModal}
|
||||
configSet={editingCfg}
|
||||
onClose={() => { showCfgModal = false; editingCfg = null; }}
|
||||
onSaved={fetchConfigSets}
|
||||
/>
|
||||
Reference in New Issue
Block a user