stabilized architecture
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user