367 lines
11 KiB
Svelte
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>
|