feat: expand dashboard with storage, network, and package management features while enhancing UI components and remote services
This commit is contained in:
@@ -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>
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()} />
|
||||
|
||||
@@ -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()} />
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user