feat: expand dashboard with storage, network, and package management features while enhancing UI components and remote services

This commit is contained in:
2026-06-24 12:34:14 +02:00
parent f8a17fa68a
commit 522ab09bd8
84 changed files with 7718 additions and 487 deletions
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import HomeIcon from '@lucide/svelte/icons/home';
import LockIcon from '@lucide/svelte/icons/lock';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import SearchXIcon from '@lucide/svelte/icons/search-x';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import { goto, invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import * as Empty from '$lib/components/ui/empty';
import { m } from '$lib/paraglide/messages';
const status = $derived(page.status);
const message = $derived(page.error?.message ?? '');
const kind = $derived(
status === 404 ? 'not_found' : status === 401 || status === 403 ? 'unauthorized' : 'generic'
);
const title = $derived(
kind === 'not_found'
? m.error_not_found_title()
: kind === 'unauthorized'
? m.error_unauthorized_title()
: m.error_generic_title()
);
const description = $derived(
kind === 'not_found'
? m.error_not_found_description()
: kind === 'unauthorized'
? m.error_unauthorized_description()
: m.error_generic_description()
);
const Icon = $derived(
kind === 'not_found' ? SearchXIcon : kind === 'unauthorized' ? LockIcon : TriangleAlertIcon
);
</script>
<PageMeta {title} {description} noIndex />
<div class="mx-auto flex w-full max-w-2xl flex-col gap-4 p-6">
<Empty.Root class="border">
<Empty.Header>
<Empty.Media variant="icon" class="size-12">
<Icon class="size-6" />
</Empty.Media>
<Empty.Title>{title}</Empty.Title>
<Empty.Description>{description}</Empty.Description>
</Empty.Header>
<Empty.Content class="gap-3">
<div class="text-muted-foreground text-xs font-mono">
{m.error_code_label({ status })}
</div>
{#if message}
<details class="w-full max-w-lg text-left">
<summary class="text-muted-foreground cursor-pointer text-xs">
{m.error_details()}
</summary>
<pre
class="bg-muted text-foreground mt-2 max-h-64 overflow-auto rounded-md border p-3 text-xs whitespace-pre-wrap">{message}</pre>
</details>
{/if}
<div class="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline" onclick={() => invalidateAll()}>
<RefreshCwIcon class="size-4" />
{m.error_action_retry()}
</Button>
<Button variant="outline" onclick={() => history.back()}>
<ArrowLeftIcon class="size-4" />
{m.error_action_back()}
</Button>
<Button onclick={() => goto(resolve('/'))}>
<HomeIcon class="size-4" />
{m.error_action_home()}
</Button>
</div>
</Empty.Content>
</Empty.Root>
</div>
+16 -6
View File
@@ -1,23 +1,31 @@
<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 TerminalDialog from '$lib/components/blocks/terminal-dialog.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';
import { setContext } from 'svelte';
let { children } = $props();
const user = $derived(getUser());
let showSidebar =$derived( (cU:null|User)=> cU && (page.url.pathname.startsWith('/dashboard')||page.url.pathname.startsWith('/admin')))
let showSidebar = $derived(
(cU: null | User) =>
cU && (page.url.pathname.startsWith('/dashboard') || page.url.pathname.startsWith('/admin'))
);
class TerminalState {
open = $state(false);
}
const terminalState = new TerminalState();
setContext('terminalState', terminalState);
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
@@ -25,7 +33,7 @@
<Sidebar.Provider style="--sidebar-width: 350px;">
{@const currentUser = await user}
{@const show = currentUser && showSidebar(currentUser)}
{#if show }
{#if show}
<AppSidebar user={currentUser} />
{/if}
<Sidebar.Inset>
@@ -49,9 +57,11 @@
<div style="display:none">
{#each locales as locale (locale)}
<a href={resolve(localizeHref(page.url.pathname, { locale }) as Pathname)}>{locale}</a>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={localizeHref(page.url.pathname, { locale })}>{locale}</a>
{/each}
</div>
<ModeWatcher />
<Toaster />
<TerminalDialog />
+6
View File
@@ -1,2 +1,8 @@
<script lang="ts">
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { m } from '$lib/paraglide/messages';
</script>
<PageMeta title={m.seo_title_root()} description={m.seo_desc_root()} noIndex />
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { m } from '$lib/paraglide/messages';
</script>
<PageMeta title={m.seo_title_admin_config()} description={m.seo_desc_admin_config()} />
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { m } from '$lib/paraglide/messages';
</script>
<PageMeta title={m.seo_title_admin_config()} description={m.seo_desc_admin_config()} />
+4 -2
View File
@@ -9,6 +9,7 @@
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { createUserSchema, inviteUserSchema, updateUserSchema } from '$lib/auth/schemas';
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';
@@ -194,9 +195,10 @@
}
</script>
<PageMeta title={m.seo_title_admin_users()} description={m.seo_desc_admin_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 bg-background p-4 py-2 z-20"
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 flex gap-2 items-center truncate">
@@ -353,7 +355,7 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} class="rounded-s-none border-s px-2" aria-label="more">
<Button {...props} class="rounded-s-none border-s px-2" aria-label={m.more()}>
<ChevronDownIcon class="size-4" />
</Button>
{/snippet}
@@ -0,0 +1,34 @@
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');
};
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Orbit } from '@lucide/svelte';
import Orbit from '@lucide/svelte/icons/orbit';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
@@ -11,7 +11,7 @@
</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']}>
<div class={['flex w-full flex-col gap-6', wide ? 'max-w-3xl' : '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" />
+2
View File
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { getAuthClient } from '$lib/auth/client';
import PageMeta from '$lib/components/seo/page-meta.svelte';
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';
@@ -34,6 +35,7 @@
}
</script>
<PageMeta title={m.seo_title_auth_2fa()} description={m.seo_desc_auth_2fa()} noIndex />
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.two_factor_title()}</Card.Title>
@@ -1,6 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { resetRequestSchema } from '$lib/auth/schemas';
import PageMeta from '$lib/components/seo/page-meta.svelte';
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';
@@ -12,6 +13,11 @@
const id = $props.id();
</script>
<PageMeta
title={m.seo_title_auth_forgot_password()}
description={m.seo_desc_auth_forgot_password()}
noIndex
/>
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.forgot_password_title()}</Card.Title>
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { resetPasswordSchema } from '$lib/auth/schemas';
import PageMeta from '$lib/components/seo/page-meta.svelte';
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';
@@ -13,6 +14,11 @@
const token = page.url.searchParams.get('token') ?? '';
</script>
<PageMeta
title={m.seo_title_auth_reset_password()}
description={m.seo_desc_auth_reset_password()}
noIndex
/>
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.reset_password_title()}</Card.Title>
+2
View File
@@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { getAuthClient } from '$lib/auth/client';
import PageMeta from '$lib/components/seo/page-meta.svelte';
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';
@@ -62,6 +63,7 @@
}
</script>
<PageMeta title={m.seo_title_auth_setup_2fa()} description={m.seo_desc_auth_setup_2fa()} noIndex />
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.setup_2fa_title()}</Card.Title>
+2
View File
@@ -3,6 +3,7 @@
import { env } from '$env/dynamic/public';
import { getAuthClient } from '$lib/auth/client';
import { loginSchema } from '$lib/auth/schemas';
import PageMeta from '$lib/components/seo/page-meta.svelte';
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';
@@ -19,6 +20,7 @@
const providers = $derived(await getOAuthProviders());
</script>
<PageMeta title={m.seo_title_auth_sign_in()} description={m.seo_desc_auth_sign_in()} noIndex />
<div class={cn('flex flex-col gap-6')}>
<Card.Root>
<Card.Header class="text-center">
+2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { registerSchema } from '$lib/auth/schemas';
import PageMeta from '$lib/components/seo/page-meta.svelte';
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';
@@ -12,6 +13,7 @@
const id = $props.id();
</script>
<PageMeta title={m.seo_title_auth_sign_up()} description={m.seo_desc_auth_sign_up()} noIndex />
{#if register.result?.email}
<Card.Root>
<Card.Header class="text-center">
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { m } from '$lib/paraglide/messages';
</script>
<PageMeta title={m.seo_title_dashboard()} description={m.seo_desc_dashboard()} />
+35 -27
View File
@@ -1,21 +1,19 @@
<script lang="ts">
import {
Activity,
Clock,
ClockAlert,
Cpu,
Ellipsis,
Globe,
MemoryStick,
Pause,
Pencil,
Play,
RefreshCw,
Server,
Trash2,
User,
X
} from '@lucide/svelte';
import Activity from '@lucide/svelte/icons/activity';
import Clock from '@lucide/svelte/icons/clock';
import ClockAlert from '@lucide/svelte/icons/clock-alert';
import Cpu from '@lucide/svelte/icons/cpu';
import Ellipsis from '@lucide/svelte/icons/ellipsis';
import Globe from '@lucide/svelte/icons/globe';
import MemoryStick from '@lucide/svelte/icons/memory-stick';
import Pause from '@lucide/svelte/icons/pause';
import Pencil from '@lucide/svelte/icons/pencil';
import Play from '@lucide/svelte/icons/play';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Server from '@lucide/svelte/icons/server';
import Trash2 from '@lucide/svelte/icons/trash-2';
import User from '@lucide/svelte/icons/user';
import X from '@lucide/svelte/icons/x';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
@@ -35,6 +33,7 @@
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 PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { ButtonGroup } from '$lib/components/ui/button-group';
@@ -49,7 +48,12 @@
import { Spinner } from '$lib/components/ui/spinner';
import { machineDeleteSchema, machineEditSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import { deleteMachine, listMachines, updateMachine } from '$lib/remotes/machines.remote';
import {
deleteMachine,
listMachines,
machineHealth,
updateMachine
} from '$lib/remotes/machines.remote';
import {
auditLog,
latestAgentRelease,
@@ -59,11 +63,12 @@
} from '$lib/remotes/server.remote';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!)
const machineId = $derived(page.params.machineId!);
const info = $derived(serverInfo(machineId));
const audit = $derived(auditLog({limit:undefined,machineId}));
const audit = $derived(auditLog({ limit: undefined, machineId }));
const details = $derived(systemDetails(machineId));
const latest = $derived(latestAgentRelease());
const machineName = $derived(info.current?.$db.name ?? machineId);
const intervals = $derived([
{ label: m.dashboard_interval_second(), value: '1' },
{ label: m.dashboard_interval_5s(), value: '5' },
@@ -80,7 +85,9 @@
const poll = async () => {
await serverInfo(machineId).refresh();
await auditLog({limit:undefined, machineId}).refresh();
await auditLog({ limit: undefined, machineId }).refresh();
// keep the sidebar dot in sync with what the dashboard sees
void machineHealth(machineId).refresh();
};
const refreshAll = async () => {
await poll();
@@ -88,7 +95,7 @@
};
let polling = $state(true);
$effect(() => {
if (!polling || intervalMs <= 0) return;
const id = setInterval(poll, intervalMs);
@@ -101,6 +108,7 @@
const formId = $props.id();
</script>
<PageMeta title={machineName} description={m.seo_desc_machine_detail()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<svelte:boundary>
{#snippet failed(err)}
@@ -113,7 +121,7 @@
<Trunk>
<Card.Root class="relative m-auto z-20">
<Card.Content>
<Empty.Root class="max-w-2xl">
<Empty.Root class="max-w-3xl">
<Empty.Header>
<p class="text-destructive font-mono text-sm">{m.machine_offline_code()}</p>
<Empty.Title>{m.machine_offline_title({ name: raw.$db.name ?? '' })}</Empty.Title>
@@ -210,7 +218,7 @@
)}
<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"
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 flex gap-2 items-center truncate">
@@ -387,8 +395,8 @@
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
/>
</div>
<div class="grid items-center gap-4 justify-center grid-cols-1 lg:grid-cols-3 p-4 py-2">
<div class="col-span-2 w-full">
<div class="grid items-center gap-4 justify-center grid-cols-1 lg:grid-cols-3 p-4 py-2">
<div class="col-span-2 w-full h-full">
<StoragePanel items={sys.disks ?? []} />
</div>
@@ -519,7 +527,7 @@
</AlertDialog.Content>
</AlertDialog.Root>
{#snippet pending()}
<Spinner class="size-24 m-auto"/>
<Spinner class="size-24 m-auto" />
{/snippet}
</svelte:boundary>
</div>
@@ -0,0 +1,64 @@
<script lang="ts">
import GlobeIcon from '@lucide/svelte/icons/globe';
import NetworkIcon from '@lucide/svelte/icons/network';
import RouteIcon from '@lucide/svelte/icons/route';
import ServerIcon from '@lucide/svelte/icons/server';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
const machineId = $derived(page.params.machineId!);
</script>
<PageMeta title={m.seo_title_networking()} description={m.seo_desc_networking()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_networking()}</h1>
<div class="grid gap-4 sm:grid-cols-2">
<a href={resolve('/dashboard/[machineId]/networking/interfaces', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<NetworkIcon class="size-5" />
<Card.Title>{m.nav_networking_interfaces()}</Card.Title>
</div>
<Card.Description>{m.nav_networking_interfaces_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/networking/routes', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<RouteIcon class="size-5" />
<Card.Title>{m.nav_networking_routes()}</Card.Title>
</div>
<Card.Description>{m.nav_networking_routes_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/networking/hosts', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<ServerIcon class="size-5" />
<Card.Title>{m.nav_networking_hosts()}</Card.Title>
</div>
<Card.Description>{m.nav_networking_hosts_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/networking/dns', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<GlobeIcon class="size-5" />
<Card.Title>{m.nav_networking_dns()}</Card.Title>
</div>
<Card.Description>{m.nav_networking_dns_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
</div>
</div>
@@ -0,0 +1,50 @@
<script lang="ts">
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { getDns } from '$lib/remotes/networking.remote';
const machineId = $derived(pageState.params.machineId!);
const dns = $derived(getDns(machineId));
</script>
<PageMeta title={m.seo_title_networking_dns()} description={m.seo_desc_networking_dns()} />
<div class="mx-auto flex w-full max-w-2xl flex-col gap-4 p-4">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_networking_dns()}</h1>
<p class="text-muted-foreground text-sm">{m.networking_dns_description()}</p>
</div>
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => dns.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
</div>
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_dns_servers()}</Card.Title>
<Card.Description>{m.networking_dns_per_interface_hint()}</Card.Description>
</Card.Header>
<Card.Content>
{#if dns.loading && !dns.current}
<div class="text-muted-foreground py-2 text-sm"></div>
{:else if !dns.current?.length}
<div class="text-muted-foreground py-2 text-sm">{m.networking_no_dns()}</div>
{:else}
<ul class="flex flex-col gap-1 font-mono text-sm">
{#each dns.current as s, i (s + i)}
<li class="bg-muted/40 rounded px-3 py-1.5">{s}</li>
{/each}
</ul>
{/if}
</Card.Content>
</Card.Root>
</div>
@@ -0,0 +1,345 @@
<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 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 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 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 { deleteHost, listHosts, upsertHost } from '$lib/remotes/networking.remote';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
type Host = { hostnames: null | string[]; ip: string };
const id = $props.id();
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: '' });
let editingExisting = $state(false);
let deleteOpen = $state(false);
let deleting = $state<Host | null>(null);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
type SortKey = 'hostnames' | 'ip';
let sort = $state<{ dir: 'asc' | 'desc'; key: SortKey }>({ dir: 'asc', key: 'ip' });
function toggleSort(key: SortKey) {
if (sort.key === key) sort = { dir: sort.dir === 'asc' ? 'desc' : 'asc', key };
else sort = { dir: 'asc', key };
}
const filtered = $derived.by(() => {
const list = (hosts.current ?? []) as Host[];
const q = search.trim().toLowerCase();
const matched = !q
? [...list]
: list.filter(
(h) =>
h.ip.toLowerCase().includes(q) ||
(h.hostnames ?? []).some((n) => n.toLowerCase().includes(q))
);
matched.sort((a, b) => {
const av = sort.key === 'ip' ? a.ip : ((a.hostnames ?? [])[0] ?? '');
const bv = sort.key === 'ip' ? b.ip : ((b.hostnames ?? [])[0] ?? '');
const cmp = av.localeCompare(bv);
return sort.dir === 'asc' ? cmp : -cmp;
});
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: '' };
editOpen = true;
}
function openEdit(h: Host) {
editingExisting = true;
editForm = { hostnames: (h.hostnames ?? []).join(' '), ip: h.ip };
editOpen = true;
}
async function doSave() {
const ip = editForm.ip.trim();
const hostnames = editForm.hostnames.split(/\s+/).filter(Boolean);
if (!ip || !hostnames.length) return;
try {
await upsertHost({ hostnames, ip, machineId });
toast.success(m.networking_host_saved());
editOpen = false;
} catch (e) {
handleError(e);
}
}
async function doDelete() {
if (!deleting) return;
try {
await deleteHost({ ip: deleting.ip, machineId });
toast.success(m.networking_host_removed());
deleteOpen = false;
deleting = null;
} catch (e) {
handleError(e);
}
}
</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"
>
<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>
{#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-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>
{/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>
<Dialog.Root bind:open={editOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{editingExisting ? m.networking_host_edit() : m.networking_host_add()}
</Dialog.Title>
<Dialog.Description>{m.networking_host_add_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doSave();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="h-ip-{id}">{m.networking_col_ip()}</Label>
<Input
id="h-ip-{id}"
bind:value={editForm.ip}
placeholder="192.168.1.10"
required
readonly={editingExisting}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="h-names-{id}">{m.networking_col_hostnames()}</Label>
<Input
id="h-names-{id}"
bind:value={editForm.hostnames}
placeholder="server server.local"
required
/>
</div>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (editOpen = false)}>
{m.cancel()}
</Button>
<Button type="submit">{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
{m.networking_host_remove_title({ ip: deleting?.ip ?? '' })}
</AlertDialog.Title>
<AlertDialog.Description>{m.networking_host_remove_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.networking_host_remove()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,391 @@
<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 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 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 {
confirmChange,
getPending,
linkDown,
linkUp,
listInterfaces,
rollbackChange
} from '$lib/remotes/networking.remote';
import { PersistedState } from 'runed';
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());
}
async function run(key: string, fn: () => Promise<unknown>, success: string) {
busy = key;
try {
await fn();
toast.success(success);
} catch (e) {
handleError(e);
} finally {
busy = null;
}
}
type SortKey = 'mac' | 'mtu' | 'name' | 'state';
let sort = $state<{ dir: 'asc' | 'desc'; key: SortKey }>({ dir: 'asc', key: 'name' });
function toggleSort(key: SortKey) {
if (sort.key === key) sort = { dir: sort.dir === 'asc' ? 'desc' : 'asc', key };
else sort = { dir: 'asc', key };
}
const filtered = $derived.by(() => {
const list = interfaces.current ?? [];
const q = search.trim().toLowerCase();
const matched = !q
? [...list]
: list.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.mac.toLowerCase().includes(q) ||
(i.ipv4 ?? []).some((a) => a.toLowerCase().includes(q)) ||
(i.ipv6 ?? []).some((a) => a.toLowerCase().includes(q))
);
matched.sort((a, b) => {
const av = a[sort.key];
const bv = b[sort.key];
const cmp =
typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return sort.dir === 'asc' ? cmp : -cmp;
});
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_interfaces()}
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"
>
<span>
{m.networking_pending_banner({
iface: pending.current.interface,
seconds: pending.current.seconds_remaining
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={!!busy}
onclick={() =>
run(
'rollback',
() => rollbackChange({ machineId, name: pending.current!.interface }),
m.networking_rolled_back()
)}
>
<RotateCcwIcon class="size-4" />
{m.networking_rollback()}
</Button>
<Button
size="sm"
disabled={!!busy}
onclick={() =>
run(
'confirm',
() => confirmChange({ machineId, name: pending.current!.interface }),
m.networking_confirmed()
)}
>
<CheckIcon class="size-4" />
{m.networking_confirm()}
</Button>
</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>
@@ -0,0 +1,159 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import PencilIcon from '@lucide/svelte/icons/pencil';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
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 Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { getInterfaceConfig } from '$lib/remotes/networking.remote';
const name = $derived(pageState.params.name!);
const machineId = $derived(pageState.params.machineId!);
const config = $derived(getInterfaceConfig({ machineId, name }).current);
const v4Badge = $derived(config?.method === 'dhcp' ? 'DHCP' : 'Static');
const v6Badge = $derived(
config?.ipv6?.method === 'auto'
? 'SLAAC'
: config?.ipv6?.method === 'static'
? 'Static'
: config?.ipv6?.method === 'ignore'
? 'Disabled'
: '—'
);
</script>
<PageMeta title={name} description={m.seo_desc_networking_interfaces()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<div class="flex items-start gap-3">
<Button
variant="ghost"
size="icon"
href={resolve('/dashboard/[machineId]/networking/interfaces', { machineId })}
title={m.error_action_back()}
>
<ArrowLeftIcon class="size-4" />
</Button>
<div class="flex min-w-0 flex-1 items-start justify-between gap-2">
<div class="flex flex-col gap-0.5">
<h1 class="font-mono text-2xl font-semibold tracking-tight">{name}</h1>
<p class="text-muted-foreground text-sm">{m.networking_interfaces_description()}</p>
</div>
{#if config}
<Button
href={resolve('/dashboard/[machineId]/networking/interfaces/[name]/configure', {
machineId,
name
})}
>
<PencilIcon class="size-4" />
{m.networking_configure()}
</Button>
{/if}
</div>
</div>
{#if config}
<!-- IPv4 -->
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between gap-2">
<Card.Title>{m.networking_ipv4_section()}</Card.Title>
<Badge variant="outline">{v4Badge}</Badge>
</Card.Header>
<Card.Content class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.machine_address()}</span>
<span class="font-mono text-sm">{config.address || '—'}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.prefix()}</span>
<span class="font-mono text-sm">{config.prefix ?? '—'}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.networking_col_gateway()}</span>
<span class="font-mono text-sm">{config.gateway || '—'}</span>
</div>
</Card.Content>
</Card.Root>
<!-- IPv6 -->
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between gap-2">
<Card.Title>{m.networking_ipv6_section()}</Card.Title>
<Badge variant="outline">{v6Badge}</Badge>
</Card.Header>
{#if config.ipv6}
<Card.Content class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.machine_address()}</span>
<span class="font-mono text-sm">{config.ipv6.address || '—'}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.prefix()}</span>
<span class="font-mono text-sm">{config.ipv6.prefix ?? '—'}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.networking_col_gateway()}</span>
<span class="font-mono text-sm">{config.ipv6.gateway || '—'}</span>
</div>
</Card.Content>
{:else}
<Card.Content>
<p class="text-muted-foreground text-sm"></p>
</Card.Content>
{/if}
</Card.Root>
<!-- DNS -->
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_dns_section()}</Card.Title>
</Card.Header>
<Card.Content>
{#if config.dns?.length}
<div class="flex flex-col gap-1.5">
{#each config.dns as server, i (i)}
<span class="font-mono text-sm">{server}</span>
{/each}
</div>
{:else}
<p class="text-muted-foreground text-sm"></p>
{/if}
</Card.Content>
</Card.Root>
<!-- Routes -->
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_routes_section()}</Card.Title>
</Card.Header>
<Card.Content>
{#if config.routes?.length}
<div class="flex flex-col gap-1.5">
{#each config.routes as route, i (i)}
<div class="grid grid-cols-[1fr_1fr] gap-2 font-mono text-sm">
<span>{route.destination}</span>
<span class="text-muted-foreground">{route.gateway}</span>
</div>
{/each}
</div>
{:else}
<p class="text-muted-foreground text-sm"></p>
{/if}
</Card.Content>
</Card.Root>
<!-- Rollback -->
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_rollback_seconds()}</Card.Title>
</Card.Header>
<Card.Content>
<p class="font-mono text-sm">{config.rollback_seconds ?? 60}s</p>
</Card.Content>
</Card.Root>
{/if}
</div>
@@ -0,0 +1,355 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import PlusIcon from '@lucide/svelte/icons/plus';
import TrashIcon from '@lucide/svelte/icons/trash-2';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import { applyInterfaceConfig, getInterfaceConfig } from '$lib/remotes/networking.remote';
import { toast } from 'svelte-sonner';
const id = $props.id();
const name = $derived(pageState.params.name!);
const machineId = $derived(pageState.params.machineId!);
const config = $derived(getInterfaceConfig({ machineId, name }).current);
let v4Method = $state<'dhcp' | 'static'>('dhcp');
let v4 = $state({ address: '', gateway: '', prefix: 24 });
let v6Method = $state<'auto' | 'ignore' | 'static'>('auto');
let v6 = $state({ address: '', gateway: '', prefix: 64 });
let dns = $state<string[]>(['']);
let routes = $state<{ destination: string; gateway: string }[]>([]);
let rollbackSeconds = $state(60);
let confirmOpen = $state(false);
let submitting = $state(false);
$effect(() => {
const c = config;
if (!c) return;
v4Method = c.method;
v4 = { address: c.address ?? '', gateway: c.gateway ?? '', prefix: c.prefix ?? 24 };
if (c.ipv6) {
v6Method = c.ipv6.method;
v6 = {
address: c.ipv6.address ?? '',
gateway: c.ipv6.gateway ?? '',
prefix: c.ipv6.prefix ?? 64
};
}
dns = c.dns?.length ? [...c.dns] : [''];
routes = c.routes?.length ? c.routes.map((r) => ({ ...r })) : [];
rollbackSeconds = c.rollback_seconds ?? 60;
});
function addDns() {
dns = [...dns, ''];
}
function removeDns(i: number) {
dns = dns.filter((_, j) => j !== i);
}
function addRoute() {
routes = [...routes, { destination: '', gateway: '' }];
}
function removeRoute(i: number) {
routes = routes.filter((_, j) => j !== i);
}
const valid = $derived.by(() => {
if (v4Method === 'static' && !v4.address.trim()) return false;
if (v6Method === 'static' && !v6.address.trim()) return false;
for (const r of routes) {
if (!r.destination.trim() || !r.gateway.trim()) return false;
}
return true;
});
async function submit() {
submitting = true;
try {
const cleanedDns = dns.map((s) => s.trim()).filter(Boolean);
await applyInterfaceConfig({
address: v4Method === 'static' ? v4.address.trim() : undefined,
dns: cleanedDns.length ? cleanedDns : undefined,
gateway: v4Method === 'static' && v4.gateway.trim() ? v4.gateway.trim() : undefined,
ipv6: {
address: v6Method === 'static' ? v6.address.trim() : undefined,
gateway: v6Method === 'static' && v6.gateway.trim() ? v6.gateway.trim() : undefined,
method: v6Method,
prefix: v6Method === 'static' ? v6.prefix : undefined
},
machineId,
method: v4Method,
name,
prefix: v4Method === 'static' ? v4.prefix : undefined,
rollback_seconds: rollbackSeconds || undefined,
routes: routes.length ? routes : undefined
});
toast.success(m.networking_apply_done());
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());
} finally {
submitting = false;
confirmOpen = false;
}
}
</script>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<div class="flex items-start gap-3">
<Button
variant="ghost"
size="icon"
href={resolve('/dashboard/[machineId]/networking/interfaces', { machineId })}
title={m.error_action_back()}
>
<ArrowLeftIcon class="size-4" />
</Button>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="font-mono text-2xl font-semibold tracking-tight">{name}</h1>
<p class="text-muted-foreground text-sm">{m.networking_configure_description()}</p>
</div>
</div>
{#if config}
<form
onsubmit={(e) => {
e.preventDefault();
if (valid) confirmOpen = true;
}}
class="flex flex-col gap-4"
>
<!-- IPv4 -->
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_ipv4_section()}</Card.Title>
<Card.Description>{m.networking_ipv4_section_hint()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<RadioGroup.Root bind:value={v4Method} class="grid grid-cols-2 gap-2">
<Label
class="border-input has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 flex cursor-pointer items-center gap-2 rounded-md border p-3 font-normal"
>
<RadioGroup.Item value="dhcp" />
{m.networking_method_dhcp()}
</Label>
<Label
class="border-input has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 flex cursor-pointer items-center gap-2 rounded-md border p-3 font-normal"
>
<RadioGroup.Item value="static" />
{m.networking_method_static()}
</Label>
</RadioGroup.Root>
{#if v4Method === 'static'}
<Separator />
<div class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_6rem]">
<div class="flex flex-col gap-1.5">
<Label for="v4-addr-{id}">{m.machine_address()}</Label>
<Input
id="v4-addr-{id}"
bind:value={v4.address}
placeholder="192.168.1.10"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v4-prefix-{id}">{m.prefix()}</Label>
<Input
id="v4-prefix-{id}"
type="number"
min="0"
max="32"
bind:value={v4.prefix}
required
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v4-gw-{id}">{m.networking_col_gateway()} {m.optional()}</Label>
<Input id="v4-gw-{id}" bind:value={v4.gateway} placeholder="192.168.1.1" />
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- IPv6 -->
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_ipv6_section()}</Card.Title>
<Card.Description>{m.networking_ipv6_section_hint()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<RadioGroup.Root bind:value={v6Method} class="grid grid-cols-3 gap-2">
<Label
class="border-input has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 flex cursor-pointer items-center gap-2 rounded-md border p-3 font-normal"
>
<RadioGroup.Item value="auto" />
{m.networking_method_slaac()}
</Label>
<Label
class="border-input has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 flex cursor-pointer items-center gap-2 rounded-md border p-3 font-normal"
>
<RadioGroup.Item value="static" />
{m.networking_method_static()}
</Label>
<Label
class="border-input has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 flex cursor-pointer items-center gap-2 rounded-md border p-3 font-normal"
>
<RadioGroup.Item value="ignore" />
{m.networking_method_ignore()}
</Label>
</RadioGroup.Root>
{#if v6Method === 'static'}
<Separator />
<div class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_6rem]">
<div class="flex flex-col gap-1.5">
<Label for="v6-addr-{id}">{m.machine_address()}</Label>
<Input
id="v6-addr-{id}"
bind:value={v6.address}
placeholder="2001:db8::10"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v6-prefix-{id}">{m.prefix()}</Label>
<Input
id="v6-prefix-{id}"
type="number"
min="0"
max="128"
bind:value={v6.prefix}
required
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v6-gw-{id}">{m.networking_col_gateway()} {m.optional()}</Label>
<Input id="v6-gw-{id}" bind:value={v6.gateway} placeholder="2001:db8::1" />
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- DNS -->
<Card.Root>
<Card.Header class="flex flex-row items-start justify-between gap-2">
<div>
<Card.Title>{m.networking_dns_section()}</Card.Title>
<Card.Description>{m.networking_dns_section_hint()}</Card.Description>
</div>
<Button type="button" variant="outline" size="sm" onclick={addDns}>
<PlusIcon class="size-4" />
{m.networking_dns_add()}
</Button>
</Card.Header>
<Card.Content class="flex flex-col gap-2">
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#each dns as _, i (i)}
<div class="flex items-center gap-2">
<Input bind:value={dns[i]} placeholder={m.networking_dns_placeholder()} />
<Button
type="button"
variant="ghost"
size="icon"
class="size-9 shrink-0"
onclick={() => removeDns(i)}
>
<TrashIcon class="size-4" />
</Button>
</div>
{/each}
</Card.Content>
</Card.Root>
<!-- Routes -->
<Card.Root>
<Card.Header class="flex flex-row items-start justify-between gap-2">
<div>
<Card.Title>{m.networking_routes_section()}</Card.Title>
<Card.Description>{m.networking_routes_section_hint()}</Card.Description>
</div>
<Button type="button" variant="outline" size="sm" onclick={addRoute}>
<PlusIcon class="size-4" />
{m.networking_route_add()}
</Button>
</Card.Header>
<Card.Content class="flex flex-col gap-2">
{#if !routes.length}
<p class="text-muted-foreground text-sm"></p>
{/if}
{#each routes as r, i (i)}
<div class="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_1fr_auto]">
<Input
bind:value={r.destination}
placeholder={m.networking_route_destination()}
required
/>
<Input bind:value={r.gateway} placeholder={m.networking_route_gateway()} required />
<Button
type="button"
variant="ghost"
size="icon"
class="size-9 shrink-0"
onclick={() => removeRoute(i)}
>
<TrashIcon class="size-4" />
</Button>
</div>
{/each}
</Card.Content>
</Card.Root>
<!-- Rollback + submit -->
<Card.Root>
<Card.Content class="flex flex-col gap-3 py-4 sm:flex-row sm:items-end sm:justify-between">
<div class="flex flex-col gap-1.5">
<Label for="rollback-{id}">{m.networking_rollback_seconds()}</Label>
<Input
id="rollback-{id}"
type="number"
min="0"
max="600"
class="w-32"
bind:value={rollbackSeconds}
/>
<p class="text-muted-foreground text-xs">{m.networking_rollback_seconds_hint()}</p>
</div>
<Button type="submit" disabled={!valid || submitting}>{m.networking_apply()}</Button>
</Card.Content>
</Card.Root>
</form>
{/if}
</div>
<AlertDialog.Root bind:open={confirmOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.networking_apply_confirm_title()}</AlertDialog.Title>
<AlertDialog.Description>
{m.networking_apply_confirm_body({ seconds: rollbackSeconds || 60 })}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={submit} disabled={submitting}>
{m.networking_apply()}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,216 @@
<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 ListFilterIcon from '@lucide/svelte/icons/list-filter';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
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' });
function toggleSort(key: SortKey) {
if (sort.key === key) sort = { dir: sort.dir === 'asc' ? 'desc' : 'asc', key };
else sort = { dir: 'asc', key };
}
const filtered = $derived.by(() => {
const list = routes.current ?? [];
const q = search.trim().toLowerCase();
const matched = !q
? [...list]
: list.filter(
(r) =>
r.destination.toLowerCase().includes(q) ||
(r.gateway ?? '').toLowerCase().includes(q) ||
r.interface.toLowerCase().includes(q)
);
matched.sort((a, b) => {
const av = a[sort.key];
const bv = b[sort.key];
const cmp =
typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av ?? '').localeCompare(String(bv ?? ''));
return sort.dir === 'asc' ? cmp : -cmp;
});
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"
>
<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}
{: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('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>
@@ -0,0 +1,40 @@
<script lang="ts">
import ArrowUpCircleIcon from '@lucide/svelte/icons/arrow-up-circle';
import PackageIcon from '@lucide/svelte/icons/package';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
const machineId = $derived(page.params.machineId!);
</script>
<PageMeta title={m.seo_title_packages()} description={m.seo_desc_packages()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_packages()}</h1>
<div class="grid gap-4 sm:grid-cols-2">
<a href={resolve('/dashboard/[machineId]/packages/installed', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<PackageIcon class="size-5" />
<Card.Title>{m.nav_packages_installed()}</Card.Title>
</div>
<Card.Description>{m.nav_packages_installed_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/packages/updates', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<ArrowUpCircleIcon class="size-5" />
<Card.Title>{m.nav_packages_updates()}</Card.Title>
</div>
<Card.Description>{m.nav_packages_updates_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
</div>
</div>
@@ -0,0 +1,436 @@
<script lang="ts">
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
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 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 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 * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
import {
listInstalledPackages,
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(listInstalledPackages(machineId));
const updatesResource = $derived(listPackageUpdates(machineId));
const manager = $derived(dataResource.current?.manager ?? '');
const packages = $derived((dataResource.current?.packages ?? []) as Package[]);
// name → new version, for the per-row "update available" badge.
const updatesMap = $derived(
new Map(
((updatesResource.current?.packages ?? []) as Package[]).map((p) => [p.name, p.version])
)
);
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('');
let sortDir = $state<'asc' | 'desc'>('asc');
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(() => {
const q = debouncedSearch.trim().toLowerCase();
let list = [...packages];
if (q) {
list = list.filter((p) => p.name.toLowerCase().includes(q));
}
list.sort((a, b) => {
const comp = a.name.localeCompare(b.name);
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));
// Dialogs
let createOpen = $state(false);
let installName = $state('');
let deleteOpen = $state(false);
let deleting = $state<null | Package>(null);
// Terminal Stream Console
let consoleOpen = $state(false);
let consoleTitle = $state('');
let consoleLogs = $state<string[]>([]);
let consoleStatus = $state<'failed' | 'running' | 'success'>('running');
let consoleEl = $state<HTMLDivElement | null>(null);
$effect(() => {
if (consoleLogs.length && consoleEl) {
consoleEl.scrollTop = consoleEl.scrollHeight;
}
});
async function runAction(action: 'install' | 'remove' | 'upgrade', name: string) {
consoleOpen = true;
consoleLogs = [];
consoleStatus = 'running';
consoleTitle =
action === 'install'
? m.packages_install_started({ name })
: action === 'upgrade'
? m.packages_update_started({ name })
: m.packages_remove_started({ name });
try {
const stream = streamPackageAction({ action, machineId, name });
for await (const chunk of stream) {
if (chunk.event === 'output') {
consoleLogs = [...consoleLogs, chunk.data.line];
} else if (chunk.event === 'error') {
consoleLogs = [...consoleLogs, m.packages_stream_error({ message: chunk.data.message })];
consoleStatus = 'failed';
} else if (chunk.event === 'done') {
if (chunk.data.success) {
consoleLogs = [
...consoleLogs,
`\n${action === 'install' ? m.packages_install_success({ name }) : action === 'upgrade' ? m.packages_update_success({ name }) : m.packages_remove_success({ name })}`
];
consoleStatus = 'success';
} else {
consoleLogs = [...consoleLogs, `\n${chunk.data.error ? m.packages_stream_error({ message: chunk.data.error }) : m.packages_stream_failed()}`];
consoleStatus = 'failed';
}
}
}
} catch (err) {
consoleLogs = [...consoleLogs, `\n${m.packages_stream_connection_error({ message: (err as Error).message || `${err}` })}`];
consoleStatus = 'failed';
} finally {
await dataResource.refresh();
await updatesResource.refresh();
}
}
const runUpdate = (name: string) => runAction('upgrade', name);
async function doInstall() {
const name = installName.trim();
if (!name) return;
createOpen = false;
installName = '';
await runAction('install', name);
}
async function doRemove() {
if (!deleting) return;
const name = deleting.name;
deleteOpen = false;
deleting = null;
await runAction('remove', name);
}
</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()}
>
<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()}
</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}
{/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>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.packages_install()}</Dialog.Title>
<Dialog.Description>{m.packages_install_desc()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doInstall();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="pkg-name-{id}">{m.packages_col_name()}</Label>
<Input id="pkg-name-{id}" bind:value={installName} placeholder="e.g. htop" required />
</div>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit">{m.packages_install_button()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title
>{m.packages_remove_confirm_title({ name: deleting?.name ?? '' })}</AlertDialog.Title
>
<AlertDialog.Description>
{m.packages_remove_confirm_desc({ name: deleting?.name ?? '' })}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action
onclick={doRemove}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>{m.delete()}</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Stream Console Modal -->
<Dialog.Root bind:open={consoleOpen}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<TerminalIcon class="size-5" />
{consoleTitle}
</Dialog.Title>
<Dialog.Description>{m.packages_terminal_desc()}</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-3 mt-2">
<div
bind:this={consoleEl}
class="bg-zinc-950 font-mono text-zinc-100 text-xs p-4 rounded-md h-96 overflow-y-auto whitespace-pre-wrap select-text leading-relaxed border border-zinc-800 shadow-inner"
>
{#each consoleLogs as log, i (i)}
{log + '\n'}
{/each}
{#if consoleStatus === 'running'}
<Loader2Icon class="size-3 animate-spin inline text-zinc-500" />
{/if}
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if consoleStatus === 'running'}
<Badge variant="secondary" class="animate-pulse">{m.dashboard_syncing()}</Badge>
{:else}
<Badge variant={consoleStatus === 'success' ? 'default' : 'destructive'}>
{consoleStatus.toUpperCase()}
</Badge>
{/if}
</div>
<Button disabled={consoleStatus === 'running'} onclick={() => (consoleOpen = false)}>
{m.finish()}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,319 @@
<script lang="ts">
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 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) {
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(() => {
const q = debouncedSearch.trim().toLowerCase();
let list = [...packages];
if (q) {
list = list.filter((p) => p.name.toLowerCase().includes(q));
}
list.sort((a, b) => {
const comp = a.name.localeCompare(b.name);
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));
// Terminal Stream Console
let consoleOpen = $state(false);
let consoleLogs = $state<string[]>([]);
let consoleStatus = $state<'failed' | 'running' | 'success'>('running');
let consoleEl = $state<HTMLDivElement | null>(null);
$effect(() => {
if (consoleLogs.length && consoleEl) {
consoleEl.scrollTop = consoleEl.scrollHeight;
}
});
// Empty `name` = upgrade all; otherwise upgrades a single package.
async function doUpgrade(name = '') {
consoleOpen = true;
consoleLogs = [];
consoleStatus = 'running';
try {
const stream = streamPackageAction({
action: 'upgrade',
machineId,
name: name || undefined
});
for await (const chunk of stream) {
if (chunk.event === 'output') {
consoleLogs = [...consoleLogs, chunk.data.line];
} else if (chunk.event === 'error') {
consoleLogs = [...consoleLogs, `ERROR: ${chunk.data.message}`];
consoleStatus = 'failed';
} else if (chunk.event === 'done') {
if (chunk.data.success) {
consoleLogs = [...consoleLogs, `\n${m.packages_upgrade_success()}`];
consoleStatus = 'success';
} else {
consoleLogs = [...consoleLogs, `\nERROR: ${chunk.data.error || 'Operation failed.'}`];
consoleStatus = 'failed';
}
}
}
} catch (err) {
consoleLogs = [...consoleLogs, `\nConnection Error: ${(err as Error).message || err}`];
consoleStatus = 'failed';
} finally {
await dataResource.refresh();
}
}
</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()}
>
<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()}
</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>
<!-- Stream Console Modal -->
<Dialog.Root bind:open={consoleOpen}>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<TerminalIcon class="size-5" />
{m.packages_upgrade_all()}
</Dialog.Title>
<Dialog.Description>{m.packages_terminal_desc()}</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-3 mt-2">
<div
bind:this={consoleEl}
class="bg-zinc-950 max-w-lg font-mono text-zinc-100 text-xs p-4 rounded-md h-96 overflow-y-auto select-text whitespace-pre-wrap leading-relaxed border border-zinc-800 shadow-inner"
>
{#each consoleLogs as log, i (i)}
{log + '\n'}
{/each}
{#if consoleStatus === 'running'}
<Loader2Icon class="size-3 animate-spin inline text-zinc-500" />
{/if}
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if consoleStatus === 'running'}
<Badge variant="secondary" class="animate-pulse">{m.dashboard_syncing()}</Badge>
{:else}
<Badge variant={consoleStatus === 'success' ? 'default' : 'destructive'}>
{consoleStatus.toUpperCase()}
</Badge>
{/if}
</div>
<Button disabled={consoleStatus === 'running'} onclick={() => (consoleOpen = false)}>
{m.finish()}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,428 @@
<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 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;
description: string;
load: string;
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,
inactive: true,
other: true
});
// Load State Filter
let filterLoad = $state({
error: true,
loaded: true,
masked: true,
notFound: true
});
// Sub State Filter
let filterSub = $state({
dead: true,
exited: true,
other: true,
running: true
});
const activeFilterCount = $derived(
(Object.values(filterActive).some((v) => !v) ? 1 : 0) +
(Object.values(filterLoad).some((v) => !v) ? 1 : 0) +
(Object.values(filterSub).some((v) => !v) ? 1 : 0)
);
function resetFilters() {
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;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
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';
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';
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';
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>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => servicesResource.refresh()}
>
<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>
@@ -0,0 +1,576 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ChevronsLeftIcon from '@lucide/svelte/icons/chevrons-left';
import ChevronsRightIcon from '@lucide/svelte/icons/chevrons-right';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import PlayIcon from '@lucide/svelte/icons/play';
import PowerIcon from '@lucide/svelte/icons/power';
import PowerOffIcon from '@lucide/svelte/icons/power-off';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import RotateCwIcon from '@lucide/svelte/icons/rotate-cw';
import SearchXIcon from '@lucide/svelte/icons/search-x';
import SquareIcon from '@lucide/svelte/icons/square';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
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 Card from '$lib/components/ui/card';
import * as Empty from '$lib/components/ui/empty';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { m } from '$lib/paraglide/messages';
import {
disableService,
enableService,
getServiceLogs,
getServiceStatus,
restartService,
startService,
stopService,
streamServiceLogs
} from '$lib/remotes/services.remote';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
const name = $derived(pageState.params.name!);
const machineId = $derived(pageState.params.machineId!);
const statusResource = $derived(getServiceStatus({ machineId, unit: name }));
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());
}
async function run(
action: 'disable' | 'enable' | 'restart' | 'start' | 'stop',
fn: () => Promise<unknown>,
successMsg: string
) {
busy = action;
try {
await fn();
toast.success(successMsg);
} catch (e) {
handleError(e);
} finally {
busy = null;
}
}
const id = $props.id();
const lines = new PersistedState<number>('services.logs.lines', 500);
const priority = new PersistedState<number>('services.logs.priority', 7);
const since = new PersistedState<string>('services.logs.since', '');
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
let logsPage = $state(1);
const logsPageSize = new PersistedState<number>('services.logs.pageSize', 100);
const live = new PersistedState<boolean>('services.logs.live', false);
const autoscroll = new PersistedState<boolean>('services.logs.autoscroll', true);
let scrollEl = $state<HTMLDivElement | null>(null);
const logsResource = $derived(
getServiceLogs({
lines: lines.current,
machineId,
priority: priority.current,
since: since.current || undefined,
unit: name
})
);
const snapshot = $derived(logsResource.current?.entries ?? []);
// Live tail buffer (capped at 5000)
let liveBuffer = $state<{ message: string; priority: number; time: string }[]>([]);
$effect(() => {
if (!live.current) return;
// ponytail: replay last snapshot as seed when live starts, then append SSE events
liveBuffer = [...snapshot];
const iter = streamServiceLogs({
machineId,
priority: priority.current,
since: since.current || undefined,
unit: name
})[Symbol.asyncIterator]();
let cancelled = false;
(async () => {
try {
while (!cancelled) {
const { done, value } = await iter.next();
if (done) break;
if (value.event === 'log' && typeof value.data === 'object') {
liveBuffer = [...liveBuffer.slice(-4999), value.data];
} else if (value.event === 'error') {
const msg =
typeof value.data === 'object' && 'message' in value.data
? (value.data as { message: string }).message
: String(value.data);
toast.error(msg || m.errors_generic());
}
}
} catch (err) {
console.error(err);
}
})();
return () => {
cancelled = true;
iter.return?.();
};
});
const entries = $derived(live.current ? liveBuffer : snapshot);
$effect(() => {
// Track entry count so autoscroll fires whenever new lines arrive
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
entries.length;
if (autoscroll.current && scrollEl) {
queueMicrotask(() => scrollEl?.scrollTo({ top: scrollEl.scrollHeight }));
}
});
const filteredLogs = $derived.by(() => {
const q = debouncedSearch.trim().toLowerCase();
if (!q) return entries;
return entries.filter((e) => e.message.toLowerCase().includes(q));
});
const totalLogPages = $derived(
Math.max(1, Math.ceil(filteredLogs.length / logsPageSize.current))
);
const pageLogs = $derived(
filteredLogs.slice((logsPage - 1) * logsPageSize.current, logsPage * logsPageSize.current)
);
$effect(() => {
if (logsPage > totalLogPages) logsPage = totalLogPages;
});
function onLogSearch(e: Event) {
search = (e.target as HTMLInputElement).value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
logsPage = 1;
}, 200);
}
function priorityClass(p: number) {
if (p <= 3) return 'text-rose-600 dark:text-rose-400';
if (p === 4) return 'text-amber-600 dark:text-amber-400';
if (p === 5) return 'text-sky-600 dark:text-sky-400';
return 'text-muted-foreground';
}
function badgeClass(state: string) {
if (state === 'active' || state === 'enabled' || state === 'loaded' || state === 'running')
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-0 capitalize';
if (state === 'inactive' || state === 'disabled' || state === 'dead' || state === 'exited')
return 'bg-zinc-500/15 text-zinc-700 dark:text-zinc-400 border-0 capitalize';
if (state === 'static' || state === 'masked')
return 'bg-amber-500/15 text-amber-700 dark:text-amber-400 border-0 capitalize';
return 'bg-rose-500/15 text-rose-700 dark:text-rose-400 border-0 capitalize';
}
</script>
<PageMeta
title={`${name} · ${m.seo_title_services()}`}
description={m.seo_desc_services_detail()}
/>
<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 status = e.status ?? 0}
{@const notFound = status === 404}
<Empty.Root class="border">
<Empty.Header>
<Empty.Media variant="icon" class="size-12">
{#if notFound}
<SearchXIcon class="size-6" />
{:else}
<TriangleAlertIcon class="size-6" />
{/if}
</Empty.Media>
<Empty.Title>
{notFound ? m.error_not_found_title() : m.error_generic_title()}
</Empty.Title>
<Empty.Description>
<span class="font-mono">{name}</span><br />
{notFound ? m.error_not_found_description() : m.error_generic_description()}
</Empty.Description>
</Empty.Header>
<Empty.Content class="gap-3">
{#if status}
<div class="text-muted-foreground text-xs font-mono">
{m.error_code_label({ status })}
</div>
{/if}
{#if e.body?.message || e.message}
<details class="w-full max-w-lg text-left">
<summary class="text-muted-foreground cursor-pointer text-xs">
{m.error_details()}
</summary>
<pre
class="bg-muted text-foreground mt-2 max-h-64 overflow-auto rounded-md border p-3 text-xs whitespace-pre-wrap">{e
.body?.message ?? e.message}</pre>
</details>
{/if}
<div class="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline" onclick={reset}>
<RefreshCwIcon class="size-4" />
{m.error_action_retry()}
</Button>
<Button href={resolve('/dashboard/[machineId]/services', { machineId })}>
<ArrowLeftIcon class="size-4" />
{m.nav_services()}
</Button>
</div>
</Empty.Content>
</Empty.Root>
{/snippet}
{@const s = await statusResource}
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight font-mono truncate" title={s.unit}>
{s.unit}
</h1>
<p class="text-muted-foreground text-sm truncate" title={s.description}>
{s.description}
</p>
</div>
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => statusResource.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
</div>
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-col lg:flex-row gap-4 lg:sticky lg:top-20 lg:self-start">
<Card.Root class="w-full">
<Card.Header>
<Card.Title>{m.services_details_title()}</Card.Title>
</Card.Header>
<Card.Content>
<dl class="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-3 text-sm">
<dt class="text-muted-foreground">{m.services_details_active_state()}</dt>
<dd><Badge class={badgeClass(s.active_state)}>{s.active_state}</Badge></dd>
<dt class="text-muted-foreground">{m.services_details_sub_state()}</dt>
<dd><Badge class={badgeClass(s.sub_state)}>{s.sub_state}</Badge></dd>
<dt class="text-muted-foreground">{m.services_details_load_state()}</dt>
<dd><Badge class={badgeClass(s.load_state)}>{s.load_state}</Badge></dd>
<dt class="text-muted-foreground">{m.services_details_unit_file_state()}</dt>
<dd><Badge class={badgeClass(s.unit_file_state)}>{s.unit_file_state}</Badge></dd>
</dl>
</Card.Content>
</Card.Root>
<Card.Root class="w-full">
<Card.Content class="grid grid-cols-2 gap-2 py-4">
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.active_state === 'active'}
onclick={() =>
run(
'start',
() => startService({ machineId, unit: s.unit }),
m.services_action_started({ name: s.unit })
)}
>
{#if busy === 'start'}
<Loader2Icon class="size-4 animate-spin" />
{:else}
<PlayIcon class="size-4" />
{/if}
{m.services_action_start()}
</Button>
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.active_state !== 'active'}
onclick={() =>
run(
'stop',
() => stopService({ machineId, unit: s.unit }),
m.services_action_stopped({ name: s.unit })
)}
>
{#if busy === 'stop'}
<Loader2Icon class="size-4 animate-spin" />
{:else}
<SquareIcon class="size-4" />
{/if}
{m.services_action_stop()}
</Button>
<Button
variant="outline"
class="col-span-2 justify-start"
disabled={!!busy}
onclick={() =>
run(
'restart',
() => restartService({ machineId, unit: s.unit }),
m.services_action_restarted({ name: s.unit })
)}
>
{#if busy === 'restart'}
<Loader2Icon class="size-4 animate-spin" />
{:else}
<RotateCwIcon class="size-4" />
{/if}
{m.services_action_restart()}
</Button>
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.unit_file_state === 'enabled' || s.unit_file_state === 'static'}
onclick={() =>
run(
'enable',
() => enableService({ machineId, unit: s.unit }),
m.services_action_enabled({ name: s.unit })
)}
>
{#if busy === 'enable'}
<Loader2Icon class="size-4 animate-spin" />
{:else}
<PowerIcon class="size-4" />
{/if}
{m.services_action_enable()}
</Button>
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.unit_file_state !== 'enabled'}
onclick={() =>
run(
'disable',
() => disableService({ machineId, unit: s.unit }),
m.services_action_disabled({ name: s.unit })
)}
>
{#if busy === 'disable'}
<Loader2Icon class="size-4 animate-spin" />
{:else}
<PowerOffIcon class="size-4" />
{/if}
{m.services_action_disable()}
</Button>
</Card.Content>
</Card.Root>
</div>
<Card.Root class="flex flex-col min-h-0">
<Card.Header class="flex flex-row items-center justify-between gap-2">
<Card.Title class="flex items-center gap-2">
{m.services_logs_title()}
{#if live.current}
<span
class="inline-flex items-center gap-1.5 text-xs font-normal text-emerald-600 dark:text-emerald-400"
>
<span class="relative flex size-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"
></span>
<span class="relative inline-flex size-2 rounded-full bg-emerald-500"></span>
</span>
{m.services_logs_live_streaming()}
</span>
{/if}
</Card.Title>
<div class="flex items-center gap-3">
<Label class="flex items-center gap-2 text-sm font-normal">
<Switch bind:checked={autoscroll.current} />
{m.services_logs_autoscroll()}
</Label>
<Label class="flex items-center gap-2 text-sm font-normal">
<Switch bind:checked={live.current} />
{m.services_logs_live()}
</Label>
{#if live.current}
<Button variant="outline" size="sm" onclick={() => (liveBuffer = [])}>
{m.services_logs_clear()}
</Button>
{:else}
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => logsResource.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
{/if}
</div>
</Card.Header>
<Card.Content class="flex flex-col gap-3">
<div class="flex flex-wrap items-end gap-2">
<div class="flex flex-col gap-1">
<Label for="logs-lines-{id}" class="text-xs">{m.services_logs_lines()}</Label>
<Input
id="logs-lines-{id}"
type="number"
min="1"
max="10000"
class="h-9 w-24"
value={lines.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1 && n <= 10000) lines.current = n;
}}
/>
</div>
<div class="flex flex-col gap-1">
<Label for="logs-priority-{id}" class="text-xs">{m.services_logs_priority()}</Label>
<select
id="logs-priority-{id}"
class="border-input bg-background h-9 w-36 rounded-md border px-2 text-sm"
value={priority.current}
onchange={(e) => (priority.current = Number((e.target as HTMLSelectElement).value))}
>
<option value={0}>{m.syslog_emerg()}</option>
<option value={1}>{m.syslog_alert()}</option>
<option value={2}>{m.syslog_crit()}</option>
<option value={3}>{m.syslog_err()}</option>
<option value={4}>{m.syslog_warning()}</option>
<option value={5}>{m.syslog_notice()}</option>
<option value={6}>{m.syslog_info()}</option>
<option value={7}>{m.syslog_debug()}</option>
</select>
</div>
<div class="flex flex-col gap-1">
<Label for="logs-since-{id}" class="text-xs">{m.services_logs_since()}</Label>
<select
id="logs-since-{id}"
class="border-input bg-background h-9 w-44 rounded-md border px-2 text-sm"
value={since.current}
onchange={(e) => (since.current = (e.target as HTMLSelectElement).value)}
>
<option value="">{m.services_logs_since_all()}</option>
<option value="15 minutes ago">{m.services_logs_since_15m()}</option>
<option value="1 hour ago">{m.services_logs_since_1h()}</option>
<option value="6 hours ago">{m.services_logs_since_6h()}</option>
<option value="today">{m.services_logs_since_today()}</option>
<option value="yesterday">{m.services_logs_since_yesterday()}</option>
</select>
</div>
<div class="flex min-w-48 flex-1 flex-col gap-1">
<Label for="logs-search-{id}" class="text-xs"
>{m.services_logs_search_placeholder()}</Label
>
<Input
id="logs-search-{id}"
placeholder={m.services_logs_search_placeholder()}
value={search}
oninput={onLogSearch}
/>
</div>
</div>
<div class="text-muted-foreground flex items-center justify-between text-xs">
<span>{filteredLogs.length} / {entries.length}</span>
</div>
<div
bind:this={scrollEl}
class="bg-muted/30 max-h-144 min-h-80 overflow-auto rounded-md border"
>
{#if logsResource.loading && !logsResource.current}
<div class="text-muted-foreground flex items-center justify-center py-8">
<Loader2Icon class="size-4 animate-spin" />
</div>
{:else if !pageLogs.length}
<div class="text-muted-foreground py-8 text-center text-sm">
{m.services_logs_empty()}
</div>
{:else}
<ul class="divide-border divide-y font-mono text-xs">
{#each pageLogs as e, i (e.time + '\0' + i)}
<li class="hover:bg-muted/50 flex gap-3 px-3 py-1.5">
<span class="text-muted-foreground shrink-0 tabular-nums">{e.time}</span>
<span class="shrink-0 tabular-nums {priorityClass(e.priority)}"
>[{e.priority}]</span
>
<span class="break-all whitespace-pre-wrap">{e.message}</span>
</li>
{/each}
</ul>
{/if}
</div>
<div class="flex items-center justify-end gap-2">
<Input
type="number"
min="10"
max="1000"
class="h-8 w-20"
value={logsPageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 10) {
logsPageSize.current = n;
logsPage = 1;
}
}}
/>
<span class="text-muted-foreground text-sm">
{m.users_page_of({ page: logsPage, total: totalLogPages })}
</span>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
title={m.users_prev()}
disabled={logsPage <= 1}
onclick={() => (logsPage = 1)}
>
<ChevronsLeftIcon class="size-4" />
</Button>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
title={m.users_prev()}
disabled={logsPage <= 1}
onclick={() => (logsPage -= 1)}
>
<ChevronLeftIcon class="size-4" />
</Button>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
title={m.users_next()}
disabled={logsPage >= totalLogPages}
onclick={() => (logsPage += 1)}
>
<ChevronRightIcon class="size-4" />
</Button>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
title={m.users_next()}
disabled={logsPage >= totalLogPages}
onclick={() => (logsPage = totalLogPages)}
>
<ChevronsRightIcon class="size-4" />
</Button>
</div>
</Card.Content>
</Card.Root>
</div>
</svelte:boundary>
</div>
@@ -0,0 +1,40 @@
<script lang="ts">
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
import ListIcon from '@lucide/svelte/icons/list';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
const machineId = $derived(page.params.machineId!);
</script>
<PageMeta title={m.seo_title_storage()} description={m.seo_desc_storage()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_storage()}</h1>
<div class="grid gap-4 sm:grid-cols-2">
<a href={resolve('/dashboard/[machineId]/storage/mounts', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<HardDriveIcon class="size-5" />
<Card.Title>{m.nav_storage_mounts()}</Card.Title>
</div>
<Card.Description>{m.nav_storage_mounts_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/storage/fstab', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<ListIcon class="size-5" />
<Card.Title>{m.nav_storage_fstab()}</Card.Title>
</div>
<Card.Description>{m.nav_storage_fstab_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
</div>
</div>
@@ -0,0 +1,255 @@
<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 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 { toast } from 'svelte-sonner';
type FstabEntry = {
device: string;
dump: number;
fstype: string;
mountpoint: string;
options: string;
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[];
const q = search.trim().toLowerCase();
return all
.filter(
(e) =>
!q ||
e.device.toLowerCase().includes(q) ||
e.mountpoint.toLowerCase().includes(q) ||
e.fstype.toLowerCase().includes(q)
)
.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);
async function doDelete() {
if (!deleting) return;
try {
await removeMount({ machineId, mountpoint: deleting.mountpoint });
toast.success(m.storage_mount_removed());
deleteOpen = false;
deleting = null;
} catch (e) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || 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>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<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_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>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title
>{m.storage_mount_remove_title({
mountpoint: deleting?.mountpoint ?? ''
})}</AlertDialog.Title
>
<AlertDialog.Description>{m.storage_mount_remove_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.storage_mount_remove()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,378 @@
<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 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 { Checkbox } from '$lib/components/ui/checkbox';
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 { addMount, listFstab, listMounts, removeMount } from '$lib/remotes/storage.remote';
import { PersistedState } from 'runed';
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'
]);
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
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 });
$effect(() => {
filtersStore.current = filters;
});
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(() => {
const all = (mounts.current ?? []) as Mount[];
const q = search.trim().toLowerCase();
return all
.filter((mt) => filters.showPseudo || !PSEUDO_FSTYPES.has(mt.fstype))
.filter(
(mt) =>
!q ||
mt.device.toLowerCase().includes(q) ||
mt.mountpoint.toLowerCase().includes(q) ||
mt.fstype.toLowerCase().includes(q)
)
.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);
let deleting = $state<Mount | null>(null);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
async function doCreate() {
try {
await addMount({
device: createForm.device.trim(),
fstype: createForm.fstype.trim(),
machineId,
mountpoint: createForm.mountpoint.trim(),
options: createForm.options.trim() || undefined
});
toast.success(m.storage_mount_added());
createOpen = false;
createForm = { device: '', fstype: '', mountpoint: '', options: 'defaults' };
} catch (e) {
handleError(e);
}
}
async function doDelete() {
if (!deleting) return;
try {
await removeMount({ machineId, mountpoint: deleting.mountpoint });
toast.success(m.storage_mount_removed());
deleteOpen = false;
deleting = null;
} catch (e) {
handleError(e);
}
}
</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>
{#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-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>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.storage_mount_add()}</Dialog.Title>
<Dialog.Description>{m.storage_mount_add_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doCreate();
}}
class="flex flex-col gap-3"
>
<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
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-mountpoint-{id}">{m.storage_col_mountpoint()}</Label>
<Input
id="sm-mountpoint-{id}"
bind:value={createForm.mountpoint}
placeholder="/mnt/data"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-fstype-{id}">{m.storage_col_fstype()}</Label>
<Input id="sm-fstype-{id}" bind:value={createForm.fstype} placeholder="ext4" required />
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-options-{id}">{m.storage_col_options()}</Label>
<Input id="sm-options-{id}" bind:value={createForm.options} placeholder="defaults" />
</div>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit">{m.storage_mount_add()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title
>{m.storage_mount_remove_title({
mountpoint: deleting?.mountpoint ?? ''
})}</AlertDialog.Title
>
<AlertDialog.Description>{m.storage_mount_remove_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.storage_mount_remove()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -1,21 +1,46 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import ClockIcon from '@lucide/svelte/icons/clock';
import FingerprintIcon from '@lucide/svelte/icons/fingerprint';
import GlobeIcon from '@lucide/svelte/icons/globe';
import PowerIcon from '@lucide/svelte/icons/power';
import ServerIcon from '@lucide/svelte/icons/server';
import {resolve} from '$app/paths'
import TerminalIcon from '@lucide/svelte/icons/terminal';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
const base = $derived(`/dashboard/${page.params.machineId}/system`);
import { m } from '$lib/paraglide/messages';
import { getUser } from '$lib/remotes/auth.remote';
import { getWhoami } from '$lib/remotes/system.remote';
import { getContext } from 'svelte';
const machineId = $derived(page.params.machineId!);
const userResource = $derived(getUser());
const whoamiResource = $derived(getWhoami(machineId));
const canShowTerminal = $derived(
userResource.current?.role === 'admin' && whoamiResource.current?.username === 'root'
);
const terminalState = getContext<{ open: boolean }>('terminalState');
</script>
<PageMeta title={m.seo_title_system()} description={m.seo_desc_system()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system()}</h1>
<div class="grid gap-4 sm:grid-cols-2">
<a href={resolve(`${base}/date-time` as Pathname)} class="block">
<a href={resolve('/dashboard/[machineId]/system/nadir', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<FingerprintIcon class="size-5" />
<Card.Title>{m.nav_system_nadir()}</Card.Title>
</div>
<Card.Description>{m.nav_system_nadir_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/system/date-time', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
@@ -26,7 +51,7 @@ import { m } from '$lib/paraglide/messages';
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/hostname` as Pathname)} class="block">
<a href={resolve('/dashboard/[machineId]/system/hostname', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
@@ -37,7 +62,7 @@ import { m } from '$lib/paraglide/messages';
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/localization` as Pathname)} class="block">
<a href={resolve('/dashboard/[machineId]/system/localization', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
@@ -48,7 +73,7 @@ import { m } from '$lib/paraglide/messages';
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/power` as Pathname)} class="block">
<a href={resolve('/dashboard/[machineId]/system/power', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
@@ -59,5 +84,21 @@ import { m } from '$lib/paraglide/messages';
</Card.Header>
</Card.Root>
</a>
{#if canShowTerminal}
<button
onclick={() => (terminalState.open = true)}
class="block text-start cursor-pointer h-full"
>
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<TerminalIcon class="size-5" />
<Card.Title>{m.nav_system_terminal()}</Card.Title>
</div>
<Card.Description>{m.nav_system_terminal_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</button>
{/if}
</div>
</div>
@@ -2,6 +2,7 @@
import CheckIcon from '@lucide/svelte/icons/check';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Command from '$lib/components/ui/command';
@@ -19,7 +20,7 @@
} from '$lib/remotes/system.remote';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const machineId = $derived(page.params.machineId!);
const time = $derived(systemTime(machineId));
const tzs = $derived(listTimezones(machineId));
@@ -39,13 +40,14 @@
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
} finally {
saving = false;
}
}
</script>
<PageMeta title={m.seo_title_system_datetime()} description={m.seo_desc_system_datetime()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_datetime()}</h1>
@@ -97,7 +99,7 @@
onSelect={async () => {
tzOpen = false;
if (z === t.timezone) return;
await withSaving(() => setTimezone({machineId,timezone:z}));
await withSaving(() => setTimezone({ machineId, timezone: z }));
}}
>
<CheckIcon
@@ -125,7 +127,7 @@
<Switch
checked={t.ntp}
disabled={saving || !t.can_ntp}
onCheckedChange={(v) => withSaving(() => setNtp({enabled:v, machineId}))}
onCheckedChange={(v) => withSaving(() => setNtp({ enabled: v, machineId }))}
/>
</Card.Content>
</Card.Root>
@@ -143,7 +145,7 @@
const fd = new FormData(e.currentTarget);
const v = String(fd.get('time') ?? '').trim();
if (!v) return;
await withSaving(() => setTime({machineId,time:rfc3339FromLocal(v)}));
await withSaving(() => setTime({ machineId, time: rfc3339FromLocal(v) }));
}}
>
<div class="flex grow flex-col gap-1.5">
@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
@@ -8,16 +9,18 @@
import { setHostname, systemHostname } from '$lib/remotes/system.remote';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const machineId = $derived(page.params.machineId!);
const host = $derived(systemHostname(machineId));
const formId = $props.id();
let saving = $state(false);
// ponytail: hostname syntax is RFC1123 — letters, digits, hyphen, max 63 chars per label.
const HOSTNAME_RE = /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const HOSTNAME_RE =
/^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
</script>
<PageMeta title={m.seo_title_system_hostname()} description={m.seo_desc_system_hostname()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_hostname()}</h1>
@@ -48,10 +51,11 @@
if (v === h.hostname) return;
saving = true;
try {
await setHostname({hostname:v,machineId,});
await setHostname({ hostname: v, machineId });
toast.success(m.saved());
} catch (err) {
toast.error((err as Error).message || 'Error');
console.log(err);
toast.error((err as { body?: { message?: string } })?.body?.message || m.errors_generic());
} finally {
saving = false;
}
@@ -64,7 +68,7 @@
name="hostname"
value={h.hostname}
required
pattern="[A-Za-z0-9.-]+"
pattern="[A-Za-z0-9.\-]+"
maxlength={253}
/>
</div>
@@ -4,6 +4,7 @@
import PlusIcon from '@lucide/svelte/icons/plus';
import XIcon from '@lucide/svelte/icons/x';
import { page } from '$app/state';
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 Card from '$lib/components/ui/card';
@@ -57,7 +58,7 @@
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
} finally {
saving = false;
}
@@ -65,7 +66,7 @@
async function handleSetLanguage() {
const l = await locale;
await setLocale({ lang: l.lang,language: langList.join(':'), machineId });
await setLocale({ lang: l.lang, language: langList.join(':'), machineId });
serverLanguage = '';
}
@@ -75,11 +76,15 @@
toast.error(m.system_locale_generate_invalid());
return;
}
await generateLocale({locale:loc,machineId});
await generateLocale({ locale: loc, machineId });
newLocale = '';
}
</script>
<PageMeta
title={m.seo_title_system_localization()}
description={m.seo_desc_system_localization()}
/>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_localization()}</h1>
@@ -125,7 +130,9 @@
onSelect={() => {
open = false;
if (loc !== l.lang)
pick(() => setLocale({ lang: loc, language: l.language || undefined,machineId }));
pick(() =>
setLocale({ lang: loc, language: l.language || undefined, machineId })
);
}}
>
<CheckIcon
@@ -245,7 +252,7 @@
value={km}
onSelect={() => {
kmOpen = false;
if (km !== l.vc_keymap) pick(() => setKeymap({keymap:km,machineId}));
if (km !== l.vc_keymap) pick(() => setKeymap({ keymap: km, machineId }));
}}
>
<CheckIcon
@@ -0,0 +1,208 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import CheckIcon from '@lucide/svelte/icons/check';
import CompassIcon from '@lucide/svelte/icons/compass';
import CpuIcon from '@lucide/svelte/icons/cpu';
import FingerprintIcon from '@lucide/svelte/icons/fingerprint';
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import MonitorCogIcon from '@lucide/svelte/icons/monitor-cog';
import NetworkIcon from '@lucide/svelte/icons/network';
import PackageIcon from '@lucide/svelte/icons/package';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
import UsersIcon from '@lucide/svelte/icons/users';
import XIcon from '@lucide/svelte/icons/x';
import { resolve } from '$app/paths';
import { page } from '$app/state';
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 Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { getModules, getWhoami } from '$lib/remotes/system.remote';
const machineId = $derived(page.params.machineId!);
const modulesResource = $derived(getModules(machineId));
const whoamiResource = $derived(getWhoami(machineId));
const dataPromise = $derived(Promise.all([modulesResource, whoamiResource]));
let refreshing = $state(false);
async function handleRefresh() {
refreshing = true;
try {
await Promise.all([modulesResource.refresh(), whoamiResource.refresh()]);
} finally {
refreshing = false;
}
}
function getModuleIcon(id: string) {
switch (id) {
case 'networking':
return NetworkIcon;
case 'packages':
return PackageIcon;
case 'services':
return CpuIcon;
case 'storage':
return HardDriveIcon;
case 'system':
return MonitorCogIcon;
case 'users':
return UsersIcon;
default:
return CompassIcon;
}
}
function hasPermission(
moduleId: string,
perm: string,
userPerms: Record<string, null | string[]>
) {
if (userPerms['*']) return true;
const modPerms = userPerms[moduleId];
if (!modPerms) return false;
return modPerms.includes('*') || modPerms.includes(perm);
}
</script>
<PageMeta title={m.seo_title_system_nadir()} description={m.seo_desc_system_nadir()} />
<div class="mx-auto flex w-full max-w-4xl flex-col gap-6 p-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<Button
variant="outline"
size="icon"
href={resolve('/dashboard/[machineId]/system', { machineId })}
title={m.error_action_back()}
>
<ArrowLeftIcon class="size-4" />
</Button>
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight">{m.seo_title_system_nadir()}</h1>
<p class="text-muted-foreground text-sm">{m.nav_system_nadir_desc()}</p>
</div>
</div>
<Button variant="outline" size="icon" onclick={handleRefresh} disabled={refreshing}>
<RefreshCwIcon class="size-4 {refreshing ? 'animate-spin' : ''}" />
</Button>
</div>
<svelte:boundary>
{#snippet failed(err)}
<Card.Root class="border-destructive/20 bg-destructive/5">
<Card.Content class="text-destructive py-6 font-mono text-sm">
{(err as Error).message || m.errors_generic()}
</Card.Content>
</Card.Root>
{/snippet}
{#await dataPromise}
<div class="flex items-center justify-center py-20">
<Loader2Icon class="text-muted-foreground size-8 animate-spin" />
</div>
{:then [modulesData, whoamiData]}
{@const hasGlobalWildcard = !!whoamiData.permissions['*']}
<div class="grid gap-6 md:grid-cols-3">
<!-- Whoami Details Card -->
<Card.Root class="relative overflow-hidden md:col-span-1 border border-border bg-linear-to-br from-card/85 to-card/50 shadow-lg backdrop-blur-md">
<!-- Visual Accent Gradient -->
<div class="absolute -right-16 -top-16 size-32 rounded-full bg-emerald-500/10 blur-3xl"></div>
<Card.Header class="pb-3">
<div class="flex items-center gap-2 text-emerald-500">
<FingerprintIcon class="size-5" />
<Card.Title class="text-sm font-semibold tracking-wide uppercase">{m.system_nadir_username()}</Card.Title>
</div>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<div class="flex flex-col">
<span class="text-2xl font-bold tracking-tight break-all">{whoamiData.username}</span>
<span class="text-muted-foreground mt-1 text-xs font-mono">Agent Identity</span>
</div>
<div class="border-t pt-3 space-y-2">
<span class="text-muted-foreground text-xs font-medium block uppercase tracking-wider">{m.system_nadir_permissions()}</span>
{#if hasGlobalWildcard}
<div class="flex items-center gap-2 rounded-md bg-emerald-500/10 p-2 text-xs border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 font-medium">
<ShieldCheckIcon class="size-4 shrink-0" />
<span>Administrator (Full Access)</span>
</div>
{:else}
<div class="flex items-center gap-2 rounded-md bg-sky-500/10 p-2 text-xs border border-sky-500/20 text-sky-600 dark:text-sky-400 font-medium">
<ShieldAlertIcon class="size-4 shrink-0" />
<span>Restricted Module Access</span>
</div>
{/if}
</div>
</Card.Content>
</Card.Root>
<!-- Module List / Grid -->
<div class="flex flex-col gap-4 md:col-span-2">
<h2 class="text-lg font-medium tracking-tight flex items-center gap-2">
<span>{m.system_nadir_modules()}</span>
<Badge variant="secondary" class="font-mono text-xs">{modulesData?.modules?.length ?? 0}</Badge>
</h2>
<div class="grid gap-4 sm:grid-cols-2">
{#each modulesData?.modules ?? [] as mod (mod.id)}
{@const Icon = getModuleIcon(mod.id)}
{@const hasModuleWildcard = !hasGlobalWildcard && whoamiData.permissions[mod.id]?.includes('*')}
<Card.Root class="group transition-all duration-300 hover:-translate-y-0.5 hover:shadow-md border border-border/80 hover:border-emerald-500/30">
<Card.Header class="pb-3 flex flex-row items-start justify-between gap-2 space-y-0">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-muted/40 p-2 text-muted-foreground group-hover:text-emerald-500 group-hover:bg-emerald-500/10 transition-colors">
<Icon class="size-4"/>
</div>
<div class="flex flex-col gap-0.5">
<Card.Title class="text-sm font-semibold tracking-tight leading-none capitalize">{mod.name}</Card.Title>
<span class="text-muted-foreground text-[10px] font-mono leading-none">{mod.id}</span>
</div>
</div>
{#if hasGlobalWildcard || hasModuleWildcard}
<Badge class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-0 p-1 rounded-full" title="Full access">
<ShieldCheckIcon class="size-3.5" />
</Badge>
{/if}
</Card.Header>
<Card.Content class="flex flex-col gap-3">
<div class="flex flex-wrap gap-1.5">
{#if mod.permissions && mod.permissions.length > 0}
{#each mod.permissions as perm (perm)}
{@const ok = hasPermission(mod.id, perm, whoamiData.permissions)}
<Badge
variant={ok ? 'default' : 'outline'}
class="text-[10px] py-0.5 px-2 font-mono transition-all flex items-center gap-1 leading-none rounded-full
{ok
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-0 hover:bg-emerald-500/20'
: 'bg-muted/10 text-muted-foreground hover:bg-muted/20 border-dashed border-border/70'}"
>
{#if ok}
<CheckIcon class="size-2.5 shrink-0" />
{:else}
<XIcon class="size-2.5 shrink-0" />
{/if}
{perm}
</Badge>
{/each}
{:else}
<span class="text-muted-foreground text-xs italic">{m.system_nadir_no_permissions()}</span>
{/if}
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
</div>
</div>
{/await}
</svelte:boundary>
</div>
@@ -2,6 +2,7 @@
import PowerIcon from '@lucide/svelte/icons/power';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { buttonVariants } from '$lib/components/ui/button';
@@ -13,14 +14,16 @@
let pending = $state<'off' | 'reboot' | null>(null);
let busy = $state(false);
const machineId = $derived(page.params.machineId!)
const machineId = $derived(page.params.machineId!);
async function run(action: 'off' | 'reboot') {
busy = true;
try {
await (action === 'off' ? powerOff({machineId,when:''}) : reboot({machineId,when:''}));
await (action === 'off'
? powerOff({ machineId, when: '' })
: reboot({ machineId, when: '' }));
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
} finally {
busy = false;
pending = null;
@@ -28,6 +31,7 @@
}
</script>
<PageMeta title={m.seo_title_system_power()} description={m.seo_desc_system_power()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_power()}</h1>
@@ -58,7 +62,9 @@
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
{pending === 'off' ? m.system_power_confirm_poweroff_title() : m.system_power_confirm_reboot_title()}
{pending === 'off'
? m.system_power_confirm_poweroff_title()
: m.system_power_confirm_reboot_title()}
</AlertDialog.Title>
<AlertDialog.Description>{m.system_power_confirm_description()}</AlertDialog.Description>
</AlertDialog.Header>
@@ -1,6 +1,4 @@
<script lang="ts">
import type { Pathname } from '$app/types';
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';
@@ -9,6 +7,7 @@
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
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';
@@ -69,9 +68,7 @@
$effect(() => {
filtersStore.current = filters;
});
const activeFilterCount = $derived(
(filters.showSystem ? 1 : 0) + (filters.shellOnly ? 1 : 0)
);
const activeFilterCount = $derived((filters.showSystem ? 1 : 0) + (filters.shellOnly ? 1 : 0));
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
@@ -117,9 +114,7 @@
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(
filtered.slice((page - 1) * pageSize.current, page * pageSize.current)
);
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
// Dialogs
let createOpen = $state(false);
@@ -154,7 +149,13 @@
});
toast.success(m.users_created());
createOpen = false;
createForm = { comment: '', create_home: true, shell: '/bin/bash', system: false, username: '' };
createForm = {
comment: '',
create_home: true,
shell: '/bin/bash',
system: false,
username: ''
};
} catch (e) {
handleError(e);
}
@@ -165,7 +166,9 @@
try {
await deletePamUser({
machineId,
remove_home: removeHome, username: deleting.username });
remove_home: removeHome,
username: deleting.username
});
toast.success(m.users_deleted());
deleteOpen = false;
deleting = null;
@@ -178,9 +181,11 @@
async function doSetPassword() {
if (!pwUser || !pwValue) return;
try {
await setPamUserPassword({
await setPamUserPassword({
machineId,
password: pwValue, username: pwUser.username });
password: pwValue,
username: pwUser.username
});
toast.success(m.saved());
pwOpen = false;
pwUser = null;
@@ -191,9 +196,10 @@
}
</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 bg-background p-4 py-2 z-20"
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>
@@ -220,10 +226,10 @@
</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>
<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) => {
@@ -296,7 +302,7 @@
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: 'UID' }, { key: 'shell', label: 'Shell' }] as col (col.key)}
{#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"
@@ -337,11 +343,14 @@
{#each pageRows as u (u.username)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve(`/dashboard/${pageState.params.machineId}/users/${u.username}` as Pathname)}
class="hover:underline">{u.username}</a
>
</Table.Cell>
<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>
@@ -398,12 +407,7 @@
<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)}
>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
@@ -449,10 +453,7 @@
{m.users_create_field_create_home()}
</label>
<label class="flex items-center gap-2 text-sm">
<Checkbox
checked={createForm.system}
onCheckedChange={(v) => (createForm.system = !!v)}
/>
<Checkbox checked={createForm.system} onCheckedChange={(v) => (createForm.system = !!v)} />
{m.users_create_field_system()}
</label>
<Dialog.Footer class="mt-2">
@@ -468,7 +469,8 @@
<Dialog.Root bind:open={pwOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.users_set_password_title({ username: pwUser?.username ?? '' })}</Dialog.Title>
<Dialog.Title>{m.users_set_password_title({ username: pwUser?.username ?? '' })}</Dialog.Title
>
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
</Dialog.Header>
<form
@@ -495,10 +497,10 @@
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.users_delete_title({ username: deleting?.username ?? '' })}</AlertDialog.Title>
<AlertDialog.Description
>{m.users_delete_description()}</AlertDialog.Description
<AlertDialog.Title
>{m.users_delete_title({ username: deleting?.username ?? '' })}</AlertDialog.Title
>
<AlertDialog.Description>{m.users_delete_description()}</AlertDialog.Description>
</AlertDialog.Header>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={removeHome} />
@@ -1,11 +1,10 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import KeyIcon from '@lucide/svelte/icons/key';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
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';
@@ -28,20 +27,21 @@
const id = $props.id();
const username = $derived(pageState.params.username!);
const machineId = $derived(pageState.params.machineId!);
const user = $derived(getPamUser({machineId,username,}));
const user = $derived(getPamUser({ machineId, username }));
const groups = $derived(listPamGroups(machineId));
const primary = $derived(
(groups.current ?? []).find((g) => g.gid === (user.current?.gid ?? -1))
);
const primary = $derived((groups.current ?? []).find((g) => g.gid === (user.current?.gid ?? -1)));
const supplementary = $derived(
(groups.current ?? []).filter((g) => g.members?.includes(username)).map((g) => g.name)
);
let editing = $state(false);
const selected = new SvelteSet<string>()
const selected = new SvelteSet<string>();
$effect(() => {
if (!editing) {selected.clear(); selected.union(new Set(supplementary))}
if (!editing) {
selected.clear();
selected.union(new Set(supplementary));
}
});
const dirty = $derived.by(() => {
const cur = new Set(supplementary);
@@ -65,8 +65,8 @@
const next = new SvelteSet(selected);
if (on) next.add(name);
else next.delete(name);
selected.clear()
selected.union(next)
selected.clear();
selected.union(next);
}
async function saveGroups() {
@@ -85,7 +85,7 @@
async function doSetPassword() {
if (!pwValue) return;
try {
await setPamUserPassword({ machineId,password: pwValue, username });
await setPamUserPassword({ machineId, password: pwValue, username });
toast.success(m.saved());
pwOpen = false;
pwValue = '';
@@ -96,15 +96,16 @@
async function doDelete() {
try {
await deletePamUser({ machineId,remove_home: removeHome, username });
await deletePamUser({ machineId, remove_home: removeHome, username });
toast.success(m.users_deleted());
await goto(resolve(`/dashboard/${machineId}/users` as Pathname));
await goto(resolve('/dashboard/[machineId]/users', { machineId }));
} catch (e) {
handleError(e);
}
}
</script>
<PageMeta title={`${username} · ${m.seo_title_users()}`} description={m.seo_desc_users_detail()} />
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<svelte:boundary>
{#snippet failed(err)}
@@ -118,8 +119,8 @@
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
{u.username}
{#if u.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge variant="outline"
>{m.users_type_user()}</Badge
{#if u.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge
variant="outline">{m.users_type_user()}</Badge
>{/if}
</h1>
<p class="text-muted-foreground text-sm">{u.comment || m.users_no_gecos()}</p>
@@ -142,7 +143,7 @@
</Card.Header>
<Card.Content>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-muted-foreground">UID</dt>
<dt class="text-muted-foreground">{m.users_col_uid()}</dt>
<dd class="font-mono">{u.uid}</dd>
<dt class="text-muted-foreground">{m.users_primary_gid()}</dt>
<dd class="font-mono">
@@ -175,8 +176,8 @@
variant="outline"
onclick={() => {
editing = false;
selected.clear()
selected.union(new Set(supplementary))
selected.clear();
selected.union(new Set(supplementary));
}}>{m.cancel()}</Button
>
<Button onclick={saveGroups} disabled={saving || !dirty}>{m.save()}</Button>
@@ -187,7 +188,9 @@
{#if !editing}
<div class="flex flex-wrap gap-1.5">
{#if primary}
<Badge variant="default" title="Primary group">{primary.name} {m.users_group_primary_badge()}</Badge>
<Badge variant="default" title={m.users_primary_group()}
>{primary.name} {m.users_group_primary_badge()}</Badge
>
{/if}
{#each supplementary as name (name)}
<Badge variant="outline">{name}</Badge>
@@ -1,6 +1,4 @@
<script lang="ts">
import type { Pathname } from '$app/types';
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';
@@ -8,7 +6,8 @@
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 { page as pageState } from '$app/state';
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';
@@ -32,7 +31,7 @@
type SortBy = 'gid' | 'members' | 'name';
type Dir = 'asc' | 'desc';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!)
const machineId = $derived(pageState.params.machineId!);
const groups = $derived(listPamGroups(machineId));
const pageSize = new PersistedState<number>('pam.groups.pageSize', 25);
@@ -96,9 +95,7 @@
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(
filtered.slice((page - 1) * pageSize.current, page * 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 });
@@ -129,7 +126,7 @@
async function doDelete() {
if (!deleting) return;
try {
await deletePamGroup({group:deleting.name, machineId});
await deletePamGroup({ group: deleting.name, machineId });
toast.success(m.groups_deleted());
deleteOpen = false;
deleting = null;
@@ -139,9 +136,10 @@
}
</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 bg-background p-4 py-2 z-20"
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>
@@ -168,10 +166,10 @@
</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>
<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}
@@ -229,7 +227,7 @@
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: 'GID' }, { key: 'members', label: 'Members' }] as col (col.key)}
{#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"
@@ -249,7 +247,7 @@
</button>
</Table.Head>
{/each}
<Table.Head>Type</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>
@@ -268,11 +266,14 @@
{#each pageRows as g (g.name)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve(`/dashboard/${pageState.params.machineId}/users/groups/${g.name}` as Pathname)}
class="hover:underline">{g.name}</a
>
</Table.Cell>
<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}
@@ -328,12 +329,7 @@
<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)}
>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
@@ -364,14 +360,11 @@
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="cg-gid-{id}">GID (optional)</Label>
<Label for="cg-gid-{id}">{m.groups_gid_optional()}</Label>
<Input id="cg-gid-{id}" type="number" min="0" bind:value={createForm.gid} />
</div>
<label class="flex items-center gap-2 text-sm">
<Checkbox
checked={createForm.system}
onCheckedChange={(v) => (createForm.system = !!v)}
/>
<Checkbox checked={createForm.system} onCheckedChange={(v) => (createForm.system = !!v)} />
{m.groups_create_field_system()}
</label>
<Dialog.Footer class="mt-2">
@@ -388,9 +381,7 @@
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.groups_delete_title({ name: deleting?.name ?? '' })}</AlertDialog.Title>
<AlertDialog.Description
>{m.groups_delete_description()}</AlertDialog.Description
>
<AlertDialog.Description>{m.groups_delete_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
@@ -1,12 +1,11 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import PlusIcon from '@lucide/svelte/icons/plus';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import XIcon from '@lucide/svelte/icons/x';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
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';
@@ -42,9 +41,7 @@
);
function supplementaryOf(username: string): string[] {
return (groups.current ?? [])
.filter((g) => g.members?.includes(username))
.map((g) => g.name);
return (groups.current ?? []).filter((g) => g.members?.includes(username)).map((g) => g.name);
}
let addOpen = $state(false);
@@ -61,7 +58,7 @@
busy = username;
try {
const next = [...new Set([...supplementaryOf(username), groupName])];
await setPamUserGroups({ groups: next,machineId, username });
await setPamUserGroups({ groups: next, machineId, username });
toast.success(m.groups_member_added({ username }));
addOpen = false;
addQuery = '';
@@ -76,7 +73,7 @@
busy = username;
try {
const next = supplementaryOf(username).filter((g) => g !== groupName);
await setPamUserGroups({ groups: next,machineId, username });
await setPamUserGroups({ groups: next, machineId, username });
toast.success(m.groups_member_removed({ username }));
} catch (e) {
handleError(e);
@@ -87,9 +84,9 @@
async function doDelete() {
try {
await deletePamGroup({group:groupName,machineId,});
await deletePamGroup({ group: groupName, machineId });
toast.success(m.groups_deleted());
await goto(resolve(`/dashboard/${machineId}/users/groups` as Pathname));
await goto(resolve('/dashboard/[machineId]/users/groups', { machineId }));
} catch (e) {
handleError(e);
}
@@ -101,6 +98,10 @@
});
</script>
<PageMeta
title={`${groupName} · ${m.seo_title_users_groups()}`}
description={m.seo_desc_users_groups_detail()}
/>
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
{#if groups.loading && !groups.current}
<Card.Root>
@@ -108,15 +109,17 @@
</Card.Root>
{:else if !group}
<Card.Root>
<Card.Content class="text-destructive py-6">{m.groups_not_found({ name: groupName })}</Card.Content>
<Card.Content class="text-destructive py-6"
>{m.groups_not_found({ name: groupName })}</Card.Content
>
</Card.Root>
{:else}
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
{group.name}
{#if group.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge variant="outline"
>{m.users_type_user()}</Badge
{#if group.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge
variant="outline">{m.users_type_user()}</Badge
>{/if}
</h1>
<p class="text-muted-foreground text-sm font-mono">gid {group.gid}</p>
@@ -132,7 +135,7 @@
<div>
<Card.Title>{m.groups_members_title()}</Card.Title>
<Card.Description>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html m.groups_members_description({ gid: group.gid })}
</Card.Description>
</div>
@@ -143,13 +146,18 @@
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<div>
<Label class="mb-1.5 block text-xs">{m.groups_supplementary_label({ count: members.length })}</Label>
<Label class="mb-1.5 block text-xs"
>{m.groups_supplementary_label({ count: members.length })}</Label
>
{#if members.length}
<div class="flex flex-wrap gap-1.5">
{#each members as name (name)}
<Badge variant="outline" class="gap-1 pe-1">
<a
href={resolve(`/dashboard/${machineId}/users/${name}` as Pathname)}
href={resolve('/dashboard/[machineId]/users/[username]', {
machineId,
username: name
})}
class="hover:underline">{name}</a
>
<button
@@ -157,7 +165,7 @@
class="hover:bg-muted rounded-sm p-0.5 disabled:opacity-50"
disabled={busy === name}
onclick={() => removeMember(name)}
aria-label="Remove {name}"
aria-label={m.groups_remove_member_aria({ name })}
>
<XIcon class="size-3" />
</button>
@@ -170,13 +178,18 @@
</div>
<div>
<Label class="mb-1.5 block text-xs">{m.groups_primary_label({ count: primaryMembers.length })}</Label>
<Label class="mb-1.5 block text-xs"
>{m.groups_primary_label({ count: primaryMembers.length })}</Label
>
{#if primaryMembers.length}
<div class="flex flex-wrap gap-1.5">
{#each primaryMembers as name (name)}
<Badge variant="default">
<a
href={resolve(`/dashboard/${machineId}/users/${name}` as Pathname)}
href={resolve('/dashboard/[machineId]/users/[username]', {
machineId,
username: name
})}
class="hover:underline">{name}</a
>
</Badge>
@@ -232,9 +245,7 @@
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.groups_delete_title({ name: groupName })}</AlertDialog.Title>
<AlertDialog.Description
>{m.groups_delete_description()}</AlertDialog.Description
>
<AlertDialog.Description>{m.groups_delete_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
+70
View File
@@ -0,0 +1,70 @@
import type { RequestHandler } from './$types';
const repo = process.env.NADIR_RELEASE_REPO ?? '__NADIR_RELEASE_REPO__';
function escapeShell(str: string) {
return str.replace(/(["\\$`])/g, '\\$1');
}
const RELEASE_REPO = escapeShell(repo);
const script = `#!/bin/sh
set -e
RELEASE_REPO="${RELEASE_REPO}"
do_install() {
if [ "$(id -u)" -ne 0 ]; then
echo "must be root" >&2
exit 1
fi
command -v curl >/dev/null 2>&1 || {
echo "curl required" >&2
exit 1
}
case "$(uname -m)" in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
*) echo "unsupported architecture" >&2; exit 1 ;;
esac
# safer repo parsing (no awk assumptions)
host=$(echo "$RELEASE_REPO" | sed -E 's#(https?://[^/]+).*#\\1#')
path=$(echo "$RELEASE_REPO" | sed -E 's#https?://[^/]+/##')
api="$host/api/v1/repos/$path/releases/latest"
echo "querying $api ..."
asset_url=$(curl -fsSL "$api" \
| grep -o '"browser_download_url":"[^"]*linux-'"$arch"'"' \
| head -n1 \
| cut -d'"' -f4)
if [ -z "$asset_url" ]; then
echo "no asset found" >&2
exit 1
fi
echo "downloading $asset_url ..."
curl -fL "$asset_url" -o /usr/local/bin/nadir.tmp
mv /usr/local/bin/nadir.tmp /usr/local/bin/nadir
chmod +x /usr/local/bin/nadir
/usr/local/bin/nadir install
echo "done"
}
do_install
`;
export const GET: RequestHandler = () => {
return new Response(script, {
headers: {
'Cache-Control': 'no-store',
'Content-Type': 'text/x-shellscript; charset=utf-8'
}
});
};