Files
Sylve/web/src/lib/components/skeleton/BottomPanel.svelte
T

482 lines
15 KiB
Svelte

<script lang="ts">
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';
import * as Table from '$lib/components/ui/table/index.js';
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';
import { resource, useInterval, watch } from 'runed';
import { toast } from 'svelte-sonner';
import { fade } from 'svelte/transition';
interface Props {
clustered?: boolean;
onLifecycleActiveChange?: (active: boolean) => void;
}
let { clustered = false, onLifecycleActiveChange }: Props = $props();
let selectedHostname = $state(storage.hostname || '');
const effectiveHostname = $derived(selectedHostname || storage.hostname || '');
const clusterNodes = resource(
() => `cluster-nodes-for-audit-${clustered ? 'clustered' : 'single'}`,
async () => {
if (!clustered) {
return [];
}
return await getNodes();
},
{
initialValue: [] as ClusterNode[]
}
);
const hostnameOptions = $derived.by(() => {
const names = new Set<string>();
if (storage.hostname) {
names.add(storage.hostname);
}
for (const node of clusterNodes.current) {
if (node.hostname) {
names.add(node.hostname);
}
}
return Array.from(names)
.sort((a, b) => a.localeCompare(b))
.map((hostname) => ({
value: hostname,
label: hostname
}));
});
const auditRecords = resource(
() => `audit-record-${effectiveHostname || 'default'}`,
async (key, prevKey, { signal }) => {
const results = await getAuditRecords(effectiveHostname || undefined);
updateCache(key, results);
return results;
}
);
const simpleVmList = resource(
() => `simple-vm-list-${effectiveHostname || 'default'}`,
async (key, prevKey, { signal }) => {
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 () => {
return await getActiveLifecycleTasks(undefined, undefined, effectiveHostname || undefined);
},
{
initialValue: [] as LifecycleTask[]
}
);
useInterval(() => 2000, {
callback: () => {
activeLifecycleTasks.refetch();
}
});
watch(
() => reload.auditLog,
(value) => {
if (value) {
auditRecords.refetch().then(() => {
reload.auditLog = false;
});
}
}
);
watch(
() => storage.hostname,
(hostname) => {
if (!selectedHostname && hostname) {
selectedHostname = hostname;
}
}
);
const pathToActionMap: Record<string, string> = $derived({
'/api/auth/passkeys/login/begin': 'Login - Passkey - Begin',
'/api/auth/passkeys/login/finish': 'Login - Passkey - Finish',
'/api/auth/passkeys/register/begin': 'Passkey - Register - Begin',
'/api/auth/passkeys/register/finish': 'Passkey - Register - Finish',
'/api/auth/passkeys/users': 'Passkey',
'/api/auth/login': 'Login',
'/api/info/notes': 'Notes',
'/api/network/switch': 'Standard Switch',
'/api/vnc': 'VNC',
'/api/disk/initialize-gpt': 'Disk - Initialize GPT',
'/api/disk/wipe': 'Disk - Wipe',
'/api/network/object': 'Network Object',
'/api/network/dhcp/range': 'DHCP Range',
'/api/network/dhcp/lease': 'DHCP Lease',
'/api/system/file-explorer/delete': 'File Explorer - Delete',
'/api/system/file-explorer': 'File Explorer',
'/api/system/ppt-devices': 'PCI Passthrough',
'/api/zfs/datasets/filesystem': 'ZFS Filesystem',
'/api/zfs/datasets/volume': 'ZFS Volume',
'/api/samba/shares': 'Samba Share',
'/api/auth/groups': 'Auth Group',
'/api/auth/users': 'Auth User',
'/api/samba/config': 'Samba Config - Edit',
'/api/zfs/datasets/bulk-delete': 'ZFS Dataset - Bulk Delete',
'/api/zfs/datasets/snapshot': 'ZFS Snapshot',
'/api/vm/start': 'VM - Start',
'/api/vm/stop': 'VM - Stop',
'/api/vm/shutdown': 'VM - Shutdown',
'/api/vm/reboot': 'VM - Reboot',
'/api/vm/description': 'VM - Update Description',
'/api/jail/action/start': 'Jail - Start',
'/api/jail/action/stop': 'Jail - Stop',
'/api/utilities/downloads/signed-url': 'Downloader - Create Signed URL',
'/api/utilities/download': 'Downloader',
'/api/vm/storage/detach': 'VM Storage - Detach',
'/api/vm/storage/attach': 'VM Storage - Attach',
'/api/vm/network/detach': 'VM Network - Detach',
'/api/vm/network/attach': 'VM Network - Attach',
'/api/vm/snapshots/rollback': 'VM Snapshot - Rollback',
'/api/vm/snapshots': 'VM Snapshot',
'/api/vm': 'VM',
'/api/network/manual-switch': 'Manual Switch',
'/api/zfs/pools': 'ZFS Pool',
'/api/disk/create-partitions': 'Disk - Create Partitions',
'/api/jail/snapshots/rollback': 'Jail Snapshot - Rollback',
'/api/jail/snapshots': 'Jail Snapshot',
'/api/jail/network/inheritance': 'Jail - Network Inherit',
'/api/jail/network/disinheritance': 'Jail - Network Disinherit',
'/api/jail/network': 'Jail Network',
'/api/jail/description': 'Jail - Update Description',
'/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',
'/api/system/basic-settings/pools': 'Basic Settings - ZFS Pools',
'/api/system/basic-settings/services/dhcp-server/toggle': 'Toggle - DHCP Server',
'/api/system/basic-settings/services/wol-server/toggle': 'Toggle - WoL Server',
'/api/system/basic-settings/services/samba-server/toggle': 'Toggle - Samba Server',
'/api/system/basic-settings/services/jails/toggle': 'Toggle - Jails',
'/api/system/basic-settings/services/virtualization/toggle': 'Toggle - Virtualization',
'/api/cluster/notes': 'DC Notes',
'/api/cluster/reset-node': 'Cluster - Reset Node',
'/api/cluster/backups/targets/validate': 'DC Backup Target - Validate',
'/api/cluster/backups/targets': 'DC Backup Target',
'/api/cluster/backups/jobs/run': 'DC Backup Job - Run',
'/api/cluster/backups/jobs': 'DC Backup Job',
'/api/cluster': 'Cluster'
});
let records = $derived.by(() => {
if (!auditRecords.current) return [];
return auditRecords.current.map((record) => {
const recordCopy = $state.snapshot(record);
const path = recordCopy.action?.path || '';
const method = recordCopy.action?.method || '';
let resolvedAction = method;
const matchedEntry = Object.entries(pathToActionMap).find(([prefix]) =>
path.startsWith(prefix)
);
if (matchedEntry) {
const label = matchedEntry[1];
if (!label.includes('-')) {
switch (method.toUpperCase()) {
case 'GET':
if (path.includes('vnc')) {
const port = path.split('/').pop();
const vm = simpleVmList.current?.find((vm) => vm.vncPort === Number(port));
resolvedAction = `${label} - ${vm ? vm.name : 'Unknown VM'} (${port})`;
} else {
resolvedAction = `${label} - View`;
}
break;
case 'POST':
resolvedAction = `${label} - Create`;
break;
case 'PUT':
case 'PATCH':
resolvedAction = `${label} - Update`;
break;
case 'DELETE':
resolvedAction = `${label} - Delete`;
recordCopy.action.body = {
id: record.id
};
break;
default:
resolvedAction = label;
}
} else {
resolvedAction = label;
}
}
if (resolvedAction === 'Login - Create') {
resolvedAction = 'Login';
}
return {
...recordCopy,
resolvedAction
};
});
});
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,
(active) => {
onLifecycleActiveChange?.(active);
}
);
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 {
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 {
switch (status) {
case 'started':
return 'Started';
case 'success':
return 'OK';
case 'client_error':
return 'Bad Request';
case 'server_error':
return 'Error';
default:
return status;
}
}
</script>
<Tabs.Root value="cluster" class="flex h-full w-full flex-col">
<Tabs.Content value="cluster" class="flex h-full flex-col border-x border-b">
<div
class="relative flex h-full flex-col overflow-hidden"
transition:fade|global={{ duration: 400 }}
>
{#if activeLifecycleCount > 0}
<div class="bg-muted/35 border-b px-3 py-1.5 text-xs">
<div class="flex items-center gap-2 overflow-x-auto whitespace-nowrap">
<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}
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">
{lifecycleTaskLabel(task)} ({lifecycleStatusLabel(task.status)})
</span>
{/each}
</div>
</div>
{/if}
<Table.Root class="w-full table-auto border-collapse">
<Table.Header class="bg-background sticky top-0 z-50">
<Table.Row class="dark:hover:bg-background ">
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white"
>Start Time</Table.Head
>
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white"
>End Time</Table.Head
>
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white">
{#if clustered && hostnameOptions.length > 0}
<div class="w-44 max-w-full">
<SimpleSelect
placeholder="Node"
options={hostnameOptions}
value={effectiveHostname}
onChange={(value: string) => {
selectedHostname = value;
}}
classes={{
parent: 'min-w-0 space-y-0',
trigger:
'inline-flex h-6 w-full items-center overflow-hidden rounded-sm border-0 bg-transparent px-1.5 text-left text-xs font-medium text-muted-foreground shadow-none ring-0 hover:bg-muted/40 focus:bg-muted/50'
}}
/>
</div>
{:else}
Node
{/if}
</Table.Head>
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white"
>User</Table.Head
>
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white"
>Action</Table.Head
>
<Table.Head class="h-10 px-4 py-2 font-semibold text-black dark:text-white"
>Status</Table.Head
>
</Table.Row>
</Table.Header>
<Table.Body class="grow overflow-auto pb-32">
{#each records as record, i (i)}
<Table.Row>
<Table.Cell class="text-wrap px-4 py-2">{convertDbTime(record.started)}</Table.Cell>
<Table.Cell class="text-wrap px-4 py-2">{convertDbTime(record.ended)}</Table.Cell>
<Table.Cell class="text-wrap px-4 py-2">{record.node}</Table.Cell>
<Table.Cell class="text-wrap px-4 py-2"
>{`${record.user}@${record.authType || 'cluster'}`}</Table.Cell
>
<Table.Cell class="text-wrap px-4 py-2" title={JSON.stringify(record.action.body)}
>{record.resolvedAction}</Table.Cell
>
<Table.Cell
class="text-wrap px-4 py-2"
title={record.action?.response != null
? typeof record.action.response === 'string'
? record.action.response
: JSON.stringify(record.action.response)
: 'No response'}
onclick={() => {
if (record.action?.response != null && record.action.response) {
try {
const data = JSON.stringify(record.action.response);
navigator.clipboard.writeText(data || '');
toast.success('Copied response to clipboard', {
position: 'bottom-center'
});
} catch (e) {
console.log('Error copying resposnse to clipboard', e);
}
}
}}
>
{formatStatus(record.status)}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</Tabs.Content>
</Tabs.Root>