Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

View 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>

View 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}
/>