jail: refactor creation from template into separate component

This commit is contained in:
hayzam
2026-03-20 02:00:55 +05:30
parent d429d6a356
commit 7db3cd460b
3 changed files with 262 additions and 121 deletions
@@ -0,0 +1,152 @@
<script lang="ts">
import { createJailFromTemplate } from '$lib/api/jail/jail';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { reload } from '$lib/stores/api.svelte';
import { watch } from 'runed';
import { toast } from 'svelte-sonner';
interface Props {
open: boolean;
templateId: number;
templateLabel: string;
sourceCtId?: number;
hostname?: string;
}
let {
open = $bindable(),
templateId,
templateLabel,
sourceCtId = 0,
hostname
}: Props = $props();
let createMode = $state<'single' | 'multiple'>('single');
let singleCTID = $state(sourceCtId || 0);
let singleName = $state('');
let multipleStartCTID = $state(sourceCtId || 0);
let multipleCount = $state(1);
let multipleNamePrefix = $state('');
let actionLoading = $state(false);
function normalizeTemplateName(label: string): string {
return label.replace(/\s*\((?:CT\s*)?\d+\)\s*$/i, '').trim();
}
let templateName = $derived.by(() => {
const cleaned = normalizeTemplateName(templateLabel);
return cleaned || `Template ${templateId}`;
});
function resetForm() {
createMode = 'single';
singleCTID = sourceCtId || 0;
singleName = '';
multipleStartCTID = sourceCtId || 0;
multipleCount = 1;
multipleNamePrefix = templateName;
}
watch(
() => open,
(isOpen) => {
if (isOpen) {
resetForm();
}
}
);
async function create() {
actionLoading = true;
const result =
createMode === 'single'
? await createJailFromTemplate(
templateId,
{
mode: 'single',
ctid: Number(singleCTID),
name: singleName || undefined
},
hostname
)
: await createJailFromTemplate(
templateId,
{
mode: 'multiple',
startCtid: Number(multipleStartCTID),
count: Number(multipleCount),
namePrefix: multipleNamePrefix || undefined
},
hostname
);
actionLoading = false;
if (result.error) {
toast.error('Failed to create jail from template', { position: 'bottom-center' });
return;
}
open = false;
reload.leftPanel = true;
toast.success('Create jail request queued', { position: 'bottom-center' });
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-lg">
<Dialog.Header class="p-0">
<Dialog.Title>Create Jail - Template {templateName}</Dialog.Title>
</Dialog.Header>
<div class="grid gap-4 py-2">
<div class="flex gap-2">
<Button
size="sm"
variant={createMode === 'single' ? 'default' : 'outline'}
onclick={() => (createMode = 'single')}>Single</Button
>
<Button
size="sm"
variant={createMode === 'multiple' ? 'default' : 'outline'}
onclick={() => (createMode = 'multiple')}>Multiple</Button
>
</div>
{#if createMode === 'single'}
<div class="grid gap-2">
<Label for={`single-ctid-${templateId}`}>CTID</Label>
<Input id={`single-ctid-${templateId}`} type="number" min="1" bind:value={singleCTID} />
</div>
<div class="grid gap-2">
<Label for={`single-name-${templateId}`}>Name (optional)</Label>
<Input id={`single-name-${templateId}`} bind:value={singleName} />
</div>
{:else}
<div class="grid gap-2">
<Label for={`multi-start-${templateId}`}>Starting CTID</Label>
<Input
id={`multi-start-${templateId}`}
type="number"
min="1"
bind:value={multipleStartCTID}
/>
</div>
<div class="grid gap-2">
<Label for={`multi-count-${templateId}`}>Count</Label>
<Input id={`multi-count-${templateId}`} type="number" min="1" bind:value={multipleCount} />
</div>
<div class="grid gap-2">
<Label for={`multi-prefix-${templateId}`}>Name Prefix</Label>
<Input id={`multi-prefix-${templateId}`} bind:value={multipleNamePrefix} />
</div>
{/if}
</div>
<Dialog.Footer>
<Button size="sm" disabled={actionLoading} onclick={() => void create()}>Create Jail</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -2,6 +2,7 @@
import { storage } from '$lib';
import { getNodes } from '$lib/api/cluster/cluster';
import { getAuditRecords } from '$lib/api/info/audit';
import { getSimpleJails, getSimpleJailTemplates } from '$lib/api/jail/jail';
import { getActiveLifecycleTasks } from '$lib/api/task/lifecycle';
import { getSimpleVMs } from '$lib/api/vm/vm';
import SimpleSelect from '$lib/components/custom/SimpleSelect.svelte';
@@ -9,6 +10,7 @@
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { reload } from '$lib/stores/api.svelte';
import type { ClusterNode } from '$lib/types/cluster/cluster';
import type { SimpleJail, SimpleJailTemplate } from '$lib/types/jail/jail';
import type { LifecycleTask } from '$lib/types/task/lifecycle';
import { updateCache } from '$lib/utils/http';
import { convertDbTime } from '$lib/utils/time';
@@ -71,14 +73,38 @@
);
const simpleVmList = resource(
() => 'simple-vm-list',
() => `simple-vm-list-${effectiveHostname || 'default'}`,
async (key, prevKey, { signal }) => {
const results = await getSimpleVMs();
const results = await getSimpleVMs(effectiveHostname || undefined);
updateCache(key, results);
return results;
}
);
const simpleJails = resource(
() => `simple-jail-list-${effectiveHostname || 'default'}`,
async (key, prevKey, { signal }) => {
const results = await getSimpleJails(effectiveHostname || undefined);
updateCache(key, results);
return results;
},
{
initialValue: [] as SimpleJail[]
}
);
const simpleJailTemplates = resource(
() => `simple-jail-template-list-${effectiveHostname || 'default'}`,
async (key, prevKey, { signal }) => {
const results = await getSimpleJailTemplates(effectiveHostname || undefined);
updateCache(key, results);
return results;
},
{
initialValue: [] as SimpleJailTemplate[]
}
);
const activeLifecycleTasks = resource(
() => `active-lifecycle-tasks-${effectiveHostname || 'default'}`,
async () => {
@@ -166,8 +192,8 @@
'/api/jail/network/disinheritance': 'Jail - Network Disinherit',
'/api/jail/network': 'Jail Network',
'/api/jail/description': 'Jail - Update Description',
'/api/jail/templates/convert': 'Jail Template - Convert',
'/api/jail/templates/create': 'Jail Template - Create Jail',
'/api/jail/templates/convert': 'Create Jail Template - From Jail',
'/api/jail/templates/create': 'Create Jail - Template',
'/api/jail/templates': 'Jail Template',
'/api/jail': 'Jail',
'/api/utilities/cloud-init/templates': 'Cloud Init Template',
@@ -248,6 +274,15 @@
let activeLifecycleCount = $derived(activeLifecycleTasks.current.length);
let lifecycleActive = $derived(activeLifecycleCount > 0);
let vmNameById = $derived.by(() => {
return new Map((simpleVmList.current || []).map((vm) => [vm.rid, vm.name]));
});
let jailNameByCtId = $derived.by(() => {
return new Map((simpleJails.current || []).map((jail) => [jail.ctId, jail.name]));
});
let templateNameById = $derived.by(() => {
return new Map((simpleJailTemplates.current || []).map((template) => [template.id, template.name]));
});
watch(
() => lifecycleActive,
@@ -256,14 +291,67 @@
}
);
function toTitleCase(value: string): string {
return value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function lifecycleActionLabel(action: string): string {
return toTitleCase(action.replace(/[_-]+/g, ' ')) || 'Working';
}
function lifecycleStatusLabel(status: LifecycleTask['status']): string {
switch (status) {
case 'queued':
return 'Queued';
case 'running':
return 'Running';
case 'success':
return 'Success';
case 'failed':
return 'Failed';
default:
return toTitleCase(status);
}
}
function lifecycleGuestLabel(task: LifecycleTask): string {
const prefix =
task.guestType === 'vm'
? 'VM'
: task.guestType === 'jail-template'
? 'Jail Template'
: 'Jail';
return `${prefix} ${task.guestId}`;
if (task.guestType === 'vm') {
const name = vmNameById.get(task.guestId);
return name ? `VM ${name} (${task.guestId})` : `VM ${task.guestId}`;
}
if (task.guestType === 'jail-template') {
const templateName = templateNameById.get(task.guestId);
return templateName
? `Template ${templateName} (${task.guestId})`
: `Jail Template ${task.guestId}`;
}
const jailName = jailNameByCtId.get(task.guestId);
return jailName ? `Jail ${jailName} (${task.guestId})` : `Jail ${task.guestId}`;
}
function lifecycleTaskLabel(task: LifecycleTask): string {
if (task.source.includes('jail/templates/create')) {
const templateName = templateNameById.get(task.guestId);
return templateName
? `Create Jail - Template ${templateName}`
: `Create Jail - Template ${task.guestId}`;
}
if (task.source.includes('jail/templates/convert')) {
const jailName = jailNameByCtId.get(task.guestId);
return jailName
? `Create Jail Template - ${jailName} (Jail CTID ${task.guestId})`
: `Create Jail Template - Jail CTID ${task.guestId}`;
}
return `${lifecycleActionLabel(task.action)} - ${lifecycleGuestLabel(task)}`;
}
export function formatStatus(status: string): string {
@@ -294,13 +382,12 @@
<span class="inline-flex items-center gap-1 font-medium">
<span class="icon-[mdi--loading] h-3.5 w-3.5 animate-spin"></span>
{activeLifecycleCount}
lifecycle action{activeLifecycleCount === 1 ? '' : 's'} in progress
active lifecycle task{activeLifecycleCount === 1 ? '' : 's'}
</span>
{#each activeLifecycleTasks.current as task (task.id)}
<span class="bg-background rounded border px-2 py-0.5">
{lifecycleGuestLabel(task)}
{task.action} ({task.status})
{lifecycleTaskLabel(task)} ({lifecycleStatusLabel(task.status)})
</span>
{/each}
</div>
@@ -3,14 +3,10 @@
import { page } from '$app/state';
import {
convertJailToTemplate,
createJailFromTemplate,
deleteJailTemplate,
jailAction
} from '$lib/api/jail/jail';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import CreateFromTemplate from '$lib/components/custom/Jail/CreateFromTemplate.svelte';
import { actionVm } from '$lib/api/vm/vm';
import * as ContextMenu from '$lib/components/ui/context-menu/index.js';
import { reload } from '$lib/stores/api.svelte';
@@ -77,13 +73,6 @@
return segments[segments.length - 1];
});
let createFromTemplateOpen = $state(false);
let createMode = $state<'single' | 'multiple'>('single');
let singleCTID = $state(item.sourceCtId || 0);
let singleName = $state('');
let multipleStartCTID = $state(item.sourceCtId || 0);
let multipleCount = $state(1);
let multipleNamePrefix = $state(item.label.replace(/\s*\(CT\s*\d+\)$/i, ''));
let actionLoading = $state(false);
const handleActionClick = async (action: 'start' | 'reboot' | 'shutdown' | 'stop') => {
if (item.resourceId === undefined || item.resourceType === undefined) {
@@ -109,9 +98,7 @@
const handleConvertToTemplate = async () => {
if (!item.resourceId) return;
actionLoading = true;
const result = await convertJailToTemplate(item.resourceId, item.nodeHostname);
actionLoading = false;
if (result.error) {
toast.error('Failed to convert jail to template', { position: 'bottom-center' });
return;
@@ -123,9 +110,7 @@
const handleDeleteTemplate = async () => {
if (!item.resourceId) return;
if (!confirm(`Delete template "${item.label}"?`)) return;
actionLoading = true;
const result = await deleteJailTemplate(item.resourceId, item.nodeHostname);
actionLoading = false;
if (result.error) {
toast.error('Failed to delete template', { position: 'bottom-center' });
return;
@@ -134,39 +119,6 @@
toast.success('Template deleted', { position: 'bottom-center' });
};
const handleCreateFromTemplate = async () => {
if (!item.resourceId) return;
actionLoading = true;
const result =
createMode === 'single'
? await createJailFromTemplate(
item.resourceId,
{
mode: 'single',
ctid: Number(singleCTID),
name: singleName || undefined
},
item.nodeHostname
)
: await createJailFromTemplate(
item.resourceId,
{
mode: 'multiple',
startCtid: Number(multipleStartCTID),
count: Number(multipleCount),
namePrefix: multipleNamePrefix || undefined
},
item.nodeHostname
);
actionLoading = false;
if (result.error) {
toast.error('Failed to create jail from template', { position: 'bottom-center' });
return;
}
createFromTemplateOpen = false;
reload.leftPanel = true;
toast.success('Template restore job queued', { position: 'bottom-center' });
};
</script>
<li class="w-full">
@@ -313,62 +265,12 @@
</ul>
{/if}
{#if item.resourceType === 'jail-template'}
<Dialog.Root bind:open={createFromTemplateOpen}>
<Dialog.Content class="max-w-lg">
<Dialog.Header class="p-0">
<Dialog.Title>Create Jail From Template</Dialog.Title>
</Dialog.Header>
<div class="grid gap-4 py-2">
<div class="flex gap-2">
<Button
size="sm"
variant={createMode === 'single' ? 'default' : 'outline'}
onclick={() => (createMode = 'single')}>Single</Button
>
<Button
size="sm"
variant={createMode === 'multiple' ? 'default' : 'outline'}
onclick={() => (createMode = 'multiple')}>Multiple</Button
>
</div>
{#if createMode === 'single'}
<div class="grid gap-2">
<Label for={`single-ctid-${item.id}`}>CTID</Label>
<Input id={`single-ctid-${item.id}`} type="number" min="1" bind:value={singleCTID} />
</div>
<div class="grid gap-2">
<Label for={`single-name-${item.id}`}>Name (optional)</Label>
<Input id={`single-name-${item.id}`} bind:value={singleName} />
</div>
{:else}
<div class="grid gap-2">
<Label for={`multi-start-${item.id}`}>Starting CTID</Label>
<Input
id={`multi-start-${item.id}`}
type="number"
min="1"
bind:value={multipleStartCTID}
/>
</div>
<div class="grid gap-2">
<Label for={`multi-count-${item.id}`}>Count</Label>
<Input id={`multi-count-${item.id}`} type="number" min="1" bind:value={multipleCount} />
</div>
<div class="grid gap-2">
<Label for={`multi-prefix-${item.id}`}>Name Prefix</Label>
<Input id={`multi-prefix-${item.id}`} bind:value={multipleNamePrefix} />
</div>
{/if}
</div>
<Dialog.Footer>
<Button
size="sm"
disabled={actionLoading}
onclick={() => void handleCreateFromTemplate()}>Create Jail</Button
>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{#if item.resourceType === 'jail-template' && item.resourceId}
<CreateFromTemplate
bind:open={createFromTemplateOpen}
templateId={item.resourceId}
templateLabel={item.label}
sourceCtId={item.sourceCtId}
hostname={item.nodeHostname}
/>
{/if}