stabilized architecture
This commit is contained in:
@@ -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=="],
|
||||
|
||||
+699
-702
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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 } }
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 } }
|
||||
});
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,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 } }
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -16,10 +16,9 @@
|
||||
|
||||
let { children } = $props();
|
||||
const user = $derived(getUser());
|
||||
let showSidebar = $derived(
|
||||
const showSidebar =
|
||||
(cU: null | User) =>
|
||||
cU && (page.url.pathname.startsWith('/dashboard') || page.url.pathname.startsWith('/admin'))
|
||||
);
|
||||
|
||||
class TerminalState {
|
||||
open = $state(false);
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
unbanUser,
|
||||
updateUser
|
||||
} from '$lib/remotes/users.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { PersistedState } from 'runed';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
@@ -147,7 +148,7 @@
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
function openEdit(u: UserRow) {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { auth } from '$lib/auth/server';
|
||||
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, platform, request }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const machineId = params.machineId;
|
||||
|
||||
if (
|
||||
request.headers.get('connection')?.toLowerCase().includes('upgrade') &&
|
||||
request.headers.get('upgrade')?.toLowerCase() === 'websocket'
|
||||
) {
|
||||
if (platform?.server && platform?.request) {
|
||||
const upgraded = await platform.server.upgrade(platform.request, {
|
||||
data: { machineId }
|
||||
});
|
||||
|
||||
if (upgraded) {
|
||||
return new Response(null, { status: 101 });
|
||||
}
|
||||
}
|
||||
error(500, 'Upgrade failed');
|
||||
}
|
||||
|
||||
error(400, 'Expected websocket upgrade request');
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { requestReset } from '$lib/remotes/auth.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
@@ -27,7 +28,7 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if requestReset.result?.sent}
|
||||
<Button href="/auth/sign-in" variant="outline" class="w-full">{m.back_to_login()}</Button>
|
||||
<Button href={resolve("/auth/sign-in")} variant="outline" class="w-full">{m.back_to_login()}</Button>
|
||||
{:else}
|
||||
<form
|
||||
oninput={() => requestReset.validate()}
|
||||
@@ -37,7 +38,7 @@
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { resetPassword } from '$lib/remotes/auth.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
@@ -36,7 +37,7 @@
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { getOAuthProviders, login } from '$lib/remotes/auth.remote';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
@@ -36,7 +37,7 @@
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { register } from '$lib/remotes/auth.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
@@ -23,7 +24,7 @@
|
||||
>
|
||||
</Card.Header>
|
||||
<Card.Footer>
|
||||
<Button href="/auth/sign-in" variant="outline" class="w-full">{m.back_to_login()}</Button>
|
||||
<Button href={resolve("/auth/sign-in")} variant="outline" class="w-full">{m.back_to_login()}</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
@@ -41,7 +42,7 @@
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
systemDetails,
|
||||
updateAgent
|
||||
} from '$lib/remotes/server.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
@@ -324,7 +325,7 @@
|
||||
if (!done) toast.error(m.agent_update_failed());
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
(e as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
extractErrorMessage(e) ?? m.errors_generic()
|
||||
);
|
||||
} finally {
|
||||
updating = false;
|
||||
@@ -442,7 +443,7 @@
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
@@ -513,7 +514,7 @@
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
extractErrorMessage(error) ?? m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import { page as pageState } from '$app/state';
|
||||
import DataTable from '$lib/components/dashboard/data-table.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -15,11 +14,10 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { deleteHost, listHosts, upsertHost } from '$lib/remotes/networking.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type Host = { hostnames: null | string[]; ip: string };
|
||||
@@ -28,8 +26,6 @@
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const hosts = $derived(listHosts(machineId));
|
||||
|
||||
const pageSize = new PersistedState<number>('networking.hosts.pageSize', 25);
|
||||
let page = $state(1);
|
||||
let search = $state('');
|
||||
let editOpen = $state(false);
|
||||
let editForm = $state({ hostnames: '', ip: '' });
|
||||
@@ -39,7 +35,7 @@
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
type SortKey = 'hostnames' | 'ip';
|
||||
@@ -68,12 +64,6 @@
|
||||
return matched;
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
|
||||
$effect(() => {
|
||||
if (page > totalPages) page = totalPages;
|
||||
});
|
||||
|
||||
function openAdd() {
|
||||
editingExisting = false;
|
||||
editForm = { hostnames: '', ip: '' };
|
||||
@@ -113,177 +103,90 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_networking_hosts()} description={m.seo_desc_networking_hosts()} />
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
|
||||
|
||||
{#snippet sortHead(key: string, label: string)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(key as SortKey)}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 class="truncate text-2xl font-semibold tracking-tight">{m.nav_networking_hosts()}</h1>
|
||||
<p class="text-muted-foreground truncate text-sm">{m.networking_hosts_description()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={m.dashboard_refresh()}
|
||||
onclick={() => hosts.refresh()}
|
||||
>
|
||||
<RefreshCwIcon class="size-4" />
|
||||
</Button>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{label}
|
||||
{#if sort.key === key}
|
||||
{#if sort.dir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-40" />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<DataTable
|
||||
title={m.nav_networking_hosts()}
|
||||
description={m.networking_hosts_description()}
|
||||
searchPlaceholder={m.networking_hosts_search_placeholder()}
|
||||
searchHint={`${filtered.length} / ${hosts.current?.length ?? 0}`}
|
||||
emptyMessage={m.networking_no_hosts()}
|
||||
items={filtered}
|
||||
loading={hosts.loading && !hosts.current}
|
||||
pageSizeKey="networking.hosts.pageSize"
|
||||
pageSizePresets={[10, 25, 50, 100]}
|
||||
bind:search
|
||||
onrefresh={() => hosts.refresh()}
|
||||
i18n={{
|
||||
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
|
||||
previous: () => m.pagination_previous(),
|
||||
next: () => m.pagination_next(),
|
||||
filterTitle: m.users_filter(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={openAdd}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.networking_host_add()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet columns()}
|
||||
<Table.Head class="w-56">{@render sortHead('ip', m.networking_col_ip())}</Table.Head>
|
||||
<Table.Head>{@render sortHead('hostnames', m.networking_col_hostnames())}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(h: Host)}
|
||||
<Table.Cell class="font-mono text-xs">{h.ip}</Table.Cell>
|
||||
<Table.Cell class="flex flex-wrap gap-1">
|
||||
{#each h.hostnames ?? [] as n (n)}
|
||||
<Badge variant="outline" class="font-mono">{n}</Badge>
|
||||
{/each}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-72 p-0" align="end">
|
||||
<div class="border-b p-2">
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
list="net-hosts-rpp-{id}"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="net-hosts-rpp-{id}">
|
||||
{#each [10, 25, 50, 100] as n (n)}
|
||||
<option value={n}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
<Button onclick={openAdd}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.networking_host_add()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.networking_hosts_search_placeholder()}
|
||||
value={search}
|
||||
oninput={(e) => {
|
||||
search = e.currentTarget.value;
|
||||
page = 1;
|
||||
}}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {hosts.current?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#snippet sortHead(key: SortKey, label: string)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(key)}
|
||||
>
|
||||
{label}
|
||||
{#if sort.key === key}
|
||||
{#if sort.dir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-40" />
|
||||
{/if}
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => openEdit(h)}>{m.edit()}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = h;
|
||||
deleteOpen = true;
|
||||
}}
|
||||
>
|
||||
{m.networking_host_remove()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-56">{@render sortHead('ip', m.networking_col_ip())}</Table.Head>
|
||||
<Table.Head>{@render sortHead('hostnames', m.networking_col_hostnames())}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if hosts.loading && !hosts.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !filtered.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center">
|
||||
{m.networking_no_hosts()}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each pageRows as h,i (h.ip,i)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-xs">{h.ip}</Table.Cell>
|
||||
<Table.Cell class="flex flex-wrap gap-1">
|
||||
{#each h.hostnames ?? [] as n (n)}
|
||||
<Badge variant="outline" class="font-mono">{n}</Badge>
|
||||
{/each}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => openEdit(h)}>{m.edit()}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = h;
|
||||
deleteOpen = true;
|
||||
}}
|
||||
>
|
||||
{m.networking_host_remove()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex items-center justify-end gap-4 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{m.pagination_page_of({ page, pages: totalPages })}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.pagination_previous()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
||||
{m.pagination_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DataTable>
|
||||
|
||||
<Dialog.Root bind:open={editOpen}>
|
||||
<Dialog.Content>
|
||||
|
||||
@@ -3,19 +3,16 @@
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page as pageState } from '$app/state';
|
||||
import DataTable from '$lib/components/dashboard/data-table.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
@@ -26,22 +23,19 @@
|
||||
listInterfaces,
|
||||
rollbackChange
|
||||
} from '$lib/remotes/networking.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const interfaces = $derived(listInterfaces(machineId));
|
||||
const pending = $derived(getPending(machineId));
|
||||
|
||||
const pageSize = new PersistedState<number>('networking.interfaces.pageSize', 25);
|
||||
let page = $state(1);
|
||||
let search = $state('');
|
||||
let busy = $state<null | string>(null);
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
async function run(key: string, fn: () => Promise<unknown>, success: string) {
|
||||
@@ -86,13 +80,6 @@
|
||||
});
|
||||
return matched;
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
|
||||
|
||||
$effect(() => {
|
||||
if (page > totalPages) page = totalPages;
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageMeta
|
||||
@@ -100,70 +87,6 @@
|
||||
description={m.seo_desc_networking_interfaces()}
|
||||
/>
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 class="truncate text-2xl font-semibold tracking-tight">
|
||||
{m.nav_networking_interfaces()}
|
||||
</h1>
|
||||
<p class="text-muted-foreground truncate text-sm">
|
||||
{m.networking_interfaces_description()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={m.dashboard_refresh()}
|
||||
onclick={() => {
|
||||
interfaces.refresh();
|
||||
pending.refresh();
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon class="size-4" />
|
||||
</Button>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-72 p-0" align="end">
|
||||
<div class="border-b p-2">
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
list="net-iface-rpp-{id}"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="net-iface-rpp-{id}">
|
||||
{#each [10, 25, 50, 100] as n (n)}
|
||||
<option value={n}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if pending.current}
|
||||
<div
|
||||
class="mx-4 mb-2 flex items-center justify-between gap-3 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm"
|
||||
@@ -205,187 +128,157 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.networking_interfaces_search_placeholder()}
|
||||
value={search}
|
||||
oninput={(e) => {
|
||||
search = e.currentTarget.value;
|
||||
page = 1;
|
||||
}}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {interfaces.current?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#snippet sortHead(key: SortKey, label: string)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(key)}
|
||||
>
|
||||
{label}
|
||||
{#if sort.key === key}
|
||||
{#if sort.dir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-40" />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{@render sortHead('name', m.networking_col_name())}</Table.Head>
|
||||
<Table.Head class="w-24"
|
||||
>{@render sortHead('state', m.networking_col_state())}</Table.Head
|
||||
>
|
||||
<Table.Head>{m.networking_col_ipv4()}</Table.Head>
|
||||
<Table.Head>{m.networking_col_ipv6()}</Table.Head>
|
||||
<Table.Head>{@render sortHead('mac', m.networking_col_mac())}</Table.Head>
|
||||
<Table.Head class="w-20">{@render sortHead('mtu', m.networking_col_mtu())}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if interfaces.loading && !interfaces.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !filtered.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">
|
||||
{m.networking_no_interfaces()}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each pageRows as i (i.name)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-xs font-medium">
|
||||
<a
|
||||
href={resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
|
||||
machineId,
|
||||
name: i.name
|
||||
})}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{i.name}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if i.state === 'up'}
|
||||
<Badge
|
||||
class="border-0 bg-emerald-500/15 capitalize text-emerald-700 dark:text-emerald-400"
|
||||
>{i.state}</Badge
|
||||
>
|
||||
{:else if i.state === 'down'}
|
||||
<Badge
|
||||
class="border-0 bg-zinc-500/15 capitalize text-zinc-700 dark:text-zinc-400"
|
||||
>{i.state}</Badge
|
||||
>
|
||||
{:else}
|
||||
<Badge variant="outline" class="capitalize">{i.state}</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
|
||||
<span class="block truncate" title={(i.ipv4 ?? []).join(', ')}>
|
||||
{(i.ipv4 ?? []).join(', ') || '—'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
|
||||
<span class="block truncate" title={(i.ipv6 ?? []).join(', ')}>
|
||||
{(i.ipv6 ?? []).join(', ') || '—'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mac}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mtu}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
|
||||
machineId,
|
||||
name: i.name
|
||||
})
|
||||
)}
|
||||
>
|
||||
{m.networking_details()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve(
|
||||
'/dashboard/[machineId]/networking/interfaces/[name]/configure',
|
||||
{ machineId, name: i.name }
|
||||
)
|
||||
)}
|
||||
>
|
||||
{m.networking_configure()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
disabled={!!busy || i.state === 'up'}
|
||||
onclick={() =>
|
||||
run(
|
||||
`up-${i.name}`,
|
||||
() => linkUp({ machineId, name: i.name }),
|
||||
m.networking_link_up_done({ name: i.name })
|
||||
)}
|
||||
>
|
||||
{m.networking_link_up()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
disabled={!!busy || i.state === 'down'}
|
||||
onclick={() =>
|
||||
run(
|
||||
`down-${i.name}`,
|
||||
() => linkDown({ machineId, name: i.name }),
|
||||
m.networking_link_down_done({ name: i.name })
|
||||
)}
|
||||
>
|
||||
{m.networking_link_down()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex items-center justify-end gap-4 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{m.pagination_page_of({ page, pages: totalPages })}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.pagination_previous()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
||||
{m.pagination_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet sortHead(key: string, label: string)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(key as SortKey)}
|
||||
>
|
||||
{label}
|
||||
{#if sort.key === key}
|
||||
{#if sort.dir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-40" />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<DataTable
|
||||
title={m.nav_networking_interfaces()}
|
||||
description={m.networking_interfaces_description()}
|
||||
searchPlaceholder={m.networking_interfaces_search_placeholder()}
|
||||
searchHint={`${filtered.length} / ${interfaces.current?.length ?? 0}`}
|
||||
emptyMessage={m.networking_no_interfaces()}
|
||||
items={filtered}
|
||||
loading={interfaces.loading && !interfaces.current}
|
||||
pageSizeKey="networking.interfaces.pageSize"
|
||||
pageSizePresets={[10, 25, 50, 100]}
|
||||
bind:search
|
||||
onrefresh={() => {
|
||||
interfaces.refresh();
|
||||
pending.refresh();
|
||||
}}
|
||||
i18n={{
|
||||
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
|
||||
previous: () => m.pagination_previous(),
|
||||
next: () => m.pagination_next(),
|
||||
filterTitle: m.users_filter(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet columns()}
|
||||
<Table.Head>{@render sortHead('name', m.networking_col_name())}</Table.Head>
|
||||
<Table.Head class="w-24">{@render sortHead('state', m.networking_col_state())}</Table.Head>
|
||||
<Table.Head>{m.networking_col_ipv4()}</Table.Head>
|
||||
<Table.Head>{m.networking_col_ipv6()}</Table.Head>
|
||||
<Table.Head>{@render sortHead('mac', m.networking_col_mac())}</Table.Head>
|
||||
<Table.Head class="w-20">{@render sortHead('mtu', m.networking_col_mtu())}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(i: NonNullable<typeof interfaces.current>[number])}
|
||||
<Table.Cell class="font-mono text-xs font-medium">
|
||||
<a
|
||||
href={resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
|
||||
machineId,
|
||||
name: i.name
|
||||
})}
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{i.name}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if i.state === 'up'}
|
||||
<Badge
|
||||
class="border-0 bg-emerald-500/15 capitalize text-emerald-700 dark:text-emerald-400"
|
||||
>{i.state}</Badge
|
||||
>
|
||||
{:else if i.state === 'down'}
|
||||
<Badge
|
||||
class="border-0 bg-zinc-500/15 capitalize text-zinc-700 dark:text-zinc-400"
|
||||
>{i.state}</Badge
|
||||
>
|
||||
{:else}
|
||||
<Badge variant="outline" class="capitalize">{i.state}</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
|
||||
<span class="block truncate" title={(i.ipv4 ?? []).join(', ')}>
|
||||
{(i.ipv4 ?? []).join(', ') || '—'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
|
||||
<span class="block truncate" title={(i.ipv6 ?? []).join(', ')}>
|
||||
{(i.ipv6 ?? []).join(', ') || '—'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mac}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mtu}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
|
||||
machineId,
|
||||
name: i.name
|
||||
})
|
||||
)}
|
||||
>
|
||||
{m.networking_details()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
goto(
|
||||
resolve(
|
||||
'/dashboard/[machineId]/networking/interfaces/[name]/configure',
|
||||
{ machineId, name: i.name }
|
||||
)
|
||||
)}
|
||||
>
|
||||
{m.networking_configure()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
disabled={!!busy || i.state === 'up'}
|
||||
onclick={() =>
|
||||
run(
|
||||
`up-${i.name}`,
|
||||
() => linkUp({ machineId, name: i.name }),
|
||||
m.networking_link_up_done({ name: i.name })
|
||||
)}
|
||||
>
|
||||
{m.networking_link_up()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
disabled={!!busy || i.state === 'down'}
|
||||
onclick={() =>
|
||||
run(
|
||||
`down-${i.name}`,
|
||||
() => linkDown({ machineId, name: i.name }),
|
||||
m.networking_link_down_done({ name: i.name })
|
||||
)}
|
||||
>
|
||||
{m.networking_link_down()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { applyInterfaceConfig, getInterfaceConfig } from '$lib/remotes/networking.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
@@ -100,7 +101,7 @@
|
||||
await goto(resolve('/dashboard/[machineId]/networking/interfaces', { machineId }));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
} finally {
|
||||
submitting = false;
|
||||
confirmOpen = false;
|
||||
|
||||
@@ -2,24 +2,16 @@
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import { page as pageState } from '$app/state';
|
||||
import DataTable from '$lib/components/dashboard/data-table.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { listRoutes } from '$lib/remotes/networking.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const routes = $derived(listRoutes(machineId));
|
||||
|
||||
const pageSize = new PersistedState<number>('networking.routes.pageSize', 25);
|
||||
let page = $state(1);
|
||||
let search = $state('');
|
||||
type SortKey = 'destination' | 'gateway' | 'interface' | 'metric';
|
||||
let sort = $state<{ dir: 'asc' | 'desc'; key: SortKey }>({ dir: 'asc', key: 'destination' });
|
||||
@@ -50,167 +42,68 @@
|
||||
});
|
||||
return matched;
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
|
||||
$effect(() => {
|
||||
if (page > totalPages) page = totalPages;
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_networking_routes()} description={m.seo_desc_networking_routes()} />
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
|
||||
|
||||
{#snippet sortHead(key: string, label: string)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(key as SortKey)}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 class="truncate text-2xl font-semibold tracking-tight">{m.nav_networking_routes()}</h1>
|
||||
<p class="text-muted-foreground truncate text-sm">{m.networking_routes_description()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={m.dashboard_refresh()}
|
||||
onclick={() => routes.refresh()}
|
||||
>
|
||||
<RefreshCwIcon class="size-4" />
|
||||
</Button>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-72 p-0" align="end">
|
||||
<div class="border-b p-2">
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-2">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
list="net-routes-rpp-{id}"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="net-routes-rpp-{id}">
|
||||
{#each [10, 25, 50, 100] as n (n)}
|
||||
<option value={n}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.networking_routes_search_placeholder()}
|
||||
value={search}
|
||||
oninput={(e) => {
|
||||
search = e.currentTarget.value;
|
||||
page = 1;
|
||||
}}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {routes.current?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#snippet sortHead(key: SortKey, label: string)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(key)}
|
||||
>
|
||||
{label}
|
||||
{#if sort.key === key}
|
||||
{#if sort.dir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{label}
|
||||
{#if sort.key === key}
|
||||
{#if sort.dir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-40" />
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-40" />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<DataTable
|
||||
title={m.nav_networking_routes()}
|
||||
description={m.networking_routes_description()}
|
||||
searchPlaceholder={m.networking_routes_search_placeholder()}
|
||||
searchHint={`${filtered.length} / ${routes.current?.length ?? 0}`}
|
||||
emptyMessage={m.networking_no_routes()}
|
||||
items={filtered}
|
||||
loading={routes.loading && !routes.current}
|
||||
pageSizeKey="networking.routes.pageSize"
|
||||
pageSizePresets={[10, 25, 50, 100]}
|
||||
bind:search
|
||||
onrefresh={() => routes.refresh()}
|
||||
i18n={{
|
||||
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
|
||||
previous: () => m.pagination_previous(),
|
||||
next: () => m.pagination_next(),
|
||||
filterTitle: m.users_filter(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet columns()}
|
||||
<Table.Head>{@render sortHead('destination', m.networking_col_destination())}</Table.Head>
|
||||
<Table.Head>{@render sortHead('gateway', m.networking_col_gateway())}</Table.Head>
|
||||
<Table.Head>{@render sortHead('interface', m.networking_col_interface())}</Table.Head>
|
||||
<Table.Head>{m.networking_col_source()}</Table.Head>
|
||||
<Table.Head class="w-20">{@render sortHead('metric', m.networking_col_metric())}</Table.Head>
|
||||
{/snippet}
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head
|
||||
>{@render sortHead('destination', m.networking_col_destination())}</Table.Head
|
||||
>
|
||||
<Table.Head>{@render sortHead('gateway', m.networking_col_gateway())}</Table.Head>
|
||||
<Table.Head>{@render sortHead('interface', m.networking_col_interface())}</Table.Head>
|
||||
<Table.Head>{m.networking_col_source()}</Table.Head>
|
||||
<Table.Head class="w-20"
|
||||
>{@render sortHead('metric', m.networking_col_metric())}</Table.Head
|
||||
>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if routes.loading && !routes.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !filtered.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
|
||||
{m.networking_no_routes()}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each pageRows as r, i (r.destination + '\0' + r.interface + i)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-xs">{r.destination}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">
|
||||
{r.gateway ?? '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">{r.interface}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">
|
||||
{r.source ?? '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">
|
||||
{r.metric ?? '—'}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex items-center justify-end gap-4 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{m.pagination_page_of({ page, pages: totalPages })}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.pagination_previous()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
||||
{m.pagination_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#snippet row(r: NonNullable<typeof routes.current>[number])}
|
||||
<Table.Cell class="font-mono text-xs">{r.destination}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">
|
||||
{r.gateway ?? '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">{r.interface}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">
|
||||
{r.source ?? '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">
|
||||
{r.metric ?? '—'}
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowUpCircleIcon from '@lucide/svelte/icons/arrow-up-circle';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import Loader2Icon from '@lucide/svelte/icons/loader-2';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import TerminalIcon from '@lucide/svelte/icons/terminal';
|
||||
import { page as pageState } from '$app/state';
|
||||
import DataTable from '$lib/components/dashboard/data-table.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -18,7 +17,6 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
@@ -27,7 +25,6 @@
|
||||
listPackageUpdates,
|
||||
streamPackageAction
|
||||
} from '$lib/remotes/packages.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
|
||||
type Package = { name: string; version: string };
|
||||
|
||||
@@ -44,8 +41,6 @@
|
||||
)
|
||||
);
|
||||
|
||||
const pageSize = new PersistedState<number>('packages.installed.pageSize', 25);
|
||||
let page = $state(1);
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let debouncedSearch = $state('');
|
||||
@@ -56,13 +51,11 @@
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
debouncedSearch = search;
|
||||
page = 1;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function toggleSort() {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
page = 1;
|
||||
}
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
@@ -78,9 +71,6 @@
|
||||
return list;
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
|
||||
|
||||
// Dialogs
|
||||
let createOpen = $state(false);
|
||||
let installName = $state('');
|
||||
@@ -161,189 +151,98 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_packages_installed()} description={m.seo_desc_packages_installed()} />
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_packages_installed()}</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">
|
||||
{m.nav_packages_installed_desc()}
|
||||
{#if manager}({m.packages_manager_label()}: {manager}){/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={m.dashboard_refresh()}
|
||||
onclick={() => dataResource.refresh()}
|
||||
<DataTable
|
||||
title={m.nav_packages_installed()}
|
||||
description={`${m.nav_packages_installed_desc()}${manager ? ` (${m.packages_manager_label()}: ${manager})` : ''}`}
|
||||
searchPlaceholder={m.packages_search_placeholder()}
|
||||
searchHint={`${filtered.length} / ${packages.length}`}
|
||||
emptyMessage={m.packages_no_packages()}
|
||||
items={filtered}
|
||||
loading={dataResource.loading && !dataResource.current}
|
||||
pageSizeKey="packages.installed.pageSize"
|
||||
bind:search
|
||||
onsearchinput={onSearchInput}
|
||||
onrefresh={() => dataResource.refresh()}
|
||||
i18n={{
|
||||
display: m.users_filter_display(),
|
||||
filterTitle: m.users_filter(),
|
||||
next: () => m.users_next(),
|
||||
pageOf: (p) => m.users_page_of({ page: p.page, total: p.total ?? p.pages }),
|
||||
previous: () => m.users_prev(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.packages_install()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet columns()}
|
||||
<Table.Head class="w-8"></Table.Head>
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={toggleSort}
|
||||
>
|
||||
<RefreshCwIcon class="size-4" />
|
||||
</Button>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{m.packages_col_name()}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
<Table.Head>{m.packages_col_version()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(p: Package)}
|
||||
{@const newVersion = updatesMap.get(p.name)}
|
||||
|
||||
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
|
||||
<Table.Cell class="w-8">
|
||||
{#if newVersion}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<AlertCircleIcon class="text-amber-500 size-4" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top">
|
||||
{m.packages_update_available({ from: p.version, to: newVersion })}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" class="relative">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-72 p-4" align="end">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label class="text-xs">{m.users_filter_display()}</Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
list="pkg-rpp-{id}"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="pkg-rpp-{id}">
|
||||
{#each [10, 25, 50, 100, 200] as n (n)}
|
||||
<option value={n}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.packages_install()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.packages_search_placeholder()}
|
||||
value={search}
|
||||
oninput={onSearchInput}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {packages.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-8"></Table.Head>
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={toggleSort}
|
||||
>
|
||||
{m.packages_col_name()}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
<Table.Head>{m.packages_col_version()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if dataResource.loading && !dataResource.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="text-muted-foreground py-8 text-center">
|
||||
<Loader2Icon class="size-4 animate-spin inline" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !pageRows.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="text-muted-foreground py-8 text-center"
|
||||
>{m.packages_no_packages()}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each pageRows as p (p.name)}
|
||||
{@const newVersion = updatesMap.get(p.name)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-8">
|
||||
{#if newVersion}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<AlertCircleIcon class="text-amber-500 size-4" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top">
|
||||
{m.packages_update_available({ from: p.version, to: newVersion })}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if newVersion}
|
||||
<DropdownMenu.Item onclick={() => runUpdate(p.name)}>
|
||||
<ArrowUpCircleIcon class="size-4" />
|
||||
{m.packages_update_single()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = p;
|
||||
deleteOpen = true;
|
||||
}}>{m.delete()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if newVersion}
|
||||
<DropdownMenu.Item onclick={() => runUpdate(p.name)}>
|
||||
<ArrowUpCircleIcon class="size-4" />
|
||||
{m.packages_update_single()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm"
|
||||
>{m.users_page_of({ page, total: totalPages })}</span
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.users_prev()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
||||
{m.users_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = p;
|
||||
deleteOpen = true;
|
||||
}}>{m.delete()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<Dialog.Root bind:open={createOpen}>
|
||||
<Dialog.Content>
|
||||
|
||||
@@ -2,52 +2,42 @@
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowUpCircleIcon from '@lucide/svelte/icons/arrow-up-circle';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import Loader2Icon from '@lucide/svelte/icons/loader-2';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import TerminalIcon from '@lucide/svelte/icons/terminal';
|
||||
import { page as pageState } from '$app/state';
|
||||
import DataTable from '$lib/components/dashboard/data-table.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { listPackageUpdates, streamPackageAction } from '$lib/remotes/packages.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
|
||||
type Package = { name: string; version: string };
|
||||
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const dataResource = $derived(listPackageUpdates(machineId));
|
||||
const manager = $derived(dataResource.current?.manager ?? '');
|
||||
const packages = $derived((dataResource.current?.packages ?? []) as Package[]);
|
||||
|
||||
const pageSize = new PersistedState<number>('packages.updates.pageSize', 25);
|
||||
let page = $state(1);
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let debouncedSearch = $state('');
|
||||
let sortDir = $state<'asc' | 'desc'>('asc');
|
||||
|
||||
function onSearchInput(e: Event) {
|
||||
function onsearchinput(e: Event) {
|
||||
search = (e.target as HTMLInputElement).value;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
debouncedSearch = search;
|
||||
page = 1;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function toggleSort() {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
page = 1;
|
||||
}
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
@@ -63,9 +53,6 @@
|
||||
return list;
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
|
||||
|
||||
// Terminal Stream Console
|
||||
let consoleOpen = $state(false);
|
||||
let consoleLogs = $state<string[]>([]);
|
||||
@@ -116,165 +103,73 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_packages_updates()} description={m.seo_desc_packages_updates()} />
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_packages_updates()}</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">
|
||||
{m.nav_packages_updates_desc()}
|
||||
{#if manager}({m.packages_manager_label()}: {manager}){/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={m.dashboard_refresh()}
|
||||
onclick={() => dataResource.refresh()}
|
||||
<DataTable
|
||||
title={m.nav_packages_updates()}
|
||||
description={`${m.nav_packages_updates_desc()}${manager ? ` (${m.packages_manager_label()}: ${manager})` : ''}`}
|
||||
searchPlaceholder={m.packages_search_placeholder()}
|
||||
searchHint={`${filtered.length} / ${packages.length}`}
|
||||
emptyMessage={m.packages_no_updates()}
|
||||
items={filtered}
|
||||
loading={dataResource.loading && !dataResource.current}
|
||||
pageSizeKey="packages.updates.pageSize"
|
||||
bind:search
|
||||
{onsearchinput}
|
||||
onrefresh={() => dataResource.refresh()}
|
||||
i18n={{
|
||||
display: m.users_filter_display(),
|
||||
filterTitle: m.users_filter(),
|
||||
next: () => m.users_next(),
|
||||
pageOf: (p) => m.users_page_of({ page: p.page, total: p.total ?? p.pages }),
|
||||
previous: () => m.users_prev(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => doUpgrade()} disabled={!packages.length}>
|
||||
<ArrowUpCircleIcon class="size-4" />
|
||||
{m.packages_upgrade_all()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet columns()}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={toggleSort}
|
||||
>
|
||||
<RefreshCwIcon class="size-4" />
|
||||
</Button>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{m.packages_col_name()}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
<Table.Head>{m.packages_col_version()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(p: Package)}
|
||||
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" class="relative">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-72 p-4" align="end">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label class="text-xs">{m.users_filter_display()}</Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
list="pkg-up-rpp-{id}"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="pkg-up-rpp-{id}">
|
||||
{#each [10, 25, 50, 100, 200] as n (n)}
|
||||
<option value={n}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Button onclick={() => doUpgrade()} disabled={!packages.length}>
|
||||
<ArrowUpCircleIcon class="size-4" />
|
||||
{m.packages_upgrade_all()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.packages_search_placeholder()}
|
||||
value={search}
|
||||
oninput={onSearchInput}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {packages.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={toggleSort}
|
||||
>
|
||||
{m.packages_col_name()}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
<Table.Head>{m.packages_col_version()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if dataResource.loading && !dataResource.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center">
|
||||
<Loader2Icon class="size-4 animate-spin inline" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !pageRows.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center"
|
||||
>{m.packages_no_updates()}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each pageRows as p (p.name)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => doUpgrade(p.name)}>
|
||||
<ArrowUpCircleIcon class="size-4" />
|
||||
{m.packages_update_single()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm"
|
||||
>{m.users_page_of({ page, total: totalPages })}</span
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.users_prev()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
||||
{m.users_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => doUpgrade(p.name)}>
|
||||
<ArrowUpCircleIcon class="size-4" />
|
||||
{m.packages_update_single()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<!-- Stream Console Modal -->
|
||||
<Dialog.Root bind:open={consoleOpen}>
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
<script lang="ts">
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import Loader2Icon from '@lucide/svelte/icons/loader-2';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page as pageState } from '$app/state';
|
||||
import DataTable from '$lib/components/dashboard/data-table.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { listServices } from '$lib/remotes/services.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
|
||||
type ServiceUnit = {
|
||||
active: string;
|
||||
@@ -25,19 +20,16 @@
|
||||
sub: string;
|
||||
unit: string;
|
||||
};
|
||||
const id = $props.id();
|
||||
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const servicesResource = $derived(listServices(machineId));
|
||||
const services = $derived((servicesResource.current?.services ?? []) as ServiceUnit[]);
|
||||
|
||||
const pageSize = new PersistedState<number>('services.pageSize', 25);
|
||||
let page = $state(1);
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let debouncedSearch = $state('');
|
||||
let sortDir = $state<'asc' | 'desc'>('asc');
|
||||
|
||||
// Active State Filter
|
||||
let filterActive = $state({
|
||||
active: true,
|
||||
failed: true,
|
||||
@@ -45,7 +37,6 @@
|
||||
other: true
|
||||
});
|
||||
|
||||
// Load State Filter
|
||||
let filterLoad = $state({
|
||||
error: true,
|
||||
loaded: true,
|
||||
@@ -53,7 +44,6 @@
|
||||
notFound: true
|
||||
});
|
||||
|
||||
// Sub State Filter
|
||||
let filterSub = $state({
|
||||
dead: true,
|
||||
exited: true,
|
||||
@@ -71,358 +61,215 @@
|
||||
filterActive = { active: true, failed: true, inactive: true, other: true };
|
||||
filterLoad = { error: true, loaded: true, masked: true, notFound: true };
|
||||
filterSub = { dead: true, exited: true, other: true, running: true };
|
||||
page = 1;
|
||||
}
|
||||
|
||||
function onSearchInput(e: Event) {
|
||||
search = (e.target as HTMLInputElement).value;
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
debouncedSearch = search;
|
||||
page = 1;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
let page = $state(1);
|
||||
|
||||
function toggleSort() {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
page = 1;
|
||||
}
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
let list = [...services];
|
||||
|
||||
// Text Search
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(s) => s.unit.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Active State Filter
|
||||
list = list.filter((s) => {
|
||||
const activeCat =
|
||||
s.active === 'active'
|
||||
? 'active'
|
||||
: s.active === 'inactive'
|
||||
? 'inactive'
|
||||
: s.active === 'failed'
|
||||
? 'failed'
|
||||
: 'other';
|
||||
s.active === 'active' ? 'active' : s.active === 'inactive' ? 'inactive' : s.active === 'failed' ? 'failed' : 'other';
|
||||
return filterActive[activeCat];
|
||||
});
|
||||
|
||||
// Load State Filter
|
||||
list = list.filter((s) => {
|
||||
const loadCat =
|
||||
s.load === 'loaded'
|
||||
? 'loaded'
|
||||
: s.load === 'masked'
|
||||
? 'masked'
|
||||
: s.load === 'not-found'
|
||||
? 'notFound'
|
||||
: s.load === 'error'
|
||||
? 'error'
|
||||
: 'loaded';
|
||||
s.load === 'loaded' ? 'loaded' : s.load === 'masked' ? 'masked' : s.load === 'not-found' ? 'notFound' : s.load === 'error' ? 'error' : 'loaded';
|
||||
return filterLoad[loadCat as keyof typeof filterLoad] ?? true;
|
||||
});
|
||||
|
||||
// Sub State Filter
|
||||
list = list.filter((s) => {
|
||||
const subCat =
|
||||
s.sub === 'running'
|
||||
? 'running'
|
||||
: s.sub === 'exited'
|
||||
? 'exited'
|
||||
: s.sub === 'dead'
|
||||
? 'dead'
|
||||
: 'other';
|
||||
s.sub === 'running' ? 'running' : s.sub === 'exited' ? 'exited' : s.sub === 'dead' ? 'dead' : 'other';
|
||||
return filterSub[subCat as keyof typeof filterSub] ?? true;
|
||||
});
|
||||
|
||||
// Sorting
|
||||
list.sort((a, b) => {
|
||||
const comp = a.unit.localeCompare(b.unit);
|
||||
return sortDir === 'asc' ? comp : -comp;
|
||||
});
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
|
||||
|
||||
$effect(() => {
|
||||
if (page > totalPages) {
|
||||
page = totalPages;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_services()} description={m.seo_desc_services()} />
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.services_title()}</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">
|
||||
{m.services_description()}
|
||||
</p>
|
||||
<DataTable
|
||||
title={m.services_title()}
|
||||
description={m.services_description()}
|
||||
searchPlaceholder={m.services_search_placeholder()}
|
||||
emptyMessage={m.services_no_services()}
|
||||
items={filtered}
|
||||
loading={servicesResource.loading}
|
||||
pageSizeKey="services.pageSize"
|
||||
defaultPageSize={25}
|
||||
bind:search
|
||||
onsearchinput={onSearchInput}
|
||||
onrefresh={() => servicesResource.refresh()}
|
||||
activeFilterCount={activeFilterCount}
|
||||
bind:page
|
||||
i18n={{
|
||||
pageOf: (p) => m.users_page_of(p),
|
||||
previous: () => m.users_prev(),
|
||||
next: () => m.users_next(),
|
||||
filterTitle: m.services_filter_title(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet filterContent()}
|
||||
<div class="flex items-center justify-between border-b p-3">
|
||||
<h3 class="text-sm font-semibold">{m.services_filter_title()}</h3>
|
||||
{#if activeFilterCount > 0}
|
||||
<Button variant="ghost" size="sm" class="h-auto p-1 text-xs" onclick={resetFilters}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={m.dashboard_refresh()}
|
||||
onclick={() => servicesResource.refresh()}
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{m.services_active_filter()}
|
||||
</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterActive.active} />
|
||||
{m.services_filter_active()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterActive.inactive} />
|
||||
{m.services_filter_inactive()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterActive.failed} />
|
||||
{m.services_filter_failed()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterActive.other} />
|
||||
{m.services_filter_other()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t pt-3">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{m.services_load_filter()}
|
||||
</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterLoad.loaded} />
|
||||
{m.services_filter_loaded()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterLoad.masked} />
|
||||
{m.services_filter_masked()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterLoad.notFound} />
|
||||
{m.services_filter_not_found()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterLoad.error} />
|
||||
{m.services_filter_error()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t pt-3">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{m.services_sub_filter()}
|
||||
</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterSub.running} />
|
||||
{m.services_filter_running()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterSub.exited} />
|
||||
{m.services_filter_exited()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterSub.dead} />
|
||||
{m.services_filter_dead()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterSub.other} />
|
||||
{m.services_filter_other()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet columns()}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={toggleSort}
|
||||
>
|
||||
<RefreshCwIcon class="size-4" />
|
||||
</Button>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" class="relative">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
{#if activeFilterCount > 0}
|
||||
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-0" align="end">
|
||||
<div class="border-b p-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">{m.services_filter_title()}</h3>
|
||||
{#if activeFilterCount > 0}
|
||||
<Button variant="ghost" size="sm" class="h-auto p-1 text-xs" onclick={resetFilters}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<!-- Active State -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{m.services_active_filter()}
|
||||
</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterActive.active} />
|
||||
{m.services_filter_active()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterActive.inactive} />
|
||||
{m.services_filter_inactive()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterActive.failed} />
|
||||
{m.services_filter_failed()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterActive.other} />
|
||||
{m.services_filter_other()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load State -->
|
||||
<div class="flex flex-col gap-2 border-t pt-3">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{m.services_load_filter()}
|
||||
</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterLoad.loaded} />
|
||||
{m.services_filter_loaded()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterLoad.masked} />
|
||||
{m.services_filter_masked()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterLoad.notFound} />
|
||||
{m.services_filter_not_found()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterLoad.error} />
|
||||
{m.services_filter_error()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub State -->
|
||||
<div class="flex flex-col gap-2 border-t pt-3">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{m.services_sub_filter()}
|
||||
</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterSub.running} />
|
||||
{m.services_filter_running()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterSub.exited} />
|
||||
{m.services_filter_exited()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterSub.dead} />
|
||||
{m.services_filter_dead()}
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterSub.other} />
|
||||
{m.services_filter_other()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rows per page -->
|
||||
<div class="flex items-center justify-between border-t pt-3">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
list="services-rpp-{id}"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="services-rpp-{id}">
|
||||
{#each [10, 25, 50, 100, 200] as n (n)}
|
||||
<option value={n}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.services_search_placeholder()}
|
||||
value={search}
|
||||
oninput={onSearchInput}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {services.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={toggleSort}
|
||||
>
|
||||
{m.services_col_unit()}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
<Table.Head class="w-24">{m.services_col_active()}</Table.Head>
|
||||
<Table.Head class="w-24">{m.services_col_sub()}</Table.Head>
|
||||
<Table.Head class="w-24">{m.services_col_load()}</Table.Head>
|
||||
<Table.Head>{m.services_col_description()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if servicesResource.loading && !servicesResource.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
|
||||
<Loader2Icon class="size-4 animate-spin inline" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !pageRows.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
|
||||
{m.services_no_services()}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each pageRows as s (s.unit)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium font-mono text-xs max-w-[20rem]">
|
||||
<a
|
||||
href={resolve('/dashboard/[machineId]/services/[name]', {
|
||||
machineId,
|
||||
name: s.unit
|
||||
})}
|
||||
class="block truncate text-primary hover:underline"
|
||||
title={s.unit}
|
||||
>
|
||||
{s.unit}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs max-w-[20rem] w-full">
|
||||
<span class="block truncate" title={s.description}>
|
||||
{s.description}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if s.active === 'active'}
|
||||
<Badge
|
||||
class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-500/20 border-0 capitalize"
|
||||
>
|
||||
{s.active}
|
||||
</Badge>
|
||||
{:else if s.active === 'inactive'}
|
||||
<Badge
|
||||
class="bg-zinc-500/15 text-zinc-700 dark:text-zinc-400 hover:bg-zinc-500/20 border-0 capitalize"
|
||||
>
|
||||
{s.active}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge
|
||||
class="bg-rose-500/15 text-rose-700 dark:text-rose-400 hover:bg-rose-500/20 border-0 capitalize"
|
||||
>
|
||||
{s.active}
|
||||
</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">
|
||||
{s.sub}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">
|
||||
{s.load}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{m.users_page_of({ page, total: totalPages })}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.users_prev()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
||||
{m.users_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{m.services_col_unit()}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
<Table.Head class="w-24">{m.services_col_active()}</Table.Head>
|
||||
<Table.Head class="w-24">{m.services_col_sub()}</Table.Head>
|
||||
<Table.Head class="w-24">{m.services_col_load()}</Table.Head>
|
||||
<Table.Head>{m.services_col_description()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(s: ServiceUnit, i: number)}
|
||||
<Table.Cell class="font-medium font-mono text-xs max-w-[20rem]">
|
||||
<a
|
||||
href={resolve('/dashboard/[machineId]/services/[name]', {
|
||||
machineId,
|
||||
name: s.unit
|
||||
})}
|
||||
class="block truncate text-primary hover:underline"
|
||||
title={s.unit}
|
||||
>
|
||||
{s.unit}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if s.active === 'active'}
|
||||
<Badge
|
||||
class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-500/20 border-0 capitalize"
|
||||
>
|
||||
{s.active}
|
||||
</Badge>
|
||||
{:else if s.active === 'inactive'}
|
||||
<Badge
|
||||
class="bg-zinc-500/15 text-zinc-700 dark:text-zinc-400 hover:bg-zinc-500/20 border-0 capitalize"
|
||||
>
|
||||
{s.active}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge
|
||||
class="bg-rose-500/15 text-rose-700 dark:text-rose-400 hover:bg-rose-500/20 border-0 capitalize"
|
||||
>
|
||||
{s.active}
|
||||
</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">{s.sub}</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">{s.load}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs max-w-[20rem] w-full">
|
||||
<span class="block truncate" title={s.description}>{s.description}</span>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
streamServiceLogs
|
||||
} from '$lib/remotes/services.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const name = $derived(pageState.params.name!);
|
||||
@@ -45,7 +46,7 @@
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
async function run(
|
||||
@@ -187,7 +188,7 @@
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
|
||||
<svelte:boundary>
|
||||
{#snippet failed(err, reset)}
|
||||
{@const e = err as { body?: { message?: string }; message?: string; status?: number }}
|
||||
{@const e = err as { body?: { message?: string }; message?: string; status?: number; detail?: string }}
|
||||
{@const status = e.status ?? 0}
|
||||
{@const notFound = status === 404}
|
||||
<Empty.Root class="border">
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<script lang="ts">
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import { page as pageState } from '$app/state';
|
||||
import DataTable from '$lib/components/dashboard/data-table.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { listFstab, removeMount } from '$lib/remotes/storage.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type FstabEntry = {
|
||||
@@ -26,13 +22,9 @@
|
||||
pass: number;
|
||||
};
|
||||
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const fstab = $derived(listFstab(machineId));
|
||||
|
||||
const pageSize = new PersistedState<number>('storage.fstab.pageSize', 10);
|
||||
let page = $state(1);
|
||||
|
||||
let search = $state('');
|
||||
const filtered = $derived.by(() => {
|
||||
const all = (fstab.current ?? []) as FstabEntry[];
|
||||
@@ -48,14 +40,6 @@
|
||||
.sort((a, b) => a.mountpoint.localeCompare(b.mountpoint));
|
||||
});
|
||||
|
||||
const paginated = $derived.by(() => {
|
||||
const start = (page - 1) * pageSize.current;
|
||||
const end = start + pageSize.current;
|
||||
return filtered.slice(start, end);
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state<FstabEntry | null>(null);
|
||||
|
||||
@@ -68,174 +52,77 @@
|
||||
deleting = null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_storage_fstab()} description={m.seo_desc_storage_fstab()} />
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_storage_fstab()}</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">{m.storage_fstab_description()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={m.dashboard_refresh()}
|
||||
onclick={() => fstab.refresh()}
|
||||
>
|
||||
<RefreshCwIcon class="size-4" />
|
||||
</Button>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<DataTable
|
||||
title={m.nav_storage_fstab()}
|
||||
description={m.storage_fstab_description()}
|
||||
searchPlaceholder={m.storage_mounts_search_placeholder()}
|
||||
emptyMessage={m.storage_no_fstab()}
|
||||
items={filtered}
|
||||
loading={fstab.loading}
|
||||
pageSizeKey="storage.fstab.pageSize"
|
||||
defaultPageSize={10}
|
||||
pageSizePresets={[10, 20, 50, 100]}
|
||||
bind:search
|
||||
onrefresh={() => fstab.refresh()}
|
||||
i18n={{
|
||||
pageOf: (p) => m.pagination_page_of(p),
|
||||
previous: () => m.pagination_previous(),
|
||||
next: () => m.pagination_next(),
|
||||
filterTitle: m.users_filter(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet columns()}
|
||||
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_device()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_fstype()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_options()}</Table.Head>
|
||||
<Table.Head class="text-right">{m.storage_col_dump()}</Table.Head>
|
||||
<Table.Head class="text-right">{m.storage_col_pass()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(e: FstabEntry, i: number)}
|
||||
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
|
||||
<span class="block truncate" title={e.mountpoint}>{e.mountpoint}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
|
||||
<span class="block truncate" title={e.device}>{e.device}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell><Badge variant="outline">{e.fstype}</Badge></Table.Cell>
|
||||
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
|
||||
<span class="block truncate" title={e.options}>{e.options}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-right tabular-nums">{e.dump}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-right tabular-nums">{e.pass}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" class="relative">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-72 p-0" align="end">
|
||||
<div class="border-b p-2">
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-2">
|
||||
<Label class="text-xs">{m.users_filter_display()}</Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
list="rpp-presets-{id}"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="rpp-presets-{id}">
|
||||
{#each [10, 20, 50, 100] as n (n)}
|
||||
<option value={n}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.storage_mounts_search_placeholder()}
|
||||
value={search}
|
||||
oninput={(e) => {
|
||||
search = e.currentTarget.value;
|
||||
page = 1;
|
||||
}}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {fstab.current?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_device()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_fstype()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_options()}</Table.Head>
|
||||
<Table.Head class="text-right">{m.storage_col_dump()}</Table.Head>
|
||||
<Table.Head class="text-right">{m.storage_col_pass()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if fstab.loading && !fstab.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !filtered.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
|
||||
>{m.storage_no_fstab()}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each paginated as e, i (e.mountpoint + '\0' + e.device + i)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
|
||||
<span class="block truncate" title={e.mountpoint}>{e.mountpoint}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
|
||||
<span class="block truncate" title={e.device}>{e.device}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell><Badge variant="outline">{e.fstype}</Badge></Table.Cell>
|
||||
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
|
||||
<span class="block truncate" title={e.options}>{e.options}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-right tabular-nums"
|
||||
>{e.dump}</Table.Cell
|
||||
>
|
||||
<Table.Cell class="text-muted-foreground text-right tabular-nums"
|
||||
>{e.pass}</Table.Cell
|
||||
>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = e;
|
||||
deleteOpen = true;
|
||||
}}>{m.storage_mount_remove()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{m.pagination_page_of({ page, pages: totalPages })}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.pagination_previous()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
||||
{m.pagination_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = e;
|
||||
deleteOpen = true;
|
||||
}}>{m.storage_mount_remove()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<AlertDialog.Root bind:open={deleteOpen}>
|
||||
<AlertDialog.Content>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import { page as pageState } from '$app/state';
|
||||
import DataTable from '$lib/components/dashboard/data-table.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -13,37 +12,19 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { addMount, listFstab, listMounts, removeMount } from '$lib/remotes/storage.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type Mount = { device: string; fstype: string; mountpoint: string; options: string };
|
||||
|
||||
// Kernel/virtual filesystems most people don't want to see by default.
|
||||
const PSEUDO_FSTYPES = new Set([
|
||||
'autofs',
|
||||
'binfmt_misc',
|
||||
'bpf',
|
||||
'cgroup',
|
||||
'cgroup2',
|
||||
'configfs',
|
||||
'debugfs',
|
||||
'devpts',
|
||||
'devtmpfs',
|
||||
'fusectl',
|
||||
'hugetlbfs',
|
||||
'mqueue',
|
||||
'nsfs',
|
||||
'proc',
|
||||
'pstore',
|
||||
'ramfs',
|
||||
'securityfs',
|
||||
'sysfs',
|
||||
'tmpfs',
|
||||
'tracefs'
|
||||
'autofs', 'binfmt_misc', 'bpf', 'cgroup', 'cgroup2', 'configfs', 'debugfs',
|
||||
'devpts', 'devtmpfs', 'fusectl', 'hugetlbfs', 'mqueue', 'nsfs', 'proc',
|
||||
'pstore', 'ramfs', 'securityfs', 'sysfs', 'tmpfs', 'tracefs'
|
||||
]);
|
||||
|
||||
const id = $props.id();
|
||||
@@ -51,9 +32,6 @@
|
||||
const mounts = $derived(listMounts(machineId));
|
||||
const fstab = $derived(listFstab(machineId));
|
||||
|
||||
const pageSize = new PersistedState<number>('storage.mounts.pageSize', 10);
|
||||
let page = $state(1);
|
||||
|
||||
let search = $state('');
|
||||
const filtersStore = new PersistedState('storage.mounts.filters', { showPseudo: false });
|
||||
let filters = $state({ ...filtersStore.current });
|
||||
@@ -62,7 +40,6 @@
|
||||
});
|
||||
const activeFilterCount = $derived(filters.showPseudo ? 1 : 0);
|
||||
|
||||
// Mountpoints present in /etc/fstab — used to flag which active mounts are persistent.
|
||||
const persistent = $derived(new Set((fstab.current ?? []).map((e) => e.mountpoint)));
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
@@ -80,14 +57,6 @@
|
||||
.sort((a, b) => a.mountpoint.localeCompare(b.mountpoint));
|
||||
});
|
||||
|
||||
const paginated = $derived.by(() => {
|
||||
const start = (page - 1) * pageSize.current;
|
||||
const end = start + pageSize.current;
|
||||
return filtered.slice(start, end);
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
|
||||
let createOpen = $state(false);
|
||||
let createForm = $state({ device: '', fstype: '', mountpoint: '', options: 'defaults' });
|
||||
let deleteOpen = $state(false);
|
||||
@@ -95,7 +64,7 @@
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
@@ -129,187 +98,93 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_storage_mounts()} description={m.seo_desc_storage_mounts()} />
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_storage_mounts()}</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">{m.storage_mounts_description()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={m.dashboard_refresh()}
|
||||
onclick={() => mounts.refresh()}
|
||||
>
|
||||
<RefreshCwIcon class="size-4" />
|
||||
</Button>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<DataTable
|
||||
title={m.nav_storage_mounts()}
|
||||
description={m.storage_mounts_description()}
|
||||
searchPlaceholder={m.storage_mounts_search_placeholder()}
|
||||
emptyMessage={m.storage_no_mounts()}
|
||||
items={filtered}
|
||||
loading={mounts.loading}
|
||||
pageSizeKey="storage.mounts.pageSize"
|
||||
defaultPageSize={10}
|
||||
pageSizePresets={[10, 20, 50, 100]}
|
||||
bind:search
|
||||
onrefresh={() => mounts.refresh()}
|
||||
activeFilterCount={activeFilterCount}
|
||||
i18n={{
|
||||
pageOf: (p) => m.pagination_page_of(p),
|
||||
previous: () => m.pagination_previous(),
|
||||
next: () => m.pagination_next(),
|
||||
filterTitle: m.users_filter(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.storage_mount_add()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet filterContent()}
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span>{m.storage_filter_show_pseudo()}</span>
|
||||
<small>{m.storage_filter_show_pseudo_hint()}</small>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={filters.showPseudo}
|
||||
onCheckedChange={(v) => {
|
||||
filters.showPseudo = !!v;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{/snippet}
|
||||
{#snippet columns()}
|
||||
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_device()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_fstype()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_options()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(mt: Mount, i: number)}
|
||||
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="block truncate" title={mt.mountpoint}>{mt.mountpoint}</span>
|
||||
{#if persistent.has(mt.mountpoint)}
|
||||
<Badge variant="secondary">{m.storage_badge_fstab()}</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
|
||||
<span class="block truncate" title={mt.device}>{mt.device}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell><Badge variant="outline">{mt.fstype}</Badge></Table.Cell>
|
||||
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
|
||||
<span class="block truncate" title={mt.options}>{mt.options}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" class="relative">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
{#if activeFilterCount > 0}
|
||||
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
|
||||
{/if}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-72 p-0" align="end">
|
||||
<div class="border-b p-2">
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-2">
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span>{m.storage_filter_show_pseudo()}</span>
|
||||
<small>{m.storage_filter_show_pseudo_hint()}</small>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={filters.showPseudo}
|
||||
onCheckedChange={(v) => {
|
||||
filters.showPseudo = !!v;
|
||||
page = 1;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t p-2">
|
||||
<Label class="text-xs">{m.users_filter_display()}</Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
list="rpp-presets-{id}"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="rpp-presets-{id}">
|
||||
{#each [10, 20, 50, 100] as n (n)}
|
||||
<option value={n}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.storage_mount_add()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.storage_mounts_search_placeholder()}
|
||||
value={search}
|
||||
oninput={(e) => {
|
||||
search = e.currentTarget.value;
|
||||
page = 1;
|
||||
}}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {mounts.current?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_device()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_fstype()}</Table.Head>
|
||||
<Table.Head>{m.storage_col_options()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if mounts.loading && !mounts.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !filtered.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"
|
||||
>{m.storage_no_mounts()}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each paginated as mt, i (mt.mountpoint + '\0' + mt.device + i)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="block truncate" title={mt.mountpoint}>{mt.mountpoint}</span>
|
||||
{#if persistent.has(mt.mountpoint)}
|
||||
<Badge variant="secondary">{m.storage_badge_fstab()}</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
|
||||
<span class="block truncate" title={mt.device}>{mt.device}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell><Badge variant="outline">{mt.fstype}</Badge></Table.Cell>
|
||||
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
|
||||
<span class="block truncate" title={mt.options}>{mt.options}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = mt;
|
||||
deleteOpen = true;
|
||||
}}>{m.storage_mount_remove()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{m.pagination_page_of({ page, pages: totalPages })}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.pagination_previous()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
||||
{m.pagination_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = mt;
|
||||
deleteOpen = true;
|
||||
}}>{m.storage_mount_remove()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<Dialog.Root bind:open={createOpen}>
|
||||
<Dialog.Content>
|
||||
@@ -326,12 +201,7 @@
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="sm-device-{id}">{m.storage_col_device()}</Label>
|
||||
<Input
|
||||
id="sm-device-{id}"
|
||||
bind:value={createForm.device}
|
||||
placeholder="/dev/sdb1"
|
||||
required
|
||||
/>
|
||||
<Input id="sm-device-{id}" bind:value={createForm.device} placeholder="/dev/sdb1" required />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="sm-mountpoint-{id}">{m.storage_col_mountpoint()}</Label>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
setTimezone,
|
||||
systemTime
|
||||
} from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
@@ -40,7 +41,7 @@
|
||||
await fn();
|
||||
toast.success(m.saved());
|
||||
} catch (e) {
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { setHostname, systemHostname } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
@@ -55,7 +56,7 @@
|
||||
toast.success(m.saved());
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
toast.error((err as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(err) ?? m.errors_generic());
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
setLocale,
|
||||
systemLocale
|
||||
} from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
@@ -58,7 +59,7 @@
|
||||
await fn();
|
||||
toast.success(m.saved());
|
||||
} catch (e) {
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { powerOff, reboot } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let pending = $state<'off' | 'reboot' | null>(null);
|
||||
@@ -23,7 +24,7 @@
|
||||
: reboot({ machineId, when: '' }));
|
||||
toast.success(m.saved());
|
||||
} catch (e) {
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
} finally {
|
||||
busy = false;
|
||||
pending = null;
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page as pageState } from '$app/state';
|
||||
import DataTable from '$lib/components/dashboard/data-table.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -16,7 +16,6 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
@@ -26,6 +25,7 @@
|
||||
setPamUserPassword
|
||||
} from '$lib/remotes/pam-users.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type PamUser = {
|
||||
@@ -44,8 +44,6 @@
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const users = $derived(listPamUsers(machineId));
|
||||
|
||||
const pageSize = new PersistedState<number>('pam.users.pageSize', 25);
|
||||
let page = $state(1);
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let debouncedSearch = $state('');
|
||||
@@ -70,8 +68,7 @@
|
||||
});
|
||||
const activeFilterCount = $derived((filters.showSystem ? 1 : 0) + (filters.shellOnly ? 1 : 0));
|
||||
|
||||
function onSearchInput(e: Event) {
|
||||
search = (e.target as HTMLInputElement).value;
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
debouncedSearch = search;
|
||||
@@ -79,6 +76,8 @@
|
||||
}, 200);
|
||||
}
|
||||
|
||||
let page = $state(1);
|
||||
|
||||
function toggleSort(col: SortBy) {
|
||||
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
else {
|
||||
@@ -113,10 +112,6 @@
|
||||
return out;
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
|
||||
|
||||
// Dialogs
|
||||
let createOpen = $state(false);
|
||||
let createForm = $state({
|
||||
comment: '',
|
||||
@@ -134,7 +129,7 @@
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
@@ -197,222 +192,139 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_users()} description={m.seo_desc_users()} />
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.users_nav_title()}</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">
|
||||
{m.users_nav_description()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<DataTable
|
||||
title={m.users_nav_title()}
|
||||
description={m.users_nav_description()}
|
||||
searchPlaceholder={m.users_pam_search_placeholder()}
|
||||
emptyMessage={m.users_no_results()}
|
||||
items={filtered}
|
||||
loading={users.loading}
|
||||
pageSizeKey="pam.users.pageSize"
|
||||
defaultPageSize={25}
|
||||
pageSizePresets={[10, 25, 50, 100, 200]}
|
||||
bind:search
|
||||
onsearchinput={onSearchInput}
|
||||
onrefresh={() => users.refresh()}
|
||||
activeFilterCount={activeFilterCount}
|
||||
bind:page
|
||||
i18n={{
|
||||
pageOf: (p) => m.users_page_of(p),
|
||||
previous: () => m.users_prev(),
|
||||
next: () => m.users_next(),
|
||||
filterTitle: m.users_filter_title(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.users_add()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet filterContent()}
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span>{m.users_filter_show_system()}</span>
|
||||
<small>{m.users_filter_system_uid_hint()}</small>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={filters.showSystem}
|
||||
onCheckedChange={(v) => {
|
||||
filters.showSystem = !!v;
|
||||
page = 1;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<span>{m.users_filter_shell_only()}</span>
|
||||
<Checkbox
|
||||
checked={filters.shellOnly}
|
||||
onCheckedChange={(v) => {
|
||||
filters.shellOnly = !!v;
|
||||
page = 1;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{/snippet}
|
||||
{#snippet columns()}
|
||||
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: m.users_col_uid() }, { key: 'shell', label: m.users_create_field_shell() }] as col (col.key)}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(col.key as SortBy)}
|
||||
>
|
||||
{col.label}
|
||||
{#if sortBy === col.key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/each}
|
||||
<Table.Head>{m.users_col_comment()}</Table.Head>
|
||||
<Table.Head>{m.users_col_home()}</Table.Head>
|
||||
<Table.Head>{m.users_col_type()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(u: PamUser, i: number)}
|
||||
<Table.Cell class="font-medium">
|
||||
<a
|
||||
href={resolve('/dashboard/[machineId]/users/[username]', {
|
||||
machineId,
|
||||
username: u.username
|
||||
})}
|
||||
class="hover:underline">{u.username}</a
|
||||
>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{u.uid}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.shell}</Table.Cell>
|
||||
<Table.Cell>{u.comment || '—'}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.home}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if u.system}
|
||||
<Badge variant="secondary">{m.users_type_system()}</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline">{m.users_type_user()}</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" class="relative">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
{#if activeFilterCount > 0}
|
||||
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
|
||||
{/if}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-72 p-0" align="end">
|
||||
<div class="border-b p-2">
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-2">
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span>{m.users_filter_show_system()}</span>
|
||||
<small>{m.users_filter_system_uid_hint()}</small>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={filters.showSystem}
|
||||
onCheckedChange={(v) => {
|
||||
filters.showSystem = !!v;
|
||||
page = 1;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<span>{m.users_filter_shell_only()}</span>
|
||||
<Checkbox
|
||||
checked={filters.shellOnly}
|
||||
onCheckedChange={(v) => {
|
||||
filters.shellOnly = !!v;
|
||||
page = 1;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t p-2">
|
||||
<Label class="text-xs">{m.users_filter_display()}</Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
list="pam-rpp-{id}"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="pam-rpp-{id}">
|
||||
{#each [10, 25, 50, 100, 200] as n (n)}
|
||||
<option value={n}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.users_add()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.users_pam_search_placeholder()}
|
||||
value={search}
|
||||
oninput={onSearchInput}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {users.current?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: m.users_col_uid() }, { key: 'shell', label: m.users_create_field_shell() }] as col (col.key)}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(col.key as SortBy)}
|
||||
>
|
||||
{col.label}
|
||||
{#if sortBy === col.key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/each}
|
||||
<Table.Head>{m.users_col_comment()}</Table.Head>
|
||||
<Table.Head>{m.users_col_home()}</Table.Head>
|
||||
<Table.Head>{m.users_col_type()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if users.loading && !users.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !pageRows.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
|
||||
>{m.users_no_results()}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each pageRows as u (u.username)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<a
|
||||
href={resolve('/dashboard/[machineId]/users/[username]', {
|
||||
machineId,
|
||||
username: u.username
|
||||
})}
|
||||
class="hover:underline">{u.username}</a
|
||||
>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{u.uid}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.shell}</Table.Cell>
|
||||
<Table.Cell>{u.comment || '—'}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.home}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if u.system}
|
||||
<Badge variant="secondary">{m.users_type_system()}</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline">{m.users_type_user()}</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
pwUser = u;
|
||||
pwValue = '';
|
||||
pwOpen = true;
|
||||
}}>{m.users_action_set_password()}</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = u;
|
||||
removeHome = false;
|
||||
deleteOpen = true;
|
||||
}}>{m.users_delete()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm"
|
||||
>{m.users_page_of({ page, total: totalPages })}</span
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.users_prev()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
||||
{m.users_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
pwUser = u;
|
||||
pwValue = '';
|
||||
pwOpen = true;
|
||||
}}>{m.users_action_set_password()}</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = u;
|
||||
removeHome = false;
|
||||
deleteOpen = true;
|
||||
}}>{m.users_delete()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<Dialog.Root bind:open={createOpen}>
|
||||
<Dialog.Content>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
setPamUserGroups,
|
||||
setPamUserPassword
|
||||
} from '$lib/remotes/pam-users.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
@@ -58,7 +59,7 @@
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
function toggle(name: string, on: boolean) {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page as pageState } from '$app/state';
|
||||
import DataTable from '$lib/components/dashboard/data-table.svelte';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -16,12 +16,13 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { createPamGroup, deletePamGroup, listPamGroups } from '$lib/remotes/pam-users.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type PamGroup = {
|
||||
gid: number;
|
||||
members: null | string[];
|
||||
@@ -30,12 +31,11 @@
|
||||
};
|
||||
type SortBy = 'gid' | 'members' | 'name';
|
||||
type Dir = 'asc' | 'desc';
|
||||
|
||||
const id = $props.id();
|
||||
const machineId = $derived(pageState.params.machineId!);
|
||||
const groups = $derived(listPamGroups(machineId));
|
||||
|
||||
const pageSize = new PersistedState<number>('pam.groups.pageSize', 25);
|
||||
let page = $state(1);
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let debouncedSearch = $state('');
|
||||
@@ -57,8 +57,7 @@
|
||||
});
|
||||
const activeFilterCount = $derived(filters.showSystem ? 1 : 0);
|
||||
|
||||
function onSearchInput(e: Event) {
|
||||
search = (e.target as HTMLInputElement).value;
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
debouncedSearch = search;
|
||||
@@ -66,6 +65,8 @@
|
||||
}, 200);
|
||||
}
|
||||
|
||||
let page = $state(1);
|
||||
|
||||
function toggleSort(col: SortBy) {
|
||||
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
else {
|
||||
@@ -94,9 +95,6 @@
|
||||
return out;
|
||||
});
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
|
||||
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
|
||||
|
||||
let createOpen = $state(false);
|
||||
let createForm = $state({ gid: '', name: '', system: false });
|
||||
let deleteOpen = $state(false);
|
||||
@@ -104,7 +102,7 @@
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
@@ -137,204 +135,124 @@
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_users_groups()} description={m.seo_desc_users_groups()} />
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.groups_nav_title()}</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">
|
||||
{m.groups_nav_description()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<DataTable
|
||||
title={m.groups_nav_title()}
|
||||
description={m.groups_nav_description()}
|
||||
searchPlaceholder={m.groups_search_placeholder()}
|
||||
emptyMessage={m.groups_no_results()}
|
||||
items={filtered}
|
||||
loading={groups.loading}
|
||||
pageSizeKey="pam.groups.pageSize"
|
||||
defaultPageSize={25}
|
||||
pageSizePresets={[10, 25, 50, 100, 200]}
|
||||
bind:search
|
||||
onsearchinput={onSearchInput}
|
||||
onrefresh={() => groups.refresh()}
|
||||
activeFilterCount={activeFilterCount}
|
||||
bind:page
|
||||
i18n={{
|
||||
pageOf: (p) => m.users_page_of(p),
|
||||
previous: () => m.users_prev(),
|
||||
next: () => m.users_next(),
|
||||
filterTitle: m.users_filter_title(),
|
||||
display: m.users_filter_display(),
|
||||
rowsPerPage: m.users_rows_per_page()
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.groups_add()}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet filterContent()}
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span>{m.groups_filter_show_system()}</span>
|
||||
<small>{m.groups_filter_system_gid_hint()}</small>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={filters.showSystem}
|
||||
onCheckedChange={(v) => {
|
||||
filters.showSystem = !!v;
|
||||
page = 1;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{/snippet}
|
||||
{#snippet columns()}
|
||||
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: m.groups_col_gid() }, { key: 'members', label: m.groups_col_members() }] as col (col.key)}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(col.key as SortBy)}
|
||||
>
|
||||
{col.label}
|
||||
{#if sortBy === col.key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/each}
|
||||
<Table.Head>{m.users_col_type()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(g: PamGroup, i: number)}
|
||||
<Table.Cell class="font-medium">
|
||||
<a
|
||||
href={resolve('/dashboard/[machineId]/users/groups/[group]', {
|
||||
group: g.name,
|
||||
machineId
|
||||
})}
|
||||
class="hover:underline">{g.name}</a
|
||||
>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{g.gid}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{#if g.members?.length}
|
||||
<span title={g.members.join(', ')}>
|
||||
{g.members.slice(0, 3).join(', ')}{g.members.length > 3 ? ` +${g.members.length - 3}` : ''}
|
||||
</span>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if g.system}
|
||||
<Badge variant="secondary">{m.users_type_system()}</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline">{m.users_type_user()}</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" class="relative">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
{#if activeFilterCount > 0}
|
||||
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
|
||||
{/if}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-72 p-0" align="end">
|
||||
<div class="border-b p-2">
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-2">
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<div class="flex flex-col">
|
||||
<span>{m.groups_filter_show_system()}</span>
|
||||
<small>{m.groups_filter_system_gid_hint()}</small>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
checked={filters.showSystem}
|
||||
onCheckedChange={(v) => {
|
||||
filters.showSystem = !!v;
|
||||
page = 1;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t p-2">
|
||||
<Label class="text-xs">{m.users_filter_display()}</Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Button onclick={() => (createOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.groups_add()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Input
|
||||
placeholder={m.groups_search_placeholder()}
|
||||
value={search}
|
||||
oninput={onSearchInput}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filtered.length} / {groups.current?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: m.groups_col_gid() }, { key: 'members', label: m.groups_col_members() }] as col (col.key)}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(col.key as SortBy)}
|
||||
>
|
||||
{col.label}
|
||||
{#if sortBy === col.key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/each}
|
||||
<Table.Head>{m.users_col_type()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if groups.loading && !groups.current}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !pageRows.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"
|
||||
>{m.groups_no_results()}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each pageRows as g (g.name)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<a
|
||||
href={resolve('/dashboard/[machineId]/users/groups/[group]', {
|
||||
group: g.name,
|
||||
machineId
|
||||
})}
|
||||
class="hover:underline">{g.name}</a
|
||||
>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{g.gid}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{#if g.members?.length}
|
||||
<span title={g.members.join(', ')}>
|
||||
{g.members.slice(0, 3).join(', ')}{g.members.length > 3
|
||||
? ` +${g.members.length - 3}`
|
||||
: ''}
|
||||
</span>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if g.system}
|
||||
<Badge variant="secondary">{m.users_type_system()}</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline">{m.users_type_user()}</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = g;
|
||||
deleteOpen = true;
|
||||
}}>{m.users_delete()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm"
|
||||
>{m.users_page_of({ page, total: totalPages })}</span
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.users_prev()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
||||
{m.users_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => {
|
||||
deleting = g;
|
||||
deleteOpen = true;
|
||||
}}>{m.users_delete()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<Dialog.Root bind:open={createOpen}>
|
||||
<Dialog.Content>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
listPamUsers,
|
||||
setPamUserGroups
|
||||
} from '$lib/remotes/pam-users.remote';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
@@ -51,7 +52,7 @@
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
async function addMember(username: string) {
|
||||
|
||||
Reference in New Issue
Block a user