stabilized architecture

This commit is contained in:
2026-06-25 14:46:48 +02:00
parent 522ab09bd8
commit 20ac1445ca
49 changed files with 2925 additions and 3192 deletions
+20
View File
@@ -16,6 +16,7 @@
"ogl": "^1.0.11",
"openapi-fetch": "^0.17.0",
"runed": "^0.37.1",
"ssh2": "^1.17.0",
"swapy": "^1.0.5",
"undici": "^8.5.0",
"uqr": "^0.1.3",
@@ -37,6 +38,7 @@
"@types/bun": "^1.3.14",
"@types/node": "^24",
"@types/nodemailer": "^8.0.1",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"bits-ui": "^2.16.3",
"clsx": "^2.1.1",
@@ -499,6 +501,8 @@
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
@@ -577,6 +581,8 @@
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"better-auth": ["better-auth@1.6.20", "", { "dependencies": { "@better-auth/core": "1.6.20", "@better-auth/drizzle-adapter": "1.6.20", "@better-auth/kysely-adapter": "1.6.20", "@better-auth/memory-adapter": "1.6.20", "@better-auth/mongo-adapter": "1.6.20", "@better-auth/prisma-adapter": "1.6.20", "@better-auth/telemetry": "1.6.20", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.6", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-fSpGHGRKiGRiYVd3QTQtuVZ8oxpiSe/7ip0Rpvt/Sy8zQbEbVKUPMOhE0gLXg+FjqTUsIo7582hxUYxtEcqUpA=="],
"better-call": ["better-call@1.3.6", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA=="],
@@ -585,6 +591,8 @@
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="],
@@ -617,6 +625,8 @@
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
@@ -949,6 +959,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="],
"nanoid": ["nanoid@3.3.14", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ=="],
"nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="],
@@ -1097,6 +1109,8 @@
"sqlite-wasm-kysely": ["sqlite-wasm-kysely@0.3.0", "", { "dependencies": { "@sqlite.org/sqlite-wasm": "^3.48.0-build2" }, "peerDependencies": { "kysely": "*" } }, "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg=="],
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
@@ -1165,6 +1179,8 @@
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
@@ -1277,6 +1293,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"bits-ui/runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
@@ -1329,6 +1347,8 @@
"@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
BIN
View File
Binary file not shown.
+699 -702
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -35,6 +35,7 @@
"@types/bun": "^1.3.14",
"@types/node": "^24",
"@types/nodemailer": "^8.0.1",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"bits-ui": "^2.16.3",
"clsx": "^2.1.1",
@@ -82,6 +83,7 @@
"ogl": "^1.0.11",
"openapi-fetch": "^0.17.0",
"runed": "^0.37.1",
"ssh2": "^1.17.0",
"swapy": "^1.0.5",
"undici": "^8.5.0",
"uqr": "^0.1.3",
+1 -2
View File
@@ -1,7 +1,6 @@
import { type Handle, redirect } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { dev } from '$app/env';
import { building } from '$app/environment';
import { building, dev } from '$app/environment';
import { auth } from '$lib/auth/server';
import { env } from '$lib/const/schema';
import { getTextDirection } from '$lib/paraglide/runtime';
@@ -253,7 +253,7 @@
const terminalState = getContext<{ open: boolean }>('terminalState');
const canShowTerminal = $derived(
user.role === 'admin' && whoamiResource?.current?.permissions?.['terminal']?.includes('root')
user.role === 'admin' && whoamiResource?.current?.permissions?.['system']?.includes('root')
);
const activeItem = $derived(
@@ -22,9 +22,10 @@
reorderMachines
} from '$lib/remotes/machines.remote';
import { auditLog, serverInfo, systemDetails } from '$lib/remotes/server.remote';
import { untrack } from 'svelte';
import { extractErrorMessage } from '$lib/utils';
import { onDestroy, tick, untrack } from 'svelte';
import { toast } from 'svelte-sonner';
import { createSwapy } from 'swapy';
import { createSwapy, type Swapy } from 'swapy';
const id = $props.id();
const isMobile = new IsMobile();
@@ -38,33 +39,39 @@
let listEl: HTMLDivElement | undefined = $state();
let items: { address: string; id: string; name: null | string }[] = $state([]);
const idSet = $derived([...items.map((i) => i.id)].sort().join('|'));
let reordering = $state(false);
let inst = $state<null|Swapy>(null);
$effect(() => {
if (!listEl || !idSet) return;
const inst = createSwapy(listEl, {
animation: 'dynamic',
autoScrollOnDrag: true,
dragAxis: 'y'
});
inst.onSwapEnd(async ({ hasChanged }) => {
if (!hasChanged) return;
const map = inst.slotItemMap().asObject;
const ids = untrack(() => items)
.map((mc) => map[mc.id])
.filter((x): x is string => !!x);
try {
inst.enable(false);
await reorderMachines({ ids, startIndex: (pageNum - 1) * PAGE_SIZE });
} catch (error) {
console.error(error);
toast.error(m.errors_generic());
} finally {
inst.enable(true);
}
});
return () => inst.destroy();
});
onDestroy(() => inst?.destroy());
async function toggleReorder() {
reordering = !reordering;
if (reordering) {
await tick();
inst = createSwapy(listEl!, {
animation: 'dynamic',
autoScrollOnDrag: true,
dragAxis: 'y'
});
inst.onSwapEnd(async ({ hasChanged }) => {
if (!hasChanged) return;
const map = inst!.slotItemMap().asObject;
const ids = items.map((mc) => map[mc.id]).filter((x): x is string => !!x);
try {
inst!.enable(false);
await reorderMachines({ ids, startIndex: (pageNum - 1) * PAGE_SIZE });
} catch (error) {
console.error(error);
toast.error(m.errors_generic());
} finally {
inst?.enable(true);
}
});
} else {
inst?.destroy();
inst = null;
}
}
const onSearch = () => {
pageNum = 1;
@@ -79,7 +86,12 @@
untrack(() => {
const cur = [...items.map((i) => i.id)].sort().join('|');
const next = [...data.items.map((i) => i.id)].sort().join('|');
if (cur !== next) items = data.items;
if (cur !== next) {
items = data.items;
reordering = false;
inst?.destroy();
inst = null;
}
});
});
});
@@ -87,9 +99,6 @@
</script>
{#snippet addForm()}
<script lang="ts">
</script>
<CopyButton text={installSH} size="sm" variant="outline">
{#snippet icon()}
<TerminalIcon />
@@ -107,7 +116,7 @@
} catch (error) {
console.error(error);
toast.error(
toast.error(
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
@@ -172,6 +181,14 @@
>
<RefreshCwIcon class="size-4" />
</Button>
<Button
variant={reordering ? 'default' : 'outline'}
size="icon"
title={reordering ? m.machine_reorder_done() : m.machine_reorder()}
onclick={toggleReorder}
>
<GripVerticalIcon class="size-4" />
</Button>
</div>
{#if isMobile.current}
@@ -244,12 +261,14 @@
>{machine.address}</span
>
</a>
</a>
<div
data-swapy-handle
class="text-muted-foreground group-hover/item:text-tertiary-foreground group-data-[active=true]/item:text-tertiary-foreground cursor-grab p-4 px-3 flex items-center justify-center"
>
<GripVerticalIcon class="size-4" />
{#if reordering}
<div
data-swapy-handle
class="text-muted-foreground group-hover/item:text-tertiary-foreground group-data-[active=true]/item:text-tertiary-foreground cursor-grab p-4 px-3 flex items-center justify-center"
>
<GripVerticalIcon class="size-4" />
</div>
{/if}
</div>
</div>
{:else}
+416 -64
View File
@@ -2,13 +2,50 @@
import type { FitAddon as TFitAddon } from '@xterm/addon-fit';
import type { Terminal as TTerminal } from '@xterm/xterm';
import KeyRoundIcon from '@lucide/svelte/icons/key-round';
import LogInIcon from '@lucide/svelte/icons/log-in';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import UserIcon from '@lucide/svelte/icons/user';
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import { Spinner } from '$lib/components/ui/spinner';
import * as Tabs from '$lib/components/ui/tabs';
import { Textarea } from '$lib/components/ui/textarea';
import '@xterm/xterm/css/xterm.css';
import { m } from '$lib/paraglide/messages';
import { getMachine } from '$lib/remotes/machines.remote';
import {
closeSshTerminal,
decryptCredential,
encryptCredential,
openSshTerminal,
resizeSshTerminal,
streamSshTerminal,
writeSshTerminal
} from '$lib/remotes/terminal.remote';
import { extractErrorMessage } from '$lib/utils';
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
type View = 'connecting' | 'ended' | 'form' | 'terminal';
function stripAnsi(text: string): string {
const E = String.fromCharCode(27);
const B = String.fromCharCode(7);
const control = new RegExp(
`[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]`,
'g'
);
return text
.replace(new RegExp(`${E}\\[[0-9;<=>?]*[a-zA-Z]`, 'g'), '')
.replace(new RegExp(`${E}\\][0-9;]*[^${E}]*(?:${E}\\\\|${B})`, 'g'), '')
.replace(new RegExp(`${E}[PX^_].*?${E}\\\\`, 'g'), '')
.replace(control, '');
}
interface TerminalState {
open: boolean;
@@ -17,59 +54,182 @@
const terminalState = getContext<TerminalState>('terminalState');
let containerElement = $state<HTMLDivElement | null>(null);
let socket = $state<null | WebSocket>(null);
let term = $state<null | TTerminal>(null);
let fitAddon = $state<null | TFitAddon>(null);
let view = $state<View>('form');
let sessionId = $state<null | string>(null);
const machineId = $derived(page.params.machineId);
const machineResource = $derived(machineId ? getMachine(machineId) : null);
const machineAddress = $derived(machineResource?.current?.address ?? null);
let username = $state('root');
let port = $state('22');
let authMethod = $state<'key' | 'password'>('password');
let password = $state('');
let privateKey = $state('');
const socketUrl = $derived.by(() => {
if (!machineAddress) return '';
try {
const url = new URL(machineAddress);
const wsProtocol = url.protocol === 'https:' ? 'wss' : 'ws';
return `${wsProtocol}://${url.host}/api/terminal`;
} catch {
return '';
let saveCredential = $state(false);
let hasSaved = $state(false);
let encryptedBlob = $state<null | string>(null);
let loadingSaved = $state(false);
let capturedHistory = $state('');
let sessionEnded = $state(false);
const machineId = $derived(page.params.machineId ?? '');
const storageKey = $derived(`terminal:cred:${machineId}:${username}`);
const sessionActive = $derived(sessionId !== null && !sessionEnded);
function resetForm() {
username = 'root';
port = '22';
authMethod = 'password';
password = '';
privateKey = '';
saveCredential = false;
hasSaved = false;
encryptedBlob = null;
loadingSaved = false;
capturedHistory = '';
sessionEnded = false;
}
$effect(() => {
if (!terminalState.open || view !== 'form') return;
const blob = localStorage.getItem(storageKey);
if (blob) {
hasSaved = true;
encryptedBlob = blob;
} else {
hasSaved = false;
encryptedBlob = null;
}
});
function sendResize(cols: number, rows: number) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ cols, rows }));
async function useSaved() {
if (!encryptedBlob) return;
loadingSaved = true;
try {
const { data } = await decryptCredential({ encrypted: encryptedBlob });
const parsed = JSON.parse(data) as { authMethod: 'key' | 'password'; credential: string };
hasSaved = false;
encryptedBlob = null;
view = 'connecting';
const result = await openSshTerminal({
machineId,
[parsed.authMethod === 'password' ? 'password' : 'privateKey']: parsed.credential,
port: parseInt(port) || 22,
username
});
sessionId = result.sessionId;
view = 'terminal';
} catch (err) {
view = 'form';
toast.error(
extractErrorMessage(err) ?? m.terminal_ssh_failed()
);
localStorage.removeItem(storageKey);
} finally {
loadingSaved = false;
}
}
function cleanup() {
if (socket) {
socket.close();
socket = null;
function forgetSaved() {
localStorage.removeItem(storageKey);
hasSaved = false;
encryptedBlob = null;
saveCredential = false;
toast.success(m.terminal_credential_forgotten({ username }));
}
async function connect() {
view = 'connecting';
const auth: {
machineId: string;
password?: string;
port: number;
privateKey?: string;
username: string;
} = {
machineId,
port: parseInt(port) || 22,
username
};
if (authMethod === 'password' && password) {
auth.password = password;
} else if (authMethod === 'key' && privateKey) {
auth.privateKey = privateKey;
}
try {
const result = await openSshTerminal(auth);
sessionId = result.sessionId;
if (saveCredential) {
const payload = JSON.stringify({ authMethod, credential: password || privateKey });
const { encrypted } = await encryptCredential({ data: payload });
localStorage.setItem(storageKey, encrypted);
toast.success(m.terminal_credential_saved_toast({ username }));
}
view = 'terminal';
} catch (err) {
view = 'form';
toast.error(
extractErrorMessage(err) ?? m.terminal_ssh_failed()
);
}
}
function downloadHistory() {
const blob = new Blob([capturedHistory], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `terminal-${machineId}-${username}-${Date.now()}.txt`;
a.click();
URL.revokeObjectURL(url);
handleClose();
}
function handleClose() {
if (sessionId) {
closeSshTerminal(sessionId);
sessionId = null;
}
if (term) {
term.dispose();
term = null;
}
fitAddon = null;
view = 'form';
resetForm();
terminalState.open = false;
}
$effect(() => {
if (!terminalState.open || !socketUrl) {
cleanup();
if (!terminalState.open) {
if (sessionId) {
closeSshTerminal(sessionId);
sessionId = null;
}
if (term) {
term.dispose();
term = null;
}
fitAddon = null;
view = 'form';
resetForm();
return;
}
let active = true;
if (!sessionId || view !== 'terminal') return;
const initTerminal = async () => {
let active = true;
let localSid = sessionId;
let iter: AsyncIterator<{ data: unknown; event: string }> | null = null;
const init = async () => {
if (!containerElement) return;
// Dynamically import xterm and addons to prevent SSR issues
const { Terminal } = await import('@xterm/xterm');
const { AttachAddon } = await import('@xterm/addon-attach');
const { FitAddon } = await import('@xterm/addon-fit');
const { Terminal } = await import('@xterm/xterm');
if (!active) return;
term = new Terminal({
@@ -77,40 +237,62 @@
fontFamily: 'Geist Mono, JetBrains Mono, Fira Code, monospace',
fontSize: 14,
theme: {
background: '#09090b', // zinc-950
background: '#09090b',
cursor: '#fafafa',
foreground: '#fafafa', // zinc-50
selectionBackground: '#27272a' // zinc-800
foreground: '#fafafa',
selectionBackground: '#27272a'
}
});
socket = new WebSocket(socketUrl);
socket.binaryType = 'arraybuffer';
socket.onopen = () => {
if (!active) {
cleanup();
return;
term.onData((data) => {
if (localSid) {
writeSshTerminal({ data, sessionId: localSid });
}
});
const attachAddon = new AttachAddon(socket!);
term?.loadAddon(attachAddon);
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(containerElement);
fitAddon.fit();
fitAddon = new FitAddon();
term?.loadAddon(fitAddon);
term?.open(containerElement!);
fitAddon?.fit();
if (term) sendResize(term.cols, term.rows);
else console.error('terminal not initialized');
};
iter = streamSshTerminal(localSid)[Symbol.asyncIterator]();
(async () => {
try {
while (active) {
const { done, value } = await iter!.next();
if (done) {
sessionEnded = true;
break;
}
if (value.event === 'output') {
term?.write(value.data as string);
const plain = stripAnsi(value.data as string);
capturedHistory += plain;
if (capturedHistory.length > 2_000_000) {
capturedHistory = capturedHistory.slice(-1_000_000);
}
} else if (value.event === 'error') {
sessionEnded = true;
toast.error(value.data as string);
}
}
} catch (err) {
if (active) {
sessionEnded = true;
toast.error(
extractErrorMessage(err) ?? m.errors_generic()
);
}
}
})();
};
initTerminal();
init();
const handleResize = () => {
if (fitAddon && term) {
if (fitAddon && term && localSid) {
fitAddon.fit();
sendResize(term.cols, term.rows);
resizeSshTerminal({ cols: term.cols, rows: term.rows, sessionId: localSid });
}
};
@@ -119,32 +301,202 @@
return () => {
active = false;
window.removeEventListener('resize', handleResize);
cleanup();
iter?.return?.();
if (term) {
term.dispose();
term = null;
}
fitAddon = null;
};
});
</script>
<Dialog.Root bind:open={terminalState.open}>
<Dialog.Root bind:open={terminalState.open} onOpenChange={(o) => { if (!o && sessionActive) return; if (!o) handleClose(); }}>
<Dialog.Content
class="max-w-4xl h-[600px] flex flex-col p-4 bg-zinc-950 text-zinc-50 border-zinc-800 shadow-2xl overflow-hidden"
>
<Dialog.Header class="pb-2 border-b border-zinc-800 flex flex-row items-center gap-2 space-y-0">
<TerminalIcon class="size-5 text-zinc-400 shrink-0" />
<Dialog.Title class="text-sm font-semibold tracking-wide text-zinc-200"
>{m.nav_system_terminal()}</Dialog.Title
class="sm:max-w-2xl sm:min-w-max -h-[500px]!"
showCloseButton={!sessionActive}
>
{#if view === 'form'}
<Dialog.Header>
<div class="flex items-center gap-3">
<div class="flex size-9 items-center justify-center rounded-lg border bg-muted">
<TerminalIcon class="size-4" />
</div>
<div>
<Dialog.Title class="text-base">{m.nav_system_terminal()}</Dialog.Title>
<Dialog.Description class="text-xs">
{m.terminal_connect_desc()}
</Dialog.Description>
</div>
</div>
</Dialog.Header>
<div class="grid gap-5 px-4 py-3">
<div class="space-y-3">
<p class="text-xs font-medium tracking-wide text-muted-foreground uppercase">{m.terminal_section_connection()}</p>
<div class="grid grid-cols-5 gap-3">
<div class="col-span-3 space-y-1.5">
<Label for="term-user" class="text-xs">{m.username()}</Label>
<div class="relative">
<UserIcon class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
id="term-user"
class="pl-8 h-9 text-sm"
bind:value={username}
placeholder="root"
/>
</div>
</div>
<div class="col-span-2 space-y-1.5">
<Label for="term-port" class="text-xs">{m.terminal_port()}</Label>
<Input id="term-port" class="w-full h-9 text-sm" bind:value={port} placeholder="22" />
</div>
</div>
</div>
<div class="space-y-3">
<div>
<p class="text-xs font-medium tracking-wide text-muted-foreground uppercase">{m.terminal_section_auth()}</p>
</div>
{#if hasSaved && !loadingSaved}
<div class="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2">
<span class="text-xs text-muted-foreground">
{m.terminal_saved_credential_for({ username })}
</span>
<div class="flex items-center gap-1.5">
<Button size="sm" variant="ghost" class="h-7 text-xs px-2" onclick={forgetSaved}>
{m.terminal_forget()}
</Button>
<Button size="sm" variant="secondary" class="h-7 text-xs px-2" onclick={useSaved}>
{m.terminal_use_saved()}
</Button>
</div>
</div>
{/if}
{#if loadingSaved}
<div class="flex items-center justify-center py-4">
<Spinner class="size-4 text-muted-foreground" />
</div>
{/if}
<Tabs.Root
value={authMethod}
onValueChange={(v) => {
if (v === 'password' || v === 'key') authMethod = v;
}}
>
<Tabs.List class="h-9 px-0">
<Tabs.Trigger value="password" class="text-xs h-9 px-3">{m.password()}</Tabs.Trigger>
<Tabs.Trigger value="key" class="text-xs h-9 px-3">{m.terminal_auth_private_key()}</Tabs.Trigger>
</Tabs.List>
<div class="mt-3">
<Tabs.Content value="password" class="space-y-1.5">
<Label for="term-password" class="text-xs">{m.password()}</Label>
<div class="relative">
<KeyRoundIcon class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
id="term-password"
class="pl-8 h-9 text-sm"
type="password"
bind:value={password}
placeholder={m.terminal_password_placeholder()}
/>
</div>
</Tabs.Content>
<Tabs.Content value="key" class="space-y-1.5">
<Label for="term-key" class="text-xs">{m.terminal_auth_private_key()}</Label>
<Textarea
id="term-key"
class="h-60 font-mono text-xs"
bind:value={privateKey}
placeholder={m.terminal_private_key_placeholder()}
/>
</Tabs.Content>
</div>
</Tabs.Root>
<div class="flex items-center gap-2 pt-1">
<Checkbox
id="term-save"
bind:checked={saveCredential}
/>
<Label for="term-save" class="text-xs text-muted-foreground cursor-pointer">
{m.terminal_remember_credential()}
</Label>
</div>
</div>
</div>
<Dialog.Footer class="gap-2">
<Button variant="outline" size="sm" onclick={() => (terminalState.open = false)}>
{m.cancel()}
</Button>
<Button size="sm" onclick={connect}>
<LogInIcon class="size-3.5" />
{m.terminal_connect()}
</Button>
</Dialog.Footer>
{:else if view === 'connecting'}
<div class="flex flex-col items-center justify-center gap-4 py-16 min-h-125">
<Spinner class="size-6 text-muted-foreground" />
<div class="space-y-1 text-center">
<p class="text-sm font-medium">{m.terminal_connecting()}</p>
<p class="text-xs text-muted-foreground">
{m.terminal_connecting_desc({ credential: `${username}@${machineId}`, port })}
</p>
</div>
</div>
{:else}
<div class="flex flex-col min-h-125">
<div class="flex items-center justify-between px-4 py-2.5">
<div class="flex items-center gap-2.5">
<div class="flex size-7 items-center justify-center rounded-md border bg-muted">
<TerminalIcon class="size-3.5 text-muted-foreground" />
</div>
<div class="flex items-center gap-2 text-xs font-medium leading-none">
{m.nav_system_terminal()}
<Separator orientation="vertical" class="h-3" />
<span class="font-mono text-muted-foreground text-[11px] tabular-nums">
{username}@{port}
</span>
</div>
</div>
</div>
<div
class="relative flex-1 min-h-0 w-full bg-zinc-950 rounded-b-lg border-x border-b border-zinc-800 overflow-hidden"
>
</Dialog.Header>
<div
class="flex-1 min-h-0 w-full mt-4 bg-zinc-950 rounded-lg overflow-hidden border border-zinc-800 p-2 relative"
>
<div bind:this={containerElement} class="w-full h-full"></div>
<div
bind:this={containerElement}
class="w-full h-full p-1.5"
></div>
{#if sessionEnded}
<div class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 border-t border-zinc-800 bg-zinc-950/90 px-3 py-2.5">
<span class="text-xs text-muted-foreground">
{capturedHistory.length > 2_000_000 ? m.terminal_history_truncated() : m.terminal_session_ended()}
</span>
<div class="flex items-center gap-1.5">
<Button size="sm" variant="ghost" class="h-7 text-xs px-2" onclick={() => handleClose()}>
{m.terminal_discard()}
</Button>
<Button size="sm" variant="secondary" class="h-7 text-xs px-2" onclick={downloadHistory}>
{m.terminal_download_history()}
</Button>
</div>
</div>
{/if}
</div>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
<style>
:global(.xterm) {
padding: 4px;
padding: 2px;
height: 100%;
}
</style>
@@ -0,0 +1,209 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
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 { PersistedState } from 'runed';
interface I18n {
display: string;
filterTitle: string;
next: () => string;
pageOf: (p: { page: number; pages: number; total: number }) => string;
previous: () => string;
rowsPerPage: string;
}
let {
actions,
activeFilterCount = $bindable(0),
columns,
defaultPageSize = $bindable(25),
description,
emptyMessage,
filterContent,
i18n,
items,
loading,
onrefresh,
onsearchinput,
page = $bindable(1),
pageSizeKey,
pageSizePresets = $bindable([10, 25, 50, 100, 200]),
row,
search = $bindable(''),
searchHint,
searchPlaceholder,
title
}: {
actions?: Snippet;
activeFilterCount?: number;
columns: Snippet;
defaultPageSize?: number;
description: string;
emptyMessage: string;
filterContent?: Snippet;
i18n: I18n;
items: T[];
loading: boolean;
onrefresh?: () => void;
onsearchinput?: (e: Event) => void;
page?: number;
pageSizeKey: string;
pageSizePresets?: number[];
row: Snippet<[T, number]>;
search?: string;
searchHint?: string;
searchPlaceholder: string;
title: string;
} = $props();
const id = $props.id();
// svelte-ignore state_referenced_locally
const pageSize = new PersistedState<number>(pageSizeKey, defaultPageSize);
const totalPages = $derived(Math.max(1, Math.ceil(items.length / pageSize.current)));
const paginated = $derived(
items.slice((page - 1) * pageSize.current, page * pageSize.current)
);
$effect(() => {
if (page > totalPages) page = totalPages;
});
</script>
<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">{title}</h1>
<p class="text-muted-foreground text-sm truncate">{description}</p>
</div>
<div class="flex items-center gap-2">
{#if onrefresh}
<Button variant="outline" size="icon" title={i18n.previous()} onclick={onrefresh}>
<RefreshCwIcon class="size-4" />
</Button>
{/if}
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{i18n.filterTitle}
{#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">{i18n.filterTitle}</h3>
</div>
{#if filterContent}
<div class="flex flex-col gap-3 p-2">
{@render filterContent()}
</div>
{/if}
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{i18n.display}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{i18n.rowsPerPage}</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 pageSizePresets as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
{#if actions}
{@render actions()}
{/if}
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={searchPlaceholder}
value={search}
oninput={onsearchinput}
class="max-w-sm"
/>
{#if searchHint}
<span class="text-muted-foreground text-xs">{searchHint}</span>
{/if}
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{@render columns()}
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={99} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !paginated.length}
<Table.Row>
<Table.Cell colspan={99} class="text-muted-foreground py-8 text-center"
>{emptyMessage}</Table.Cell
>
</Table.Row>
{:else}
{#each paginated as item, i (i)}
<Table.Row>
{@render row(item, i)}
</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">
{#if items.length > 0}
<span class="text-muted-foreground text-sm">
{i18n.pageOf({ page, pages: totalPages, total: totalPages })}
</span>
{/if}
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{i18n.previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{i18n.next()}
</Button>
</div>
</div>
</div>
+12 -15
View File
@@ -36,20 +36,18 @@ export const reorderMachines = command(
v.object({ ids: v.array(v.string()), startIndex: v.optional(v.number(), 0) }),
async ({ ids, startIndex }) => {
// ponytail: two-phase to satisfy UNIQUE(order); negative offsets avoid collision with existing.
await db.transaction(async (tx) => {
for (const id of ids) {
await tx
.update(machines)
.set({ order: -1 - Math.floor(Math.random() * 1_000_000_000) })
.where(eq(machines.id, id));
}
for (const [i, mid] of ids.entries()) {
await tx
.update(machines)
.set({ order: startIndex + i })
.where(eq(machines.id, mid));
}
});
for (const id of ids) {
await db
.update(machines)
.set({ order: -1 - Math.floor(Math.random() * 1_000_000_000) })
.where(eq(machines.id, id));
}
for (const [i, mid] of ids.entries()) {
await db
.update(machines)
.set({ order: startIndex + i })
.where(eq(machines.id, mid));
}
return { ok: true };
}
);
@@ -63,7 +61,6 @@ export const machineHealth = query.batch(v.string(), async (machineIds) => {
rows.map(async (mc) => {
const token = decryptValue(mc.token);
if (!token) return [mc.id, false] as const;
// ponytail: 3s ceiling so one dead box doesn't stall the badge for live ones
const { data } = await getClient(mc.address, token).GET('/api/health', {
signal: AbortSignal.timeout(3000)
});
+13 -13
View File
@@ -4,28 +4,28 @@ import { v } from '$lib';
import { nadirForMachine, throwNadirError } from './utils';
export const listInterfaces = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/networking/interfaces');
if (!data) throwNadirError(err);
return data.interfaces ?? [];
});
export const listHosts = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/networking/hosts');
if (!data) throwNadirError(err);
return data.entries ?? [];
});
export const listRoutes = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/networking/routes');
if (!data) throwNadirError(err);
return data.routes ?? [];
});
export const getDns = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/networking/dns');
if (!data) throwNadirError(err);
return data.servers ?? [];
@@ -33,7 +33,7 @@ export const getDns = query(v.string(), async (machineId) => {
// 404 when nothing is pending — treat as null instead of throwing.
export const getPending = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/networking/pending');
if (err) {
if (err.status === 404) return null;
@@ -45,7 +45,7 @@ export const getPending = query(v.string(), async (machineId) => {
export const getInterfaceConfig = query(
v.object({ machineId: v.string(), name: v.string() }),
async ({ machineId, name }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/networking/interfaces/{name}', {
params: { path: { name } }
});
@@ -75,7 +75,7 @@ export const applyInterfaceConfig = command(
routes: v.optional(v.array(v.object({ destination: v.string(), gateway: v.string() })))
}),
async ({ machineId, name, ...body }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.PUT('/api/networking/interfaces/{name}', {
body,
params: { path: { name } }
@@ -89,7 +89,7 @@ export const applyInterfaceConfig = command(
export const linkUp = command(
v.object({ machineId: v.string(), name: v.string() }),
async ({ machineId, name }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/networking/interfaces/{name}/up', {
params: { path: { name } }
});
@@ -101,7 +101,7 @@ export const linkUp = command(
export const linkDown = command(
v.object({ machineId: v.string(), name: v.string() }),
async ({ machineId, name }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/networking/interfaces/{name}/down', {
params: { path: { name } }
});
@@ -113,7 +113,7 @@ export const linkDown = command(
export const confirmChange = command(
v.object({ machineId: v.string(), name: v.string() }),
async ({ machineId, name }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/networking/interfaces/{name}/confirm', {
params: { path: { name } }
});
@@ -126,7 +126,7 @@ export const confirmChange = command(
export const rollbackChange = command(
v.object({ machineId: v.string(), name: v.string() }),
async ({ machineId, name }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/networking/interfaces/{name}/rollback', {
params: { path: { name } }
});
@@ -143,7 +143,7 @@ export const upsertHost = command(
machineId: v.string()
}),
async ({ hostnames, ip, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.PUT('/api/networking/hosts/{ip}', {
body: { hostnames },
params: { path: { ip } }
@@ -156,7 +156,7 @@ export const upsertHost = command(
export const deleteHost = command(
v.object({ ip: v.string(), machineId: v.string() }),
async ({ ip, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.DELETE('/api/networking/hosts/{ip}', {
params: { path: { ip } }
});
+6 -43
View File
@@ -2,10 +2,10 @@ import { error } from '@sveltejs/kit';
import { query } from '$app/server';
import { v } from '$lib';
import { nadirForMachine, parsePackageUpdates, throwNadirError } from './utils';
import { nadirForMachine, parsePackageUpdates, parseSseStream, throwNadirError } from './utils';
export const listInstalledPackages = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/packages');
if (!data) throwNadirError(err);
return data;
@@ -15,7 +15,7 @@ export const listInstalledPackages = query(v.string(), async (machineId) => {
// agent streams progress). Schema says JSON. Parse whichever we get; drop the SSE
// branch once openapi-typescript regen reflects the change.
export const listPackageUpdates = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const {
data,
error: err,
@@ -34,7 +34,7 @@ export const streamPackageAction = query.live(
name: v.optional(v.string())
}),
async function* ({ action, machineId, name }) {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
let response: Response;
if (action === 'install') {
@@ -76,45 +76,8 @@ export const streamPackageAction = query.live(
throw error(500, { message: 'Response body is empty' });
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
if (!part.trim()) continue;
const lines = part.split('\n');
let eventName = '';
let dataString = '';
for (const line of lines) {
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
} else if (line.startsWith('data:')) {
dataString = line.slice(5).trim();
}
}
if (eventName && dataString) {
try {
const parsed = JSON.parse(dataString);
yield { data: parsed, event: eventName };
} catch {
yield { data: dataString, event: eventName };
}
}
}
}
} finally {
reader.releaseLock();
for await (const chunk of parseSseStream(response)) {
yield chunk as { data: { error: string; line: string; message: string; success: boolean }; event: string };
}
}
);
+9 -9
View File
@@ -4,7 +4,7 @@ import { v } from '$lib';
import { nadirForMachine, throwNadirError } from './utils';
export const listPamUsers = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/users');
if (!data) throwNadirError(err);
return data.users ?? [];
@@ -21,7 +21,7 @@ export const createPamUser = command(
username: v.string()
}),
async (body) => {
const nadir = await nadirForMachine(body.machineId);
const { client: nadir } = await nadirForMachine(body.machineId);
const { error: err } = await nadir.POST('/api/users', { body });
if (err) throwNadirError(err);
await listPamUsers(body.machineId).refresh();
@@ -31,7 +31,7 @@ export const createPamUser = command(
export const deletePamUser = command(
v.object({ machineId: v.string(), remove_home: v.optional(v.boolean()), username: v.string() }),
async ({ machineId, remove_home, username }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.DELETE('/api/users/{username}', {
params: { path: { username }, query: { remove_home } }
});
@@ -43,7 +43,7 @@ export const deletePamUser = command(
export const setPamUserPassword = command(
v.object({ machineId: v.string(), password: v.string(), username: v.string() }),
async ({ machineId, password, username }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/users/{username}/password', {
body: { password },
params: { path: { username } }
@@ -55,7 +55,7 @@ export const setPamUserPassword = command(
export const getPamUser = query(
v.object({ machineId: v.string(), username: v.string() }),
async ({ machineId, username }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/users/{username}', {
params: { path: { username } }
});
@@ -67,7 +67,7 @@ export const getPamUser = query(
export const setPamUserGroups = command(
v.object({ groups: v.array(v.string()), machineId: v.string(), username: v.string() }),
async ({ groups, machineId, username }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.PUT('/api/users/{username}/groups', {
body: { groups },
params: { path: { username } }
@@ -78,7 +78,7 @@ export const setPamUserGroups = command(
);
export const listPamGroups = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/groups');
if (!data) throwNadirError(err);
return data.groups ?? [];
@@ -92,7 +92,7 @@ export const createPamGroup = command(
system: v.optional(v.boolean())
}),
async (body) => {
const nadir = await nadirForMachine(body.machineId);
const { client: nadir } = await nadirForMachine(body.machineId);
const { error: err } = await nadir.POST('/api/groups', { body });
if (err) throwNadirError(err);
await listPamGroups(body.machineId).refresh();
@@ -102,7 +102,7 @@ export const createPamGroup = command(
export const deletePamGroup = command(
v.object({ group: v.string(), machineId: v.string() }),
async ({ group, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.DELETE('/api/groups/{group}', {
params: { path: { group } }
});
+7 -46
View File
@@ -1,27 +1,13 @@
import { error } from '@sveltejs/kit';
import { command, getRequestEvent, query } from '$app/server';
import { command, query } from '$app/server';
import { v } from '$lib';
import { env } from '$lib/const/schema';
import { m } from '$lib/paraglide/messages';
import { db } from '$lib/server/db';
import { decryptValue } from '$lib/server/db/custom-types';
import { getClient } from '$lib/server/nadir-agent/client';
import { parsePackageUpdates, throwNadirError } from './utils';
import { nadirForMachine, parsePackageUpdates, throwNadirError } from './utils';
export const serverInfo = query(v.string(), async (machineId) => {
const {
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
if (!machine) error(404, { message: m.errors_not_found() });
const token = decryptValue(machine.token);
if (!token) error(500, { message: m.errors_generic() });
const nadir = getClient(machine.address, token);
const { client: nadir, machine: safe } = await nadirForMachine(machineId);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { token: _, ...safe } = machine;
try {
const [info, health] = await Promise.all([
nadir.GET('/api/system/info'),
@@ -44,15 +30,8 @@ export const serverInfo = query(v.string(), async (machineId) => {
export const auditLog = query(
v.object({ limit: v.optional(v.number(), 20), machineId: v.string() }),
async ({ limit, machineId }) => {
const {
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
if (!machine) error(404, { message: m.errors_not_found() });
const token = decryptValue(machine.token);
if (!token) error(500, { message: m.errors_generic() });
const nadir = getClient(machine.address, token);
const { client: nadir } = await nadirForMachine(machineId);
try {
const { data } = await nadir.GET('/api/audit', { params: { query: { limit } } });
return data?.entries ?? [];
@@ -76,30 +55,12 @@ export const latestAgentRelease = query(async () => {
});
export const updateAgent = command(v.string(), async (machineId) => {
const {
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
if (!machine) error(404, { message: m.errors_not_found() });
const token = decryptValue(machine.token);
if (!token) error(500, { message: m.errors_generic() });
const nadir = getClient(machine.address, token);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/update');
if (err) throwNadirError(err);
});
export const systemDetails = query(v.string(), async (machineId) => {
const {
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
if (!machine) error(404, { message: m.errors_not_found() });
const token = decryptValue(machine.token);
if (!token) error(500, { message: m.errors_generic() });
const nadir = getClient(machine.address, token);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { token: _, ...safe } = machine;
const { client: nadir, machine: safe } = await nadirForMachine(machineId);
const updatesPromise = nadir
.GET('/api/packages/updates', { parseAs: 'text' })
+12 -39
View File
@@ -2,12 +2,12 @@ import { error } from '@sveltejs/kit';
import { command, query } from '$app/server';
import { v } from '$lib';
import { nadirForMachine, throwNadirError } from './utils';
import { nadirForMachine, parseSseStream, throwNadirError } from './utils';
type LogEntry = { message: string; priority: number; time: string };
export const listServices = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/services');
if (err) throwNadirError(err);
return data;
@@ -16,7 +16,7 @@ export const listServices = query(v.string(), async (machineId) => {
export const getServiceStatus = query(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/services/{unit}', {
params: { path: { unit } }
});
@@ -34,7 +34,7 @@ export const getServiceLogs = query(
unit: v.string()
}),
async ({ lines, machineId, priority, since, unit }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/services/{unit}/logs', {
params: { path: { unit }, query: { lines, priority, since } }
});
@@ -51,7 +51,7 @@ export const streamServiceLogs = query.live(
unit: v.string()
}),
async function* ({ machineId, priority, since, unit }) {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err, response } = await nadir.GET('/api/services/{unit}/logs/stream', {
params: { path: { unit }, query: { priority, since } },
parseAs: 'stream'
@@ -59,35 +59,8 @@ export const streamServiceLogs = query.live(
if (err) throwNadirError(err);
if (!response.body) throw error(500, { message: 'Response body is empty' });
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
if (!part.trim()) continue;
let eventName = '';
let dataString = '';
for (const line of part.split('\n')) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
else if (line.startsWith('data:')) dataString = line.slice(5).trim();
}
if (eventName && dataString) {
try {
yield { data: JSON.parse(dataString) as LogEntry, event: eventName };
} catch {
yield { data: dataString, event: eventName };
}
}
}
}
} finally {
reader.releaseLock();
for await (const chunk of parseSseStream(response)) {
yield chunk as { data: LogEntry; event: string };
}
}
);
@@ -95,7 +68,7 @@ export const streamServiceLogs = query.live(
export const enableService = command(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/services/{unit}/enable', {
params: { path: { unit } }
});
@@ -108,7 +81,7 @@ export const enableService = command(
export const disableService = command(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/services/{unit}/disable', {
params: { path: { unit } }
});
@@ -121,7 +94,7 @@ export const disableService = command(
export const startService = command(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/services/{unit}/start', {
params: { path: { unit } }
});
@@ -134,7 +107,7 @@ export const startService = command(
export const stopService = command(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/services/{unit}/stop', {
params: { path: { unit } }
});
@@ -147,7 +120,7 @@ export const stopService = command(
export const restartService = command(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/services/{unit}/restart', {
params: { path: { unit } }
});
+4 -4
View File
@@ -4,14 +4,14 @@ import { v } from '$lib';
import { nadirForMachine, throwNadirError } from './utils';
export const listMounts = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/storage/mounts');
if (!data) throwNadirError(err);
return data.mounts ?? [];
});
export const listFstab = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/storage/fstab');
if (!data) throwNadirError(err);
return data.entries ?? [];
@@ -28,7 +28,7 @@ export const addMount = command(
pass: v.optional(v.number())
}),
async ({ machineId, ...body }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/storage/mounts', { body });
if (err) throwNadirError(err);
await listMounts(machineId).refresh();
@@ -39,7 +39,7 @@ export const addMount = command(
export const removeMount = command(
v.object({ machineId: v.string(), mountpoint: v.string() }),
async ({ machineId, mountpoint }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.DELETE('/api/storage/mounts', {
params: { query: { mountpoint } }
});
+19 -36
View File
@@ -1,48 +1,31 @@
import { error } from '@sveltejs/kit';
import { command, getRequestEvent, query } from '$app/server';
import { command, query } from '$app/server';
import { v } from '$lib';
import { m } from '$lib/paraglide/messages';
import { db } from '$lib/server/db';
import { decryptValue } from '$lib/server/db/custom-types';
import { getClient } from '$lib/server/nadir-agent/client';
import { systemDetails } from './server.remote';
import { throwNadirError } from './utils';
async function nadirForMachine(machineId: string) {
const {
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
if (!machine) error(404, { message: m.errors_not_found() });
const token = decryptValue(machine.token);
if (!token) error(500, { message: m.errors_generic() });
return getClient(machine.address, token);
}
import { nadirForMachine, throwNadirError } from './utils';
export const systemTime = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/system/time');
if (!data) throwNadirError(err);
return data;
});
export const systemLocale = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/system/locale');
if (!data) throwNadirError(err);
return data;
});
export const listTimezones = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data } = await nadir.GET('/api/system/timezones');
return data?.timezones ?? [];
});
export const listLocales = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data } = await nadir.GET('/api/system/locales');
return data?.locales ?? [];
});
@@ -50,7 +33,7 @@ export const listLocales = query(v.string(), async (machineId) => {
export const setTimezone = command(
v.object({ machineId: v.string(), timezone: v.string() }),
async ({ machineId, timezone }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/timezone', { body: { timezone } });
if (err) throwNadirError(err);
await systemTime(machineId).refresh();
@@ -61,7 +44,7 @@ export const setTimezone = command(
export const setNtp = command(
v.object({ enabled: v.boolean(), machineId: v.string() }),
async ({ enabled, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/ntp', { body: { enabled } });
if (err) throwNadirError(err);
await systemTime(machineId).refresh();
@@ -72,7 +55,7 @@ export const setNtp = command(
export const setTime = command(
v.object({ machineId: v.string(), time: v.string() }),
async ({ machineId, time }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/time', { body: { time } });
if (err) throwNadirError(err);
await systemTime(machineId).refresh();
@@ -80,7 +63,7 @@ export const setTime = command(
);
export const systemHostname = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/system/hostname');
console.log(err)
if (!data) throwNadirError(err);
@@ -90,7 +73,7 @@ export const systemHostname = query(v.string(), async (machineId) => {
export const setHostname = command(
v.object({ hostname: v.string(), machineId: v.string() }),
async ({ hostname, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/hostname', { body: { hostname } });
if (err) throwNadirError(err);
await systemHostname(machineId).refresh();
@@ -98,7 +81,7 @@ export const setHostname = command(
);
export const listKeymaps = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data } = await nadir.GET('/api/system/keymaps');
const d = data as { keymaps?: null | string[]; reason?: string } | undefined;
return { keymaps: d?.keymaps ?? [], reason: d?.reason ?? '' };
@@ -107,7 +90,7 @@ export const listKeymaps = query(v.string(), async (machineId) => {
export const setKeymap = command(
v.object({ keymap: v.string(), machineId: v.string() }),
async ({ keymap, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/keymap', { body: { keymap } });
if (err) throwNadirError(err);
await systemLocale(machineId).refresh();
@@ -118,7 +101,7 @@ export const setKeymap = command(
export const powerOff = command(
v.object({ machineId: v.string(), when: v.optional(v.string(), '') }),
async ({ machineId, when }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/poweroff', { body: { when } });
if (err) throwNadirError(err);
}
@@ -127,7 +110,7 @@ export const powerOff = command(
export const reboot = command(
v.object({ machineId: v.string(), when: v.optional(v.string(), '') }),
async ({ machineId, when }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/reboot', { body: { when } });
if (err) throwNadirError(err);
}
@@ -140,7 +123,7 @@ export const setLocale = command(
machineId: v.string()
}),
async ({ lang, language, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/locale', {
body: { lang, language: language || undefined }
});
@@ -151,21 +134,21 @@ export const setLocale = command(
);
export const generateLocale = command(v.object({ locale: v.string(), machineId: v.string() }), async ({ locale, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/locale/generate', { body: { locale } });
if (err) throwNadirError(err);
await listLocales(machineId).refresh();
});
export const getModules = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/_modules');
if (err) throwNadirError(err);
return data;
});
export const getWhoami = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/whoami');
if (err) throwNadirError(err);
return data;
+125
View File
@@ -0,0 +1,125 @@
import { error } from '@sveltejs/kit';
import { command, getRequestEvent, query } from '$app/server';
import { v } from '$lib';
import { env } from '$lib/const/schema';
import { m } from '$lib/paraglide/messages';
import { db } from '$lib/server/db';
import { machines } from '$lib/server/db/schema';
import { decryptValue, encrypt } from '$lib/server/db/custom-types';
import {
closeSession,
createSession,
getSession,
resizeSession,
writeToSession,
type SessionAuth
} from '$lib/server/terminal/session';
import { eq } from 'drizzle-orm';
export const openSshTerminal = command(
v.object({
machineId: v.string(),
password: v.optional(v.string()),
port: v.optional(v.number(), 22),
privateKey: v.optional(v.string()),
username: v.optional(v.string(), 'root')
}),
async ({ machineId, password, port, privateKey, username }) => {
const {
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db
.select({ address: machines.address, id: machines.id })
.from(machines)
.where(eq(machines.id, machineId))
.limit(1);
if (!machine[0]) error(404, { message: m.errors_not_found() });
const host = new URL(machine[0].address).hostname;
const auth: SessionAuth = { username };
if (password) auth.password = password;
if (privateKey) auth.privateKey = privateKey;
const sessionId = createSession(host, port, auth);
return { sessionId };
}
);
export const streamSshTerminal = query.live(
v.string(),
async function* (sessionId: string) {
const session = getSession(sessionId);
if (!session) {
yield { data: 'SSH connection failed or session expired', event: 'error' };
return;
}
if (session.outputStream.locked) {
throw error(409, 'Terminal stream is already being consumed');
}
const reader = session.outputStream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield { data: value, event: 'output' };
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Terminal connection lost';
yield { data: message, event: 'error' };
} finally {
reader.releaseLock();
closeSession(sessionId);
}
}
);
export const writeSshTerminal = command(
v.object({ data: v.string(), sessionId: v.string() }),
async ({ data, sessionId }) => {
writeToSession(sessionId, data);
return { ok: true };
}
);
export const resizeSshTerminal = command(
v.object({ cols: v.number(), rows: v.number(), sessionId: v.string() }),
async ({ cols, rows, sessionId }) => {
resizeSession(sessionId, cols, rows);
return { ok: true };
}
);
export const closeSshTerminal = command(v.string(), async (sessionId: string) => {
closeSession(sessionId);
return { ok: true };
});
export const encryptCredential = command(
v.object({ data: v.string() }),
async ({ data }) => {
const {
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
return { encrypted: encrypt(data, env.CRYPTO_SECRET) };
}
);
export const decryptCredential = command(
v.object({ encrypted: v.string() }),
async ({ encrypted }) => {
const {
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const decrypted = decryptValue(encrypted);
if (decrypted === null) error(400, { message: 'Invalid encrypted data' });
return { data: decrypted };
}
);
+4 -3
View File
@@ -10,6 +10,7 @@ import {
import { auth } from '$lib/auth/server';
import { env } from '$lib/const/schema';
import { m } from '$lib/paraglide/messages';
import { extractErrorMessage } from './utils';
import { db } from '$lib/server/db';
import {
session as sessionTable,
@@ -155,7 +156,7 @@ export const createUser = form(
return { ok: true };
} catch (e) {
console.log(e);
const msg = (e as { body?: { message?: string } })?.body?.message || m.errors_generic();
const msg = extractErrorMessage(e) ?? m.errors_generic();
throw error(400, { message: msg });
}
}
@@ -183,7 +184,7 @@ export const inviteUser = form(inviteUserSchema, async ({ email, name, role, use
return { ok: true };
} catch (e) {
console.log(e);
const msg = (e as { body?: { message?: string } })?.body?.message || m.errors_generic();
const msg = extractErrorMessage(e) ?? m.errors_generic();
throw error(400, { message: msg });
}
});
@@ -199,7 +200,7 @@ export const updateUser = form(updateUserSchema, async ({ email, id, name, role,
return { ok: true };
} catch (e) {
console.log(e);
const msg = (e as { body?: { message?: string } })?.body?.message || m.errors_generic();
const msg = extractErrorMessage(e) ?? m.errors_generic();
throw error(400, { message: msg });
}
});
+47 -1
View File
@@ -5,9 +5,17 @@ import { db } from '$lib/server/db';
import { decryptValue } from '$lib/server/db/custom-types';
import { getClient } from '$lib/server/nadir-agent/client';
export type AgentError = {
body?: { code?: string; message?: string; };
detail?: string;
message?: string;
status?: number;
};
export type Package = { name: string; version: string };
export type PackageList = { manager: string; packages: Package[] };
export const nadirForMachine = async (machineId: string) => {
const {
locals: { user }
@@ -17,9 +25,13 @@ export const nadirForMachine = async (machineId: string) => {
if (!machine) error(404, { message: m.errors_not_found() });
const token = decryptValue(machine.token);
if (!token) error(500, { message: m.errors_generic() });
return getClient(machine.address, token);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { token: _, ...safe } = machine;
return { client: getClient(machine.address, token), machine: safe };
};
export type SseEvent = { data: unknown; event: string };
export function parsePackageUpdates(data: unknown, response: Response): PackageList {
const raw = (typeof data === 'string' ? data : '').trim();
const ct = response.headers.get('content-type') ?? '';
@@ -61,6 +73,40 @@ export function parsePackageUpdates(data: unknown, response: Response): PackageL
return out;
}
export async function* parseSseStream(response: Response): AsyncGenerator<SseEvent> {
if (!response.body) throw error(500, { message: m.errors_generic() });
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
if (!part.trim()) continue;
let eventName = '';
let dataString = '';
for (const line of part.split('\n')) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
else if (line.startsWith('data:')) dataString = line.slice(5).trim();
}
if (eventName && dataString) {
try {
yield { data: JSON.parse(dataString), event: eventName };
} catch {
yield { data: dataString, event: eventName };
}
}
}
}
} finally {
reader.releaseLock();
}
}
export function throwNadirError(err: { detail?: string; status?: number; } | null | undefined): never {
const status = err?.status || 500;
let message = err?.detail;
+1 -1
View File
@@ -49,7 +49,7 @@ function decrypt(value: string, secret: string) {
return decrypted.toString('utf8');
}
function encrypt(value: string, secret: string) {
export function encrypt(value: string, secret: string) {
const key = getKey(secret);
const iv = crypto.randomBytes(IV_LENGTH);
+181
View File
@@ -0,0 +1,181 @@
import crypto from 'crypto';
import { Client, type ClientChannel } from 'ssh2';
export interface SessionAuth {
password?: string;
privateKey?: string;
username: string;
}
interface SshSession {
authBuffer: string;
authFinish: ((response: string) => void) | null;
channel: ClientChannel | null;
closed: boolean;
outputStream: ReadableStream<string>;
ready: Promise<void>;
readyResolve: (() => void) | null;
ssh: Client;
}
const sessions = new Map<string, SshSession>();
export function closeSession(sessionId: string): void {
const session = sessions.get(sessionId);
if (!session || session.closed) return;
session.closed = true;
session.ssh.end();
sessions.delete(sessionId);
}
export function createSession(host: string, port: number, auth: SessionAuth): string {
const sessionId = crypto.randomUUID();
const ssh = new Client();
let outputController: null | ReadableStreamDefaultController<string> = null;
let readyResolve: (() => void) | null = null;
const outputStream = new ReadableStream<string>({
start(controller) {
outputController = controller;
}
});
const session: SshSession = {
authBuffer: '',
authFinish: null,
channel: null,
closed: false,
outputStream,
ready: new Promise((resolve) => {
readyResolve = resolve;
}),
readyResolve,
ssh
};
ssh.on('keyboard-interactive', (_name, _instructions, _lang, prompts, finish) => {
for (const p of prompts) {
safeEnqueue(outputController, p.prompt);
}
session.authFinish = (response: string) => {
finish([response]);
session.authFinish = null;
};
});
ssh.on('ready', () => {
ssh.shell({ cols: 80, rows: 24, term: 'xterm-256color' }, (err, channel) => {
if (err) {
safeError(outputController, err);
readyResolve?.();
closeSession(sessionId);
return;
}
session.channel = channel;
channel.on('data', (data: Buffer) => {
safeEnqueue(outputController, data.toString('utf-8'));
});
channel.stderr?.on('data', (data: Buffer) => {
safeEnqueue(outputController, data.toString('utf-8'));
});
channel.on('close', () => {
safeClose(outputController);
closeSession(sessionId);
});
channel.on('error', (err: Error) => {
safeError(outputController, err);
closeSession(sessionId);
});
readyResolve?.();
});
});
ssh.on('error', (err: Error) => {
safeError(outputController, err);
closeSession(sessionId);
});
ssh.on('close', () => {
safeClose(outputController);
closeSession(sessionId);
});
const connectConfig: {
host: string;
hostVerifier: () => boolean;
keepaliveInterval: number;
password?: string;
port: number;
privateKey?: string;
readyTimeout: number;
tryKeyboard?: boolean;
username: string;
} = {
host,
hostVerifier: () => true,
keepaliveInterval: 30000,
port,
readyTimeout: 10000,
username: auth.username
};
if (auth.password) {
connectConfig.password = auth.password;
} else if (auth.privateKey) {
connectConfig.privateKey = auth.privateKey;
} else {
connectConfig.tryKeyboard = true;
}
ssh.connect(connectConfig);
sessions.set(sessionId, session);
return sessionId;
}
export function getSession(sessionId: string): SshSession | undefined {
return sessions.get(sessionId);
}
export function resizeSession(sessionId: string, cols: number, rows: number): void {
const session = sessions.get(sessionId);
if (!session || session.closed) return;
if (session.channel) {
session.channel.setWindow(rows, cols, 0, 0);
}
}
export function writeToSession(sessionId: string, data: string): void {
const session = sessions.get(sessionId);
if (!session || session.closed) return;
if (session.authFinish) {
session.authBuffer += data;
if (data === '\r' || data === '\n') {
const response = session.authBuffer.replace(/[\r\n]/g, '');
session.authFinish(response);
session.authBuffer = '';
}
} else if (session.channel) {
session.channel.write(data);
}
}
function safeClose(ctrl: null | ReadableStreamDefaultController<string>) {
if (!ctrl) return;
try { ctrl.close(); } catch { /* controller already closed */ }
}
function safeEnqueue(ctrl: null | ReadableStreamDefaultController<string>, data: string) {
if (!ctrl) return;
try { ctrl.enqueue(data); } catch { /* controller already closed */ }
}
function safeError(ctrl: null | ReadableStreamDefaultController<string>, err: Error) {
if (!ctrl) return;
try { ctrl.error(err); } catch { /* controller already errored */ }
}
+9
View File
@@ -1,6 +1,8 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { AgentError } from './remotes/utils';
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = { ref?: null | U } & T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -11,3 +13,10 @@ export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function extractErrorMessage(err: unknown): string | undefined {
if (!err || typeof err !== 'object') return undefined;
const a = err as AgentError;
return a.body?.message ?? a.message ?? a.detail ?? undefined;
}
+1 -2
View File
@@ -16,10 +16,9 @@
let { children } = $props();
const user = $derived(getUser());
let showSidebar = $derived(
const showSidebar =
(cU: null | User) =>
cU && (page.url.pathname.startsWith('/dashboard') || page.url.pathname.startsWith('/admin'))
);
class TerminalState {
open = $state(false);
+2 -1
View File
@@ -33,6 +33,7 @@
unbanUser,
updateUser
} from '$lib/remotes/users.remote';
import { extractErrorMessage } from '$lib/utils';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
@@ -147,7 +148,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
function openEdit(u: UserRow) {
@@ -1,34 +0,0 @@
import { error } from '@sveltejs/kit';
import { auth } from '$lib/auth/server';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, platform, request }) => {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session) {
error(401, 'Unauthorized');
}
const machineId = params.machineId;
if (
request.headers.get('connection')?.toLowerCase().includes('upgrade') &&
request.headers.get('upgrade')?.toLowerCase() === 'websocket'
) {
if (platform?.server && platform?.request) {
const upgraded = await platform.server.upgrade(platform.request, {
data: { machineId }
});
if (upgraded) {
return new Response(null, { status: 101 });
}
}
error(500, 'Upgrade failed');
}
error(400, 'Expected websocket upgrade request');
};
+3 -2
View File
@@ -8,6 +8,7 @@
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { requestReset } from '$lib/remotes/auth.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -27,7 +28,7 @@
</Card.Header>
<Card.Content>
{#if requestReset.result?.sent}
<Button href="/auth/sign-in" variant="outline" class="w-full">{m.back_to_login()}</Button>
<Button href={resolve("/auth/sign-in")} variant="outline" class="w-full">{m.back_to_login()}</Button>
{:else}
<form
oninput={() => requestReset.validate()}
@@ -37,7 +38,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
+2 -1
View File
@@ -8,6 +8,7 @@
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { resetPassword } from '$lib/remotes/auth.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -36,7 +37,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
+2 -1
View File
@@ -12,6 +12,7 @@
import { m } from '$lib/paraglide/messages';
import { getOAuthProviders, login } from '$lib/remotes/auth.remote';
import { cn } from '$lib/utils.js';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -36,7 +37,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
+3 -2
View File
@@ -8,6 +8,7 @@
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { register } from '$lib/remotes/auth.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -23,7 +24,7 @@
>
</Card.Header>
<Card.Footer>
<Button href="/auth/sign-in" variant="outline" class="w-full">{m.back_to_login()}</Button>
<Button href={resolve("/auth/sign-in")} variant="outline" class="w-full">{m.back_to_login()}</Button>
</Card.Footer>
</Card.Root>
{:else}
@@ -41,7 +42,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
@@ -61,6 +61,7 @@
systemDetails,
updateAgent
} from '$lib/remotes/server.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
@@ -324,7 +325,7 @@
if (!done) toast.error(m.agent_update_failed());
} catch (e) {
toast.error(
(e as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(e) ?? m.errors_generic()
);
} finally {
updating = false;
@@ -442,7 +443,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
@@ -513,7 +514,7 @@
} catch (error) {
console.error(error);
toast.error(
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
extractErrorMessage(error) ?? m.errors_generic()
);
}
})}
@@ -2,11 +2,10 @@
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
@@ -15,11 +14,10 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { deleteHost, listHosts, upsertHost } from '$lib/remotes/networking.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type Host = { hostnames: null | string[]; ip: string };
@@ -28,8 +26,6 @@
const machineId = $derived(pageState.params.machineId!);
const hosts = $derived(listHosts(machineId));
const pageSize = new PersistedState<number>('networking.hosts.pageSize', 25);
let page = $state(1);
let search = $state('');
let editOpen = $state(false);
let editForm = $state({ hostnames: '', ip: '' });
@@ -39,7 +35,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
type SortKey = 'hostnames' | 'ip';
@@ -68,12 +64,6 @@
return matched;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) page = totalPages;
});
function openAdd() {
editingExisting = false;
editForm = { hostnames: '', ip: '' };
@@ -113,177 +103,90 @@
</script>
<PageMeta title={m.seo_title_networking_hosts()} description={m.seo_desc_networking_hosts()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
{#snippet sortHead(key: string, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key as SortKey)}
>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="truncate text-2xl font-semibold tracking-tight">{m.nav_networking_hosts()}</h1>
<p class="text-muted-foreground truncate text-sm">{m.networking_hosts_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => hosts.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
{/snippet}
<DataTable
title={m.nav_networking_hosts()}
description={m.networking_hosts_description()}
searchPlaceholder={m.networking_hosts_search_placeholder()}
searchHint={`${filtered.length} / ${hosts.current?.length ?? 0}`}
emptyMessage={m.networking_no_hosts()}
items={filtered}
loading={hosts.loading && !hosts.current}
pageSizeKey="networking.hosts.pageSize"
pageSizePresets={[10, 25, 50, 100]}
bind:search
onrefresh={() => hosts.refresh()}
i18n={{
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={openAdd}>
<PlusIcon class="size-4" />
{m.networking_host_add()}
</Button>
{/snippet}
{#snippet columns()}
<Table.Head class="w-56">{@render sortHead('ip', m.networking_col_ip())}</Table.Head>
<Table.Head>{@render sortHead('hostnames', m.networking_col_hostnames())}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(h: Host)}
<Table.Cell class="font-mono text-xs">{h.ip}</Table.Cell>
<Table.Cell class="flex flex-wrap gap-1">
{#each h.hostnames ?? [] as n (n)}
<Badge variant="outline" class="font-mono">{n}</Badge>
{/each}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
<ListFilterIcon class="size-4" />
{m.users_filter()}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
</div>
<div class="flex items-center justify-between p-2">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="net-hosts-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="net-hosts-rpp-{id}">
{#each [10, 25, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={openAdd}>
<PlusIcon class="size-4" />
{m.networking_host_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.networking_hosts_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {hosts.current?.length ?? 0}
</span>
</div>
{#snippet sortHead(key: SortKey, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key)}
>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(h)}>{m.edit()}</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = h;
deleteOpen = true;
}}
>
{m.networking_host_remove()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-56">{@render sortHead('ip', m.networking_col_ip())}</Table.Head>
<Table.Head>{@render sortHead('hostnames', m.networking_col_hostnames())}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if hosts.loading && !hosts.current}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center">
{m.networking_no_hosts()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as h,i (h.ip,i)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{h.ip}</Table.Cell>
<Table.Cell class="flex flex-wrap gap-1">
{#each h.hostnames ?? [] as n (n)}
<Badge variant="outline" class="font-mono">{n}</Badge>
{/each}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(h)}>{m.edit()}</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = h;
deleteOpen = true;
}}
>
{m.networking_host_remove()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="mt-auto flex items-center justify-end gap-4 p-4">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
</DataTable>
<Dialog.Root bind:open={editOpen}>
<Dialog.Content>
@@ -3,19 +3,16 @@
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import CheckIcon from '@lucide/svelte/icons/check';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {
@@ -26,22 +23,19 @@
listInterfaces,
rollbackChange
} from '$lib/remotes/networking.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const interfaces = $derived(listInterfaces(machineId));
const pending = $derived(getPending(machineId));
const pageSize = new PersistedState<number>('networking.interfaces.pageSize', 25);
let page = $state(1);
let search = $state('');
let busy = $state<null | string>(null);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function run(key: string, fn: () => Promise<unknown>, success: string) {
@@ -86,13 +80,6 @@
});
return matched;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) page = totalPages;
});
</script>
<PageMeta
@@ -100,70 +87,6 @@
description={m.seo_desc_networking_interfaces()}
/>
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="truncate text-2xl font-semibold tracking-tight">
{m.nav_networking_interfaces()}
</h1>
<p class="text-muted-foreground truncate text-sm">
{m.networking_interfaces_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => {
interfaces.refresh();
pending.refresh();
}}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
<ListFilterIcon class="size-4" />
{m.users_filter()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
</div>
<div class="flex items-center justify-between p-2">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="net-iface-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="net-iface-rpp-{id}">
{#each [10, 25, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
{#if pending.current}
<div
class="mx-4 mb-2 flex items-center justify-between gap-3 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm"
@@ -205,187 +128,157 @@
</div>
</div>
{/if}
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.networking_interfaces_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {interfaces.current?.length ?? 0}
</span>
</div>
{#snippet sortHead(key: SortKey, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key)}
>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
{/snippet}
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{@render sortHead('name', m.networking_col_name())}</Table.Head>
<Table.Head class="w-24"
>{@render sortHead('state', m.networking_col_state())}</Table.Head
>
<Table.Head>{m.networking_col_ipv4()}</Table.Head>
<Table.Head>{m.networking_col_ipv6()}</Table.Head>
<Table.Head>{@render sortHead('mac', m.networking_col_mac())}</Table.Head>
<Table.Head class="w-20">{@render sortHead('mtu', m.networking_col_mtu())}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if interfaces.loading && !interfaces.current}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">
{m.networking_no_interfaces()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as i (i.name)}
<Table.Row>
<Table.Cell class="font-mono text-xs font-medium">
<a
href={resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
machineId,
name: i.name
})}
class="text-primary hover:underline"
>
{i.name}
</a>
</Table.Cell>
<Table.Cell>
{#if i.state === 'up'}
<Badge
class="border-0 bg-emerald-500/15 capitalize text-emerald-700 dark:text-emerald-400"
>{i.state}</Badge
>
{:else if i.state === 'down'}
<Badge
class="border-0 bg-zinc-500/15 capitalize text-zinc-700 dark:text-zinc-400"
>{i.state}</Badge
>
{:else}
<Badge variant="outline" class="capitalize">{i.state}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
<span class="block truncate" title={(i.ipv4 ?? []).join(', ')}>
{(i.ipv4 ?? []).join(', ') || '—'}
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
<span class="block truncate" title={(i.ipv6 ?? []).join(', ')}>
{(i.ipv6 ?? []).join(', ') || '—'}
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mac}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mtu}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
goto(
resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
machineId,
name: i.name
})
)}
>
{m.networking_details()}
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() =>
goto(
resolve(
'/dashboard/[machineId]/networking/interfaces/[name]/configure',
{ machineId, name: i.name }
)
)}
>
{m.networking_configure()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
disabled={!!busy || i.state === 'up'}
onclick={() =>
run(
`up-${i.name}`,
() => linkUp({ machineId, name: i.name }),
m.networking_link_up_done({ name: i.name })
)}
>
{m.networking_link_up()}
</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
disabled={!!busy || i.state === 'down'}
onclick={() =>
run(
`down-${i.name}`,
() => linkDown({ machineId, name: i.name }),
m.networking_link_down_done({ name: i.name })
)}
>
{m.networking_link_down()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="mt-auto flex items-center justify-end gap-4 p-4">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
{#snippet sortHead(key: string, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key as SortKey)}
>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
{/snippet}
<DataTable
title={m.nav_networking_interfaces()}
description={m.networking_interfaces_description()}
searchPlaceholder={m.networking_interfaces_search_placeholder()}
searchHint={`${filtered.length} / ${interfaces.current?.length ?? 0}`}
emptyMessage={m.networking_no_interfaces()}
items={filtered}
loading={interfaces.loading && !interfaces.current}
pageSizeKey="networking.interfaces.pageSize"
pageSizePresets={[10, 25, 50, 100]}
bind:search
onrefresh={() => {
interfaces.refresh();
pending.refresh();
}}
i18n={{
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet columns()}
<Table.Head>{@render sortHead('name', m.networking_col_name())}</Table.Head>
<Table.Head class="w-24">{@render sortHead('state', m.networking_col_state())}</Table.Head>
<Table.Head>{m.networking_col_ipv4()}</Table.Head>
<Table.Head>{m.networking_col_ipv6()}</Table.Head>
<Table.Head>{@render sortHead('mac', m.networking_col_mac())}</Table.Head>
<Table.Head class="w-20">{@render sortHead('mtu', m.networking_col_mtu())}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(i: NonNullable<typeof interfaces.current>[number])}
<Table.Cell class="font-mono text-xs font-medium">
<a
href={resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
machineId,
name: i.name
})}
class="text-primary hover:underline"
>
{i.name}
</a>
</Table.Cell>
<Table.Cell>
{#if i.state === 'up'}
<Badge
class="border-0 bg-emerald-500/15 capitalize text-emerald-700 dark:text-emerald-400"
>{i.state}</Badge
>
{:else if i.state === 'down'}
<Badge
class="border-0 bg-zinc-500/15 capitalize text-zinc-700 dark:text-zinc-400"
>{i.state}</Badge
>
{:else}
<Badge variant="outline" class="capitalize">{i.state}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
<span class="block truncate" title={(i.ipv4 ?? []).join(', ')}>
{(i.ipv4 ?? []).join(', ') || '—'}
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
<span class="block truncate" title={(i.ipv6 ?? []).join(', ')}>
{(i.ipv6 ?? []).join(', ') || '—'}
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mac}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mtu}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
goto(
resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
machineId,
name: i.name
})
)}
>
{m.networking_details()}
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() =>
goto(
resolve(
'/dashboard/[machineId]/networking/interfaces/[name]/configure',
{ machineId, name: i.name }
)
)}
>
{m.networking_configure()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
disabled={!!busy || i.state === 'up'}
onclick={() =>
run(
`up-${i.name}`,
() => linkUp({ machineId, name: i.name }),
m.networking_link_up_done({ name: i.name })
)}
>
{m.networking_link_up()}
</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
disabled={!!busy || i.state === 'down'}
onclick={() =>
run(
`down-${i.name}`,
() => linkDown({ machineId, name: i.name }),
m.networking_link_down_done({ name: i.name })
)}
>
{m.networking_link_down()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
@@ -14,6 +14,7 @@
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import { applyInterfaceConfig, getInterfaceConfig } from '$lib/remotes/networking.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -100,7 +101,7 @@
await goto(resolve('/dashboard/[machineId]/networking/interfaces', { machineId }));
} catch (e) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
} finally {
submitting = false;
confirmOpen = false;
@@ -2,24 +2,16 @@
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listRoutes } from '$lib/remotes/networking.remote';
import { PersistedState } from 'runed';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const routes = $derived(listRoutes(machineId));
const pageSize = new PersistedState<number>('networking.routes.pageSize', 25);
let page = $state(1);
let search = $state('');
type SortKey = 'destination' | 'gateway' | 'interface' | 'metric';
let sort = $state<{ dir: 'asc' | 'desc'; key: SortKey }>({ dir: 'asc', key: 'destination' });
@@ -50,167 +42,68 @@
});
return matched;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) page = totalPages;
});
</script>
<PageMeta title={m.seo_title_networking_routes()} description={m.seo_desc_networking_routes()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
{#snippet sortHead(key: string, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key as SortKey)}
>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="truncate text-2xl font-semibold tracking-tight">{m.nav_networking_routes()}</h1>
<p class="text-muted-foreground truncate text-sm">{m.networking_routes_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => routes.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
<ListFilterIcon class="size-4" />
{m.users_filter()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
</div>
<div class="flex items-center justify-between p-2">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="net-routes-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="net-routes-rpp-{id}">
{#each [10, 25, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.networking_routes_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {routes.current?.length ?? 0}
</span>
</div>
{#snippet sortHead(key: SortKey, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key)}
>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
<ArrowDownIcon class="size-3" />
{/if}
</button>
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
{/snippet}
<DataTable
title={m.nav_networking_routes()}
description={m.networking_routes_description()}
searchPlaceholder={m.networking_routes_search_placeholder()}
searchHint={`${filtered.length} / ${routes.current?.length ?? 0}`}
emptyMessage={m.networking_no_routes()}
items={filtered}
loading={routes.loading && !routes.current}
pageSizeKey="networking.routes.pageSize"
pageSizePresets={[10, 25, 50, 100]}
bind:search
onrefresh={() => routes.refresh()}
i18n={{
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet columns()}
<Table.Head>{@render sortHead('destination', m.networking_col_destination())}</Table.Head>
<Table.Head>{@render sortHead('gateway', m.networking_col_gateway())}</Table.Head>
<Table.Head>{@render sortHead('interface', m.networking_col_interface())}</Table.Head>
<Table.Head>{m.networking_col_source()}</Table.Head>
<Table.Head class="w-20">{@render sortHead('metric', m.networking_col_metric())}</Table.Head>
{/snippet}
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head
>{@render sortHead('destination', m.networking_col_destination())}</Table.Head
>
<Table.Head>{@render sortHead('gateway', m.networking_col_gateway())}</Table.Head>
<Table.Head>{@render sortHead('interface', m.networking_col_interface())}</Table.Head>
<Table.Head>{m.networking_col_source()}</Table.Head>
<Table.Head class="w-20"
>{@render sortHead('metric', m.networking_col_metric())}</Table.Head
>
</Table.Row>
</Table.Header>
<Table.Body>
{#if routes.loading && !routes.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
{m.networking_no_routes()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as r, i (r.destination + '\0' + r.interface + i)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{r.destination}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.gateway ?? '—'}
</Table.Cell>
<Table.Cell class="font-mono text-xs">{r.interface}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.source ?? '—'}
</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.metric ?? '—'}
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="mt-auto flex items-center justify-end gap-4 p-4">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
{#snippet row(r: NonNullable<typeof routes.current>[number])}
<Table.Cell class="font-mono text-xs">{r.destination}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.gateway ?? '—'}
</Table.Cell>
<Table.Cell class="font-mono text-xs">{r.interface}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.source ?? '—'}
</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.metric ?? '—'}
</Table.Cell>
{/snippet}
</DataTable>
@@ -3,13 +3,12 @@
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpCircleIcon from '@lucide/svelte/icons/arrow-up-circle';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
@@ -18,7 +17,6 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
@@ -27,7 +25,6 @@
listPackageUpdates,
streamPackageAction
} from '$lib/remotes/packages.remote';
import { PersistedState } from 'runed';
type Package = { name: string; version: string };
@@ -44,8 +41,6 @@
)
);
const pageSize = new PersistedState<number>('packages.installed.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
@@ -56,13 +51,11 @@
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
function toggleSort() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
page = 1;
}
const filtered = $derived.by(() => {
@@ -78,9 +71,6 @@
return list;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
// Dialogs
let createOpen = $state(false);
let installName = $state('');
@@ -161,189 +151,98 @@
</script>
<PageMeta title={m.seo_title_packages_installed()} description={m.seo_desc_packages_installed()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_packages_installed()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.nav_packages_installed_desc()}
{#if manager}({m.packages_manager_label()}: {manager}){/if}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => dataResource.refresh()}
<DataTable
title={m.nav_packages_installed()}
description={`${m.nav_packages_installed_desc()}${manager ? ` (${m.packages_manager_label()}: ${manager})` : ''}`}
searchPlaceholder={m.packages_search_placeholder()}
searchHint={`${filtered.length} / ${packages.length}`}
emptyMessage={m.packages_no_packages()}
items={filtered}
loading={dataResource.loading && !dataResource.current}
pageSizeKey="packages.installed.pageSize"
bind:search
onsearchinput={onSearchInput}
onrefresh={() => dataResource.refresh()}
i18n={{
display: m.users_filter_display(),
filterTitle: m.users_filter(),
next: () => m.users_next(),
pageOf: (p) => m.users_page_of({ page: p.page, total: p.total ?? p.pages }),
previous: () => m.users_prev(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.packages_install()}
</Button>
{/snippet}
{#snippet columns()}
<Table.Head class="w-8"></Table.Head>
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{m.packages_col_name()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head>{m.packages_col_version()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(p: Package)}
{@const newVersion = updatesMap.get(p.name)}
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
<Table.Cell class="w-8">
{#if newVersion}
<Tooltip.Root>
<Tooltip.Trigger>
<AlertCircleIcon class="text-amber-500 size-4" />
</Tooltip.Trigger>
<Tooltip.Content side="top">
{m.packages_update_available({ from: p.version, to: newVersion })}
</Tooltip.Content>
</Tooltip.Root>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-4" align="end">
<div class="flex flex-col gap-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="pkg-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="pkg-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.packages_install()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.packages_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {packages.length}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-8"></Table.Head>
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
{m.packages_col_name()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head>{m.packages_col_version()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if dataResource.loading && !dataResource.current}
<Table.Row>
<Table.Cell colspan={4} class="text-muted-foreground py-8 text-center">
<Loader2Icon class="size-4 animate-spin inline" />
</Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={4} class="text-muted-foreground py-8 text-center"
>{m.packages_no_packages()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as p (p.name)}
{@const newVersion = updatesMap.get(p.name)}
<Table.Row>
<Table.Cell class="w-8">
{#if newVersion}
<Tooltip.Root>
<Tooltip.Trigger>
<AlertCircleIcon class="text-amber-500 size-4" />
</Tooltip.Trigger>
<Tooltip.Content side="top">
{m.packages_update_available({ from: p.version, to: newVersion })}
</Tooltip.Content>
</Tooltip.Root>
{/if}
</Table.Cell>
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if newVersion}
<DropdownMenu.Item onclick={() => runUpdate(p.name)}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = p;
deleteOpen = true;
}}>{m.delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if newVersion}
<DropdownMenu.Item onclick={() => runUpdate(p.name)}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = p;
deleteOpen = true;
}}>{m.delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
@@ -2,52 +2,42 @@
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpCircleIcon from '@lucide/svelte/icons/arrow-up-circle';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listPackageUpdates, streamPackageAction } from '$lib/remotes/packages.remote';
import { PersistedState } from 'runed';
type Package = { name: string; version: string };
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const dataResource = $derived(listPackageUpdates(machineId));
const manager = $derived(dataResource.current?.manager ?? '');
const packages = $derived((dataResource.current?.packages ?? []) as Package[]);
const pageSize = new PersistedState<number>('packages.updates.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
let sortDir = $state<'asc' | 'desc'>('asc');
function onSearchInput(e: Event) {
function onsearchinput(e: Event) {
search = (e.target as HTMLInputElement).value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
function toggleSort() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
page = 1;
}
const filtered = $derived.by(() => {
@@ -63,9 +53,6 @@
return list;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
// Terminal Stream Console
let consoleOpen = $state(false);
let consoleLogs = $state<string[]>([]);
@@ -116,165 +103,73 @@
</script>
<PageMeta title={m.seo_title_packages_updates()} description={m.seo_desc_packages_updates()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_packages_updates()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.nav_packages_updates_desc()}
{#if manager}({m.packages_manager_label()}: {manager}){/if}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => dataResource.refresh()}
<DataTable
title={m.nav_packages_updates()}
description={`${m.nav_packages_updates_desc()}${manager ? ` (${m.packages_manager_label()}: ${manager})` : ''}`}
searchPlaceholder={m.packages_search_placeholder()}
searchHint={`${filtered.length} / ${packages.length}`}
emptyMessage={m.packages_no_updates()}
items={filtered}
loading={dataResource.loading && !dataResource.current}
pageSizeKey="packages.updates.pageSize"
bind:search
{onsearchinput}
onrefresh={() => dataResource.refresh()}
i18n={{
display: m.users_filter_display(),
filterTitle: m.users_filter(),
next: () => m.users_next(),
pageOf: (p) => m.users_page_of({ page: p.page, total: p.total ?? p.pages }),
previous: () => m.users_prev(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => doUpgrade()} disabled={!packages.length}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_upgrade_all()}
</Button>
{/snippet}
{#snippet columns()}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{m.packages_col_name()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head>{m.packages_col_version()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(p: Package)}
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-4" align="end">
<div class="flex flex-col gap-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="pkg-up-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="pkg-up-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => doUpgrade()} disabled={!packages.length}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_upgrade_all()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.packages_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {packages.length}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
{m.packages_col_name()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head>{m.packages_col_version()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if dataResource.loading && !dataResource.current}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center">
<Loader2Icon class="size-4 animate-spin inline" />
</Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center"
>{m.packages_no_updates()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as p (p.name)}
<Table.Row>
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => doUpgrade(p.name)}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => doUpgrade(p.name)}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<!-- Stream Console Modal -->
<Dialog.Root bind:open={consoleOpen}>
@@ -1,22 +1,17 @@
<script lang="ts">
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listServices } from '$lib/remotes/services.remote';
import { PersistedState } from 'runed';
type ServiceUnit = {
active: string;
@@ -25,19 +20,16 @@
sub: string;
unit: string;
};
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const servicesResource = $derived(listServices(machineId));
const services = $derived((servicesResource.current?.services ?? []) as ServiceUnit[]);
const pageSize = new PersistedState<number>('services.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
let sortDir = $state<'asc' | 'desc'>('asc');
// Active State Filter
let filterActive = $state({
active: true,
failed: true,
@@ -45,7 +37,6 @@
other: true
});
// Load State Filter
let filterLoad = $state({
error: true,
loaded: true,
@@ -53,7 +44,6 @@
notFound: true
});
// Sub State Filter
let filterSub = $state({
dead: true,
exited: true,
@@ -71,358 +61,215 @@
filterActive = { active: true, failed: true, inactive: true, other: true };
filterLoad = { error: true, loaded: true, masked: true, notFound: true };
filterSub = { dead: true, exited: true, other: true, running: true };
page = 1;
}
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
let page = $state(1);
function toggleSort() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
page = 1;
}
const filtered = $derived.by(() => {
const q = debouncedSearch.trim().toLowerCase();
let list = [...services];
// Text Search
if (q) {
list = list.filter(
(s) => s.unit.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
);
}
// Active State Filter
list = list.filter((s) => {
const activeCat =
s.active === 'active'
? 'active'
: s.active === 'inactive'
? 'inactive'
: s.active === 'failed'
? 'failed'
: 'other';
s.active === 'active' ? 'active' : s.active === 'inactive' ? 'inactive' : s.active === 'failed' ? 'failed' : 'other';
return filterActive[activeCat];
});
// Load State Filter
list = list.filter((s) => {
const loadCat =
s.load === 'loaded'
? 'loaded'
: s.load === 'masked'
? 'masked'
: s.load === 'not-found'
? 'notFound'
: s.load === 'error'
? 'error'
: 'loaded';
s.load === 'loaded' ? 'loaded' : s.load === 'masked' ? 'masked' : s.load === 'not-found' ? 'notFound' : s.load === 'error' ? 'error' : 'loaded';
return filterLoad[loadCat as keyof typeof filterLoad] ?? true;
});
// Sub State Filter
list = list.filter((s) => {
const subCat =
s.sub === 'running'
? 'running'
: s.sub === 'exited'
? 'exited'
: s.sub === 'dead'
? 'dead'
: 'other';
s.sub === 'running' ? 'running' : s.sub === 'exited' ? 'exited' : s.sub === 'dead' ? 'dead' : 'other';
return filterSub[subCat as keyof typeof filterSub] ?? true;
});
// Sorting
list.sort((a, b) => {
const comp = a.unit.localeCompare(b.unit);
return sortDir === 'asc' ? comp : -comp;
});
return list;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) {
page = totalPages;
}
});
</script>
<PageMeta title={m.seo_title_services()} description={m.seo_desc_services()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.services_title()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.services_description()}
</p>
<DataTable
title={m.services_title()}
description={m.services_description()}
searchPlaceholder={m.services_search_placeholder()}
emptyMessage={m.services_no_services()}
items={filtered}
loading={servicesResource.loading}
pageSizeKey="services.pageSize"
defaultPageSize={25}
bind:search
onsearchinput={onSearchInput}
onrefresh={() => servicesResource.refresh()}
activeFilterCount={activeFilterCount}
bind:page
i18n={{
pageOf: (p) => m.users_page_of(p),
previous: () => m.users_prev(),
next: () => m.users_next(),
filterTitle: m.services_filter_title(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet filterContent()}
<div class="flex items-center justify-between border-b p-3">
<h3 class="text-sm font-semibold">{m.services_filter_title()}</h3>
{#if activeFilterCount > 0}
<Button variant="ghost" size="sm" class="h-auto p-1 text-xs" onclick={resetFilters}>
{m.cancel()}
</Button>
{/if}
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => servicesResource.refresh()}
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-2">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_active_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.active} />
{m.services_filter_active()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.inactive} />
{m.services_filter_inactive()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.failed} />
{m.services_filter_failed()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_load_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.loaded} />
{m.services_filter_loaded()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.masked} />
{m.services_filter_masked()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.notFound} />
{m.services_filter_not_found()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.error} />
{m.services_filter_error()}
</Label>
</div>
</div>
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_sub_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.running} />
{m.services_filter_running()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.exited} />
{m.services_filter_exited()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.dead} />
{m.services_filter_dead()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
</div>
{/snippet}
{#snippet columns()}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0" align="end">
<div class="border-b p-3 flex items-center justify-between">
<h3 class="text-sm font-semibold">{m.services_filter_title()}</h3>
{#if activeFilterCount > 0}
<Button variant="ghost" size="sm" class="h-auto p-1 text-xs" onclick={resetFilters}>
{m.cancel()}
</Button>
{/if}
</div>
<div class="flex flex-col gap-4 p-4">
<!-- Active State -->
<div class="flex flex-col gap-2">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_active_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.active} />
{m.services_filter_active()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.inactive} />
{m.services_filter_inactive()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.failed} />
{m.services_filter_failed()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
<!-- Load State -->
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_load_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.loaded} />
{m.services_filter_loaded()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.masked} />
{m.services_filter_masked()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.notFound} />
{m.services_filter_not_found()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.error} />
{m.services_filter_error()}
</Label>
</div>
</div>
<!-- Sub State -->
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_sub_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.running} />
{m.services_filter_running()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.exited} />
{m.services_filter_exited()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.dead} />
{m.services_filter_dead()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
<!-- Rows per page -->
<div class="flex items-center justify-between border-t pt-3">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="services-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="services-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.services_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {services.length}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
{m.services_col_unit()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head class="w-24">{m.services_col_active()}</Table.Head>
<Table.Head class="w-24">{m.services_col_sub()}</Table.Head>
<Table.Head class="w-24">{m.services_col_load()}</Table.Head>
<Table.Head>{m.services_col_description()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if servicesResource.loading && !servicesResource.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
<Loader2Icon class="size-4 animate-spin inline" />
</Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
{m.services_no_services()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as s (s.unit)}
<Table.Row>
<Table.Cell class="font-medium font-mono text-xs max-w-[20rem]">
<a
href={resolve('/dashboard/[machineId]/services/[name]', {
machineId,
name: s.unit
})}
class="block truncate text-primary hover:underline"
title={s.unit}
>
{s.unit}
</a>
</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs max-w-[20rem] w-full">
<span class="block truncate" title={s.description}>
{s.description}
</span>
</Table.Cell>
<Table.Cell>
{#if s.active === 'active'}
<Badge
class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else if s.active === 'inactive'}
<Badge
class="bg-zinc-500/15 text-zinc-700 dark:text-zinc-400 hover:bg-zinc-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else}
<Badge
class="bg-rose-500/15 text-rose-700 dark:text-rose-400 hover:bg-rose-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{/if}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">
{s.sub}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">
{s.load}
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.users_page_of({ page, total: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
{m.services_col_unit()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head class="w-24">{m.services_col_active()}</Table.Head>
<Table.Head class="w-24">{m.services_col_sub()}</Table.Head>
<Table.Head class="w-24">{m.services_col_load()}</Table.Head>
<Table.Head>{m.services_col_description()}</Table.Head>
{/snippet}
{#snippet row(s: ServiceUnit, i: number)}
<Table.Cell class="font-medium font-mono text-xs max-w-[20rem]">
<a
href={resolve('/dashboard/[machineId]/services/[name]', {
machineId,
name: s.unit
})}
class="block truncate text-primary hover:underline"
title={s.unit}
>
{s.unit}
</a>
</Table.Cell>
<Table.Cell>
{#if s.active === 'active'}
<Badge
class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else if s.active === 'inactive'}
<Badge
class="bg-zinc-500/15 text-zinc-700 dark:text-zinc-400 hover:bg-zinc-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else}
<Badge
class="bg-rose-500/15 text-rose-700 dark:text-rose-400 hover:bg-rose-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{/if}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">{s.sub}</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">{s.load}</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs max-w-[20rem] w-full">
<span class="block truncate" title={s.description}>{s.description}</span>
</Table.Cell>
{/snippet}
</DataTable>
@@ -35,6 +35,7 @@
streamServiceLogs
} from '$lib/remotes/services.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const name = $derived(pageState.params.name!);
@@ -45,7 +46,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function run(
@@ -187,7 +188,7 @@
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<svelte:boundary>
{#snippet failed(err, reset)}
{@const e = err as { body?: { message?: string }; message?: string; status?: number }}
{@const e = err as { body?: { message?: string }; message?: string; status?: number; detail?: string }}
{@const status = e.status ?? 0}
{@const notFound = status === 404}
<Empty.Root class="border">
@@ -1,20 +1,16 @@
<script lang="ts">
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listFstab, removeMount } from '$lib/remotes/storage.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type FstabEntry = {
@@ -26,13 +22,9 @@
pass: number;
};
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const fstab = $derived(listFstab(machineId));
const pageSize = new PersistedState<number>('storage.fstab.pageSize', 10);
let page = $state(1);
let search = $state('');
const filtered = $derived.by(() => {
const all = (fstab.current ?? []) as FstabEntry[];
@@ -48,14 +40,6 @@
.sort((a, b) => a.mountpoint.localeCompare(b.mountpoint));
});
const paginated = $derived.by(() => {
const start = (page - 1) * pageSize.current;
const end = start + pageSize.current;
return filtered.slice(start, end);
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
let deleteOpen = $state(false);
let deleting = $state<FstabEntry | null>(null);
@@ -68,174 +52,77 @@
deleting = null;
} catch (e) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
}
</script>
<PageMeta title={m.seo_title_storage_fstab()} description={m.seo_desc_storage_fstab()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_storage_fstab()}</h1>
<p class="text-muted-foreground text-sm truncate">{m.storage_fstab_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => fstab.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
<DataTable
title={m.nav_storage_fstab()}
description={m.storage_fstab_description()}
searchPlaceholder={m.storage_mounts_search_placeholder()}
emptyMessage={m.storage_no_fstab()}
items={filtered}
loading={fstab.loading}
pageSizeKey="storage.fstab.pageSize"
defaultPageSize={10}
pageSizePresets={[10, 20, 50, 100]}
bind:search
onrefresh={() => fstab.refresh()}
i18n={{
pageOf: (p) => m.pagination_page_of(p),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet columns()}
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
<Table.Head>{m.storage_col_device()}</Table.Head>
<Table.Head>{m.storage_col_fstype()}</Table.Head>
<Table.Head>{m.storage_col_options()}</Table.Head>
<Table.Head class="text-right">{m.storage_col_dump()}</Table.Head>
<Table.Head class="text-right">{m.storage_col_pass()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(e: FstabEntry, i: number)}
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
<span class="block truncate" title={e.mountpoint}>{e.mountpoint}</span>
</Table.Cell>
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={e.device}>{e.device}</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{e.fstype}</Badge></Table.Cell>
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={e.options}>{e.options}</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground text-right tabular-nums">{e.dump}</Table.Cell>
<Table.Cell class="text-muted-foreground text-right tabular-nums">{e.pass}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-2 p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="rpp-presets-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="rpp-presets-{id}">
{#each [10, 20, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.storage_mounts_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {fstab.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
<Table.Head>{m.storage_col_device()}</Table.Head>
<Table.Head>{m.storage_col_fstype()}</Table.Head>
<Table.Head>{m.storage_col_options()}</Table.Head>
<Table.Head class="text-right">{m.storage_col_dump()}</Table.Head>
<Table.Head class="text-right">{m.storage_col_pass()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if fstab.loading && !fstab.current}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
>{m.storage_no_fstab()}</Table.Cell
>
</Table.Row>
{:else}
{#each paginated as e, i (e.mountpoint + '\0' + e.device + i)}
<Table.Row>
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
<span class="block truncate" title={e.mountpoint}>{e.mountpoint}</span>
</Table.Cell>
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={e.device}>{e.device}</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{e.fstype}</Badge></Table.Cell>
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={e.options}>{e.options}</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground text-right tabular-nums"
>{e.dump}</Table.Cell
>
<Table.Cell class="text-muted-foreground text-right tabular-nums"
>{e.pass}</Table.Cell
>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = e;
deleteOpen = true;
}}>{m.storage_mount_remove()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = e;
deleteOpen = true;
}}>{m.storage_mount_remove()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
@@ -1,9 +1,8 @@
<script lang="ts">
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
@@ -13,37 +12,19 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { addMount, listFstab, listMounts, removeMount } from '$lib/remotes/storage.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type Mount = { device: string; fstype: string; mountpoint: string; options: string };
// Kernel/virtual filesystems most people don't want to see by default.
const PSEUDO_FSTYPES = new Set([
'autofs',
'binfmt_misc',
'bpf',
'cgroup',
'cgroup2',
'configfs',
'debugfs',
'devpts',
'devtmpfs',
'fusectl',
'hugetlbfs',
'mqueue',
'nsfs',
'proc',
'pstore',
'ramfs',
'securityfs',
'sysfs',
'tmpfs',
'tracefs'
'autofs', 'binfmt_misc', 'bpf', 'cgroup', 'cgroup2', 'configfs', 'debugfs',
'devpts', 'devtmpfs', 'fusectl', 'hugetlbfs', 'mqueue', 'nsfs', 'proc',
'pstore', 'ramfs', 'securityfs', 'sysfs', 'tmpfs', 'tracefs'
]);
const id = $props.id();
@@ -51,9 +32,6 @@
const mounts = $derived(listMounts(machineId));
const fstab = $derived(listFstab(machineId));
const pageSize = new PersistedState<number>('storage.mounts.pageSize', 10);
let page = $state(1);
let search = $state('');
const filtersStore = new PersistedState('storage.mounts.filters', { showPseudo: false });
let filters = $state({ ...filtersStore.current });
@@ -62,7 +40,6 @@
});
const activeFilterCount = $derived(filters.showPseudo ? 1 : 0);
// Mountpoints present in /etc/fstab — used to flag which active mounts are persistent.
const persistent = $derived(new Set((fstab.current ?? []).map((e) => e.mountpoint)));
const filtered = $derived.by(() => {
@@ -80,14 +57,6 @@
.sort((a, b) => a.mountpoint.localeCompare(b.mountpoint));
});
const paginated = $derived.by(() => {
const start = (page - 1) * pageSize.current;
const end = start + pageSize.current;
return filtered.slice(start, end);
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
let createOpen = $state(false);
let createForm = $state({ device: '', fstype: '', mountpoint: '', options: 'defaults' });
let deleteOpen = $state(false);
@@ -95,7 +64,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function doCreate() {
@@ -129,187 +98,93 @@
</script>
<PageMeta title={m.seo_title_storage_mounts()} description={m.seo_desc_storage_mounts()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_storage_mounts()}</h1>
<p class="text-muted-foreground text-sm truncate">{m.storage_mounts_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => mounts.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
<DataTable
title={m.nav_storage_mounts()}
description={m.storage_mounts_description()}
searchPlaceholder={m.storage_mounts_search_placeholder()}
emptyMessage={m.storage_no_mounts()}
items={filtered}
loading={mounts.loading}
pageSizeKey="storage.mounts.pageSize"
defaultPageSize={10}
pageSizePresets={[10, 20, 50, 100]}
bind:search
onrefresh={() => mounts.refresh()}
activeFilterCount={activeFilterCount}
i18n={{
pageOf: (p) => m.pagination_page_of(p),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.storage_mount_add()}
</Button>
{/snippet}
{#snippet filterContent()}
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.storage_filter_show_pseudo()}</span>
<small>{m.storage_filter_show_pseudo_hint()}</small>
</div>
<Checkbox
checked={filters.showPseudo}
onCheckedChange={(v) => {
filters.showPseudo = !!v;
}}
/>
</label>
{/snippet}
{#snippet columns()}
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
<Table.Head>{m.storage_col_device()}</Table.Head>
<Table.Head>{m.storage_col_fstype()}</Table.Head>
<Table.Head>{m.storage_col_options()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(mt: Mount, i: number)}
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
<span class="flex items-center gap-2">
<span class="block truncate" title={mt.mountpoint}>{mt.mountpoint}</span>
{#if persistent.has(mt.mountpoint)}
<Badge variant="secondary">{m.storage_badge_fstab()}</Badge>
{/if}
</span>
</Table.Cell>
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={mt.device}>{mt.device}</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{mt.fstype}</Badge></Table.Cell>
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={mt.options}>{mt.options}</span>
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.storage_filter_show_pseudo()}</span>
<small>{m.storage_filter_show_pseudo_hint()}</small>
</div>
<Checkbox
checked={filters.showPseudo}
onCheckedChange={(v) => {
filters.showPseudo = !!v;
page = 1;
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="rpp-presets-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="rpp-presets-{id}">
{#each [10, 20, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.storage_mount_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.storage_mounts_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {mounts.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
<Table.Head>{m.storage_col_device()}</Table.Head>
<Table.Head>{m.storage_col_fstype()}</Table.Head>
<Table.Head>{m.storage_col_options()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if mounts.loading && !mounts.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"
>{m.storage_no_mounts()}</Table.Cell
>
</Table.Row>
{:else}
{#each paginated as mt, i (mt.mountpoint + '\0' + mt.device + i)}
<Table.Row>
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
<span class="flex items-center gap-2">
<span class="block truncate" title={mt.mountpoint}>{mt.mountpoint}</span>
{#if persistent.has(mt.mountpoint)}
<Badge variant="secondary">{m.storage_badge_fstab()}</Badge>
{/if}
</span>
</Table.Cell>
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={mt.device}>{mt.device}</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{mt.fstype}</Badge></Table.Cell>
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={mt.options}>{mt.options}</span>
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = mt;
deleteOpen = true;
}}>{m.storage_mount_remove()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = mt;
deleteOpen = true;
}}>{m.storage_mount_remove()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
@@ -326,12 +201,7 @@
>
<div class="flex flex-col gap-1.5">
<Label for="sm-device-{id}">{m.storage_col_device()}</Label>
<Input
id="sm-device-{id}"
bind:value={createForm.device}
placeholder="/dev/sdb1"
required
/>
<Input id="sm-device-{id}" bind:value={createForm.device} placeholder="/dev/sdb1" required />
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-mountpoint-{id}">{m.storage_col_mountpoint()}</Label>
@@ -18,6 +18,7 @@
setTimezone,
systemTime
} from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
@@ -40,7 +41,7 @@
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
} finally {
saving = false;
}
@@ -7,6 +7,7 @@
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import { setHostname, systemHostname } from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
@@ -55,7 +56,7 @@
toast.success(m.saved());
} catch (err) {
console.log(err);
toast.error((err as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(err) ?? m.errors_generic());
} finally {
saving = false;
}
@@ -20,6 +20,7 @@
setLocale,
systemLocale
} from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
@@ -58,7 +59,7 @@
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
} finally {
saving = false;
}
@@ -9,6 +9,7 @@
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { powerOff, reboot } from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
let pending = $state<'off' | 'reboot' | null>(null);
@@ -23,7 +24,7 @@
: reboot({ machineId, when: '' }));
toast.success(m.saved());
} catch (e) {
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
} finally {
busy = false;
pending = null;
@@ -2,11 +2,11 @@
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
@@ -16,7 +16,6 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {
@@ -26,6 +25,7 @@
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type PamUser = {
@@ -44,8 +44,6 @@
const machineId = $derived(pageState.params.machineId!);
const users = $derived(listPamUsers(machineId));
const pageSize = new PersistedState<number>('pam.users.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
@@ -70,8 +68,7 @@
});
const activeFilterCount = $derived((filters.showSystem ? 1 : 0) + (filters.shellOnly ? 1 : 0));
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
@@ -79,6 +76,8 @@
}, 200);
}
let page = $state(1);
function toggleSort(col: SortBy) {
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else {
@@ -113,10 +112,6 @@
return out;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
// Dialogs
let createOpen = $state(false);
let createForm = $state({
comment: '',
@@ -134,7 +129,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function doCreate() {
@@ -197,222 +192,139 @@
</script>
<PageMeta title={m.seo_title_users()} description={m.seo_desc_users()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.users_nav_title()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.users_nav_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Popover.Root>
<Popover.Trigger>
<DataTable
title={m.users_nav_title()}
description={m.users_nav_description()}
searchPlaceholder={m.users_pam_search_placeholder()}
emptyMessage={m.users_no_results()}
items={filtered}
loading={users.loading}
pageSizeKey="pam.users.pageSize"
defaultPageSize={25}
pageSizePresets={[10, 25, 50, 100, 200]}
bind:search
onsearchinput={onSearchInput}
onrefresh={() => users.refresh()}
activeFilterCount={activeFilterCount}
bind:page
i18n={{
pageOf: (p) => m.users_page_of(p),
previous: () => m.users_prev(),
next: () => m.users_next(),
filterTitle: m.users_filter_title(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.users_add()}
</Button>
{/snippet}
{#snippet filterContent()}
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.users_filter_show_system()}</span>
<small>{m.users_filter_system_uid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
<label class="flex items-center justify-between text-sm">
<span>{m.users_filter_shell_only()}</span>
<Checkbox
checked={filters.shellOnly}
onCheckedChange={(v) => {
filters.shellOnly = !!v;
page = 1;
}}
/>
</label>
{/snippet}
{#snippet columns()}
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: m.users_col_uid() }, { key: 'shell', label: m.users_create_field_shell() }] as col (col.key)}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(col.key as SortBy)}
>
{col.label}
{#if sortBy === col.key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-50" />
{/if}
</button>
</Table.Head>
{/each}
<Table.Head>{m.users_col_comment()}</Table.Head>
<Table.Head>{m.users_col_home()}</Table.Head>
<Table.Head>{m.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(u: PamUser, i: number)}
<Table.Cell class="font-medium">
<a
href={resolve('/dashboard/[machineId]/users/[username]', {
machineId,
username: u.username
})}
class="hover:underline">{u.username}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.uid}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.shell}</Table.Cell>
<Table.Cell>{u.comment || '—'}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.home}</Table.Cell>
<Table.Cell>
{#if u.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.users_filter_show_system()}</span>
<small>{m.users_filter_system_uid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
<label class="flex items-center justify-between text-sm">
<span>{m.users_filter_shell_only()}</span>
<Checkbox
checked={filters.shellOnly}
onCheckedChange={(v) => {
filters.shellOnly = !!v;
page = 1;
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="pam-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="pam-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.users_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.users_pam_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {users.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: m.users_col_uid() }, { key: 'shell', label: m.users_create_field_shell() }] as col (col.key)}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(col.key as SortBy)}
>
{col.label}
{#if sortBy === col.key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-50" />
{/if}
</button>
</Table.Head>
{/each}
<Table.Head>{m.users_col_comment()}</Table.Head>
<Table.Head>{m.users_col_home()}</Table.Head>
<Table.Head>{m.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if users.loading && !users.current}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
>{m.users_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as u (u.username)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve('/dashboard/[machineId]/users/[username]', {
machineId,
username: u.username
})}
class="hover:underline">{u.username}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.uid}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.shell}</Table.Cell>
<Table.Cell>{u.comment || '—'}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.home}</Table.Cell>
<Table.Cell>
{#if u.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => {
pwUser = u;
pwValue = '';
pwOpen = true;
}}>{m.users_action_set_password()}</DropdownMenu.Item
>
<DropdownMenu.Separator />
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = u;
removeHome = false;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => {
pwUser = u;
pwValue = '';
pwOpen = true;
}}>{m.users_action_set_password()}</DropdownMenu.Item
>
<DropdownMenu.Separator />
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = u;
removeHome = false;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
@@ -21,6 +21,7 @@
setPamUserGroups,
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
import { SvelteSet } from 'svelte/reactivity';
@@ -58,7 +59,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
function toggle(name: string, on: boolean) {
@@ -2,11 +2,11 @@
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
@@ -16,12 +16,13 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { createPamGroup, deletePamGroup, listPamGroups } from '$lib/remotes/pam-users.remote';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type PamGroup = {
gid: number;
members: null | string[];
@@ -30,12 +31,11 @@
};
type SortBy = 'gid' | 'members' | 'name';
type Dir = 'asc' | 'desc';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const groups = $derived(listPamGroups(machineId));
const pageSize = new PersistedState<number>('pam.groups.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
@@ -57,8 +57,7 @@
});
const activeFilterCount = $derived(filters.showSystem ? 1 : 0);
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
@@ -66,6 +65,8 @@
}, 200);
}
let page = $state(1);
function toggleSort(col: SortBy) {
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else {
@@ -94,9 +95,6 @@
return out;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
let createOpen = $state(false);
let createForm = $state({ gid: '', name: '', system: false });
let deleteOpen = $state(false);
@@ -104,7 +102,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function doCreate() {
@@ -137,204 +135,124 @@
</script>
<PageMeta title={m.seo_title_users_groups()} description={m.seo_desc_users_groups()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.groups_nav_title()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.groups_nav_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Popover.Root>
<Popover.Trigger>
<DataTable
title={m.groups_nav_title()}
description={m.groups_nav_description()}
searchPlaceholder={m.groups_search_placeholder()}
emptyMessage={m.groups_no_results()}
items={filtered}
loading={groups.loading}
pageSizeKey="pam.groups.pageSize"
defaultPageSize={25}
pageSizePresets={[10, 25, 50, 100, 200]}
bind:search
onsearchinput={onSearchInput}
onrefresh={() => groups.refresh()}
activeFilterCount={activeFilterCount}
bind:page
i18n={{
pageOf: (p) => m.users_page_of(p),
previous: () => m.users_prev(),
next: () => m.users_next(),
filterTitle: m.users_filter_title(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.groups_add()}
</Button>
{/snippet}
{#snippet filterContent()}
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.groups_filter_show_system()}</span>
<small>{m.groups_filter_system_gid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
{/snippet}
{#snippet columns()}
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: m.groups_col_gid() }, { key: 'members', label: m.groups_col_members() }] as col (col.key)}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(col.key as SortBy)}
>
{col.label}
{#if sortBy === col.key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-50" />
{/if}
</button>
</Table.Head>
{/each}
<Table.Head>{m.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(g: PamGroup, i: number)}
<Table.Cell class="font-medium">
<a
href={resolve('/dashboard/[machineId]/users/groups/[group]', {
group: g.name,
machineId
})}
class="hover:underline">{g.name}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{g.gid}</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">
{#if g.members?.length}
<span title={g.members.join(', ')}>
{g.members.slice(0, 3).join(', ')}{g.members.length > 3 ? ` +${g.members.length - 3}` : ''}
</span>
{:else}
{/if}
</Table.Cell>
<Table.Cell>
{#if g.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.groups_filter_show_system()}</span>
<small>{m.groups_filter_system_gid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.groups_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.groups_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {groups.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: m.groups_col_gid() }, { key: 'members', label: m.groups_col_members() }] as col (col.key)}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(col.key as SortBy)}
>
{col.label}
{#if sortBy === col.key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-50" />
{/if}
</button>
</Table.Head>
{/each}
<Table.Head>{m.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if groups.loading && !groups.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"
>{m.groups_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as g (g.name)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve('/dashboard/[machineId]/users/groups/[group]', {
group: g.name,
machineId
})}
class="hover:underline">{g.name}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{g.gid}</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">
{#if g.members?.length}
<span title={g.members.join(', ')}>
{g.members.slice(0, 3).join(', ')}{g.members.length > 3
? ` +${g.members.length - 3}`
: ''}
</span>
{:else}
{/if}
</Table.Cell>
<Table.Cell>
{#if g.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = g;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = g;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
@@ -20,6 +20,7 @@
listPamUsers,
setPamUserGroups
} from '$lib/remotes/pam-users.remote';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -51,7 +52,7 @@
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function addMember(username: string) {