Files
nadir-webui/src/routes/dashboard/+page.svelte
T
2026-06-25 23:11:32 +02:00

367 lines
11 KiB
Svelte

<script lang="ts">
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import * as Table from '$lib/components/ui/table';
import { machineDeleteSchema, machineEditSchema, machineSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import {
addMachine,
allMachines,
deleteMachine,
listMachines,
machineHealth,
updateMachine
} from '$lib/remotes/machines.remote';
import { extractErrorMessage } from '$lib/utils';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
type Machine = { address: string; id: string; name: null | string };
type SortBy = 'address' | 'name';
type Dir = 'asc' | 'desc';
const id = $props.id();
const machines = $derived(allMachines());
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
const sortStore = new PersistedState<{ sortBy: SortBy; sortDir: Dir }>('machines.sort', {
sortBy: 'name',
sortDir: 'asc'
});
let sortBy = $state<SortBy>(sortStore.current.sortBy);
let sortDir = $state<Dir>(sortStore.current.sortDir);
$effect(() => {
sortStore.current = { sortBy, sortDir };
});
let page = $state(1);
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
function toggleSort(col: SortBy) {
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else {
sortBy = col;
sortDir = 'asc';
}
page = 1;
}
const filtered = $derived.by(() => {
const all = (machines.current ?? []) as Machine[];
const q = debouncedSearch.trim().toLowerCase();
const out = q
? all.filter((m) => m.name?.toLowerCase().includes(q) || m.address.toLowerCase().includes(q))
: all;
const dir = sortDir === 'asc' ? 1 : -1;
out.sort((a, b) => {
const av = a[sortBy] ?? '';
const bv = b[sortBy] ?? '';
return av < bv ? -1 * dir : av > bv ? 1 * dir : 0;
});
return out;
});
function handleError(e: unknown) {
console.error(e);
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
let addOpen = $state(false);
let editingMachine = $state<Machine | null>(null);
let editOpen = $state(false);
let deletingMachine = $state<Machine | null>(null);
let deleteOpen = $state(false);
async function afterMutate() {
allMachines().refresh();
listMachines({ page: 1, search: '' }).refresh();
}
</script>
<PageMeta title={m.seo_title_dashboard()} description={m.seo_desc_dashboard()} />
<DataTable
title={m.dashboard()}
description={m.dashboard_machines_description()}
searchPlaceholder={m.machine_search_placeholder()}
emptyMessage={m.machine_none()}
items={filtered}
loading={machines.loading}
pageSizeKey="machines.pageSize"
defaultPageSize={25}
pageSizePresets={[10, 25, 50, 100]}
bind:search
onsearchinput={onSearchInput}
onrefresh={() => machines.refresh()}
bind:page
i18n={{
display: m.dashboard_filter_display(),
filterTitle: m.dashboard_filter_title(),
next: () => m.dashboard_next(),
pageOf: (p) => m.dashboard_page_of(p),
previous: () => m.dashboard_prev(),
rowsPerPage: m.dashboard_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (addOpen = true)}>
<PlusIcon class="size-4" />
{m.machine_add()}
</Button>
{/snippet}
{#snippet columns()}
{#each [{ key: 'name', label: m.machine_name() }, { key: 'address', label: m.machine_address() }] as col (col.key)}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(col.key as SortBy)}
>
{col.label}
{#if sortBy === col.key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-50" />
{/if}
</button>
</Table.Head>
{/each}
<Table.Head>{m.dashboard_col_status()}</Table.Head>
<Table.Head class="w-12 text-right">{m.dashboard_col_actions()}</Table.Head>
{/snippet}
{#snippet row(machine: Machine)}
{@const health = machineHealth(machine.id).current}
<Table.Cell class="font-medium">
<a href={resolve('/dashboard/[machineId]', { machineId: machine.id })} class="hover:underline"
>{machine.name ?? machine.id.slice(0, 8)}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{machine.address}</Table.Cell>
<Table.Cell>
<span
class="inline-flex items-center gap-1.5 text-xs font-medium {health
? 'text-emerald-500'
: 'text-destructive'}"
>
<span class="size-1.5 rounded-full {health ? 'bg-emerald-500' : 'bg-destructive'}"></span>
{health ? m.machine_online() : m.machine_offline()}
</span>
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => {
editingMachine = machine;
editOpen = true;
}}>{m.machine_edit()}</DropdownMenu.Item
>
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deletingMachine = machine;
deleteOpen = true;
}}>{m.delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<Dialog.Root bind:open={addOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.machine_add()}</Dialog.Title>
<Dialog.Description>{m.machine_add_description()}</Dialog.Description>
</Dialog.Header>
<form
oninput={() => addMachine.validate()}
{...addMachine.preflight(machineSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.machine_add());
addOpen = false;
await afterMutate();
} catch (e) {
handleError(e);
}
})}
>
<Field.Group>
<Field.Field>
<Field.Label for="add-name-{id}">{m.machine_name()}</Field.Label>
<Input
id="add-name-{id}"
placeholder={m.machine_name_placeholder()}
{...addMachine.fields.name.as('text')}
required
/>
{#each addMachine.fields.name.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="add-address-{id}">{m.machine_address()}</Field.Label>
<Input
id="add-address-{id}"
placeholder={m.machine_address_placeholder()}
{...addMachine.fields.address.as('text')}
/>
{#each addMachine.fields.address.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="add-token-{id}">{m.machine_token()}</Field.Label>
<Input
id="add-token-{id}"
placeholder={m.machine_token_placeholder()}
{...addMachine.fields.token.as('password')}
required
/>
{#each addMachine.fields.token.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Button type="submit" disabled={!!addMachine.pending}>{m.machine_save()}</Button>
</Field.Group>
</form>
</Dialog.Content>
</Dialog.Root>
{#if editingMachine}
{@const editForm = updateMachine.for(editingMachine.id)}
<Dialog.Root bind:open={editOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.machine_edit()}</Dialog.Title>
<Dialog.Description>{m.machine_edit_description()}</Dialog.Description>
</Dialog.Header>
<form
oninput={() => editForm.validate()}
{...editForm.preflight(machineEditSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.machine_edit());
editOpen = false;
editingMachine = null;
await afterMutate();
} catch (e) {
handleError(e);
}
})}
>
<input type="hidden" name="id" value={editingMachine.id} />
<Field.Group>
<Field.Field>
<Field.Label for="edit-name-{id}">{m.machine_name()}</Field.Label>
<Input
id="edit-name-{id}"
placeholder={m.machine_name_placeholder()}
{...editForm.fields.name.as('text')}
value={editingMachine.name ?? ''}
required
/>
{#each editForm.fields.name.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="edit-address-{id}">{m.machine_address()}</Field.Label>
<Input
id="edit-address-{id}"
placeholder={m.machine_address_placeholder()}
{...editForm.fields.address.as('text')}
value={editingMachine.address}
/>
{#each editForm.fields.address.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="edit-token-{id}">{m.machine_token()}</Field.Label>
<Input
id="edit-token-{id}"
placeholder={m.machine_token_placeholder()}
{...editForm.fields.token.as('password')}
/>
<Field.Description>{m.machine_token_keep()}</Field.Description>
{#each editForm.fields.token.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Button type="submit" disabled={!!editForm.pending}>{m.machine_save_edit()}</Button>
</Field.Group>
</form>
</Dialog.Content>
</Dialog.Root>
{/if}
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.machine_delete_title()}</AlertDialog.Title>
<AlertDialog.Description>
{m.machine_delete_confirm({ name: deletingMachine?.name ?? '' })}
</AlertDialog.Description>
</AlertDialog.Header>
<form
{...deleteMachine.preflight(machineDeleteSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.machine_delete_title());
deleteOpen = false;
deletingMachine = null;
await afterMutate();
} catch (e) {
handleError(e);
}
})}
>
<input type="hidden" name="id" value={deletingMachine?.id} />
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action type="submit" disabled={!!deleteMachine.pending}>
{m.delete()}
</AlertDialog.Action>
</AlertDialog.Footer>
</form>
</AlertDialog.Content>
</AlertDialog.Root>