initial commit

This commit is contained in:
2026-06-22 17:47:16 +02:00
commit 8a81eb2634
491 changed files with 26185 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import './layout.css';
import type { Pathname } from '$app/types';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import favicon from '$lib/assets/favicon.svg';
import AppControls from '$lib/components/blocks/app-controls.svelte';
import Breadcrumbs from '$lib/components/blocks/breadcrumbs/breadcrumbs.svelte';
import AppSidebar from '$lib/components/blocks/sidebar/app-sidebar.svelte';
import { Separator } from '$lib/components/ui/separator';
import * as Sidebar from '$lib/components/ui/sidebar';
import { Toaster } from '$lib/components/ui/sonner';
import { locales, localizeHref } from '$lib/paraglide/runtime';
import { getUser } from '$lib/remotes/auth.remote';
import { ModeWatcher } from 'mode-watcher';
let { children } = $props();
const user = $derived(getUser());
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<Sidebar.Provider style="--sidebar-width: 350px;">
{@const currentUser = await user}
{#if currentUser}
<AppSidebar user={currentUser} />
{/if}
<Sidebar.Inset>
{#if await user}
<header
class="bg-background sticky top-0 flex shrink-0 items-center gap-2 border-b p-4 h-15 z-50"
>
<Sidebar.Trigger class="-ms-1" />
<Separator orientation="vertical" class="me-2 data-[orientation=vertical]:h-4" />
<Breadcrumbs />
<div class="ms-auto">
<AppControls />
</div>
</header>
{/if}
<div class="w-full flex flex-col h-full">
{@render children()}
</div>
</Sidebar.Inset>
</Sidebar.Provider>
<div style="display:none">
{#each locales as locale (locale)}
<a href={resolve(localizeHref(page.url.pathname, { locale }) as Pathname)}>{locale}</a>
{/each}
</div>
<ModeWatcher />
<Toaster />
+2
View File
@@ -0,0 +1,2 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
+3
View File
@@ -0,0 +1,3 @@
// Forces hooks.server.ts to run on every /admin navigation (client-side router
// skips the server otherwise when no server load is defined).
export const load = () => {};
View File
+721
View File
@@ -0,0 +1,721 @@
<script lang="ts">
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import CalendarIcon from '@lucide/svelte/icons/calendar';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import InfoIcon from '@lucide/svelte/icons/info';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { createUserSchema, inviteUserSchema, updateUserSchema } from '$lib/auth/schemas';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { NativeSelect } from '$lib/components/ui/native-select';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {
banUser,
createUser,
deleteUser,
inviteUser,
listUsers,
resendInvite,
unbanUser,
updateUser
} from '$lib/remotes/users.remote';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
type SortBy = 'createdAt' | 'email' | 'name' | 'username';
type Dir = 'asc' | 'desc';
type Within = '24h' | '30d' | '7d' | 'all';
type UserRow = {
banExpires?: Date | null | string;
banned?: boolean | null;
createdAt: Date | string;
email: string;
emailVerified?: boolean | null;
id: string;
inviteExpiresAt?: Date | null | string;
name: string;
role?: null | string;
username?: null | string;
};
const id = $props.id();
const pageSize = new PersistedState<number>('admin.users.pageSize', 10);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
// Persisted sort
const sortStore = new PersistedState<{ sortBy: SortBy; sortDir: Dir }>('admin.users.sort', {
sortBy: 'createdAt',
sortDir: 'desc'
});
let sortBy = $state<SortBy>(sortStore.current.sortBy);
let sortDir = $state<Dir>(sortStore.current.sortDir);
$effect(() => {
sortStore.current = { sortBy, sortDir };
});
// Persisted filters
const defaults = {
activeWithin: 'all' as Within,
emailVerifiedOnly: false,
joinedFrom: '',
joinedTo: '',
joinedWithin: 'all' as Within,
onlineOnly: false,
showBanned: true
};
const filtersStore = new PersistedState<typeof defaults>('admin.users.filters', defaults);
let filters = $state({ ...defaults, ...filtersStore.current });
$effect(() => {
filtersStore.current = filters;
});
const activeFilterCount = $derived(
(filters.activeWithin !== 'all' ? 1 : 0) +
(filters.joinedWithin !== 'all' ? 1 : 0) +
(filters.joinedFrom || filters.joinedTo ? 1 : 0) +
(filters.emailVerifiedOnly ? 1 : 0) +
(filters.onlineOnly ? 1 : 0) +
(!filters.showBanned ? 1 : 0)
);
function resetFilters() {
filters = { ...defaults };
page = 1;
}
function bump() {
page = 1;
}
const offset = $derived((page - 1) * pageSize.current);
const usersQuery = $derived(
listUsers({
...filters,
limit: pageSize.current,
offset,
searchValue: debouncedSearch,
sortBy,
sortDirection: sortDir
})
);
const data = $derived(
(usersQuery.current ?? { total: 0, users: [] }) as { total: number; users: UserRow[] }
);
const totalPages = $derived(Math.max(1, Math.ceil(data.total / pageSize.current)));
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 250);
}
function toggleSort(col: SortBy) {
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else {
sortBy = col;
sortDir = 'asc';
}
page = 1;
}
// Dialog state
let createOpen = $state(false);
let inviteOpen = $state(false);
let editOpen = $state(false);
let editing = $state<null | UserRow>(null);
let deleteOpen = $state(false);
let deleting = $state<null | UserRow>(null);
let alsoBan = $state(false);
let banReason = $state('');
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
function openEdit(u: UserRow) {
editing = u;
editOpen = true;
}
function openDelete(u: UserRow) {
deleting = u;
alsoBan = false;
banReason = '';
deleteOpen = true;
}
async function confirmDelete() {
if (!deleting) return;
try {
if (alsoBan && !deleting.banned) {
await banUser({ banReason, id: deleting.id });
}
await deleteUser(deleting.id);
toast.success(m.users_deleted());
deleteOpen = false;
deleting = null;
} catch (e) {
handleError(e);
}
}
async function doResendInvite(u: UserRow) {
try {
await resendInvite(u.id);
toast.success(m.users_invited());
} catch (e) {
handleError(e);
}
}
async function toggleBan(u: UserRow) {
try {
if (u.banned) await unbanUser(u.id);
else await banUser({ banReason: '', id: u.id });
} catch (e) {
handleError(e);
}
}
</script>
<div class="flex flex-col gap-4 p-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">{m.users_title()}</h1>
<p class="text-muted-foreground text-sm">{m.users_description()}</p>
</div>
<div class="flex items-center gap-2">
<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-4">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="grid grid-cols-2 gap-3 p-4">
<div class="flex flex-col gap-1.5">
<Label class="flex items-center gap-1 text-xs"
>{m.users_filter_active()} <InfoIcon class="size-3 opacity-60" /></Label
>
<NativeSelect bind:value={filters.activeWithin} onchange={bump} class="h-9">
<option value="all">{m.users_filter_any_time()}</option>
<option value="24h">{m.users_filter_24h()}</option>
<option value="7d">{m.users_filter_7d()}</option>
<option value="30d">{m.users_filter_30d()}</option>
</NativeSelect>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">{m.users_filter_joined()}</Label>
<NativeSelect bind:value={filters.joinedWithin} onchange={bump} class="h-9">
<option value="all">{m.users_filter_any_time()}</option>
<option value="24h">{m.users_filter_24h()}</option>
<option value="7d">{m.users_filter_7d()}</option>
<option value="30d">{m.users_filter_30d()}</option>
</NativeSelect>
</div>
</div>
<div class="border-t p-4">
<Label class="mb-2 block text-xs">{m.users_filter_date_range()}</Label>
<div class="grid grid-cols-2 gap-2">
<div class="relative">
<CalendarIcon
class="text-muted-foreground pointer-events-none absolute inset-s-2 top-1/2 size-4 -translate-y-1/2"
/>
<Input
type="date"
class="ps-8"
bind:value={filters.joinedFrom}
onchange={bump}
placeholder={m.users_filter_date_from()}
/>
</div>
<div class="relative">
<CalendarIcon
class="text-muted-foreground pointer-events-none absolute inset-s-2 top-1/2 size-4 -translate-y-1/2"
/>
<Input
type="date"
class="ps-8"
bind:value={filters.joinedTo}
onchange={bump}
placeholder={m.users_filter_date_to()}
/>
</div>
</div>
</div>
<div class="flex flex-col gap-3 border-t p-4">
<label class="flex items-center justify-between text-sm">
<span>{m.users_filter_email_verified()}</span>
<Checkbox
checked={filters.emailVerifiedOnly}
onCheckedChange={(v) => {
filters.emailVerifiedOnly = !!v;
bump();
}}
/>
</label>
<label class="flex items-center justify-between text-sm">
<span class="flex items-center gap-1"
>{m.users_filter_online_only()} <InfoIcon class="size-3 opacity-60" /></span
>
<Checkbox
checked={filters.onlineOnly}
onCheckedChange={(v) => {
filters.onlineOnly = !!v;
bump();
}}
/>
</label>
<label class="flex items-center justify-between text-sm">
<span>{m.users_filter_show_banned()}</span>
<Checkbox
checked={filters.showBanned}
onCheckedChange={(v) => {
filters.showBanned = !!v;
bump();
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-4">
<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>
<div class="flex items-center justify-between border-t p-3">
<Button variant="ghost" size="sm" onclick={resetFilters}
>{m.users_filter_reset()}</Button
>
<span class="text-muted-foreground text-xs"
>{m.users_filter_count({ n: activeFilterCount })}</span
>
</div>
</Popover.Content>
</Popover.Root>
<div class="flex items-stretch">
<Button class="rounded-e-none" onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.users_add()}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} class="rounded-s-none border-s px-2" aria-label="more">
<ChevronDownIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => (createOpen = true)}
>{m.users_create()}</DropdownMenu.Item
>
<DropdownMenu.Item onclick={() => (inviteOpen = true)}
>{m.users_invite()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<Input
placeholder={m.users_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'name', label: m.name() }, { key: 'username', label: m.username() }, { key: 'email', label: m.email() }, { key: 'createdAt', label: m.users_created_at() }] 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_role()}</Table.Head>
<Table.Head>{m.users_status()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if usersQuery.loading && !data.users.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !data.users.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 data.users as u (u.id)}
<Table.Row>
<Table.Cell class="font-medium">{u.name}</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.username ?? '—'}</Table.Cell>
<Table.Cell>{u.email}</Table.Cell>
<Table.Cell>{new Date(u.createdAt).toLocaleDateString()}</Table.Cell>
<Table.Cell>
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
{u.role === 'admin' ? m.users_role_admin() : m.users_role_user()}
</Badge>
</Table.Cell>
<Table.Cell>
{#if u.banned}
<Badge variant="destructive">{m.users_banned()}</Badge>
{:else if !u.emailVerified}
<Badge
variant="secondary"
title={u.inviteExpiresAt
? m.users_pending_expires({
date: new Date(u.inviteExpiresAt).toLocaleString()
})
: m.users_pending_no_invite()}
>
{m.users_pending()}
</Badge>
{:else}
<Badge variant="outline">{m.users_active()}</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={() => openEdit(u)}
>{m.users_edit()}</DropdownMenu.Item
>
{#if !u.emailVerified}
<DropdownMenu.Item onclick={() => doResendInvite(u)}
>{m.users_resend_invite()}</DropdownMenu.Item
>
{/if}
<DropdownMenu.Item onclick={() => toggleBan(u)}>
{u.banned ? m.users_unban() : m.users_ban()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(u)}>
{m.users_delete()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
<div class="flex items-center justify-end gap-4">
<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>
<!-- Create dialog -->
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.users_create_title()}</Dialog.Title>
<Dialog.Description>{m.users_create_description()}</Dialog.Description>
</Dialog.Header>
<form
oninput={() => createUser.validate()}
{...createUser.preflight(createUserSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.users_created());
createOpen = false;
} catch (e) {
handleError(e);
}
})}
>
<Field.Group>
<Field.Field>
<Field.Label for="cu-name-{id}">{m.name()}</Field.Label>
<Input id="cu-name-{id}" {...createUser.fields.name.as('text')} required />
{#each createUser.fields.name.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="cu-username-{id}">{m.username()}</Field.Label>
<Input id="cu-username-{id}" {...createUser.fields.username.as('text')} required />
{#each createUser.fields.username.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="cu-email-{id}">{m.email()}</Field.Label>
<Input id="cu-email-{id}" {...createUser.fields.email.as('email')} required />
{#each createUser.fields.email.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="cu-password-{id}">{m.password()}</Field.Label>
<Input id="cu-password-{id}" {...createUser.fields._password.as('password')} required />
{#each createUser.fields._password.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="cu-role-{id}">{m.users_role()}</Field.Label>
<NativeSelect id="cu-role-{id}" {...createUser.fields.role.as('text')}>
<option value="user">{m.users_role_user()}</option>
<option value="admin">{m.users_role_admin()}</option>
</NativeSelect>
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit" disabled={!!createUser.pending}>{m.users_create()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- Edit dialog -->
<Dialog.Root bind:open={editOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.users_edit_title()}</Dialog.Title>
<Dialog.Description>{m.users_edit_description()}</Dialog.Description>
</Dialog.Header>
{#if editing}
<form
oninput={() => updateUser.validate()}
{...updateUser.preflight(updateUserSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.users_saved());
editOpen = false;
editing = null;
} catch (e) {
handleError(e);
}
})}
>
<input type="hidden" name="id" value={editing.id} />
<Field.Group>
<Field.Field>
<Field.Label for="eu-name-{id}">{m.name()}</Field.Label>
<Input
id="eu-name-{id}"
{...updateUser.fields.name.as('text')}
value={editing.name}
required
/>
{#each updateUser.fields.name.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="eu-username-{id}">{m.username()}</Field.Label>
<Input
id="eu-username-{id}"
{...updateUser.fields.username.as('text')}
value={editing.username ?? ''}
required
/>
{#each updateUser.fields.username.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="eu-email-{id}">{m.email()}</Field.Label>
<Input
id="eu-email-{id}"
{...updateUser.fields.email.as('email')}
value={editing.email}
required
/>
{#each updateUser.fields.email.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="eu-role-{id}">{m.users_role()}</Field.Label>
<NativeSelect
id="eu-role-{id}"
{...updateUser.fields.role.as('text')}
value={editing.role ?? 'user'}
>
<option value="user">{m.users_role_user()}</option>
<option value="admin">{m.users_role_admin()}</option>
</NativeSelect>
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Button type="button" variant="outline" onclick={() => (editOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit" disabled={!!updateUser.pending}>{m.users_create()}</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Delete confirm -->
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.users_delete_confirm_title()}</AlertDialog.Title>
<AlertDialog.Description>{m.users_delete_confirm_description()}</AlertDialog.Description>
</AlertDialog.Header>
{#if deleting && !deleting.banned}
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={alsoBan} />
{m.users_delete_ban_email()}
</label>
{#if alsoBan}
<Input placeholder={m.users_ban_reason()} bind:value={banReason} />
{/if}
</div>
{/if}
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmDelete}>{m.users_delete()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Invite dialog -->
<Dialog.Root bind:open={inviteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.users_invite_title()}</Dialog.Title>
<Dialog.Description>{m.users_invite_description()}</Dialog.Description>
</Dialog.Header>
<form
oninput={() => inviteUser.validate()}
{...inviteUser.preflight(inviteUserSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.users_invited());
inviteOpen = false;
} catch (e) {
handleError(e);
}
})}
>
<Field.Group>
<Field.Field>
<Field.Label for="iu-email-{id}">{m.email()}</Field.Label>
<Input id="iu-email-{id}" {...inviteUser.fields.email.as('email')} required />
{#each inviteUser.fields.email.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="iu-name-{id}">{m.name()}</Field.Label>
<Input id="iu-name-{id}" {...inviteUser.fields.name.as('text')} />
</Field.Field>
<Field.Field>
<Field.Label for="iu-username-{id}">{m.username()}</Field.Label>
<Input id="iu-username-{id}" {...inviteUser.fields.username.as('text')} />
</Field.Field>
<Field.Field>
<Field.Label for="iu-role-{id}">{m.users_role()}</Field.Label>
<NativeSelect id="iu-role-{id}" {...inviteUser.fields.role.as('text')}>
<option value="user">{m.users_role_user()}</option>
<option value="admin">{m.users_role_admin()}</option>
</NativeSelect>
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Button type="button" variant="outline" onclick={() => (inviteOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit" disabled={!!inviteUser.pending}>{m.users_invite()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
+23
View File
@@ -0,0 +1,23 @@
import { json } from '@sveltejs/kit';
import { v } from '$lib';
import { MailSchema } from '$lib/server/emails/schemas';
import { sendMail } from '$lib/server/emails/send';
export const POST = async ({ request }) => {
let data: v.InferOutput<typeof MailSchema>;
try {
data = v.parse(MailSchema, await request.json());
} catch (err) {
console.error(err);
return json({ ok: false });
}
try {
await sendMail(data);
} catch (err) {
console.error(err);
return json({ ok: false });
}
return json({ ok: true });
};
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { Orbit } from '@lucide/svelte';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
let { children } = $props();
// 2FA enrollment needs room for its two-column layout; every other auth page stays narrow.
const wide = $derived(page.route.id === '/auth/setup-2fa');
</script>
<div class="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10 relative z-10">
<div class={['flex w-full flex-col gap-6', wide ? 'max-w-2xl' : 'max-w-sm']}>
<a href={resolve('/')} class="flex items-center gap-2 self-center font-medium">
<div class="text-primary flex size-6 items-center justify-center rounded-md">
<Orbit class="size-6" />
</div>
<h3 class="font-bold text-2xl">{m.appname()}</h3>
</a>
{@render children()}
</div>
</div>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { getAuthClient } from '$lib/auth/client';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import * as InputOTP from '$lib/components/ui/input-otp/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { toast } from 'svelte-sonner';
const authClient = getAuthClient();
const id = $props.id();
let code = $state('');
let backup = $state(false);
let trust = $state(true);
let loading = $state(false);
async function verify(event: SubmitEvent) {
event.preventDefault();
loading = true;
const { error } = backup
? await authClient.twoFactor.verifyBackupCode({ code, trustDevice: trust })
: await authClient.twoFactor.verifyTotp({ code, trustDevice: trust });
loading = false;
if (error) {
toast.error(error.message || m.errors_invalid_code());
return;
}
await goto(resolve('/'));
}
</script>
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.two_factor_title()}</Card.Title>
<Card.Description class="text-balance">{m.two_factor_description()}</Card.Description>
</Card.Header>
<Card.Content>
<form onsubmit={verify}>
<Field.Group>
<Field.Field>
<Field.Label for="code-{id}">{backup ? m.backup_code() : m.code()}</Field.Label>
{#if backup}
<Input id="code-{id}" bind:value={code} autocomplete="one-time-code" required />
{:else}
<InputOTP.Root maxlength={6} id="code-{id}" bind:value={code} class="justify-center">
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell (cell)}
<InputOTP.Slot class="size-12 text-lg" {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(3, 6) as cell (cell)}
<InputOTP.Slot class="size-12 text-lg" {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Checkbox id="trust-{id}" bind:checked={trust} />
<Field.Label for="trust-{id}" class="font-normal">{m.trust_device()}</Field.Label>
</Field.Field>
<Field.Field>
<Button type="submit" disabled={loading || (!backup && code.length < 6)}>
{m.verify()}
</Button>
<Field.Description class="text-center">
<button
type="button"
class="link"
onclick={() => {
backup = !backup;
code = '';
}}
>
{backup ? m.use_authenticator() : m.use_backup_code()}
</button>
</Field.Description>
</Field.Field>
</Field.Group>
</form>
</Card.Content>
</Card.Root>
@@ -0,0 +1,62 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { resetRequestSchema } from '$lib/auth/schemas';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { requestReset } from '$lib/remotes/auth.remote';
import { toast } from 'svelte-sonner';
const id = $props.id();
</script>
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.forgot_password_title()}</Card.Title>
<Card.Description class="text-balance">
{requestReset.result?.sent ? m.reset_link_sent() : m.forgot_password_description()}
</Card.Description>
</Card.Header>
<Card.Content>
{#if requestReset.result?.sent}
<Button href="/auth/sign-in" variant="outline" class="w-full">{m.back_to_login()}</Button>
{:else}
<form
oninput={() => requestReset.validate()}
{...requestReset.preflight(resetRequestSchema).enhance(async ({ submit }) => {
try {
await submit();
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
);
}
})}
>
<Field.Group>
<Field.Field>
<Field.Label for="email-{id}">{m.email()}</Field.Label>
<Input
id="email-{id}"
placeholder={m.email_placeholder()}
{...requestReset.fields.email.as('email')}
required
/>
{#each requestReset.fields.email.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Button type="submit" disabled={!!requestReset.pending}>{m.send_reset_link()}</Button>
<Field.Description class="text-center">
<a href={resolve('/auth/sign-in')} class="link">{m.back_to_login()}</a>
</Field.Description>
</Field.Field>
</Field.Group>
</form>
{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,72 @@
<script lang="ts">
import { page } from '$app/state';
import { resetPasswordSchema } from '$lib/auth/schemas';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { resetPassword } from '$lib/remotes/auth.remote';
import { toast } from 'svelte-sonner';
const id = $props.id();
const token = page.url.searchParams.get('token') ?? '';
</script>
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.reset_password_title()}</Card.Title>
<Card.Description class="text-balance">
{token ? m.reset_password_description() : m.invalid_reset_link()}
</Card.Description>
</Card.Header>
<Card.Content>
{#if token}
<form
oninput={() => resetPassword.validate()}
{...resetPassword.preflight(resetPasswordSchema).enhance(async ({ submit }) => {
try {
await submit();
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
);
}
})}
>
<Field.Group>
<input {...resetPassword.fields.token.as('hidden', token)} />
<Field.Field>
<Field.Label for="password-{id}">{m.new_password()}</Field.Label>
<Input
id="password-{id}"
{...resetPassword.fields.newPassword.as('password')}
required
/>
{#each resetPassword.fields.newPassword.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="confirm-{id}">{m.confirm_password()}</Field.Label>
<Input id="confirm-{id}" {...resetPassword.fields._confirm.as('password')} required />
{#each resetPassword.fields._confirm.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
<Field.Description>{m.password_hint()}</Field.Description>
</Field.Field>
<Field.Field>
<Button type="submit" disabled={!!resetPassword.pending}>
{m.reset_password_action()}
</Button>
</Field.Field>
</Field.Group>
</form>
{:else}
<Button href="/auth/forgot-password" variant="outline" class="w-full">
{m.forgot_password_title()}
</Button>
{/if}
</Card.Content>
</Card.Root>
+162
View File
@@ -0,0 +1,162 @@
<script lang="ts">
import DownloadIcon from '@lucide/svelte/icons/download';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { getAuthClient } from '$lib/auth/client';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import * as InputOTP from '$lib/components/ui/input-otp/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { toast } from 'svelte-sonner';
import { renderSVG } from 'uqr';
const authClient = getAuthClient();
const id = $props.id();
let step = $state<'password' | 'verify'>('password');
let password = $state('');
let code = $state('');
let totpURI = $state('');
let secret = $state('');
let backupCodes = $state<string[]>([]);
let loading = $state(false);
async function enable(event: SubmitEvent) {
event.preventDefault();
loading = true;
const { data, error } = await authClient.twoFactor.enable({ password });
loading = false;
if (error || !data) {
toast.error(error?.message || m.errors_generic());
return;
}
totpURI = data.totpURI;
secret = new URL(data.totpURI).searchParams.get('secret') ?? '';
backupCodes = data.backupCodes;
step = 'verify';
}
function downloadBackupCodes() {
const blob = new Blob([backupCodes.join('\n') + '\n'], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${m.appname()}-backup-codes.txt`;
a.click();
URL.revokeObjectURL(url);
}
async function confirm(event: SubmitEvent) {
event.preventDefault();
loading = true;
// twoFactorEnabled flips to true only once the first code verifies.
const { error } = await authClient.twoFactor.verifyTotp({ code, trustDevice: true });
loading = false;
if (error) {
toast.error(error.message || m.errors_invalid_code());
return;
}
await goto(resolve('/'));
}
</script>
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.setup_2fa_title()}</Card.Title>
<Card.Description class="text-balance">
{step === 'password' ? m.setup_2fa_description() : m.scan_qr()}
</Card.Description>
</Card.Header>
<Card.Content>
{#if step === 'password'}
<form onsubmit={enable} class="mx-auto w-full max-w-sm">
<Field.Group>
<Field.Field>
<Field.Label for="password-{id}">{m.enter_password_to_continue()}</Field.Label>
<Input
id="password-{id}"
type="password"
bind:value={password}
autocomplete="current-password"
required
/>
</Field.Field>
<Field.Field>
<Button type="submit" disabled={loading}>{m.continue_action()}</Button>
</Field.Field>
</Field.Group>
</form>
{:else}
<form onsubmit={confirm} class="grid gap-8 md:grid-cols-2">
<!-- Left: scan / manual entry -->
<div class="flex flex-col gap-3">
<div class="[&>svg]:h-auto [&>svg]:w-full mx-auto w-full rounded-md bg-white p-3">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderSVG(totpURI)}
</div>
<div class="flex flex-col gap-1">
<span class="text-sm font-medium">{m.manual_entry_key()}</span>
<code class="bg-muted rounded-md p-2 font-mono text-xs break-all select-all">
{secret}
</code>
</div>
</div>
<!-- Right: backup codes + verification -->
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium">{m.backup_codes_title()}</span>
</div>
<span class="text-muted-foreground text-sm">{m.backup_codes_notice()}</span>
<div
class="bg-muted grid grid-cols-2 gap-1 rounded-md p-3 text-center font-mono text-sm"
>
{#each backupCodes as backupCode (backupCode)}
<span class="select-all">{backupCode}</span>
{/each}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onclick={downloadBackupCodes}
disabled={!backupCodes.length}
>
<DownloadIcon />
{m.download()}
</Button>
<Field.Field>
<Field.Label for="code-{id}">{m.code()}</Field.Label>
<InputOTP.Root
maxlength={6}
id="code-{id}"
bind:value={code}
class="flex justify-center"
>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(3, 6) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
</Field.Field>
<Button type="submit" class="mt-auto" disabled={loading || code.length < 6}>
{m.finish()}
</Button>
</div>
</form>
{/if}
</Card.Content>
</Card.Root>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { env } from '$env/dynamic/public';
import { getAuthClient } from '$lib/auth/client';
import { loginSchema } from '$lib/auth/schemas';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { getOAuthProviders, login } from '$lib/remotes/auth.remote';
import { cn } from '$lib/utils.js';
import { toast } from 'svelte-sonner';
const id = $props.id();
const authClient = getAuthClient();
const providers = $derived(await getOAuthProviders());
</script>
<div class={cn('flex flex-col gap-6')}>
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.welcome_back()}</Card.Title>
<Card.Description class="text-balance">{m.login_social_description()}</Card.Description>
</Card.Header>
<Card.Content>
<form
oninput={() => login.validate()}
{...login.preflight(loginSchema).enhance(async ({ submit }) => {
try {
await submit();
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
);
}
})}
>
<Field.Group>
{#each providers.socialProviders as provider (provider)}
<Field.Field>
<Button
variant="outline"
type="button"
onclick={async () => {
const { error } = await authClient.signIn.social({
callbackURL: new URL('/dashboard', env.PUBLIC_ORIGIN).href,
provider
});
if (error) toast.error(error.message || m.errors_generic());
}}
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html m.login_with({ social: provider })}
</Button>
</Field.Field>
{/each}
{#each providers.oauthConfig as provider (provider.providerId)}
<Field.Field>
<Button
variant="outline"
type="button"
onclick={async () => {
const { error } = await authClient.signIn.oauth2({
callbackURL: new URL('/dashboard', env.PUBLIC_ORIGIN).href,
providerId: provider.providerId
});
if (error) toast.error(error.message || m.errors_generic());
}}
>
{m.login_with({ social: provider.providerId })}
</Button>
</Field.Field>
{/each}
{#if providers.oauthConfig.length || providers.socialProviders.length}
<Field.Separator class="*:data-[slot=field-separator-content]:bg-card">
{m.or()}
</Field.Separator>
{/if}
<Field.Field>
<Field.Label for="username-{id}">{m.username()}</Field.Label>
<Input
id="username-{id}"
autocomplete="username"
placeholder={m.username_placeholder()}
{...login.fields.username.as('text')}
required
/>
{#each login.fields.username.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<div class="flex items-center">
<Field.Label for="password-{id}">{m.password()}</Field.Label>
<a href={resolve('/auth/forgot-password')} class="ms-auto link text-sm underline">
{m.forgot_password()}
</a>
</div>
<Input
id="password-{id}"
autocomplete="current-password"
{...login.fields._password.as('password')}
required
/>
{#each login.fields._password.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field orientation="horizontal">
<Checkbox id="remember-{id}" name="rememberMe" value="yes" />
<Field.Label for="remember-{id}" class="font-normal">{m.remember_me()}</Field.Label>
</Field.Field>
<Field.Field>
<Button type="submit" disabled={!!login.pending}>{m.login()}</Button>
<Field.Description class="text-center">
{m.no_account()} <a href={resolve('/auth/sign-up')} class="link">{m.sign_up()}</a>
</Field.Description>
</Field.Field>
</Field.Group>
</form>
</Card.Content>
</Card.Root>
<Field.Description class="px-6 text-center text-balance">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html m.terms_notice({ privacy: '/privacy-policy', terms: '/terms-of-service' })}
</Field.Description>
</div>
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { registerSchema } from '$lib/auth/schemas';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { register } from '$lib/remotes/auth.remote';
import { toast } from 'svelte-sonner';
const id = $props.id();
</script>
{#if register.result?.email}
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.check_your_email()}</Card.Title>
<Card.Description class="text-balance"
>{m.verification_sent({ email: register.result.email })}</Card.Description
>
</Card.Header>
<Card.Footer>
<Button href="/auth/sign-in" variant="outline" class="w-full">{m.back_to_login()}</Button>
</Card.Footer>
</Card.Root>
{:else}
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.sign_up_title()}</Card.Title>
<Card.Description class="text-balance">{m.sign_up_description()}</Card.Description>
</Card.Header>
<Card.Content>
<form
oninput={() => register.validate()}
{...register.preflight(registerSchema).enhance(async ({ submit }) => {
try {
await submit();
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
);
}
})}
>
<Field.Group>
<Field.Field>
<Field.Label for="username-{id}">{m.username()}</Field.Label>
<Input
id="username-{id}"
placeholder={m.username_placeholder()}
{...register.fields.username.as('text')}
required
/>
{#each register.fields.username.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="email-{id}">{m.email()}</Field.Label>
<Input
id="email-{id}"
placeholder={m.email_placeholder()}
{...register.fields.email.as('email')}
required
/>
{#each register.fields.email.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="password-{id}">{m.password()}</Field.Label>
<Input id="password-{id}" {...register.fields._password.as('password')} required />
{#each register.fields._password.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="confirm-{id}">{m.confirm_password()}</Field.Label>
<Input id="confirm-{id}" {...register.fields._confirm.as('password')} required />
{#each register.fields._confirm.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
<Field.Description>{m.password_hint()}</Field.Description>
</Field.Field>
<Field.Field>
<Button type="submit" disabled={!!register.pending}>{m.create_account()}</Button>
<Field.Description class="text-center">
{m.already_have_account()}
<a href={resolve('/auth/sign-in')} class="link">{m.login()}</a>
</Field.Description>
</Field.Field>
</Field.Group>
</form>
</Card.Content>
</Card.Root>
{/if}
View File
@@ -0,0 +1,380 @@
<script lang="ts">
import {
Activity,
Clock,
Cpu,
Ellipsis,
MemoryStick,
Pause,
Pencil,
Play,
RefreshCw,
Trash2
} from '@lucide/svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import ActivityPanel from '$lib/components/dashboard/activity-panel.svelte';
import CpuHeatmap from '$lib/components/dashboard/cpu-heatmap.svelte';
import {
fmtDateTime,
gb,
pct,
uptime,
usageBar,
usageText
} from '$lib/components/dashboard/format';
import KpiCard from '$lib/components/dashboard/kpi-card.svelte';
import NetworkPanel from '$lib/components/dashboard/network-panel.svelte';
import StoragePanel from '$lib/components/dashboard/storage-panel.svelte';
import SystemPanel from '$lib/components/dashboard/system-panel.svelte';
import TemperaturePanel from '$lib/components/dashboard/temperature-panel.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Progress } from '$lib/components/ui/progress';
import * as Select from '$lib/components/ui/select';
import { machineDeleteSchema, machineEditSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import { deleteMachine, listMachines, updateMachine } from '$lib/remotes/machines.remote';
import {
auditLog,
latestAgentRelease,
serverInfo,
systemDetails
} from '$lib/remotes/server.remote';
import { toast } from 'svelte-sonner';
const info = $derived(serverInfo());
const audit = $derived(auditLog());
const details = $derived(systemDetails());
const latest = $derived(latestAgentRelease());
const intervals = $derived([
{ label: m.dashboard_interval_second(), value: '1' },
{ label: m.dashboard_interval_5s(), value: '5' },
{ label: m.dashboard_interval_10s(), value: '10' },
{ label: m.dashboard_interval_30s(), value: '30' },
{ label: m.dashboard_interval_custom(), value: 'custom' }
]);
let syncInterval = $state('5');
let customSec = $state(15);
const intervalMs = $derived(
(syncInterval === 'custom' ? customSec : Number(syncInterval)) * 1000
);
const intervalLabel = $derived(intervals.find((i) => i.value === syncInterval)?.label);
const poll = async () => {
console.log('refreshed poll');
await serverInfo().refresh();
await auditLog().refresh();
};
const refreshAll = async () => {
console.log('refreshed all');
await poll();
await systemDetails().refresh();
};
let polling = $state(true);
// $effect re-runs when rate or polling toggle, so changing the selector takes
// effect immediately and pausing actually stops the timer.
$effect(() => {
if (!polling || intervalMs <= 0) return;
const id = setInterval(poll, intervalMs);
return () => clearInterval(id);
});
let editOpen = $state(false);
let deleteOpen = $state(false);
const formId = $props.id();
</script>
<div class="mx-auto flex w-full flex-col gap-0 grow">
<svelte:boundary>
{#snippet failed(err)}
{console.log('errore', err)}
{/snippet}
{@const sys = await info}
{@const log = await audit}
{@const d = await details}
{@const latestTag = await latest}
{@const agentVersion = (sys as unknown as { agent_version?: string }).agent_version}
{@const outdated = !!(agentVersion && latestTag && agentVersion !== latestTag)}
{@const memPct = pct(sys.memory.used_bytes, sys.memory.total_bytes)}
{@const loadPct = Math.round((sys.load.load1 / sys.cpu.logical_cpus) * 100)}
{@const swapPct = pct(
sys.memory.swap_total_bytes - sys.memory.swap_free_bytes,
sys.memory.swap_total_bytes
)}
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 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">{sys.$db.name}</h1>
<p class="text-muted-foreground text-sm truncate">
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address}
</p>
</div>
<div class="flex flex-wrap items-center lg:justify-end gap-2 py-1">
<Select.Root type="single" bind:value={syncInterval}>
<Select.Trigger class="h-8 w-auto grow lg:w-35">{intervalLabel}</Select.Trigger>
<Select.Content>
{#each intervals as opt (opt.value)}
<Select.Item value={opt.value}>{opt.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if syncInterval === 'custom'}
<div class="flex items-center gap-1.5">
<Input type="number" min="1" bind:value={customSec} class="h-8 w-16" />
</div>
{/if}
<Button
variant="outline"
size="icon"
class="h-8 w-8"
onclick={() => (polling = !polling)}
aria-label={polling ? m.dashboard_pause() : m.dashboard_resume()}
>
{#if polling}
<Pause class="size-4" />
{:else}
<Play class="size-4" />
{/if}
</Button>
<Button
variant="outline"
class="h-8 px-2 sm:px-3"
onclick={refreshAll}
aria-label={m.dashboard_refresh()}
>
<RefreshCw class="size-4" />
<span class="hidden sm:inline">{m.dashboard_refresh()}</span>
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="icon"
class="h-8 w-8 relative"
aria-label={m.machine_actions()}
>
<Ellipsis class="size-4" />
{#if outdated}
<span
class="absolute top-1 right-1 size-2 rounded-full bg-red-500 ring-2 ring-background"
title={`nadir-agent ${agentVersion}${latestTag}`}
></span>
{/if}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onSelect={() => (editOpen = true)}>
<Pencil class="size-4" />
{m.edit()}
</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onSelect={() => (deleteOpen = true)}>
<Trash2 class="size-4" />
{m.delete()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 px-4 py-2">
<!-- CPU Heatmap (replaces the old CPU KpiCard) -->
<KpiCard
label={m.dashboard_cpu()}
icon={Cpu}
value={sys.cpu.model}
detail={m.dashboard_logical_cores({ cores: sys.cpu.logical_cpus })}
/>
<KpiCard
label={m.dashboard_memory()}
icon={MemoryStick}
value="{memPct}%"
valueClass="tabular-nums {usageText(memPct)}"
>
{#snippet extra()}
<div
class="text-muted-foreground flex items-baseline justify-between text-xs tabular-nums"
>
<span>{gb(sys.memory.used_bytes)} / {gb(sys.memory.total_bytes)}</span>
</div>
<Progress value={memPct} class={usageBar(memPct)} />
{#if sys.memory.swap_total_bytes > 0}
<div
class="text-muted-foreground flex items-baseline justify-between text-xs tabular-nums"
>
<span
>{gb(sys.memory.swap_total_bytes - sys.memory.swap_free_bytes)} / {gb(
sys.memory.swap_total_bytes
)}</span
>
<span>{m.dashboard_swap({ swapPct })}</span>
</div>
<Progress value={swapPct} class={usageBar(swapPct)} />
{/if}
{/snippet}
</KpiCard>
<KpiCard
label={m.dashboard_load_average()}
icon={Activity}
value={sys.load.load1.toFixed(2)}
valueClass="tabular-nums {usageText(loadPct)}"
detail={m.dashboard_load_detail({
cores: sys.cpu.logical_cpus,
load15: sys.load.load15.toFixed(2),
load5: sys.load.load5.toFixed(2),
loadPct
})}
/>
<KpiCard
label={m.dashboard_uptime()}
icon={Clock}
value={uptime(sys.uptime_seconds)}
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
/>
</div>
<div class="grid items-center gap-4 justify-center lg:grid-cols-3 p-4 py-2">
<div class="col-span-2">
<StoragePanel items={sys.disks ?? []} />
</div>
<CpuHeatmap
cpuUsage={sys.load.cpu_usage}
cpuModel={sys.cpu.model}
logicalCpus={sys.cpu.logical_cpus}
minMhz={sys.cpu.min_mhz}
maxMhz={sys.cpu.max_mhz}
currentMhz={sys.cpu.current_mhz}
/>
</div>
<div class="grid items-start gap-4 lg:grid-cols-3 p-4 py-2">
<SystemPanel os={sys.os} cpu={sys.cpu} details={d} />
<NetworkPanel items={sys.network_interfaces ?? []} />
<TemperaturePanel items={sys.temperatures ?? []} />
</div>
<div class="px-4 py-2 pb-4">
<ActivityPanel items={log} />
</div>
<Dialog.Root bind:open={editOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.machine_edit()}</Dialog.Title>
<Dialog.Description>{m.machine_edit_description()}</Dialog.Description>
</Dialog.Header>
<form
oninput={() => updateMachine.validate()}
{...updateMachine.preflight(machineEditSchema).enhance(async ({ submit }) => {
try {
await submit();
await Promise.all([
listMachines({ page: 1, search: '' }).refresh(),
info.refresh(),
details.refresh()
]);
editOpen = false;
toast.success(m.machine_edit());
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
);
}
})}
>
<input type="hidden" name="id" value={sys.$db.id} />
<Field.Group>
<Field.Field>
<Field.Label for="edit-name-{formId}">{m.machine_name()}</Field.Label>
<Input
id="edit-name-{formId}"
placeholder={m.machine_name_placeholder()}
{...updateMachine.fields.name.as('text')}
value={sys.$db.name}
required
/>
{#each updateMachine.fields.name.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="edit-address-{formId}">{m.machine_address()}</Field.Label>
<Input
id="edit-address-{formId}"
placeholder={m.machine_address_placeholder()}
{...updateMachine.fields.address.as('text')}
value={sys.$db.address}
/>
{#each updateMachine.fields.address.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="edit-token-{formId}">{m.machine_token()}</Field.Label>
<Input
id="edit-token-{formId}"
placeholder={m.machine_token_placeholder()}
{...updateMachine.fields.token.as('password')}
/>
<Field.Description>{m.machine_token_keep()}</Field.Description>
{#each updateMachine.fields.token.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Button type="submit" disabled={!!updateMachine.pending}>{m.machine_save_edit()}</Button
>
</Field.Group>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.machine_delete_title()}</AlertDialog.Title>
<AlertDialog.Description>
{m.machine_delete_confirm({ name: sys.$db.name ?? '' })}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<form
{...deleteMachine.preflight(machineDeleteSchema).enhance(async ({ submit }) => {
try {
await submit();
await listMachines({ page: 1, search: '' }).refresh();
deleteOpen = false;
goto(resolve('/dashboard'));
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
);
}
})}
>
<input type="hidden" name="id" value={sys.$db.id} />
<AlertDialog.Action type="submit" disabled={!!deleteMachine.pending}>
{m.delete()}
</AlertDialog.Action>
</form>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</svelte:boundary>
</div>
+171
View File
@@ -0,0 +1,171 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn-svelte/tailwind.css';
@custom-variant dark (&:is(.dark *));
:root {
--theme-light: oklch(0.985 0 0);
--theme-dark: oklch(0.145 0 0);
--theme-primary: oklch(75% 0.15 250);
--theme-secondary: oklch(0.68 0.14 35);
--theme-tertiary: oklch(75% 0.1 255);
--background: var(--theme-light);
--foreground: var(--theme-dark);
--card: oklch(from var(--theme-light) l c h / 100%);
--card-foreground: var(--theme-dark);
--popover: oklch(from var(--theme-light) l c h / 100%);
--popover-foreground: var(--theme-dark);
--primary: var(--theme-primary);
--primary-foreground: var(--theme-dark);
--secondary: var(--theme-secondary);
--secondary-foreground: var(--theme-dark);
--tertiary: var(--theme-tertiary);
--tertiary-foreground: var(--theme-dark);
--accent: var(--theme-tertiary);
--accent-foreground: var(--theme-dark);
--muted: oklch(from var(--theme-light) calc(l - 0.03) c h);
--muted-foreground: oklch(from var(--theme-dark) calc(l + 0.4) c h);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(from var(--theme-light) calc(l - 0.07) c h);
--input: oklch(from var(--theme-light) calc(l - 0.07) c h);
--ring: var(--theme-primary);
--chart-1: var(--theme-primary);
--chart-2: var(--theme-secondary);
--chart-3: var(--theme-tertiary);
--chart-4: oklch(from var(--theme-primary) calc(l - 0.15) c h);
--chart-5: oklch(from var(--theme-secondary) calc(l - 0.15) c h);
--radius: 0.25rem;
--sidebar: oklch(from var(--theme-light) calc(l - 0.01) c h);
--sidebar-foreground: var(--theme-dark);
--sidebar-primary: var(--theme-primary);
--sidebar-primary-foreground: var(--theme-light);
--sidebar-accent: var(--theme-secondary);
--sidebar-accent-foreground: var(--theme-dark);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
}
.dark {
--background: var(--theme-dark);
--foreground: var(--theme-light);
--card: oklch(from var(--theme-dark) calc(l + 0.06) c h);
--card-foreground: var(--theme-light);
--popover: oklch(from var(--theme-dark) calc(l + 0.06) c h);
--popover-foreground: var(--theme-light);
--primary: var(--theme-primary);
--primary-foreground: var(--theme-dark);
--secondary: var(--theme-secondary);
--secondary-foreground: var(--theme-dark);
--accent: var(--theme-tertiary);
--accent-foreground: var(--theme-dark);
--muted: oklch(from var(--theme-dark) calc(l + 0.12) c h);
--muted-foreground: oklch(from var(--theme-light) calc(l - 0.3) c h);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(from var(--theme-dark) calc(l + 0.125) c h / 30%);
--input: oklch(from var(--theme-dark) calc(l + 0.25) c h / 100%);
--ring: var(--theme-primary);
--chart-1: var(--theme-primary);
--chart-2: var(--theme-secondary);
--chart-3: var(--theme-tertiary);
--chart-4: oklch(from var(--theme-primary) calc(l + 0.1) c h);
--chart-5: oklch(from var(--theme-secondary) calc(l + 0.1) c h);
--sidebar: oklch(from var(--theme-dark) calc(l + 0.04) c h);
--sidebar-foreground: var(--theme-light);
--sidebar-primary: var(--theme-primary);
--sidebar-primary-foreground: var(--theme-light);
--sidebar-accent: var(--theme-secondary);
--sidebar-accent-foreground: var(--theme-dark);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
}
@theme inline {
--font-sans: 'Work Sans', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-tertiary: var(--tertiary);
--color-tertiary-foreground: var(--tertiary-foreground);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
.link {
@apply font-medium decoration-inherit decoration-2 underline-offset-1!;
}
::selection {
@apply bg-primary text-background;
}
::-webkit-scrollbar {
@apply h-2 w-2;
}
::-webkit-scrollbar-track {
@apply border-s border-border bg-background;
}
::-webkit-scrollbar-thumb {
@apply bg-muted;
}
input:-webkit-autofill,
textarea:-webkit-autofill,
select:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px hsl(var(--background)) inset !important;
box-shadow: 0 0 0 1000px hsl(var(--background)) inset !important;
-webkit-text-fill-color: hsl(var(--foreground)) !important;
}
input:-webkit-autofill,
textarea:-webkit-autofill,
select:-webkit-autofill {
-webkit-background-clip: text;
}
}
View File