stabilized architecture

This commit is contained in:
2026-06-25 14:46:48 +02:00
parent 522ab09bd8
commit 20ac1445ca
49 changed files with 2925 additions and 3192 deletions
+1 -2
View File
@@ -16,10 +16,9 @@
let { children } = $props();
const user = $derived(getUser());
let showSidebar = $derived(
const showSidebar =
(cU: null | User) =>
cU && (page.url.pathname.startsWith('/dashboard') || page.url.pathname.startsWith('/admin'))
);
class TerminalState {
open = $state(false);
+2 -1
View File
@@ -33,6 +33,7 @@
unbanUser,
updateUser
} from '$lib/remotes/users.remote';
import { extractErrorMessage } from '$lib/utils';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
@@ -147,7 +148,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
function openEdit(u: UserRow) {
@@ -1,34 +0,0 @@
import { error } from '@sveltejs/kit';
import { auth } from '$lib/auth/server';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, platform, request }) => {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session) {
error(401, 'Unauthorized');
}
const machineId = params.machineId;
if (
request.headers.get('connection')?.toLowerCase().includes('upgrade') &&
request.headers.get('upgrade')?.toLowerCase() === 'websocket'
) {
if (platform?.server && platform?.request) {
const upgraded = await platform.server.upgrade(platform.request, {
data: { machineId }
});
if (upgraded) {
return new Response(null, { status: 101 });
}
}
error(500, 'Upgrade failed');
}
error(400, 'Expected websocket upgrade request');
};
+3 -2
View File
@@ -8,6 +8,7 @@
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { requestReset } from '$lib/remotes/auth.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -27,7 +28,7 @@
</Card.Header>
<Card.Content>
{#if requestReset.result?.sent}
<Button href="/auth/sign-in" variant="outline" class="w-full">{m.back_to_login()}</Button>
<Button href={resolve("/auth/sign-in")} variant="outline" class="w-full">{m.back_to_login()}</Button>
{:else}
<form
oninput={() => requestReset.validate()}
@@ -37,7 +38,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
+2 -1
View File
@@ -8,6 +8,7 @@
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { resetPassword } from '$lib/remotes/auth.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -36,7 +37,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
+2 -1
View File
@@ -12,6 +12,7 @@
import { m } from '$lib/paraglide/messages';
import { getOAuthProviders, login } from '$lib/remotes/auth.remote';
import { cn } from '$lib/utils.js';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -36,7 +37,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
+3 -2
View File
@@ -8,6 +8,7 @@
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { register } from '$lib/remotes/auth.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -23,7 +24,7 @@
>
</Card.Header>
<Card.Footer>
<Button href="/auth/sign-in" variant="outline" class="w-full">{m.back_to_login()}</Button>
<Button href={resolve("/auth/sign-in")} variant="outline" class="w-full">{m.back_to_login()}</Button>
</Card.Footer>
</Card.Root>
{:else}
@@ -41,7 +42,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
@@ -61,6 +61,7 @@
systemDetails,
updateAgent
} from '$lib/remotes/server.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
@@ -324,7 +325,7 @@
if (!done) toast.error(m.agent_update_failed());
} catch (e) {
toast.error(
(e as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(e) ?? m.errors_generic()
);
} finally {
updating = false;
@@ -442,7 +443,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
@@ -513,7 +514,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
@@ -2,11 +2,10 @@
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 ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
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 { Badge } from '$lib/components/ui/badge';
@@ -15,11 +14,10 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { deleteHost, listHosts, upsertHost } from '$lib/remotes/networking.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type Host = { hostnames: null | string[]; ip: string };
@@ -28,8 +26,6 @@
const machineId = $derived(pageState.params.machineId!);
const hosts = $derived(listHosts(machineId));
const pageSize = new PersistedState<number>('networking.hosts.pageSize', 25);
let page = $state(1);
let search = $state('');
let editOpen = $state(false);
let editForm = $state({ hostnames: '', ip: '' });
@@ -39,7 +35,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
type SortKey = 'hostnames' | 'ip';
@@ -68,12 +64,6 @@
return matched;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) page = totalPages;
});
function openAdd() {
editingExisting = false;
editForm = { hostnames: '', ip: '' };
@@ -113,177 +103,90 @@
</script>
<PageMeta title={m.seo_title_networking_hosts()} description={m.seo_desc_networking_hosts()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
{#snippet sortHead(key: string, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key as SortKey)}
>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="truncate text-2xl font-semibold tracking-tight">{m.nav_networking_hosts()}</h1>
<p class="text-muted-foreground truncate text-sm">{m.networking_hosts_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => hosts.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
{/snippet}
<DataTable
title={m.nav_networking_hosts()}
description={m.networking_hosts_description()}
searchPlaceholder={m.networking_hosts_search_placeholder()}
searchHint={`${filtered.length} / ${hosts.current?.length ?? 0}`}
emptyMessage={m.networking_no_hosts()}
items={filtered}
loading={hosts.loading && !hosts.current}
pageSizeKey="networking.hosts.pageSize"
pageSizePresets={[10, 25, 50, 100]}
bind:search
onrefresh={() => hosts.refresh()}
i18n={{
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={openAdd}>
<PlusIcon class="size-4" />
{m.networking_host_add()}
</Button>
{/snippet}
{#snippet columns()}
<Table.Head class="w-56">{@render sortHead('ip', m.networking_col_ip())}</Table.Head>
<Table.Head>{@render sortHead('hostnames', m.networking_col_hostnames())}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(h: Host)}
<Table.Cell class="font-mono text-xs">{h.ip}</Table.Cell>
<Table.Cell class="flex flex-wrap gap-1">
{#each h.hostnames ?? [] as n (n)}
<Badge variant="outline" class="font-mono">{n}</Badge>
{/each}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
<ListFilterIcon class="size-4" />
{m.users_filter()}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
</div>
<div class="flex items-center justify-between p-2">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="net-hosts-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="net-hosts-rpp-{id}">
{#each [10, 25, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={openAdd}>
<PlusIcon class="size-4" />
{m.networking_host_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.networking_hosts_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {hosts.current?.length ?? 0}
</span>
</div>
{#snippet sortHead(key: SortKey, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key)}
>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(h)}>{m.edit()}</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = h;
deleteOpen = true;
}}
>
{m.networking_host_remove()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-56">{@render sortHead('ip', m.networking_col_ip())}</Table.Head>
<Table.Head>{@render sortHead('hostnames', m.networking_col_hostnames())}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if hosts.loading && !hosts.current}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center">
{m.networking_no_hosts()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as h,i (h.ip,i)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{h.ip}</Table.Cell>
<Table.Cell class="flex flex-wrap gap-1">
{#each h.hostnames ?? [] as n (n)}
<Badge variant="outline" class="font-mono">{n}</Badge>
{/each}
</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={() => openEdit(h)}>{m.edit()}</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = h;
deleteOpen = true;
}}
>
{m.networking_host_remove()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="mt-auto flex items-center justify-end gap-4 p-4">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
</DataTable>
<Dialog.Root bind:open={editOpen}>
<Dialog.Content>
@@ -3,19 +3,16 @@
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import CheckIcon from '@lucide/svelte/icons/check';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {
@@ -26,22 +23,19 @@
listInterfaces,
rollbackChange
} from '$lib/remotes/networking.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const interfaces = $derived(listInterfaces(machineId));
const pending = $derived(getPending(machineId));
const pageSize = new PersistedState<number>('networking.interfaces.pageSize', 25);
let page = $state(1);
let search = $state('');
let busy = $state<null | string>(null);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function run(key: string, fn: () => Promise<unknown>, success: string) {
@@ -86,13 +80,6 @@
});
return matched;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) page = totalPages;
});
</script>
<PageMeta
@@ -100,70 +87,6 @@
description={m.seo_desc_networking_interfaces()}
/>
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="truncate text-2xl font-semibold tracking-tight">
{m.nav_networking_interfaces()}
</h1>
<p class="text-muted-foreground truncate text-sm">
{m.networking_interfaces_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => {
interfaces.refresh();
pending.refresh();
}}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
<ListFilterIcon class="size-4" />
{m.users_filter()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
</div>
<div class="flex items-center justify-between p-2">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="net-iface-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="net-iface-rpp-{id}">
{#each [10, 25, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
{#if pending.current}
<div
class="mx-4 mb-2 flex items-center justify-between gap-3 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm"
@@ -205,187 +128,157 @@
</div>
</div>
{/if}
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.networking_interfaces_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {interfaces.current?.length ?? 0}
</span>
</div>
{#snippet sortHead(key: SortKey, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key)}
>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
{/snippet}
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{@render sortHead('name', m.networking_col_name())}</Table.Head>
<Table.Head class="w-24"
>{@render sortHead('state', m.networking_col_state())}</Table.Head
>
<Table.Head>{m.networking_col_ipv4()}</Table.Head>
<Table.Head>{m.networking_col_ipv6()}</Table.Head>
<Table.Head>{@render sortHead('mac', m.networking_col_mac())}</Table.Head>
<Table.Head class="w-20">{@render sortHead('mtu', m.networking_col_mtu())}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if interfaces.loading && !interfaces.current}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">
{m.networking_no_interfaces()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as i (i.name)}
<Table.Row>
<Table.Cell class="font-mono text-xs font-medium">
<a
href={resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
machineId,
name: i.name
})}
class="text-primary hover:underline"
>
{i.name}
</a>
</Table.Cell>
<Table.Cell>
{#if i.state === 'up'}
<Badge
class="border-0 bg-emerald-500/15 capitalize text-emerald-700 dark:text-emerald-400"
>{i.state}</Badge
>
{:else if i.state === 'down'}
<Badge
class="border-0 bg-zinc-500/15 capitalize text-zinc-700 dark:text-zinc-400"
>{i.state}</Badge
>
{:else}
<Badge variant="outline" class="capitalize">{i.state}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
<span class="block truncate" title={(i.ipv4 ?? []).join(', ')}>
{(i.ipv4 ?? []).join(', ') || '—'}
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
<span class="block truncate" title={(i.ipv6 ?? []).join(', ')}>
{(i.ipv6 ?? []).join(', ') || '—'}
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mac}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mtu}</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={() =>
goto(
resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
machineId,
name: i.name
})
)}
>
{m.networking_details()}
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() =>
goto(
resolve(
'/dashboard/[machineId]/networking/interfaces/[name]/configure',
{ machineId, name: i.name }
)
)}
>
{m.networking_configure()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
disabled={!!busy || i.state === 'up'}
onclick={() =>
run(
`up-${i.name}`,
() => linkUp({ machineId, name: i.name }),
m.networking_link_up_done({ name: i.name })
)}
>
{m.networking_link_up()}
</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
disabled={!!busy || i.state === 'down'}
onclick={() =>
run(
`down-${i.name}`,
() => linkDown({ machineId, name: i.name }),
m.networking_link_down_done({ name: i.name })
)}
>
{m.networking_link_down()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="mt-auto flex items-center justify-end gap-4 p-4">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
{#snippet sortHead(key: string, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key as SortKey)}
>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
{/snippet}
<DataTable
title={m.nav_networking_interfaces()}
description={m.networking_interfaces_description()}
searchPlaceholder={m.networking_interfaces_search_placeholder()}
searchHint={`${filtered.length} / ${interfaces.current?.length ?? 0}`}
emptyMessage={m.networking_no_interfaces()}
items={filtered}
loading={interfaces.loading && !interfaces.current}
pageSizeKey="networking.interfaces.pageSize"
pageSizePresets={[10, 25, 50, 100]}
bind:search
onrefresh={() => {
interfaces.refresh();
pending.refresh();
}}
i18n={{
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet columns()}
<Table.Head>{@render sortHead('name', m.networking_col_name())}</Table.Head>
<Table.Head class="w-24">{@render sortHead('state', m.networking_col_state())}</Table.Head>
<Table.Head>{m.networking_col_ipv4()}</Table.Head>
<Table.Head>{m.networking_col_ipv6()}</Table.Head>
<Table.Head>{@render sortHead('mac', m.networking_col_mac())}</Table.Head>
<Table.Head class="w-20">{@render sortHead('mtu', m.networking_col_mtu())}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(i: NonNullable<typeof interfaces.current>[number])}
<Table.Cell class="font-mono text-xs font-medium">
<a
href={resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
machineId,
name: i.name
})}
class="text-primary hover:underline"
>
{i.name}
</a>
</Table.Cell>
<Table.Cell>
{#if i.state === 'up'}
<Badge
class="border-0 bg-emerald-500/15 capitalize text-emerald-700 dark:text-emerald-400"
>{i.state}</Badge
>
{:else if i.state === 'down'}
<Badge
class="border-0 bg-zinc-500/15 capitalize text-zinc-700 dark:text-zinc-400"
>{i.state}</Badge
>
{:else}
<Badge variant="outline" class="capitalize">{i.state}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
<span class="block truncate" title={(i.ipv4 ?? []).join(', ')}>
{(i.ipv4 ?? []).join(', ') || '—'}
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
<span class="block truncate" title={(i.ipv6 ?? []).join(', ')}>
{(i.ipv6 ?? []).join(', ') || '—'}
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mac}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mtu}</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={() =>
goto(
resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
machineId,
name: i.name
})
)}
>
{m.networking_details()}
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() =>
goto(
resolve(
'/dashboard/[machineId]/networking/interfaces/[name]/configure',
{ machineId, name: i.name }
)
)}
>
{m.networking_configure()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
disabled={!!busy || i.state === 'up'}
onclick={() =>
run(
`up-${i.name}`,
() => linkUp({ machineId, name: i.name }),
m.networking_link_up_done({ name: i.name })
)}
>
{m.networking_link_up()}
</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
disabled={!!busy || i.state === 'down'}
onclick={() =>
run(
`down-${i.name}`,
() => linkDown({ machineId, name: i.name }),
m.networking_link_down_done({ name: i.name })
)}
>
{m.networking_link_down()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
@@ -14,6 +14,7 @@
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import { applyInterfaceConfig, getInterfaceConfig } from '$lib/remotes/networking.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -100,7 +101,7 @@
await goto(resolve('/dashboard/[machineId]/networking/interfaces', { machineId }));
} catch (e) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
} finally {
submitting = false;
confirmOpen = false;
@@ -2,24 +2,16 @@
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 ListFilterIcon from '@lucide/svelte/icons/list-filter';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listRoutes } from '$lib/remotes/networking.remote';
import { PersistedState } from 'runed';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const routes = $derived(listRoutes(machineId));
const pageSize = new PersistedState<number>('networking.routes.pageSize', 25);
let page = $state(1);
let search = $state('');
type SortKey = 'destination' | 'gateway' | 'interface' | 'metric';
let sort = $state<{ dir: 'asc' | 'desc'; key: SortKey }>({ dir: 'asc', key: 'destination' });
@@ -50,167 +42,68 @@
});
return matched;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) page = totalPages;
});
</script>
<PageMeta title={m.seo_title_networking_routes()} description={m.seo_desc_networking_routes()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
{#snippet sortHead(key: string, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key as SortKey)}
>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="truncate text-2xl font-semibold tracking-tight">{m.nav_networking_routes()}</h1>
<p class="text-muted-foreground truncate text-sm">{m.networking_routes_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => routes.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
<ListFilterIcon class="size-4" />
{m.users_filter()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
</div>
<div class="flex items-center justify-between p-2">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="net-routes-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="net-routes-rpp-{id}">
{#each [10, 25, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.networking_routes_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {routes.current?.length ?? 0}
</span>
</div>
{#snippet sortHead(key: SortKey, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key)}
>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
<ArrowDownIcon class="size-3" />
{/if}
</button>
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
{/snippet}
<DataTable
title={m.nav_networking_routes()}
description={m.networking_routes_description()}
searchPlaceholder={m.networking_routes_search_placeholder()}
searchHint={`${filtered.length} / ${routes.current?.length ?? 0}`}
emptyMessage={m.networking_no_routes()}
items={filtered}
loading={routes.loading && !routes.current}
pageSizeKey="networking.routes.pageSize"
pageSizePresets={[10, 25, 50, 100]}
bind:search
onrefresh={() => routes.refresh()}
i18n={{
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet columns()}
<Table.Head>{@render sortHead('destination', m.networking_col_destination())}</Table.Head>
<Table.Head>{@render sortHead('gateway', m.networking_col_gateway())}</Table.Head>
<Table.Head>{@render sortHead('interface', m.networking_col_interface())}</Table.Head>
<Table.Head>{m.networking_col_source()}</Table.Head>
<Table.Head class="w-20">{@render sortHead('metric', m.networking_col_metric())}</Table.Head>
{/snippet}
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head
>{@render sortHead('destination', m.networking_col_destination())}</Table.Head
>
<Table.Head>{@render sortHead('gateway', m.networking_col_gateway())}</Table.Head>
<Table.Head>{@render sortHead('interface', m.networking_col_interface())}</Table.Head>
<Table.Head>{m.networking_col_source()}</Table.Head>
<Table.Head class="w-20"
>{@render sortHead('metric', m.networking_col_metric())}</Table.Head
>
</Table.Row>
</Table.Header>
<Table.Body>
{#if routes.loading && !routes.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
{m.networking_no_routes()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as r, i (r.destination + '\0' + r.interface + i)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{r.destination}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.gateway ?? '—'}
</Table.Cell>
<Table.Cell class="font-mono text-xs">{r.interface}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.source ?? '—'}
</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.metric ?? '—'}
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="mt-auto flex items-center justify-end gap-4 p-4">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
{#snippet row(r: NonNullable<typeof routes.current>[number])}
<Table.Cell class="font-mono text-xs">{r.destination}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.gateway ?? '—'}
</Table.Cell>
<Table.Cell class="font-mono text-xs">{r.interface}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.source ?? '—'}
</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.metric ?? '—'}
</Table.Cell>
{/snippet}
</DataTable>
@@ -3,13 +3,12 @@
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpCircleIcon from '@lucide/svelte/icons/arrow-up-circle';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import { page as pageState } from '$app/state';
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 { Badge } from '$lib/components/ui/badge';
@@ -18,7 +17,6 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
@@ -27,7 +25,6 @@
listPackageUpdates,
streamPackageAction
} from '$lib/remotes/packages.remote';
import { PersistedState } from 'runed';
type Package = { name: string; version: string };
@@ -44,8 +41,6 @@
)
);
const pageSize = new PersistedState<number>('packages.installed.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
@@ -56,13 +51,11 @@
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
function toggleSort() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
page = 1;
}
const filtered = $derived.by(() => {
@@ -78,9 +71,6 @@
return list;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
// Dialogs
let createOpen = $state(false);
let installName = $state('');
@@ -161,189 +151,98 @@
</script>
<PageMeta title={m.seo_title_packages_installed()} description={m.seo_desc_packages_installed()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_packages_installed()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.nav_packages_installed_desc()}
{#if manager}({m.packages_manager_label()}: {manager}){/if}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => dataResource.refresh()}
<DataTable
title={m.nav_packages_installed()}
description={`${m.nav_packages_installed_desc()}${manager ? ` (${m.packages_manager_label()}: ${manager})` : ''}`}
searchPlaceholder={m.packages_search_placeholder()}
searchHint={`${filtered.length} / ${packages.length}`}
emptyMessage={m.packages_no_packages()}
items={filtered}
loading={dataResource.loading && !dataResource.current}
pageSizeKey="packages.installed.pageSize"
bind:search
onsearchinput={onSearchInput}
onrefresh={() => dataResource.refresh()}
i18n={{
display: m.users_filter_display(),
filterTitle: m.users_filter(),
next: () => m.users_next(),
pageOf: (p) => m.users_page_of({ page: p.page, total: p.total ?? p.pages }),
previous: () => m.users_prev(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.packages_install()}
</Button>
{/snippet}
{#snippet columns()}
<Table.Head class="w-8"></Table.Head>
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{m.packages_col_name()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head>{m.packages_col_version()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(p: Package)}
{@const newVersion = updatesMap.get(p.name)}
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
<Table.Cell class="w-8">
{#if newVersion}
<Tooltip.Root>
<Tooltip.Trigger>
<AlertCircleIcon class="text-amber-500 size-4" />
</Tooltip.Trigger>
<Tooltip.Content side="top">
{m.packages_update_available({ from: p.version, to: newVersion })}
</Tooltip.Content>
</Tooltip.Root>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-4" align="end">
<div class="flex flex-col gap-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="pkg-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="pkg-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.packages_install()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.packages_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {packages.length}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-8"></Table.Head>
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
{m.packages_col_name()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head>{m.packages_col_version()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if dataResource.loading && !dataResource.current}
<Table.Row>
<Table.Cell colspan={4} class="text-muted-foreground py-8 text-center">
<Loader2Icon class="size-4 animate-spin inline" />
</Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={4} class="text-muted-foreground py-8 text-center"
>{m.packages_no_packages()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as p (p.name)}
{@const newVersion = updatesMap.get(p.name)}
<Table.Row>
<Table.Cell class="w-8">
{#if newVersion}
<Tooltip.Root>
<Tooltip.Trigger>
<AlertCircleIcon class="text-amber-500 size-4" />
</Tooltip.Trigger>
<Tooltip.Content side="top">
{m.packages_update_available({ from: p.version, to: newVersion })}
</Tooltip.Content>
</Tooltip.Root>
{/if}
</Table.Cell>
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</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">
{#if newVersion}
<DropdownMenu.Item onclick={() => runUpdate(p.name)}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = p;
deleteOpen = true;
}}>{m.delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if newVersion}
<DropdownMenu.Item onclick={() => runUpdate(p.name)}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = p;
deleteOpen = true;
}}>{m.delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
@@ -2,52 +2,42 @@
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpCircleIcon from '@lucide/svelte/icons/arrow-up-circle';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
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 { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listPackageUpdates, streamPackageAction } from '$lib/remotes/packages.remote';
import { PersistedState } from 'runed';
type Package = { name: string; version: string };
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const dataResource = $derived(listPackageUpdates(machineId));
const manager = $derived(dataResource.current?.manager ?? '');
const packages = $derived((dataResource.current?.packages ?? []) as Package[]);
const pageSize = new PersistedState<number>('packages.updates.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
let sortDir = $state<'asc' | 'desc'>('asc');
function onSearchInput(e: Event) {
function onsearchinput(e: Event) {
search = (e.target as HTMLInputElement).value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
function toggleSort() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
page = 1;
}
const filtered = $derived.by(() => {
@@ -63,9 +53,6 @@
return list;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
// Terminal Stream Console
let consoleOpen = $state(false);
let consoleLogs = $state<string[]>([]);
@@ -116,165 +103,73 @@
</script>
<PageMeta title={m.seo_title_packages_updates()} description={m.seo_desc_packages_updates()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_packages_updates()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.nav_packages_updates_desc()}
{#if manager}({m.packages_manager_label()}: {manager}){/if}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => dataResource.refresh()}
<DataTable
title={m.nav_packages_updates()}
description={`${m.nav_packages_updates_desc()}${manager ? ` (${m.packages_manager_label()}: ${manager})` : ''}`}
searchPlaceholder={m.packages_search_placeholder()}
searchHint={`${filtered.length} / ${packages.length}`}
emptyMessage={m.packages_no_updates()}
items={filtered}
loading={dataResource.loading && !dataResource.current}
pageSizeKey="packages.updates.pageSize"
bind:search
{onsearchinput}
onrefresh={() => dataResource.refresh()}
i18n={{
display: m.users_filter_display(),
filterTitle: m.users_filter(),
next: () => m.users_next(),
pageOf: (p) => m.users_page_of({ page: p.page, total: p.total ?? p.pages }),
previous: () => m.users_prev(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => doUpgrade()} disabled={!packages.length}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_upgrade_all()}
</Button>
{/snippet}
{#snippet columns()}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{m.packages_col_name()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head>{m.packages_col_version()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(p: Package)}
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-4" align="end">
<div class="flex flex-col gap-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="pkg-up-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="pkg-up-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => doUpgrade()} disabled={!packages.length}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_upgrade_all()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.packages_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {packages.length}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
{m.packages_col_name()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head>{m.packages_col_version()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if dataResource.loading && !dataResource.current}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center">
<Loader2Icon class="size-4 animate-spin inline" />
</Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center"
>{m.packages_no_updates()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as p (p.name)}
<Table.Row>
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</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={() => doUpgrade(p.name)}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => doUpgrade(p.name)}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<!-- Stream Console Modal -->
<Dialog.Root bind:open={consoleOpen}>
@@ -1,22 +1,17 @@
<script lang="ts">
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listServices } from '$lib/remotes/services.remote';
import { PersistedState } from 'runed';
type ServiceUnit = {
active: string;
@@ -25,19 +20,16 @@
sub: string;
unit: string;
};
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const servicesResource = $derived(listServices(machineId));
const services = $derived((servicesResource.current?.services ?? []) as ServiceUnit[]);
const pageSize = new PersistedState<number>('services.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
let sortDir = $state<'asc' | 'desc'>('asc');
// Active State Filter
let filterActive = $state({
active: true,
failed: true,
@@ -45,7 +37,6 @@
other: true
});
// Load State Filter
let filterLoad = $state({
error: true,
loaded: true,
@@ -53,7 +44,6 @@
notFound: true
});
// Sub State Filter
let filterSub = $state({
dead: true,
exited: true,
@@ -71,358 +61,215 @@
filterActive = { active: true, failed: true, inactive: true, other: true };
filterLoad = { error: true, loaded: true, masked: true, notFound: true };
filterSub = { dead: true, exited: true, other: true, running: true };
page = 1;
}
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
let page = $state(1);
function toggleSort() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
page = 1;
}
const filtered = $derived.by(() => {
const q = debouncedSearch.trim().toLowerCase();
let list = [...services];
// Text Search
if (q) {
list = list.filter(
(s) => s.unit.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
);
}
// Active State Filter
list = list.filter((s) => {
const activeCat =
s.active === 'active'
? 'active'
: s.active === 'inactive'
? 'inactive'
: s.active === 'failed'
? 'failed'
: 'other';
s.active === 'active' ? 'active' : s.active === 'inactive' ? 'inactive' : s.active === 'failed' ? 'failed' : 'other';
return filterActive[activeCat];
});
// Load State Filter
list = list.filter((s) => {
const loadCat =
s.load === 'loaded'
? 'loaded'
: s.load === 'masked'
? 'masked'
: s.load === 'not-found'
? 'notFound'
: s.load === 'error'
? 'error'
: 'loaded';
s.load === 'loaded' ? 'loaded' : s.load === 'masked' ? 'masked' : s.load === 'not-found' ? 'notFound' : s.load === 'error' ? 'error' : 'loaded';
return filterLoad[loadCat as keyof typeof filterLoad] ?? true;
});
// Sub State Filter
list = list.filter((s) => {
const subCat =
s.sub === 'running'
? 'running'
: s.sub === 'exited'
? 'exited'
: s.sub === 'dead'
? 'dead'
: 'other';
s.sub === 'running' ? 'running' : s.sub === 'exited' ? 'exited' : s.sub === 'dead' ? 'dead' : 'other';
return filterSub[subCat as keyof typeof filterSub] ?? true;
});
// Sorting
list.sort((a, b) => {
const comp = a.unit.localeCompare(b.unit);
return sortDir === 'asc' ? comp : -comp;
});
return list;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) {
page = totalPages;
}
});
</script>
<PageMeta title={m.seo_title_services()} description={m.seo_desc_services()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.services_title()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.services_description()}
</p>
<DataTable
title={m.services_title()}
description={m.services_description()}
searchPlaceholder={m.services_search_placeholder()}
emptyMessage={m.services_no_services()}
items={filtered}
loading={servicesResource.loading}
pageSizeKey="services.pageSize"
defaultPageSize={25}
bind:search
onsearchinput={onSearchInput}
onrefresh={() => servicesResource.refresh()}
activeFilterCount={activeFilterCount}
bind:page
i18n={{
pageOf: (p) => m.users_page_of(p),
previous: () => m.users_prev(),
next: () => m.users_next(),
filterTitle: m.services_filter_title(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet filterContent()}
<div class="flex items-center justify-between border-b p-3">
<h3 class="text-sm font-semibold">{m.services_filter_title()}</h3>
{#if activeFilterCount > 0}
<Button variant="ghost" size="sm" class="h-auto p-1 text-xs" onclick={resetFilters}>
{m.cancel()}
</Button>
{/if}
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => servicesResource.refresh()}
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-2">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_active_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.active} />
{m.services_filter_active()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.inactive} />
{m.services_filter_inactive()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.failed} />
{m.services_filter_failed()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_load_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.loaded} />
{m.services_filter_loaded()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.masked} />
{m.services_filter_masked()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.notFound} />
{m.services_filter_not_found()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.error} />
{m.services_filter_error()}
</Label>
</div>
</div>
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_sub_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.running} />
{m.services_filter_running()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.exited} />
{m.services_filter_exited()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.dead} />
{m.services_filter_dead()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
</div>
{/snippet}
{#snippet columns()}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0" align="end">
<div class="border-b p-3 flex items-center justify-between">
<h3 class="text-sm font-semibold">{m.services_filter_title()}</h3>
{#if activeFilterCount > 0}
<Button variant="ghost" size="sm" class="h-auto p-1 text-xs" onclick={resetFilters}>
{m.cancel()}
</Button>
{/if}
</div>
<div class="flex flex-col gap-4 p-4">
<!-- Active State -->
<div class="flex flex-col gap-2">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_active_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.active} />
{m.services_filter_active()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.inactive} />
{m.services_filter_inactive()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.failed} />
{m.services_filter_failed()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
<!-- Load State -->
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_load_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.loaded} />
{m.services_filter_loaded()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.masked} />
{m.services_filter_masked()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.notFound} />
{m.services_filter_not_found()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.error} />
{m.services_filter_error()}
</Label>
</div>
</div>
<!-- Sub State -->
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_sub_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.running} />
{m.services_filter_running()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.exited} />
{m.services_filter_exited()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.dead} />
{m.services_filter_dead()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
<!-- Rows per page -->
<div class="flex items-center justify-between border-t pt-3">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="services-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="services-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.services_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {services.length}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
{m.services_col_unit()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head class="w-24">{m.services_col_active()}</Table.Head>
<Table.Head class="w-24">{m.services_col_sub()}</Table.Head>
<Table.Head class="w-24">{m.services_col_load()}</Table.Head>
<Table.Head>{m.services_col_description()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if servicesResource.loading && !servicesResource.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
<Loader2Icon class="size-4 animate-spin inline" />
</Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
{m.services_no_services()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as s (s.unit)}
<Table.Row>
<Table.Cell class="font-medium font-mono text-xs max-w-[20rem]">
<a
href={resolve('/dashboard/[machineId]/services/[name]', {
machineId,
name: s.unit
})}
class="block truncate text-primary hover:underline"
title={s.unit}
>
{s.unit}
</a>
</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs max-w-[20rem] w-full">
<span class="block truncate" title={s.description}>
{s.description}
</span>
</Table.Cell>
<Table.Cell>
{#if s.active === 'active'}
<Badge
class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else if s.active === 'inactive'}
<Badge
class="bg-zinc-500/15 text-zinc-700 dark:text-zinc-400 hover:bg-zinc-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else}
<Badge
class="bg-rose-500/15 text-rose-700 dark:text-rose-400 hover:bg-rose-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{/if}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">
{s.sub}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">
{s.load}
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.users_page_of({ page, total: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
{m.services_col_unit()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head class="w-24">{m.services_col_active()}</Table.Head>
<Table.Head class="w-24">{m.services_col_sub()}</Table.Head>
<Table.Head class="w-24">{m.services_col_load()}</Table.Head>
<Table.Head>{m.services_col_description()}</Table.Head>
{/snippet}
{#snippet row(s: ServiceUnit, i: number)}
<Table.Cell class="font-medium font-mono text-xs max-w-[20rem]">
<a
href={resolve('/dashboard/[machineId]/services/[name]', {
machineId,
name: s.unit
})}
class="block truncate text-primary hover:underline"
title={s.unit}
>
{s.unit}
</a>
</Table.Cell>
<Table.Cell>
{#if s.active === 'active'}
<Badge
class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else if s.active === 'inactive'}
<Badge
class="bg-zinc-500/15 text-zinc-700 dark:text-zinc-400 hover:bg-zinc-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else}
<Badge
class="bg-rose-500/15 text-rose-700 dark:text-rose-400 hover:bg-rose-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{/if}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">{s.sub}</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">{s.load}</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs max-w-[20rem] w-full">
<span class="block truncate" title={s.description}>{s.description}</span>
</Table.Cell>
{/snippet}
</DataTable>
@@ -35,6 +35,7 @@
streamServiceLogs
} from '$lib/remotes/services.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const name = $derived(pageState.params.name!);
@@ -45,7 +46,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function run(
@@ -187,7 +188,7 @@
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<svelte:boundary>
{#snippet failed(err, reset)}
{@const e = err as { body?: { message?: string }; message?: string; status?: number }}
{@const e = err as { body?: { message?: string }; message?: string; status?: number; detail?: string }}
{@const status = e.status ?? 0}
{@const notFound = status === 404}
<Empty.Root class="border">
@@ -1,20 +1,16 @@
<script lang="ts">
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
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 { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listFstab, removeMount } from '$lib/remotes/storage.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type FstabEntry = {
@@ -26,13 +22,9 @@
pass: number;
};
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const fstab = $derived(listFstab(machineId));
const pageSize = new PersistedState<number>('storage.fstab.pageSize', 10);
let page = $state(1);
let search = $state('');
const filtered = $derived.by(() => {
const all = (fstab.current ?? []) as FstabEntry[];
@@ -48,14 +40,6 @@
.sort((a, b) => a.mountpoint.localeCompare(b.mountpoint));
});
const paginated = $derived.by(() => {
const start = (page - 1) * pageSize.current;
const end = start + pageSize.current;
return filtered.slice(start, end);
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
let deleteOpen = $state(false);
let deleting = $state<FstabEntry | null>(null);
@@ -68,174 +52,77 @@
deleting = null;
} catch (e) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
}
</script>
<PageMeta title={m.seo_title_storage_fstab()} description={m.seo_desc_storage_fstab()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_storage_fstab()}</h1>
<p class="text-muted-foreground text-sm truncate">{m.storage_fstab_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => fstab.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
<DataTable
title={m.nav_storage_fstab()}
description={m.storage_fstab_description()}
searchPlaceholder={m.storage_mounts_search_placeholder()}
emptyMessage={m.storage_no_fstab()}
items={filtered}
loading={fstab.loading}
pageSizeKey="storage.fstab.pageSize"
defaultPageSize={10}
pageSizePresets={[10, 20, 50, 100]}
bind:search
onrefresh={() => fstab.refresh()}
i18n={{
pageOf: (p) => m.pagination_page_of(p),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet columns()}
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
<Table.Head>{m.storage_col_device()}</Table.Head>
<Table.Head>{m.storage_col_fstype()}</Table.Head>
<Table.Head>{m.storage_col_options()}</Table.Head>
<Table.Head class="text-right">{m.storage_col_dump()}</Table.Head>
<Table.Head class="text-right">{m.storage_col_pass()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(e: FstabEntry, i: number)}
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
<span class="block truncate" title={e.mountpoint}>{e.mountpoint}</span>
</Table.Cell>
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={e.device}>{e.device}</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{e.fstype}</Badge></Table.Cell>
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={e.options}>{e.options}</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground text-right tabular-nums">{e.dump}</Table.Cell>
<Table.Cell class="text-muted-foreground text-right tabular-nums">{e.pass}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-2 p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="rpp-presets-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="rpp-presets-{id}">
{#each [10, 20, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.storage_mounts_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {fstab.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
<Table.Head>{m.storage_col_device()}</Table.Head>
<Table.Head>{m.storage_col_fstype()}</Table.Head>
<Table.Head>{m.storage_col_options()}</Table.Head>
<Table.Head class="text-right">{m.storage_col_dump()}</Table.Head>
<Table.Head class="text-right">{m.storage_col_pass()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if fstab.loading && !fstab.current}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
>{m.storage_no_fstab()}</Table.Cell
>
</Table.Row>
{:else}
{#each paginated as e, i (e.mountpoint + '\0' + e.device + i)}
<Table.Row>
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
<span class="block truncate" title={e.mountpoint}>{e.mountpoint}</span>
</Table.Cell>
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={e.device}>{e.device}</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{e.fstype}</Badge></Table.Cell>
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={e.options}>{e.options}</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground text-right tabular-nums"
>{e.dump}</Table.Cell
>
<Table.Cell class="text-muted-foreground text-right tabular-nums"
>{e.pass}</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
variant="destructive"
onclick={() => {
deleting = e;
deleteOpen = true;
}}>{m.storage_mount_remove()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = e;
deleteOpen = true;
}}>{m.storage_mount_remove()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
@@ -1,9 +1,8 @@
<script lang="ts">
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
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 { Badge } from '$lib/components/ui/badge';
@@ -13,37 +12,19 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { addMount, listFstab, listMounts, removeMount } from '$lib/remotes/storage.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type Mount = { device: string; fstype: string; mountpoint: string; options: string };
// Kernel/virtual filesystems most people don't want to see by default.
const PSEUDO_FSTYPES = new Set([
'autofs',
'binfmt_misc',
'bpf',
'cgroup',
'cgroup2',
'configfs',
'debugfs',
'devpts',
'devtmpfs',
'fusectl',
'hugetlbfs',
'mqueue',
'nsfs',
'proc',
'pstore',
'ramfs',
'securityfs',
'sysfs',
'tmpfs',
'tracefs'
'autofs', 'binfmt_misc', 'bpf', 'cgroup', 'cgroup2', 'configfs', 'debugfs',
'devpts', 'devtmpfs', 'fusectl', 'hugetlbfs', 'mqueue', 'nsfs', 'proc',
'pstore', 'ramfs', 'securityfs', 'sysfs', 'tmpfs', 'tracefs'
]);
const id = $props.id();
@@ -51,9 +32,6 @@
const mounts = $derived(listMounts(machineId));
const fstab = $derived(listFstab(machineId));
const pageSize = new PersistedState<number>('storage.mounts.pageSize', 10);
let page = $state(1);
let search = $state('');
const filtersStore = new PersistedState('storage.mounts.filters', { showPseudo: false });
let filters = $state({ ...filtersStore.current });
@@ -62,7 +40,6 @@
});
const activeFilterCount = $derived(filters.showPseudo ? 1 : 0);
// Mountpoints present in /etc/fstab — used to flag which active mounts are persistent.
const persistent = $derived(new Set((fstab.current ?? []).map((e) => e.mountpoint)));
const filtered = $derived.by(() => {
@@ -80,14 +57,6 @@
.sort((a, b) => a.mountpoint.localeCompare(b.mountpoint));
});
const paginated = $derived.by(() => {
const start = (page - 1) * pageSize.current;
const end = start + pageSize.current;
return filtered.slice(start, end);
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
let createOpen = $state(false);
let createForm = $state({ device: '', fstype: '', mountpoint: '', options: 'defaults' });
let deleteOpen = $state(false);
@@ -95,7 +64,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function doCreate() {
@@ -129,187 +98,93 @@
</script>
<PageMeta title={m.seo_title_storage_mounts()} description={m.seo_desc_storage_mounts()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_storage_mounts()}</h1>
<p class="text-muted-foreground text-sm truncate">{m.storage_mounts_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => mounts.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
<DataTable
title={m.nav_storage_mounts()}
description={m.storage_mounts_description()}
searchPlaceholder={m.storage_mounts_search_placeholder()}
emptyMessage={m.storage_no_mounts()}
items={filtered}
loading={mounts.loading}
pageSizeKey="storage.mounts.pageSize"
defaultPageSize={10}
pageSizePresets={[10, 20, 50, 100]}
bind:search
onrefresh={() => mounts.refresh()}
activeFilterCount={activeFilterCount}
i18n={{
pageOf: (p) => m.pagination_page_of(p),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.storage_mount_add()}
</Button>
{/snippet}
{#snippet filterContent()}
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.storage_filter_show_pseudo()}</span>
<small>{m.storage_filter_show_pseudo_hint()}</small>
</div>
<Checkbox
checked={filters.showPseudo}
onCheckedChange={(v) => {
filters.showPseudo = !!v;
}}
/>
</label>
{/snippet}
{#snippet columns()}
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
<Table.Head>{m.storage_col_device()}</Table.Head>
<Table.Head>{m.storage_col_fstype()}</Table.Head>
<Table.Head>{m.storage_col_options()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(mt: Mount, i: number)}
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
<span class="flex items-center gap-2">
<span class="block truncate" title={mt.mountpoint}>{mt.mountpoint}</span>
{#if persistent.has(mt.mountpoint)}
<Badge variant="secondary">{m.storage_badge_fstab()}</Badge>
{/if}
</span>
</Table.Cell>
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={mt.device}>{mt.device}</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{mt.fstype}</Badge></Table.Cell>
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={mt.options}>{mt.options}</span>
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.storage_filter_show_pseudo()}</span>
<small>{m.storage_filter_show_pseudo_hint()}</small>
</div>
<Checkbox
checked={filters.showPseudo}
onCheckedChange={(v) => {
filters.showPseudo = !!v;
page = 1;
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="rpp-presets-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="rpp-presets-{id}">
{#each [10, 20, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.storage_mount_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.storage_mounts_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {mounts.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
<Table.Head>{m.storage_col_device()}</Table.Head>
<Table.Head>{m.storage_col_fstype()}</Table.Head>
<Table.Head>{m.storage_col_options()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if mounts.loading && !mounts.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"
>{m.storage_no_mounts()}</Table.Cell
>
</Table.Row>
{:else}
{#each paginated as mt, i (mt.mountpoint + '\0' + mt.device + i)}
<Table.Row>
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
<span class="flex items-center gap-2">
<span class="block truncate" title={mt.mountpoint}>{mt.mountpoint}</span>
{#if persistent.has(mt.mountpoint)}
<Badge variant="secondary">{m.storage_badge_fstab()}</Badge>
{/if}
</span>
</Table.Cell>
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={mt.device}>{mt.device}</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{mt.fstype}</Badge></Table.Cell>
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={mt.options}>{mt.options}</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
variant="destructive"
onclick={() => {
deleting = mt;
deleteOpen = true;
}}>{m.storage_mount_remove()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = mt;
deleteOpen = true;
}}>{m.storage_mount_remove()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
@@ -326,12 +201,7 @@
>
<div class="flex flex-col gap-1.5">
<Label for="sm-device-{id}">{m.storage_col_device()}</Label>
<Input
id="sm-device-{id}"
bind:value={createForm.device}
placeholder="/dev/sdb1"
required
/>
<Input id="sm-device-{id}" bind:value={createForm.device} placeholder="/dev/sdb1" required />
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-mountpoint-{id}">{m.storage_col_mountpoint()}</Label>
@@ -18,6 +18,7 @@
setTimezone,
systemTime
} from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
@@ -40,7 +41,7 @@
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
} finally {
saving = false;
}
@@ -7,6 +7,7 @@
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import { setHostname, systemHostname } from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
@@ -55,7 +56,7 @@
toast.success(m.saved());
} catch (err) {
console.log(err);
toast.error((err as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(err) ?? m.errors_generic());
} finally {
saving = false;
}
@@ -20,6 +20,7 @@
setLocale,
systemLocale
} from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
@@ -58,7 +59,7 @@
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
} finally {
saving = false;
}
@@ -9,6 +9,7 @@
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { powerOff, reboot } from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
let pending = $state<'off' | 'reboot' | null>(null);
@@ -23,7 +24,7 @@
: reboot({ machineId, when: '' }));
toast.success(m.saved());
} catch (e) {
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
} finally {
busy = false;
pending = null;
@@ -2,11 +2,11 @@
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 ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
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 { Badge } from '$lib/components/ui/badge';
@@ -16,7 +16,6 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {
@@ -26,6 +25,7 @@
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type PamUser = {
@@ -44,8 +44,6 @@
const machineId = $derived(pageState.params.machineId!);
const users = $derived(listPamUsers(machineId));
const pageSize = new PersistedState<number>('pam.users.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
@@ -70,8 +68,7 @@
});
const activeFilterCount = $derived((filters.showSystem ? 1 : 0) + (filters.shellOnly ? 1 : 0));
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
@@ -79,6 +76,8 @@
}, 200);
}
let page = $state(1);
function toggleSort(col: SortBy) {
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else {
@@ -113,10 +112,6 @@
return out;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
// Dialogs
let createOpen = $state(false);
let createForm = $state({
comment: '',
@@ -134,7 +129,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function doCreate() {
@@ -197,222 +192,139 @@
</script>
<PageMeta title={m.seo_title_users()} description={m.seo_desc_users()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.users_nav_title()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.users_nav_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Popover.Root>
<Popover.Trigger>
<DataTable
title={m.users_nav_title()}
description={m.users_nav_description()}
searchPlaceholder={m.users_pam_search_placeholder()}
emptyMessage={m.users_no_results()}
items={filtered}
loading={users.loading}
pageSizeKey="pam.users.pageSize"
defaultPageSize={25}
pageSizePresets={[10, 25, 50, 100, 200]}
bind:search
onsearchinput={onSearchInput}
onrefresh={() => users.refresh()}
activeFilterCount={activeFilterCount}
bind:page
i18n={{
pageOf: (p) => m.users_page_of(p),
previous: () => m.users_prev(),
next: () => m.users_next(),
filterTitle: m.users_filter_title(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.users_add()}
</Button>
{/snippet}
{#snippet filterContent()}
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.users_filter_show_system()}</span>
<small>{m.users_filter_system_uid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
<label class="flex items-center justify-between text-sm">
<span>{m.users_filter_shell_only()}</span>
<Checkbox
checked={filters.shellOnly}
onCheckedChange={(v) => {
filters.shellOnly = !!v;
page = 1;
}}
/>
</label>
{/snippet}
{#snippet columns()}
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: m.users_col_uid() }, { key: 'shell', label: m.users_create_field_shell() }] 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.users_col_comment()}</Table.Head>
<Table.Head>{m.users_col_home()}</Table.Head>
<Table.Head>{m.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(u: PamUser, i: number)}
<Table.Cell class="font-medium">
<a
href={resolve('/dashboard/[machineId]/users/[username]', {
machineId,
username: u.username
})}
class="hover:underline">{u.username}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.uid}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.shell}</Table.Cell>
<Table.Cell>{u.comment || '—'}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.home}</Table.Cell>
<Table.Cell>
{#if u.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.users_filter_show_system()}</span>
<small>{m.users_filter_system_uid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
<label class="flex items-center justify-between text-sm">
<span>{m.users_filter_shell_only()}</span>
<Checkbox
checked={filters.shellOnly}
onCheckedChange={(v) => {
filters.shellOnly = !!v;
page = 1;
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="pam-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="pam-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.users_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.users_pam_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {users.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: m.users_col_uid() }, { key: 'shell', label: m.users_create_field_shell() }] 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.users_col_comment()}</Table.Head>
<Table.Head>{m.users_col_home()}</Table.Head>
<Table.Head>{m.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if users.loading && !users.current}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
>{m.users_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as u (u.username)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve('/dashboard/[machineId]/users/[username]', {
machineId,
username: u.username
})}
class="hover:underline">{u.username}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.uid}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.shell}</Table.Cell>
<Table.Cell>{u.comment || '—'}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.home}</Table.Cell>
<Table.Cell>
{#if u.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</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={() => {
pwUser = u;
pwValue = '';
pwOpen = true;
}}>{m.users_action_set_password()}</DropdownMenu.Item
>
<DropdownMenu.Separator />
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = u;
removeHome = false;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => {
pwUser = u;
pwValue = '';
pwOpen = true;
}}>{m.users_action_set_password()}</DropdownMenu.Item
>
<DropdownMenu.Separator />
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = u;
removeHome = false;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
@@ -21,6 +21,7 @@
setPamUserGroups,
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
import { SvelteSet } from 'svelte/reactivity';
@@ -58,7 +59,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
function toggle(name: string, on: boolean) {
@@ -2,11 +2,11 @@
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 ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
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 { Badge } from '$lib/components/ui/badge';
@@ -16,12 +16,13 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { createPamGroup, deletePamGroup, listPamGroups } from '$lib/remotes/pam-users.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type PamGroup = {
gid: number;
members: null | string[];
@@ -30,12 +31,11 @@
};
type SortBy = 'gid' | 'members' | 'name';
type Dir = 'asc' | 'desc';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const groups = $derived(listPamGroups(machineId));
const pageSize = new PersistedState<number>('pam.groups.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
@@ -57,8 +57,7 @@
});
const activeFilterCount = $derived(filters.showSystem ? 1 : 0);
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
@@ -66,6 +65,8 @@
}, 200);
}
let page = $state(1);
function toggleSort(col: SortBy) {
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else {
@@ -94,9 +95,6 @@
return out;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
let createOpen = $state(false);
let createForm = $state({ gid: '', name: '', system: false });
let deleteOpen = $state(false);
@@ -104,7 +102,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function doCreate() {
@@ -137,204 +135,124 @@
</script>
<PageMeta title={m.seo_title_users_groups()} description={m.seo_desc_users_groups()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.groups_nav_title()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.groups_nav_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Popover.Root>
<Popover.Trigger>
<DataTable
title={m.groups_nav_title()}
description={m.groups_nav_description()}
searchPlaceholder={m.groups_search_placeholder()}
emptyMessage={m.groups_no_results()}
items={filtered}
loading={groups.loading}
pageSizeKey="pam.groups.pageSize"
defaultPageSize={25}
pageSizePresets={[10, 25, 50, 100, 200]}
bind:search
onsearchinput={onSearchInput}
onrefresh={() => groups.refresh()}
activeFilterCount={activeFilterCount}
bind:page
i18n={{
pageOf: (p) => m.users_page_of(p),
previous: () => m.users_prev(),
next: () => m.users_next(),
filterTitle: m.users_filter_title(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.groups_add()}
</Button>
{/snippet}
{#snippet filterContent()}
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.groups_filter_show_system()}</span>
<small>{m.groups_filter_system_gid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
{/snippet}
{#snippet columns()}
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: m.groups_col_gid() }, { key: 'members', label: m.groups_col_members() }] 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.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(g: PamGroup, i: number)}
<Table.Cell class="font-medium">
<a
href={resolve('/dashboard/[machineId]/users/groups/[group]', {
group: g.name,
machineId
})}
class="hover:underline">{g.name}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{g.gid}</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">
{#if g.members?.length}
<span title={g.members.join(', ')}>
{g.members.slice(0, 3).join(', ')}{g.members.length > 3 ? ` +${g.members.length - 3}` : ''}
</span>
{:else}
{/if}
</Table.Cell>
<Table.Cell>
{#if g.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.groups_filter_show_system()}</span>
<small>{m.groups_filter_system_gid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.groups_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.groups_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {groups.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: m.groups_col_gid() }, { key: 'members', label: m.groups_col_members() }] 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.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if groups.loading && !groups.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"
>{m.groups_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as g (g.name)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve('/dashboard/[machineId]/users/groups/[group]', {
group: g.name,
machineId
})}
class="hover:underline">{g.name}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{g.gid}</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">
{#if g.members?.length}
<span title={g.members.join(', ')}>
{g.members.slice(0, 3).join(', ')}{g.members.length > 3
? ` +${g.members.length - 3}`
: ''}
</span>
{:else}
{/if}
</Table.Cell>
<Table.Cell>
{#if g.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</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
variant="destructive"
onclick={() => {
deleting = g;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = g;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
@@ -20,6 +20,7 @@
listPamUsers,
setPamUserGroups
} from '$lib/remotes/pam-users.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -51,7 +52,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function addMember(username: string) {