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

This commit is contained in:
2026-06-24 12:34:14 +02:00
parent f8a17fa68a
commit 522ab09bd8
84 changed files with 7718 additions and 487 deletions
+76
View File
@@ -0,0 +1,76 @@
# Working on this repo
A SvelteKit web UI that drives **nadir-agent**, a per-host REST agent. Default to lazy.
## Stack
- SvelteKit 2.27+ with `experimental.remoteFunctions` and Svelte 5 `compilerOptions.experimental.async`. Use `await` directly in templates, `query`/`query.batch`/`command`/`form` from `$app/server`.
- Svelte 5 runes only: `$state`, `$derived`, `$derived.by`, `$effect`, `$props`, `$bindable`. No stores, no `$:`. New `.svelte` files are runes mode (legacy is auto-disabled outside `node_modules`).
- shadcn-svelte for UI (`$lib/components/ui/*`). Bits UI underneath. Lucide icons (`@lucide/svelte/icons/*`).
- Paraglide for i18n. Every user-visible string goes through `m.xxx()` and lives in `messages/en.json` (alphabetical). No raw English in components.
- Drizzle ORM (sqlite, `src/lib/server/db`). Better-auth (`src/lib/auth/server.ts`).
- `openapi-fetch` over the generated `src/lib/server/nadir-agent/schema.d.ts`. Always `import type` from it.
- Toasts via `svelte-sonner` (`toast.success` / `toast.error`).
- Persisted UI state via `PersistedState` from `runed`.
- Drag-and-drop via `swapy` (existing usage in `machines-nav.svelte` is the reference).
## Routing
- Typed routes are mandatory. Always `resolve('/dashboard/[machineId]/foo', { machineId })`. Never `resolve(\`/dashboard/${id}/foo\` as Pathname)`— the cast bypasses the new`RouteId` overload.
- Pre-resolve URLs at the data-definition site when you build a list; store the resolved string. Consumer just uses `<a href={item.url}>`.
- For internal components that receive a runtime-built href (e.g. `Button`, `Breadcrumb`), accept the string as-is. Callers resolve before passing.
## Remote functions (`src/lib/remotes/*.remote.ts`)
- Group per-domain (`machines`, `system`, `storage`, `pam-users`, …). One file per agent area.
- Talk to the agent via `nadirForMachine(machineId)` from `./utils` — it handles auth, 401/404/500 routing, token decryption. Don't reinvent.
- Single-instance per-page reads stay as `query`. Use `query.batch` only for the genuine n+1 (e.g. one row per machine). Don't batch by default — it's machinery, not a free win.
- After a `command` that mutates remote state, call `.refresh()` on every query whose data it just invalidated. Refresh inside the command, not at the call site.
- Validate inputs with `v.object({...})` from `$lib` (valibot). Optional fields are `v.optional(v.string())`, no `?` shortcuts.
## Pages
- New section pattern (`storage`, `system`, `users`) for an agent area:
- `dashboard/[machineId]/<area>/+page.svelte` is the landing card grid.
- Sub-pages do the work. List pages get search, filters in a `Popover`, pagination via `PersistedState`, a refresh icon-button. Mirror `users/groups/+page.svelte` for shape.
- Add a section to `app-sidebar.svelte` (typed `resolve()` calls) and the breadcrumb labels in `breadcrumbs.svelte`. New i18n keys in `en.json`.
- Long strings (mount options, device paths, overlay merged dirs) MUST truncate. Pattern: `class="max-w-[Nrem]"` on the cell, `<span class="block truncate" title={value}>{value}</span>` inside. Native `title` tooltip — no tooltip component for plain truncation.
- Keys for `{#each}` over kernel/system data must allow duplicates (e.g. `binfmt_misc` mounts can repeat). Compose: `(mt.mountpoint + '\0' + mt.device + i)`.
- For queries displayed inside an `{#each}` that may unmount, read `.current` instead of `{#await}``derived_inert` fires when the each block dies while the promise is still in-flight.
## Sidebar machine health
- Status dot reads `machineHealth(id).current` (a `query.batch`). Refresh paths:
- Dashboard polling tick refreshes it for the selected machine.
- Sidebar refresh button refreshes all visible rows AND the selected machine's dashboard queries — both directions stay aligned when the agent flaps.
## Style and conventions
- The `as` cast is a code smell. Reach for typed APIs (`resolve(routeId, params)`, valibot schemas, generic helpers) before reaching for `as`.
- No defensive code at internal boundaries — trust your own functions. Validate only at trust boundaries (HTTP input, user input).
- Comments are rare. Mark deliberate simplifications with `// ponytail: <ceiling>, <upgrade path>`. Never explain what the code does.
- No emojis anywhere unless I ask.
- Prefer editing existing files; resist creating new ones. No `*.md` summaries, no walkthrough docs, no planning files.
- Run `npm run check` after non-trivial edits — 0 errors is the bar before reporting done. Don't ship if it doesn't pass.
## Iconography
- Any visual marker (cursor, check, cross, arrow, status dot, "loading…" decoration) goes through a Lucide icon from `@lucide/svelte/icons/*`. No ASCII or Unicode glyphs as UI (e.g. `▋`, `✓`, `✗`, `→`, `★`, `●`). Plain text inside copy/messages is fine.
- Spinners → `Loader2Icon class="size-N animate-spin"`. Cursors → `square` or `terminal` icon. Status arrows → `arrow-up`/`arrow-down` etc.
## Browser-side gotchas
- `<input pattern>` is now `/v` flag — `-` inside a char class is a range operator. Escape it: `[A-Za-z0-9.\-]+`, not `[A-Za-z0-9.-]+`.
- Modern Svelte/Vite already strips `import type`. If a chunk seems bloated, suspect a heavy runtime dep (e.g. better-auth) before suspecting the schema.
## Things I have already decided
- No mock databases in tests — integration tests hit a real DB.
- Bundled refactors over many small PRs in this area.
- Terse responses, no trailing recap. The diff is the report.
## Things to ask before doing
- Anything destructive across the agent (mass unmount, mass delete, bulk power-off).
- New external dependencies.
- Changes that touch auth, encryption, or token handling.
+2 -2
View File
@@ -28,7 +28,7 @@ on the dashboard, and drive everyday tasks from the browser.
## Getting started
Prerequisites: [Bun](https://bun.com) and a reachable nadir-agent instance with
a machine token (see the agent README's *Connecting a dashboard* section).
a machine token (see the agent README's _Connecting a dashboard_ section).
```sh
bun install
@@ -111,7 +111,7 @@ PORT=3000 ORIGIN=https://nadir.example.com bun run build/index.js
Put it behind the same reverse proxy you use for nadir-agent, or co-host them.
The agent's CSRF rules apply when the UI calls it cross-origin - see the agent
README's *Connecting a dashboard* section.
README's _Connecting a dashboard_ section.
---
+17
View File
@@ -8,12 +8,16 @@
"@better-auth/infra": "^0.2.14",
"@better-svelte-email/components": "^2.1.1",
"@better-svelte-email/server": "^2.1.1",
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"better-auth": "^1.6.20",
"nodemailer": "^9.0.1",
"ogl": "^1.0.11",
"openapi-fetch": "^0.17.0",
"runed": "^0.37.1",
"swapy": "^1.0.5",
"undici": "^8.5.0",
"uqr": "^0.1.3",
"valibot": "^1.4.1",
},
@@ -33,6 +37,7 @@
"@types/bun": "^1.3.14",
"@types/node": "^24",
"@types/nodemailer": "^8.0.1",
"@types/ws": "^8.18.1",
"bits-ui": "^2.16.3",
"clsx": "^2.1.1",
"drizzle-kit": "^1.0.0-beta.22",
@@ -53,6 +58,7 @@
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn-svelte": "^1.3.0",
"svelte": "^5.56.1",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.6.0",
"svelte-sonner": "^1.1.0",
"sveltekit-superforms": "^2.30.0",
@@ -64,6 +70,7 @@
"typescript-eslint": "^8.60.1",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^8.0.16",
"ws": "^8.21.0",
},
},
},
@@ -536,6 +543,12 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"@xterm/addon-attach": ["@xterm/addon-attach@0.12.0", "", {}, "sha512-1lxvXM4JYSm60lbFmE8WMOy2oF2ip3Ye8jWorSAmwy7x8FiC53netEJ5RguL8+FSRj79MUQsNCb2hprY2QA2ig=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -1100,6 +1113,8 @@
"svelte": ["svelte@5.56.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.11", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA=="],
"svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
"svelte-check": ["svelte-check@4.6.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "0.1.1", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ=="],
"svelte-eslint-parser": ["svelte-eslint-parser@1.8.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg=="],
@@ -1160,6 +1175,8 @@
"typescript-eslint": ["typescript-eslint@8.61.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.61.1", "@typescript-eslint/parser": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/utils": "8.61.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw=="],
"undici": ["undici@8.5.0", "", {}, "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
BIN
View File
Binary file not shown.
@@ -0,0 +1 @@
ALTER TABLE `machines` ADD `ca_cert` text;
@@ -0,0 +1,770 @@
{
"version": "7",
"dialect": "sqlite",
"id": "1a550330-2944-441c-aea2-18b98455929c",
"prevIds": [
"b3403d40-d4c3-4f1a-8d1d-763c548f5bc7"
],
"ddl": [
{
"name": "machines",
"entityType": "tables"
},
{
"name": "account",
"entityType": "tables"
},
{
"name": "session",
"entityType": "tables"
},
{
"name": "two_factor",
"entityType": "tables"
},
{
"name": "user",
"entityType": "tables"
},
{
"name": "verification",
"entityType": "tables"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'http://127.0.0.1:9999'",
"generated": null,
"name": "address",
"entityType": "columns",
"table": "machines"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ca_cert",
"entityType": "columns",
"table": "machines"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "machines"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "machines"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "order",
"entityType": "columns",
"table": "machines"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token",
"entityType": "columns",
"table": "machines"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token_expires_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "account_id",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id_token",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "password",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "provider_id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token_expires_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "scope",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "impersonated_by",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ip_address",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_agent",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "backup_codes",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "secret",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "true",
"generated": null,
"name": "verified",
"entityType": "columns",
"table": "two_factor"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ban_expires",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "banned",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ban_reason",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "display_username",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "email_verified",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "role",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "two_factor_enabled",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "user"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "username",
"entityType": "columns",
"table": "user"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "verification"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "verification"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "verification"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "identifier",
"entityType": "columns",
"table": "verification"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "verification"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "verification"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_account_user_id_user_id_fk",
"entityType": "fks",
"table": "account"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_user_id_user_id_fk",
"entityType": "fks",
"table": "session"
},
{
"columns": [
"user_id"
],
"tableTo": "user",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_two_factor_user_id_user_id_fk",
"entityType": "fks",
"table": "two_factor"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "machines_pk",
"table": "machines",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "two_factor_pk",
"table": "two_factor",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "user_pk",
"table": "user",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "verification_pk",
"table": "verification",
"entityType": "pks"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "account_userId_idx",
"entityType": "indexes",
"table": "account"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_userId_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "secret",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "twoFactor_secret_idx",
"entityType": "indexes",
"table": "two_factor"
},
{
"columns": [
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "twoFactor_userId_idx",
"entityType": "indexes",
"table": "two_factor"
},
{
"columns": [
{
"value": "identifier",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "verification_identifier_idx",
"entityType": "indexes",
"table": "verification"
},
{
"columns": [
"order"
],
"nameExplicit": false,
"name": "machines_order_unique",
"entityType": "uniques",
"table": "machines"
},
{
"columns": [
"token"
],
"nameExplicit": false,
"name": "session_token_unique",
"entityType": "uniques",
"table": "session"
},
{
"columns": [
"email"
],
"nameExplicit": false,
"name": "user_email_unique",
"entityType": "uniques",
"table": "user"
},
{
"columns": [
"username"
],
"nameExplicit": false,
"name": "user_username_unique",
"entityType": "uniques",
"table": "user"
}
],
"renames": []
}
+318 -2
View File
@@ -16,6 +16,9 @@
"code": "Code",
"confirm_password": "Confirm password",
"continue_action": "Continue",
"copied": "Copied",
"copy": "Copy",
"copy_failed": "Failed to copy",
"create_account": "Create account",
"dashboard": "Dashboard",
"dashboard_architecture": "Architecture",
@@ -115,6 +118,17 @@
"email_verify_ignore": "If you didn't create an account, you can safely ignore this email.",
"email_verify_subject": "Verify your email address",
"enter_password_to_continue": "Confirm your password to continue",
"error_action_back": "Go back",
"error_action_home": "Go home",
"error_action_retry": "Try again",
"error_code_label": "Error {status}",
"error_details": "Details",
"error_generic_description": "Something went wrong while loading this page.",
"error_generic_title": "Unexpected error",
"error_not_found_description": "The page or resource you are looking for could not be found. It may have been removed, renamed, or never existed.",
"error_not_found_title": "Not found",
"error_unauthorized_description": "You do not have access to this resource. Try signing in again or contact your administrator.",
"error_unauthorized_title": "Access denied",
"errors_address_invalid": "Enter a valid URL, e.g. http://127.0.0.1:9999",
"errors_email_invalid": "Enter a valid email address",
"errors_generic": "An error occurred during this operation, please review Nadir Logs for more information.",
@@ -132,6 +146,7 @@
"forgot_password": "Forgot your password?",
"forgot_password_description": "Enter your email and we'll send you a reset link.",
"forgot_password_title": "Forgot your password?",
"groups": "Groups",
"groups_add": "Add group",
"groups_add_member": "Add member",
"groups_add_member_action": "add",
@@ -139,6 +154,9 @@
"groups_add_member_no_results": "No matching users.",
"groups_add_member_search_placeholder": "Search user…",
"groups_add_member_title": "Add member to {name}",
"groups_col_gid": "GID",
"groups_col_members": "Members",
"groups_gid_optional": "GID (optional)",
"groups_create_description": "Adds a Unix group via groupadd.",
"groups_create_field_system": "System group",
"groups_create_title": "Create group",
@@ -155,6 +173,7 @@
"groups_nav_description": "Unix groups from /etc/group on this machine.",
"groups_nav_title": "Groups",
"groups_no_results": "No groups found.",
"groups_remove_member_aria": "Remove {name}",
"groups_no_supplementary_members": "No supplementary members.",
"groups_not_found": "Group not found: {name}",
"groups_primary_empty": "None.",
@@ -165,6 +184,7 @@
"invalid_reset_link": "This link is invalid or has expired.",
"language": "Language",
"login": "Login",
"more": "More",
"login_social_description": "You have to login to use this platform. Use your favorite social or your credentials",
"login_with": "Login with <span class=capitalize>{social}</span>",
"logout": "Logout",
@@ -180,6 +200,7 @@
"machine_name": "Name",
"machine_name_placeholder": "Production server",
"machine_none": "No servers yet.",
"machine_offline": "Offline",
"machine_offline_code": "Error 502",
"machine_offline_description": "Could not reach the nadir-agent at {address}. The host may be down, the agent stopped, or the address is wrong.",
"machine_offline_details": "Show error details",
@@ -189,6 +210,8 @@
"machine_offline_status_connected": "CONNECTED",
"machine_offline_status_unreachable": "UNREACHABLE",
"machine_offline_title": "{name} is offline",
"machine_online": "Online",
"machine_refresh_health": "Refresh status",
"machine_save": "Add server",
"machine_save_edit": "Save changes",
"machine_search_placeholder": "Search servers…",
@@ -205,6 +228,29 @@
"nav_admin_users_desc": "Manage user accounts, roles and access.",
"nav_dashboard_overview": "Overview",
"nav_dashboard_overview_desc": "System status at a glance.",
"nav_networking": "Networking",
"nav_networking_desc": "Interfaces, routes, hosts, DNS.",
"nav_networking_dns": "DNS",
"nav_networking_dns_desc": "Nameservers from /etc/resolv.conf.",
"nav_networking_hosts": "Hosts",
"nav_networking_hosts_desc": "Static host entries in /etc/hosts.",
"nav_networking_interfaces": "Interfaces",
"nav_networking_interfaces_desc": "Network interfaces and their addresses.",
"nav_networking_routes": "Routes",
"nav_networking_routes_desc": "Kernel routing table.",
"nav_packages": "Packages",
"nav_packages_desc": "Manage installed packages and system updates.",
"nav_packages_installed": "Installed packages",
"nav_packages_installed_desc": "List, search, and remove installed software packages.",
"nav_packages_updates": "Available updates",
"nav_packages_updates_desc": "Check and install updates for existing packages.",
"nav_services": "Services",
"nav_services_desc": "Manage systemd service units.",
"nav_storage": "Storage",
"nav_storage_fstab": "Fstab",
"nav_storage_fstab_desc": "Persistent mount definitions in /etc/fstab.",
"nav_storage_mounts": "Mounts",
"nav_storage_mounts_desc": "Active filesystem mounts on this machine.",
"nav_system": "System",
"nav_system_datetime": "Date & Time",
"nav_system_datetime_desc": "Clock, timezone and time synchronisation.",
@@ -212,20 +258,129 @@
"nav_system_hostname_desc": "Identify this machine on the network.",
"nav_system_localization": "Localization",
"nav_system_localization_desc": "Language, locale and region settings.",
"nav_system_nadir": "Nadir Agent",
"nav_system_nadir_desc": "Active modules and user permissions.",
"nav_system_power": "Power",
"nav_system_power_desc": "Reboot or power off the machine.",
"nav_system_terminal": "Terminal",
"nav_system_terminal_desc": "Open an interactive terminal session on this host.",
"nav_users_groups": "Groups",
"nav_users_groups_desc": "Unix groups from /etc/group.",
"nav_users_system_users": "System users",
"nav_users_system_users_desc": "PAM/Unix accounts on this machine.",
"networking_apply": "Apply",
"networking_apply_confirm_body": "The agent will activate this configuration and auto-revert after {seconds}s unless you confirm. Make sure you can still reach the host after applying.",
"networking_apply_confirm_title": "Apply with rollback?",
"networking_apply_done": "Configuration applied — waiting for confirmation.",
"networking_col_destination": "Destination",
"networking_col_gateway": "Gateway",
"networking_col_hostnames": "Hostnames",
"networking_col_interface": "Interface",
"networking_col_ip": "IP",
"networking_col_ipv4": "IPv4",
"networking_col_ipv6": "IPv6",
"networking_col_mac": "MAC",
"networking_col_metric": "Metric",
"networking_col_mtu": "MTU",
"networking_col_name": "Name",
"networking_col_source": "Source",
"networking_col_state": "State",
"networking_configure": "Configure",
"networking_configure_description": "Apply addressing, gateway, DNS and static routes. Changes auto-rollback after the configured timeout unless confirmed.",
"networking_confirm": "Confirm",
"networking_confirmed": "Change confirmed.",
"networking_details": "Details",
"networking_dns_add": "Add nameserver",
"networking_dns_description": "Read-only view of the system resolver. Configure per interface.",
"networking_dns_per_interface_hint": "DNS is set per interface; there is no standalone write endpoint.",
"networking_dns_placeholder": "1.1.1.1",
"networking_dns_section": "DNS",
"networking_dns_section_hint": "Resolvers for this interface (IPv4 or IPv6).",
"networking_dns_servers": "Nameservers",
"networking_host_add": "Add host",
"networking_host_add_description": "Map an IP to one or more hostnames. Multiple names separated by spaces.",
"networking_host_edit": "Edit host",
"networking_host_remove": "Remove host",
"networking_host_remove_description": "This will delete the entry from /etc/hosts.",
"networking_host_remove_title": "Remove {ip}?",
"networking_host_removed": "Host entry removed.",
"networking_host_saved": "Host entry saved.",
"networking_hosts_description": "Static name-to-address mappings in /etc/hosts.",
"networking_hosts_search_placeholder": "Search by IP or hostname...",
"networking_interfaces_description": "Network interfaces and their current addresses.",
"networking_interfaces_search_placeholder": "Search by name, MAC, address...",
"networking_ipv4_section": "IPv4",
"networking_ipv4_section_hint": "Static address or DHCP.",
"networking_ipv6_section": "IPv6",
"networking_ipv6_section_hint": "Auto (SLAAC), static, or ignore to disable.",
"networking_link_down": "Bring down",
"networking_link_down_done": "Interface {name} brought down.",
"networking_link_up": "Bring up",
"networking_link_up_done": "Interface {name} brought up.",
"networking_method_dhcp": "DHCP",
"networking_method_ignore": "Ignore (disable)",
"networking_method_slaac": "Auto (SLAAC)",
"networking_method_static": "Static",
"networking_no_dns": "No nameservers configured.",
"networking_no_hosts": "No host entries.",
"networking_no_interfaces": "No interfaces.",
"networking_no_routes": "No routes.",
"networking_pending_banner": "Pending change on {iface} — auto-rollback in {seconds}s.",
"networking_rollback": "Rollback",
"networking_rollback_seconds": "Rollback timeout (seconds)",
"networking_rollback_seconds_hint": "Auto-revert after this many seconds unless confirmed. Leave 0 for the agent default (60s).",
"networking_rolled_back": "Change rolled back.",
"networking_route_add": "Add route",
"networking_route_destination": "Destination (CIDR or 'default')",
"networking_route_gateway": "Next-hop gateway",
"networking_routes_description": "Kernel routing table.",
"networking_routes_search_placeholder": "Search by destination, gateway, interface...",
"networking_routes_section": "Static routes",
"networking_routes_section_hint": "Optional routes installed alongside this interface.",
"new_password": "New password",
"no_account": "No account yet?",
"optional": "Optional",
"or": "Or",
"packages_col_name": "Package",
"packages_col_version": "Version",
"packages_install": "Install Package",
"packages_install_button": "Install",
"packages_install_desc": "Enter the name of the package you want to install from the repositories.",
"packages_install_failed": "Installation of package {name} failed.",
"packages_install_started": "Installing package {name}...",
"packages_install_success": "Package {name} installed successfully.",
"packages_installed": "Installed",
"packages_manager_label": "Package Manager",
"packages_no_packages": "No packages found.",
"packages_no_updates": "No updates available.",
"packages_remove_confirm_desc": "This will uninstall {name} from the host. Any dependent packages might also be affected.",
"packages_remove_confirm_title": "Remove {name}?",
"packages_remove_failed": "Removal of package {name} failed.",
"packages_remove_started": "Removing package {name}...",
"packages_remove_success": "Package {name} removed successfully.",
"packages_search_placeholder": "Search packages by name...",
"packages_stream_connection_error": "Connection Error: {message}",
"packages_stream_error": "ERROR: {message}",
"packages_stream_failed": "Operation failed.",
"packages_terminal_desc": "Streaming output from the package manager.",
"packages_terminal_output": "Terminal Output",
"packages_title": "Packages",
"packages_update_available": "Update available {from} > {to}",
"packages_update_single": "Update",
"packages_update_started": "Upgrading package {name}...",
"packages_update_success": "Package {name} upgraded successfully.",
"packages_updates": "Updates",
"packages_upgrade_all": "Upgrade All",
"packages_upgrade_all_desc": "Upgrade all packages to their latest versions.",
"packages_upgrade_failed": "Upgrade failed.",
"packages_upgrade_started": "Starting upgrade of all packages...",
"packages_upgrade_success": "Upgrade completed successfully.",
"pagination_next": "Next",
"pagination_page_of": "Page {page} of {pages}",
"pagination_previous": "Previous",
"password": "Password",
"password_hint": "At least 8 characters, mixing upper- and lower-case letters and a number.",
"prefix": "Prefix",
"privacy_policy": "Privacy Policy",
"remember_me": "Remember me",
"reset_link_sent": "If an account exists for that email, a reset link is on its way.",
@@ -236,12 +391,157 @@
"saved": "Saved",
"scan_qr": "Add this key to your authenticator app, then enter the generated code below.",
"send_reset_link": "Send reset link",
"seo_desc_admin_config": "Application-wide configuration settings.",
"seo_desc_admin_users": "Manage user accounts, roles and access.",
"seo_desc_auth_2fa": "Verify your identity with a two-factor authentication code.",
"seo_desc_auth_forgot_password": "Reset your password via email.",
"seo_desc_auth_reset_password": "Choose a new password for your account.",
"seo_desc_auth_setup_2fa": "Add an extra layer of security with two-factor authentication.",
"seo_desc_auth_sign_in": "Sign in to manage your servers.",
"seo_desc_auth_sign_up": "Create an account to get started.",
"seo_desc_dashboard": "Server overview and health at a glance.",
"seo_desc_machine_detail": "System status, performance metrics and key information for this machine.",
"seo_desc_networking": "Network interfaces, routes, hosts and DNS configuration.",
"seo_desc_networking_dns": "Nameserver configuration from /etc/resolv.conf.",
"seo_desc_networking_hosts": "Static host entries in /etc/hosts.",
"seo_desc_networking_interfaces": "Network interfaces and their addresses.",
"seo_desc_networking_routes": "Kernel routing table.",
"seo_desc_packages": "Manage installed packages and system updates.",
"seo_desc_packages_installed": "List, search, and remove installed software packages.",
"seo_desc_packages_updates": "Check and install available package updates.",
"seo_desc_root": "Web-based server management dashboard.",
"seo_desc_services": "List, filter, and manage systemd service units.",
"seo_desc_services_detail": "Manage and monitor a systemd service unit.",
"seo_desc_storage": "Filesystem mounts and fstab configuration.",
"seo_desc_storage_fstab": "Persistent mount definitions in /etc/fstab.",
"seo_desc_storage_mounts": "Active filesystem mounts on this machine.",
"seo_desc_system": "Date and time, hostname, localization and power controls.",
"seo_desc_system_datetime": "Clock, timezone and time synchronisation settings.",
"seo_desc_system_hostname": "View and change the machine hostname.",
"seo_desc_system_localization": "Language, locale and region settings.",
"seo_desc_system_nadir": "View active modules and permission matrix for the connected Nadir agent.",
"seo_desc_system_power": "Reboot or power off the machine.",
"seo_desc_users": "PAM/Unix user accounts on this machine.",
"seo_desc_users_detail": "View and manage a PAM/Unix user account.",
"seo_desc_users_groups": "Unix groups from /etc/group on this machine.",
"seo_desc_users_groups_detail": "View and manage a Unix group.",
"seo_title_admin_config": "Config",
"seo_title_admin_users": "Users",
"seo_title_auth_2fa": "Two-factor authentication",
"seo_title_auth_forgot_password": "Forgot password",
"seo_title_auth_reset_password": "Reset password",
"seo_title_auth_setup_2fa": "Set up two-factor authentication",
"seo_title_auth_sign_in": "Sign in",
"seo_title_auth_sign_up": "Sign up",
"seo_title_dashboard": "Dashboard",
"seo_title_machine_detail": "Machine overview",
"seo_title_networking": "Networking",
"seo_title_networking_dns": "DNS",
"seo_title_networking_hosts": "Hosts",
"seo_title_networking_interfaces": "Interfaces",
"seo_title_networking_routes": "Routes",
"seo_title_packages": "Packages",
"seo_title_packages_installed": "Installed packages",
"seo_title_packages_updates": "Available updates",
"seo_title_root": "Home",
"seo_title_services": "Services",
"seo_title_services_detail": "Service detail",
"seo_title_storage": "Storage",
"seo_title_storage_fstab": "Fstab",
"seo_title_storage_mounts": "Mounts",
"seo_title_system": "System",
"seo_title_system_datetime": "Date and time",
"seo_title_system_hostname": "Hostname",
"seo_title_system_localization": "Localization",
"seo_title_system_nadir": "Nadir Agent",
"seo_title_system_power": "Power",
"seo_title_users": "System users",
"seo_title_users_detail": "User detail",
"seo_title_users_groups": "Groups",
"seo_title_users_groups_detail": "Group detail",
"services_action_disable": "Disable",
"services_action_disabled": "Service {name} disabled successfully.",
"services_action_enable": "Enable",
"services_action_enabled": "Service {name} enabled successfully.",
"services_action_restart": "Restart",
"services_action_restarted": "Service {name} restarted successfully.",
"services_action_start": "Start",
"services_action_started": "Service {name} started successfully.",
"services_action_stop": "Stop",
"services_action_stopped": "Service {name} stopped successfully.",
"services_active_filter": "Active State",
"services_col_active": "Active State",
"services_col_description": "Description",
"services_col_load": "Load State",
"services_col_sub": "Sub State",
"services_col_unit": "Unit",
"services_description": "List, filter, and manage systemd service units.",
"services_details_active_state": "Active State",
"services_details_load_state": "Load State",
"services_details_sub_state": "Sub State",
"services_details_title": "Service Details",
"services_details_unit_file_state": "Startup Type",
"services_filter_active": "Active",
"services_filter_dead": "Dead",
"services_filter_error": "Error",
"services_filter_exited": "Exited",
"services_filter_failed": "Failed",
"services_filter_inactive": "Inactive",
"services_filter_loaded": "Loaded",
"services_filter_masked": "Masked",
"services_filter_not_found": "Not Found",
"services_filter_other": "Other",
"services_filter_running": "Running",
"services_filter_title": "Filter Services",
"services_load_filter": "Load State",
"services_logs_autoscroll": "Autoscroll",
"services_logs_clear": "Clear",
"services_logs_empty": "No log entries.",
"services_logs_lines": "Lines",
"services_logs_live": "Live",
"services_logs_live_paused": "Paused",
"services_logs_live_streaming": "Streaming",
"services_logs_priority": "Max priority",
"services_logs_search_placeholder": "Filter log lines...",
"services_logs_since": "Since",
"services_logs_since_15m": "Last 15 minutes",
"services_logs_since_1h": "Last hour",
"services_logs_since_6h": "Last 6 hours",
"services_logs_since_all": "All",
"services_logs_since_today": "Today",
"services_logs_since_yesterday": "Since yesterday",
"services_logs_title": "Logs",
"services_no_services": "No services found.",
"services_search_placeholder": "Search service units...",
"services_sub_filter": "Sub State",
"services_title": "Services",
"settings": "Settings",
"setup_2fa_description": "Add an extra layer of security to your account.",
"setup_2fa_title": "Set up two-factor authentication",
"sign_up": "Sign up",
"sign_up_description": "Sign up with your email and a username.",
"sign_up_title": "Create your account",
"storage_badge_fstab": "fstab",
"storage_col_device": "Device",
"storage_col_dump": "Dump",
"storage_col_fstype": "Type",
"storage_col_mountpoint": "Mount point",
"storage_col_options": "Options",
"storage_col_pass": "Pass",
"storage_filter_show_pseudo": "Show pseudo filesystems",
"storage_filter_show_pseudo_hint": "proc, sysfs, tmpfs, cgroup, …",
"storage_fstab_description": "Persistent mount definitions from /etc/fstab.",
"storage_mount_add": "Add mount",
"storage_mount_add_description": "Appends an /etc/fstab entry and mounts it. If the mount fails the entry is rolled back.",
"storage_mount_added": "Filesystem mounted",
"storage_mount_remove": "Unmount",
"storage_mount_remove_description": "Unmounts the filesystem and removes its /etc/fstab entry. This cannot be undone.",
"storage_mount_remove_title": "Unmount {mountpoint}?",
"storage_mount_removed": "Filesystem unmounted",
"storage_mounts_description": "Active mounts from the kernel mount table.",
"storage_mounts_search_placeholder": "Search by device, mount point or type…",
"storage_no_fstab": "No fstab entries.",
"storage_no_mounts": "No mounts found.",
"system_hostname_current": "Current hostname",
"system_hostname_invalid": "Hostname is invalid",
"system_locale_generate": "Generate new locale",
@@ -277,6 +577,14 @@
"system_time_ntp_synced": "Synchronized",
"system_time_search_timezone_placeholder": "Search timezone…",
"system_time_timezone": "Timezone",
"syslog_alert": "1 alert",
"syslog_crit": "2 crit",
"syslog_debug": "7 debug",
"syslog_emerg": "0 emerg",
"syslog_err": "3 err",
"syslog_info": "6 info",
"syslog_notice": "5 notice",
"syslog_warning": "4 warning",
"terms_notice": "By clicking continue, you agree to our <a class='link' href={terms}>Terms of Service</a> and <a class='link' href={privacy}>Privacy Policy</a>.",
"theme": "Theme",
"theme_dark": "Dark",
@@ -300,6 +608,7 @@
"users_col_comment": "Comment",
"users_col_home": "Home",
"users_col_type": "Type",
"users_col_uid": "UID",
"users_create": "Create",
"users_create_description": "Add a new user to the system.",
"users_create_field_comment": "Comment (GECOS)",
@@ -347,7 +656,6 @@
"users_group_primary_badge": "(primary)",
"users_group_sys_badge": "sys",
"users_groups_title": "Groups",
"users_pam_groups_description": "Supplementary groups. Replaces the full set via <code>usermod -G</code>. Primary group is set at user creation and not editable here.",
"users_groups_updated": "Groups updated",
"users_invite": "Invite",
"users_invite_description": "Send an email invitation. The user sets their own password.",
@@ -361,12 +669,14 @@
"users_no_results": "No users found.",
"users_page_of": "Page {page} of {total}",
"users_pam_create_description": "Adds a PAM account via useradd. Password stays locked until you set one.",
"users_pam_groups_description": "Supplementary groups. Replaces the full set via <code>usermod -G</code>. Primary group is set at user creation and not editable here.",
"users_pam_search_placeholder": "Search username, GECOS, uid…",
"users_pending": "Pending",
"users_pending_expires": "Invite expires {date}",
"users_pending_no_invite": "Email not verified",
"users_prev": "Previous",
"users_primary_gid": "Primary GID",
"users_primary_group": "Primary group",
"users_resend_invite": "Resend invite",
"users_role": "Role",
"users_role_admin": "Admin",
@@ -384,5 +694,11 @@
"verification_sent": "We sent a verification link to {email}. Click it to activate your account.",
"verify": "Verify",
"verify_your_email": "You need to first verify your email address",
"welcome_back": "Welcome back"
"welcome_back": "Welcome back",
"system_nadir_username": "Authenticated User",
"system_nadir_permissions": "Resolved Permissions",
"system_nadir_modules": "Registered Modules",
"system_nadir_module_id": "Module ID",
"system_nadir_module_name": "Display Name",
"system_nadir_no_permissions": "No permissions defined"
}
+9 -1
View File
@@ -4,6 +4,7 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev:types": "bun run type:generate && bun --bun vite dev --host",
"dev": "bun --bun vite dev --host",
"build": "vite build",
"preview": "vite preview",
@@ -34,6 +35,7 @@
"@types/bun": "^1.3.14",
"@types/node": "^24",
"@types/nodemailer": "^8.0.1",
"@types/ws": "^8.18.1",
"bits-ui": "^2.16.3",
"clsx": "^2.1.1",
"drizzle-kit": "^1.0.0-beta.22",
@@ -54,6 +56,7 @@
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn-svelte": "^1.3.0",
"svelte": "^5.56.1",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.6.0",
"svelte-sonner": "^1.1.0",
"sveltekit-superforms": "^2.30.0",
@@ -64,18 +67,23 @@
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^8.0.16"
"vite": "^8.0.16",
"ws": "^8.21.0"
},
"dependencies": {
"@better-auth/infra": "^0.2.14",
"@better-svelte-email/components": "^2.1.1",
"@better-svelte-email/server": "^2.1.1",
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"better-auth": "^1.6.20",
"nodemailer": "^9.0.1",
"ogl": "^1.0.11",
"openapi-fetch": "^0.17.0",
"runed": "^0.37.1",
"swapy": "^1.0.5",
"undici": "^8.5.0",
"uqr": "^0.1.3",
"valibot": "^1.4.1"
}
+112
View File
@@ -0,0 +1,112 @@
// scripts/replace-lucide-imports.ts
const lucidePath = '@lucide/svelte';
function parseSpec(spec: string) {
const parts = spec.split(/\s+as\s+/).map((s) => s.trim());
const name = parts[0];
const alias = parts[1] ?? name;
return { alias, name };
}
async function processFile(path: string) {
const file = Bun.file(path);
const text = await file.text();
const regex = /import\s*\{[^}]+\}\s*from\s*["']@lucide\/svelte["'];?/g;
const matches = [...text.matchAll(regex)];
if (matches.length === 0) return;
let updatedText = text;
for (const match of matches) {
const block = match[0];
const inside = block.match(/\{([^}]+)\}/);
if (!inside) continue;
const items = inside[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const resolved: string[] = [];
for (const item of items) {
const { name } = parseSpec(item);
const path = await resolveIconPath(name);
resolved.push(path);
}
const replacement = transformImportBlock(block, resolved);
updatedText = updatedText.replace(block, replacement);
}
if (updatedText !== text) {
console.log(`Updated ${path}`);
await Bun.write(path, updatedText);
}
}
/**
* Safe import builder with fallback prompt
*/
async function resolveIconPath(name: string): Promise<string> {
let cleanName = name;
if (cleanName.endsWith('Icon')) {
cleanName = cleanName.slice(0, -4);
}
const kebab = toKebab(cleanName);
const candidate = `${lucidePath}/icons/${kebab}`;
// Bun runtime check: verify physical file exists in dist/icons
const file = Bun.file(`node_modules/${lucidePath}/dist/icons/${kebab}.js`);
if (await file.exists()) return candidate;
// fallback interactive prompt
const input = await prompt(
`Icon not found: "${name}" → "${kebab}". Enter correct kebab name (or press enter to skip): `
);
if (!input) return candidate; // fallback anyway
return `${lucidePath}/icons/${input}`;
}
function toKebab(input: string): string {
return input
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
.replace(/([a-zA-Z])([0-9])/g, '$1-$2') // handle digit transitions like Trash2 -> trash-2
.toLowerCase();
}
function transformImportBlock(block: string, resolved: string[]): string {
const inside = block.match(/\{([^}]+)\}/);
if (!inside) return block;
const items = inside[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const imports: string[] = [];
for (const item of items) {
const { alias } = parseSpec(item);
const iconPath = resolved.shift();
imports.push(`import ${alias} from "${iconPath}";`);
}
return imports.join('\n');
}
const glob = new Bun.Glob('src/**/*.{ts,tsx,js,jsx,svelte}');
for await (const file of glob.scan('.')) {
await processFile(file);
}
@@ -17,7 +17,6 @@
const labelOf = (l: string) => names.of(l) ?? l;
const pickLocale = (l: ReturnType<typeof getLocale>) => setLocale(l);
</script>
{#snippet localePicker()}
@@ -55,13 +54,16 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => setMode('light')}>
<SunIcon class="size-4" /> {m.theme_light()}
<SunIcon class="size-4" />
{m.theme_light()}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setMode('dark')}>
<MoonIcon class="size-4" /> {m.theme_dark()}
<MoonIcon class="size-4" />
{m.theme_dark()}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setMode('system')}>
<MonitorIcon class="size-4" /> {m.theme_system()}
<MonitorIcon class="size-4" />
{m.theme_system()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -1,7 +1,4 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Breadcrumb from '$lib/components/ui/breadcrumb';
import { m } from '$lib/paraglide/messages';
@@ -13,12 +10,22 @@
'/': m.home,
admin: m.nav_admin,
config: m.nav_admin_config,
configure: m.networking_configure,
dashboard: m.dashboard,
'date-time': m.nav_system_datetime,
groups: () => 'Groups',
dns: m.nav_networking_dns,
fstab: m.nav_storage_fstab,
groups: m.groups,
hostname: m.nav_system_hostname,
hosts: m.nav_networking_hosts,
interfaces: m.nav_networking_interfaces,
localization: m.nav_system_localization,
mounts: m.nav_storage_mounts,
networking: m.nav_networking,
power: m.nav_system_power,
routes: m.nav_networking_routes,
services: m.nav_services,
storage: m.nav_storage,
system: m.nav_system,
users: m.nav_admin_users
};
@@ -52,7 +59,7 @@
{#if last}
<Breadcrumb.Page>{crumb.label}</Breadcrumb.Page>
{:else}
<Breadcrumb.Link href={resolve(crumb.href as Pathname)}>{crumb.label}</Breadcrumb.Link>
<Breadcrumb.Link href={crumb.href}>{crumb.label}</Breadcrumb.Link>
{/if}
</Breadcrumb.Item>
{/each}
@@ -1,21 +1,30 @@
<script lang="ts" module>
import CpuIcon from '@lucide/svelte/icons/cpu';
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
import ServerIcon from '@lucide/svelte/icons/server';
import MonitorCogIcon from '@lucide/svelte/icons/monitor-cog';
import NetworkIcon from '@lucide/svelte/icons/network';
import PackageIcon from '@lucide/svelte/icons/package';
import ShieldIcon from '@lucide/svelte/icons/shield';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import UsersIcon from '@lucide/svelte/icons/users';
import favicon from '$lib/assets/favicon.svg?raw';
import { m } from '$lib/paraglide/messages';
type NavSection = {
icon: typeof ShieldIcon;
items: { description: () => string; title: () => string; url: Pathname }[];
id?: string;
items: { description: () => string; title: () => string; url: ResolvedPathname }[];
// Dashboard section embeds the <MachinesNav>; flagged here so the snippet doesn't have
// to recompare resolved URLs.
showsMachines?: true;
title: () => string;
url: Pathname;
url: ResolvedPathname;
};
</script>
<script lang="ts">
import type { Pathname } from '$app/types';
import type { ResolvedPathname } from '$app/types';
import type { ComponentProps } from 'svelte';
import { resolve } from '$app/paths';
@@ -24,6 +33,8 @@
import * as Sidebar from '$lib/components/ui/sidebar';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { getModules, getWhoami } from '$lib/remotes/system.remote';
import { getContext } from 'svelte';
import MachinesNav from './machines-nav.svelte';
import NavUser from './nav-user.svelte';
@@ -32,8 +43,31 @@
const sidebar = useSidebar();
let mobileSection = $state<NavSection | null>(null);
const machineId = $derived(page.params.machineId);
const modulesResource = $derived(machineId ? getModules(machineId) : null);
const whoamiResource = $derived(machineId ? getWhoami(machineId) : null);
const availableModuleIds = $derived(
modulesResource?.current?.modules?.map((mod) => mod.id) ?? null
);
const whoamiPermissions = $derived(whoamiResource?.current?.permissions ?? null);
const isModuleAvailable = (id: string = '') => {
if (!id) return true;
// If either list is still loading/null, show by default on initial render to prevent flashing
if (availableModuleIds === null || whoamiPermissions === null) return true;
// 1. Must be a registered module on the agent
if (!availableModuleIds.includes(id)) return false;
// 2. Must have authorization: either wildcard '*' (full admin) or explicit module key exists
const hasGlobalWildcard = !!whoamiPermissions['*'];
const hasModuleAccess = !!whoamiPermissions[id];
return hasGlobalWildcard || hasModuleAccess;
};
const navMain = $derived.by<NavSection[]>(() => {
const machineId = page.params.machineId;
const mId = page.params.machineId;
const sections: NavSection[] = [
{
icon: LayoutDashboardIcon,
@@ -41,78 +75,167 @@
{
description: m.nav_dashboard_overview_desc,
title: m.nav_dashboard_overview,
url: '/dashboard'
url: resolve('/dashboard')
}
],
showsMachines: true,
title: m.dashboard,
url: machineId ? `/dashboard/${machineId}`:'/dashboard'
},
{
url: mId ? resolve('/dashboard/[machineId]', { machineId: mId }) : resolve('/dashboard')
}
];
if (mId) {
sections.push(
{
icon: PackageIcon,
id: 'packages',
items: [
{
description: m.nav_packages_installed_desc,
title: m.nav_packages_installed,
url: resolve('/dashboard/[machineId]/packages/installed', { machineId: mId })
},
{
description: m.nav_packages_updates_desc,
title: m.nav_packages_updates,
url: resolve('/dashboard/[machineId]/packages/updates', { machineId: mId })
}
],
title: m.nav_packages,
url: resolve('/dashboard/[machineId]/packages', { machineId: mId })
},
{
icon: CpuIcon,
id: 'services',
items: [
{
description: m.nav_services_desc,
title: m.nav_services,
url: resolve('/dashboard/[machineId]/services', { machineId: mId })
}
],
title: m.nav_services,
url: resolve('/dashboard/[machineId]/services', { machineId: mId })
},
{
icon: HardDriveIcon,
id: 'storage',
items: [
{
description: m.nav_storage_mounts_desc,
title: m.nav_storage_mounts,
url: resolve('/dashboard/[machineId]/storage/mounts', { machineId: mId })
},
{
description: m.nav_storage_fstab_desc,
title: m.nav_storage_fstab,
url: resolve('/dashboard/[machineId]/storage/fstab', { machineId: mId })
}
],
title: m.nav_storage,
url: resolve('/dashboard/[machineId]/storage', { machineId: mId })
},
{
icon: NetworkIcon,
id: 'networking',
items: [
{
description: m.nav_networking_interfaces_desc,
title: m.nav_networking_interfaces,
url: resolve('/dashboard/[machineId]/networking/interfaces', { machineId: mId })
},
{
description: m.nav_networking_routes_desc,
title: m.nav_networking_routes,
url: resolve('/dashboard/[machineId]/networking/routes', { machineId: mId })
},
{
description: m.nav_networking_hosts_desc,
title: m.nav_networking_hosts,
url: resolve('/dashboard/[machineId]/networking/hosts', { machineId: mId })
},
{
description: m.nav_networking_dns_desc,
title: m.nav_networking_dns,
url: resolve('/dashboard/[machineId]/networking/dns', { machineId: mId })
}
],
title: m.nav_networking,
url: resolve('/dashboard/[machineId]/networking', { machineId: mId })
},
{
icon: MonitorCogIcon,
id: 'system',
items: [
{
description: m.nav_system_nadir_desc,
title: m.nav_system_nadir,
url: resolve('/dashboard/[machineId]/system/nadir', { machineId: mId })
},
{
description: m.nav_system_datetime_desc,
title: m.nav_system_datetime,
url: resolve('/dashboard/[machineId]/system/date-time', { machineId: mId })
},
{
description: m.nav_system_localization_desc,
title: m.nav_system_localization,
url: resolve('/dashboard/[machineId]/system/localization', { machineId: mId })
},
{
description: m.nav_system_hostname_desc,
title: m.nav_system_hostname,
url: resolve('/dashboard/[machineId]/system/hostname', { machineId: mId })
},
{
description: m.nav_system_power_desc,
title: m.nav_system_power,
url: resolve('/dashboard/[machineId]/system/power', { machineId: mId })
}
],
title: m.nav_system,
url: resolve('/dashboard/[machineId]/system', { machineId: mId })
},
{
icon: UsersIcon,
id: 'users',
items: [
{
description: m.nav_users_system_users_desc,
title: m.nav_users_system_users,
url: resolve('/dashboard/[machineId]/users', { machineId: mId })
},
{
description: m.nav_users_groups_desc,
title: m.nav_users_groups,
url: resolve('/dashboard/[machineId]/users/groups', { machineId: mId })
}
],
title: m.users_title,
url: resolve('/dashboard/[machineId]/users', { machineId: mId })
}
);
}
if (user.role === 'admin') {
sections.push({
icon: ShieldIcon,
items: [
{
description: m.nav_admin_users_desc,
title: m.nav_admin_users,
url: '/admin/users'
url: resolve('/admin/users')
},
{
description: m.nav_admin_config_desc,
title: m.nav_admin_config,
url: '/admin/config'
url: resolve('/admin/config')
}
],
title: m.nav_admin,
url: '/admin'
}
];
if (machineId) {
const base = `/dashboard/${machineId}/system`;
sections.splice(1,0,{
icon: ServerIcon,
items: [
{
description: m.nav_system_datetime_desc,
title: m.nav_system_datetime,
url: `${base}/date-time` as Pathname
},
{
description: m.nav_system_localization_desc,
title: m.nav_system_localization,
url: `${base}/localization` as Pathname
},
{
description: m.nav_system_hostname_desc,
title: m.nav_system_hostname,
url: `${base}/hostname` as Pathname
},
{
description: m.nav_system_power_desc,
title: m.nav_system_power,
url: `${base}/power` as Pathname
}
],
title: m.nav_system,
url: base as Pathname
});
sections.splice(2, 0, {
icon: UsersIcon,
items: [
{
description: m.nav_users_system_users_desc,
title: m.nav_users_system_users,
url: `/dashboard/${machineId}/users` as Pathname
},
{
description: m.nav_users_groups_desc,
title: m.nav_users_groups,
url: `/dashboard/${machineId}/users/groups` as Pathname
}
],
title: m.users_title,
url: `/dashboard/${machineId}/users` as Pathname
url: resolve('/admin')
});
}
return sections;
return sections.filter((section) => isModuleAvailable(section.id));
});
// Navigating from any link in either drawer should drop the user on the page,
@@ -127,31 +250,36 @@
...restProps
}: { user: User } & ComponentProps<typeof Sidebar.Root> = $props();
const terminalState = getContext<{ open: boolean }>('terminalState');
const canShowTerminal = $derived(
user.role === 'admin' && whoamiResource?.current?.permissions?.['terminal']?.includes('root')
);
const activeItem = $derived(
[...navMain].sort((a, b) => b.url.length - a.url.length).find((section) =>
page.url.pathname.startsWith(section.url)
) ?? navMain[0]!
[...navMain]
.sort((a, b) => b.url.length - a.url.length)
.find((section) => page.url.pathname.startsWith(section.url)) ?? navMain[0]!
);
</script>
{#snippet sectionContent(section: NavSection)}
{#each section.items as item (item.url)}
<a
href={resolve(item.url)}
data-active={page.url.pathname === item.url}
onclick={closeMobileSidebars}
class="transition-all group/link rounded-l-2xl hover:bg-tertiary hover:text-tertiary-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground flex flex-col items-start gap-1 p-4 text-sm leading-tight"
{#each section.items as item (item.url)}
<a
href={item.url}
data-active={page.url.pathname === item.url}
onclick={closeMobileSidebars}
class="transition-all group/link rounded-l-2xl hover:bg-tertiary hover:text-tertiary-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground flex flex-col items-start gap-1 p-4 text-sm leading-tight"
>
<span class="transition-all font-medium">{item.title()}</span>
<span
class="text-foreground transition-all group-data-[active=true]/link:text-tertiary-foreground group-hover/link:text-tertiary-foreground line-clamp-2 text-xs whitespace-break-spaces"
>
<span class="transition-all font-medium">{item.title()}</span>
<span
class="text-foreground transition-all group-data-[active=true]/link:text-tertiary-foreground group-hover/link:text-tertiary-foreground line-clamp-2 text-xs whitespace-break-spaces"
>
{item.description()}
</span>
</a>
{/each}
{#if section.url === '/dashboard'|| section.url === `/dashboard/${page.params.machineId}`}
{item.description()}
</span>
</a>
{/each}
{#if section.showsMachines}
<MachinesNav onnavigate={closeMobileSidebars} />
{/if}
{/snippet}
@@ -192,7 +320,28 @@
<Sidebar.GroupContent class="px-1.5 h-full md:px-0">
<Sidebar.Menu class="space-y-2 h-full">
{#each navMain as item (item.url)}
<Sidebar.MenuItem class="last-of-type:mt-auto">
{#if item.url === resolve('/admin') && canShowTerminal}
<Sidebar.MenuItem class="mt-auto">
<Sidebar.MenuButton
tooltipContentProps={{ hidden: false }}
class="px-2.5 md:px-2"
onclick={() => (terminalState.open = true)}
>
{#snippet tooltipContent()}
{m.nav_system_terminal()}
{/snippet}
{#snippet child({ props })}
<button {...props}>
<TerminalIcon />
<span>{m.nav_system_terminal()}</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
<Sidebar.MenuItem
class={user.role === 'admin' ? [canShowTerminal ? '' : 'last-of-type:mt-auto'] : []}
>
<Sidebar.MenuButton
tooltipContentProps={{ hidden: false }}
isActive={activeItem.url === item.url}
@@ -203,7 +352,7 @@
{/snippet}
{#snippet child({ props })}
<a
href={resolve(item.url)}
href={item.url}
{...props}
onclick={(e) => {
// On mobile, open the second drawer instead of navigating —
@@ -1,9 +1,13 @@
<script lang="ts">
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { env } from '$env/dynamic/public';
import { Button } from '$lib/components/ui/button';
import { CopyButton } from '$lib/components/ui/copy-button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Drawer from '$lib/components/ui/drawer';
import * as Field from '$lib/components/ui/field';
@@ -11,7 +15,13 @@
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { machineSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import { addMachine, listMachines, reorderMachines } from '$lib/remotes/machines.remote';
import {
addMachine,
listMachines,
machineHealth,
reorderMachines
} from '$lib/remotes/machines.remote';
import { auditLog, serverInfo, systemDetails } from '$lib/remotes/server.remote';
import { untrack } from 'svelte';
import { toast } from 'svelte-sonner';
import { createSwapy } from 'swapy';
@@ -27,9 +37,7 @@
const PAGE_SIZE = 3;
let listEl: HTMLDivElement | undefined = $state();
let items: { address: string; id: string; name: null | string; }[] = $state([]);
// Recreate swapy whenever the set of ids changes (add/remove/page/search), so it
// binds to the fresh DOM. Reorders don't change the set, so swapy is left alone.
let items: { address: string; id: string; name: null | string }[] = $state([]);
const idSet = $derived([...items.map((i) => i.id)].sort().join('|'));
$effect(() => {
@@ -41,20 +49,18 @@
});
inst.onSwapEnd(async ({ hasChanged }) => {
if (!hasChanged) return;
// slots are fixed (= items order); read which item now sits in each slot.
const map = inst.slotItemMap().asObject;
const ids = untrack(() => items)
.map((mc) => map[mc.id])
.filter((x): x is string => !!x);
try {
inst.enable(false)
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)
} finally {
inst.enable(true);
}
});
return () => inst.destroy();
@@ -71,17 +77,26 @@
machines.then((data) => {
pageInfo = { page: data.page, pages: data.pages };
untrack(() => {
// Only replace items when the *set* changes. A reorder leaves the set
// identical — keep our array so swapy's DOM arrangement isn't clobbered.
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;
});
});
});
const installSH = `curl -fsSL ${env.PUBLIC_ORIGIN}/install.sh|sh`;
</script>
{#snippet addForm()}
<script lang="ts">
</script>
<CopyButton text={installSH} size="sm" variant="outline">
{#snippet icon()}
<TerminalIcon />
{/snippet}
<span class="font-mono text-sm font-light">{installSH}</span>
</CopyButton>
<form
oninput={() => addMachine.validate()}
@@ -139,7 +154,25 @@
</form>
{/snippet}
<Input bind:value={search} oninput={onSearch} placeholder={m.machine_search_placeholder()} />
<div class="flex flex-col gap-2 p-2">
<div class="flex gap-2">
<Input bind:value={search} oninput={onSearch} placeholder={m.machine_search_placeholder()} />
<Button
variant="outline"
size="icon"
title={m.machine_refresh_health()}
onclick={() => {
items.forEach((mc) => machineHealth(mc.id).refresh());
// also un-stick the open dashboard for the selected machine
if (selectedId) {
void serverInfo(selectedId).refresh();
void systemDetails(selectedId).refresh();
void auditLog({ limit: undefined, machineId: selectedId }).refresh();
}
}}
>
<RefreshCwIcon class="size-4" />
</Button>
</div>
{#if isMobile.current}
@@ -197,7 +230,15 @@
draggable="false"
data-swapy-no-drag
class="flex-1 flex flex-col items-start gap-1 p-4 text-sm leading-tight transition-all"
<span class="font-medium transition-all">{machine.name}</span>
>
<span class="flex items-center gap-2 font-medium transition-all">
<span
class="size-2 shrink-0 rounded-full {machineHealth(machine.id).current
? 'bg-green-500'
: 'bg-destructive'}"
title={machineHealth(machine.id).current ? m.machine_online() : m.machine_offline()}
></span>
{machine.name}
</span>
<span
class="text-foreground group-hover/item:text-tertiary-foreground group-data-[active=true]/item:text-tertiary-foreground transition-all text-xs"
@@ -0,0 +1,150 @@
<script lang="ts">
import type { FitAddon as TFitAddon } from '@xterm/addon-fit';
import type { Terminal as TTerminal } from '@xterm/xterm';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import { page } from '$app/state';
import * as Dialog from '$lib/components/ui/dialog';
import '@xterm/xterm/css/xterm.css';
import { m } from '$lib/paraglide/messages';
import { getMachine } from '$lib/remotes/machines.remote';
import { getContext } from 'svelte';
interface TerminalState {
open: boolean;
}
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);
const machineId = $derived(page.params.machineId);
const machineResource = $derived(machineId ? getMachine(machineId) : null);
const machineAddress = $derived(machineResource?.current?.address ?? null);
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 '';
}
});
function sendResize(cols: number, rows: number) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ cols, rows }));
}
}
function cleanup() {
if (socket) {
socket.close();
socket = null;
}
if (term) {
term.dispose();
term = null;
}
fitAddon = null;
}
$effect(() => {
if (!terminalState.open || !socketUrl) {
cleanup();
return;
}
let active = true;
const initTerminal = 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');
if (!active) return;
term = new Terminal({
cursorBlink: true,
fontFamily: 'Geist Mono, JetBrains Mono, Fira Code, monospace',
fontSize: 14,
theme: {
background: '#09090b', // zinc-950
cursor: '#fafafa',
foreground: '#fafafa', // zinc-50
selectionBackground: '#27272a' // zinc-800
}
});
socket = new WebSocket(socketUrl);
socket.binaryType = 'arraybuffer';
socket.onopen = () => {
if (!active) {
cleanup();
return;
}
const attachAddon = new AttachAddon(socket!);
term?.loadAddon(attachAddon);
fitAddon = new FitAddon();
term?.loadAddon(fitAddon);
term?.open(containerElement!);
fitAddon?.fit();
if (term) sendResize(term.cols, term.rows);
else console.error('terminal not initialized');
};
};
initTerminal();
const handleResize = () => {
if (fitAddon && term) {
fitAddon.fit();
sendResize(term.cols, term.rows);
}
};
window.addEventListener('resize', handleResize);
return () => {
active = false;
window.removeEventListener('resize', handleResize);
cleanup();
};
});
</script>
<Dialog.Root bind:open={terminalState.open}>
<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
>
</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>
</Dialog.Content>
</Dialog.Root>
<style>
:global(.xterm) {
padding: 4px;
height: 100%;
}
</style>
+2 -2
View File
@@ -190,8 +190,8 @@
width: 100%;
height: 100%;
overflow: hidden;
display:grid;
place-content:center
display: grid;
place-content: center;
}
.trunk :global(canvas) {
display: block;
@@ -1,5 +1,5 @@
<script lang="ts">
import { History } from '@lucide/svelte';
import History from '@lucide/svelte/icons/history';
import { m } from '$lib/paraglide/messages';
import type { LogEntry } from './types';
@@ -1,5 +1,5 @@
<script lang="ts">
import { Cpu } from '@lucide/svelte';
import Cpu from '@lucide/svelte/icons/cpu';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
@@ -1,7 +1,9 @@
<script lang="ts" generics="T">
import type { Component, Snippet } from 'svelte';
import { ArrowDown, ArrowUp, Search } from '@lucide/svelte';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import Search from '@lucide/svelte/icons/search';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
@@ -1,5 +1,5 @@
<script lang="ts">
import { Network } from '@lucide/svelte';
import Network from '@lucide/svelte/icons/network';
import { m } from '$lib/paraglide/messages';
import type { NetIface } from './types';
@@ -1,5 +1,5 @@
<script lang="ts">
import { HardDrive } from '@lucide/svelte';
import HardDrive from '@lucide/svelte/icons/hard-drive';
import { Progress } from '$lib/components/ui/progress';
import { m } from '$lib/paraglide/messages';
@@ -12,7 +12,7 @@
</script>
<DataPanel
class="shrink-0 h-max"
class="grow"
title={m.dashboard_storage()}
icon={HardDrive}
{items}
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Server } from '@lucide/svelte';
import Server from '@lucide/svelte/icons/server';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
@@ -1,5 +1,5 @@
<script lang="ts">
import { Thermometer } from '@lucide/svelte';
import Thermometer from '@lucide/svelte/icons/thermometer';
import { m } from '$lib/paraglide/messages';
import type { Temp } from './types';
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
let {
description = '',
noIndex = false,
title = ''
}: {
description?: string;
noIndex?: boolean;
title?: string;
} = $props();
const fullTitle = $derived(title ? `${m.appname()} · ${title}` : m.appname());
</script>
<svelte:head>
<title>{fullTitle}</title>
<meta name="description" content={description} />
{#if noIndex}
<meta name="robots" content="noindex, nofollow" />
{/if}
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={page.url.href} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
</svelte:head>
+2 -1
View File
@@ -13,7 +13,8 @@
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
outline: 'border-foreground /50 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
outline:
'border-foreground /50 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80'
}
}
+1 -2
View File
@@ -1,5 +1,4 @@
<script lang="ts" module>
import type { Pathname } from '$app/types';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { resolve } from '$app/paths';
@@ -70,7 +69,7 @@
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ size, variant }), className)}
href={disabled ? undefined : resolve(href as Pathname)}
href={disabled ? undefined : resolve(href as '/')}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
@@ -0,0 +1,96 @@
<script lang="ts" module>
import type { ButtonProps } from '$lib/components/ui/button';
import type { WithChildren, WithoutChildren } from 'bits-ui';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
export type CopyButtonPropsWithoutHTML = WithChildren<
{
animationDuration?: number;
icon?: Snippet<[]>;
onCopy?: (status: 'failure' | 'success' | undefined) => void;
ref?: HTMLButtonElement | null;
text: string;
} & Pick<ButtonProps, 'size' | 'variant'>
>;
export type CopyButtonProps = CopyButtonPropsWithoutHTML &
WithoutChildren<HTMLAttributes<HTMLButtonElement>>;
</script>
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import CopyIcon from '@lucide/svelte/icons/copy';
import XIcon from '@lucide/svelte/icons/x';
import Button from '$lib/components/ui/button/button.svelte';
import { UseClipboard } from '$lib/hooks/use-clipboard.svelte';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils.js';
import { mergeProps } from 'bits-ui';
import { scale } from 'svelte/transition';
let {
animationDuration = 500,
children,
class: className,
icon,
onCopy,
ref = $bindable(null),
size = 'icon',
tabindex = -1,
text,
variant = 'ghost',
...rest
}: CopyButtonProps = $props();
// this way if the user passes text then the button will be the default size
// svelte-ignore state_referenced_locally
if (size === 'icon' && children) {
size = 'default';
}
const clipboard = new UseClipboard();
const merged = $derived(
mergeProps(rest, {
onclick: async () => {
const status = await clipboard.copy(text);
onCopy?.(status);
}
})
);
</script>
<Button
bind:ref
{variant}
{size}
{tabindex}
class={cn('flex items-center gap-2', className)}
type="button"
name="copy"
{...merged as /* eslint-disable-line @typescript-eslint/no-explicit-any */ any}
>
{#if clipboard.status === 'success'}
<div in:scale={{ duration: animationDuration, start: 0.85 }}>
<CheckIcon tabindex={-1} />
<span class="sr-only">{m.copied()}</span>
</div>
{:else if clipboard.status === 'failure'}
<div in:scale={{ duration: animationDuration, start: 0.85 }}>
<XIcon tabindex={-1} />
<span class="sr-only">{m.copy_failed()}</span>
</div>
{:else}
<div in:scale={{ duration: animationDuration, start: 0.85 }}>
{#if icon}
{@render icon()}
{:else}
<CopyIcon tabindex={-1} />
{/if}
<span class="sr-only">{m.copy()}</span>
</div>
{/if}
{@render children?.()}
</Button>
@@ -0,0 +1,3 @@
import CopyButton from './copy-button.svelte';
export { CopyButton };
@@ -30,7 +30,7 @@
bind:ref
data-slot="dialog-content"
class={cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none',
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-lg sm:min-w-max fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none',
className
)}
{...restProps}
+3 -3
View File
@@ -83,10 +83,10 @@
<div
data-slot="sidebar-container"
class={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
'fixed inset-y-0 z-25 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'start-0 group-data-[collapsible=offcanvas]:start-[calc(var(--sidebar-width)*-1)]'
: 'end-0 group-data-[collapsible=offcanvas]:end-[calc(var(--sidebar-width)*-1)]',
? 'inset-s-0 group-data-[collapsible=offcanvas]:-inset-s-(--sidebar-width)'
: 'inset-e-0 group-data-[collapsible=offcanvas]:-inset-e-(--sidebar-width)',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
+99
View File
@@ -0,0 +1,99 @@
type Options = {
/** The time before the copied status is reset. */
delay: number;
};
/** Use this hook to copy text to the clipboard and show a copied state.
*
* ## Usage
* ```svelte
* <script lang="ts">
* import { UseClipboard } from "$lib/hooks/use-clipboard.svelte";
*
* const clipboard = new UseClipboard();
* </script>
*
* <button onclick={() => clipboard.copy('Hello, World!')}>
* {#if clipboard.copied === 'success'}
* Copied!
* {:else if clipboard.copied === 'failure'}
* Failed to copy!
* {:else}
* Copy
* {/if}
* </button>
* ```
*
*/
export class UseClipboard {
/** true when the user has just copied to the clipboard. */
get copied() {
return this.#copiedStatus === 'success';
}
/** Indicates whether a copy has occurred
* and gives a status of either `success` or `failure`. */
get status() {
return this.#copiedStatus;
}
#copiedStatus = $state<'failure' | 'success'>();
private delay: number;
private timeout: ReturnType<typeof setTimeout> | undefined = undefined;
constructor({ delay = 500 }: Partial<Options> = {}) {
this.delay = delay;
}
/** Copies the given text to the users clipboard.
*
* ## Usage
* ```ts
* clipboard.copy('Hello, World!');
* ```
*
* @param text
* @returns
*/
async copy(text: string) {
if (this.timeout) {
this.#copiedStatus = undefined;
clearTimeout(this.timeout);
}
this.#copiedStatus = await copyText(text);
this.timeout = setTimeout(() => {
this.#copiedStatus = undefined;
}, this.delay);
return this.#copiedStatus;
}
}
export async function copyText(text: string): Promise<'failure' | 'success'> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return 'success';
}
// when navigator.clipboard is unavailable we fallback to this for wider browser compatibility
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.top = '0';
textArea.style.left = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful ? 'success' : 'failure';
} catch {
return 'failure';
}
}
+23 -1
View File
@@ -4,8 +4,10 @@ import { v } from '$lib';
import { machineDeleteSchema, machineEditSchema, machineSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import { db } from '$lib/server/db';
import { decryptValue } from '$lib/server/db/custom-types';
import { machines } from '$lib/server/db/schema';
import { asc, count, eq, like, or, sql } from 'drizzle-orm';
import { getClient } from '$lib/server/nadir-agent/client';
import { asc, count, eq, inArray, like, or, sql } from 'drizzle-orm';
const PAGE_SIZE = 10;
@@ -52,6 +54,26 @@ export const reorderMachines = command(
}
);
export const machineHealth = query.batch(v.string(), async (machineIds) => {
const rows = await db
.select({ address: machines.address, id: machines.id, token: machines.token })
.from(machines)
.where(inArray(machines.id, machineIds));
const checks = await Promise.allSettled(
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)
});
return [mc.id, !!data] as const;
})
);
const online = new Map(checks.map((c) => (c.status === 'fulfilled' ? c.value : ['', false])));
return (id: string) => online.get(id) ?? false;
});
export const getMachine = query(v.string(), async (id) => {
const row = await db
.select({ address: machines.address, id: machines.id, name: machines.name })
+166
View File
@@ -0,0 +1,166 @@
import { command, query } from '$app/server';
import { v } from '$lib';
import { nadirForMachine, throwNadirError } from './utils';
export const listInterfaces = query(v.string(), async (machineId) => {
const 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 { 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 { 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 { data, error: err } = await nadir.GET('/api/networking/dns');
if (!data) throwNadirError(err);
return data.servers ?? [];
});
// 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 { data, error: err } = await nadir.GET('/api/networking/pending');
if (err) {
if (err.status === 404) return null;
throwNadirError(err);
}
return data;
});
export const getInterfaceConfig = query(
v.object({ machineId: v.string(), name: v.string() }),
async ({ machineId, name }) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/networking/interfaces/{name}', {
params: { path: { name } }
});
if (!data) throwNadirError(err);
return data;
}
);
export const applyInterfaceConfig = command(
v.object({
address: v.optional(v.string()),
dns: v.optional(v.array(v.string())),
gateway: v.optional(v.string()),
ipv6: v.optional(
v.object({
address: v.optional(v.string()),
gateway: v.optional(v.string()),
method: v.union([v.literal('auto'), v.literal('static'), v.literal('ignore')]),
prefix: v.optional(v.number())
})
),
machineId: v.string(),
method: v.union([v.literal('static'), v.literal('dhcp')]),
name: v.string(),
prefix: v.optional(v.number()),
rollback_seconds: v.optional(v.number()),
routes: v.optional(v.array(v.object({ destination: v.string(), gateway: v.string() })))
}),
async ({ machineId, name, ...body }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.PUT('/api/networking/interfaces/{name}', {
body,
params: { path: { name } }
});
if (err) throwNadirError(err);
await getPending(machineId).refresh();
await listInterfaces(machineId).refresh();
}
);
export const linkUp = command(
v.object({ machineId: v.string(), name: v.string() }),
async ({ machineId, name }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/networking/interfaces/{name}/up', {
params: { path: { name } }
});
if (err) throwNadirError(err);
await listInterfaces(machineId).refresh();
}
);
export const linkDown = command(
v.object({ machineId: v.string(), name: v.string() }),
async ({ machineId, name }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/networking/interfaces/{name}/down', {
params: { path: { name } }
});
if (err) throwNadirError(err);
await listInterfaces(machineId).refresh();
}
);
export const confirmChange = command(
v.object({ machineId: v.string(), name: v.string() }),
async ({ machineId, name }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/networking/interfaces/{name}/confirm', {
params: { path: { name } }
});
if (err) throwNadirError(err);
await getPending(machineId).refresh();
await listInterfaces(machineId).refresh();
}
);
export const rollbackChange = command(
v.object({ machineId: v.string(), name: v.string() }),
async ({ machineId, name }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/networking/interfaces/{name}/rollback', {
params: { path: { name } }
});
if (err) throwNadirError(err);
await getPending(machineId).refresh();
await listInterfaces(machineId).refresh();
}
);
export const upsertHost = command(
v.object({
hostnames: v.array(v.string()),
ip: v.string(),
machineId: v.string()
}),
async ({ hostnames, ip, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.PUT('/api/networking/hosts/{ip}', {
body: { hostnames },
params: { path: { ip } }
});
if (err) throwNadirError(err);
await listHosts(machineId).refresh();
}
);
export const deleteHost = command(
v.object({ ip: v.string(), machineId: v.string() }),
async ({ ip, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.DELETE('/api/networking/hosts/{ip}', {
params: { path: { ip } }
});
if (err) throwNadirError(err);
await listHosts(machineId).refresh();
}
);
+120
View File
@@ -0,0 +1,120 @@
import { error } from '@sveltejs/kit';
import { query } from '$app/server';
import { v } from '$lib';
import { nadirForMachine, parsePackageUpdates, throwNadirError } from './utils';
export const listInstalledPackages = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/packages');
if (!data) throwNadirError(err);
return data;
});
// ponytail: agent's /api/packages/updates may emit SSE (apt/dnf can be slow and the
// 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 {
data,
error: err,
response
} = await nadir.GET('/api/packages/updates', {
parseAs: 'text'
});
if (err) throwNadirError(err);
return parsePackageUpdates(data, response);
});
export const streamPackageAction = query.live(
v.object({
action: v.union([v.literal('install'), v.literal('upgrade'), v.literal('remove')]),
machineId: v.string(),
name: v.optional(v.string())
}),
async function* ({ action, machineId, name }) {
const nadir = await nadirForMachine(machineId);
let response: Response;
if (action === 'install') {
const { error: err, response: res } = await nadir.POST('/api/packages', {
body: { name: name || '' },
parseAs: 'stream'
});
if (err) throwNadirError(err);
response = res;
} else if (action === 'upgrade') {
let res: Response;
if (name) {
const { error: err, response: r } = await nadir.POST('/api/packages/upgrade/{name}', {
params: { path: { name } },
parseAs: 'stream'
});
if (err) throwNadirError(err);
res = r;
} else {
const { error: err, response: r } = await nadir.POST('/api/packages/upgrade', {
parseAs: 'stream'
});
if (err) throwNadirError(err);
res = r;
}
response = res;
} else if (action === 'remove') {
const { error: err, response: res } = await nadir.DELETE('/api/packages/{name}', {
params: { path: { name: name || '' } },
parseAs: 'stream'
});
if (err) throwNadirError(err);
response = res;
} else {
throw error(400, { message: 'Invalid action' });
}
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;
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();
}
}
);
+36 -34
View File
@@ -1,16 +1,12 @@
import { error } from '@sveltejs/kit';
import { command, query } from '$app/server';
import { v } from '$lib';
import { m } from '$lib/paraglide/messages';
import { nadirForMachine } from './utils';
import { nadirForMachine, throwNadirError } from './utils';
export const listPamUsers = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/users');
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
if (!data) throwNadirError(err);
return data.users ?? [];
});
@@ -27,61 +23,64 @@ export const createPamUser = command(
async (body) => {
const nadir = await nadirForMachine(body.machineId);
const { error: err } = await nadir.POST('/api/users', { body });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
if (err) throwNadirError(err);
await listPamUsers(body.machineId).refresh();
}
);
export const deletePamUser = command(
v.object({ machineId:v.string(),remove_home: v.optional(v.boolean()), username: v.string() }),
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 { error: err } = await nadir.DELETE('/api/users/{username}', {
params: { path: { username }, query: { remove_home } }
});
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
if (err) throwNadirError(err);
await listPamUsers(machineId).refresh();
}
);
export const setPamUserPassword = command(
v.object({ machineId:v.string(),password: v.string(), username: v.string() }),
async ({machineId, password, username }) => {
v.object({ machineId: v.string(), password: v.string(), username: v.string() }),
async ({ machineId, password, username }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/users/{username}/password', {
body: { password },
params: { path: { username } }
});
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
if (err) throwNadirError(err);
}
);
export const getPamUser = query(v.object({machineId:v.string(), username:v.string()}), async ({machineId,username}) => {
const nadir = await nadirForMachine(machineId,);
const { data, error: err } = await nadir.GET('/api/users/{username}', {
params: { path: { username } }
});
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
return data;
});
export const getPamUser = query(
v.object({ machineId: v.string(), username: v.string() }),
async ({ machineId, username }) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/users/{username}', {
params: { path: { username } }
});
if (!data) throwNadirError(err);
return data;
}
);
export const setPamUserGroups = command(
v.object({ groups: v.array(v.string()),machineId:v.string(), username: v.string() }),
v.object({ groups: v.array(v.string()), machineId: v.string(), username: v.string() }),
async ({ groups, machineId, username }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.PUT('/api/users/{username}/groups', {
body: { groups },
params: { path: { username } }
});
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await listPamGroups(machineId,).refresh();
if (err) throwNadirError(err);
await listPamGroups(machineId).refresh();
}
);
export const listPamGroups = query(v.string(),async (machineId) => {
export const listPamGroups = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/groups');
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
if (!data) throwNadirError(err);
return data.groups ?? [];
});
@@ -95,16 +94,19 @@ export const createPamGroup = command(
async (body) => {
const nadir = await nadirForMachine(body.machineId);
const { error: err } = await nadir.POST('/api/groups', { body });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
if (err) throwNadirError(err);
await listPamGroups(body.machineId).refresh();
}
);
export const deletePamGroup = command(v.object({group:v.string(), machineId:v.string()}), async ({group, machineId}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.DELETE('/api/groups/{group}', {
params: { path: { group } }
});
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await listPamGroups(machineId).refresh();
});
export const deletePamGroup = command(
v.object({ group: v.string(), machineId: v.string() }),
async ({ group, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.DELETE('/api/groups/{group}', {
params: { path: { group } }
});
if (err) throwNadirError(err);
await listPamGroups(machineId).refresh();
}
);
+37 -30
View File
@@ -7,9 +7,11 @@ import { db } from '$lib/server/db';
import { decryptValue } from '$lib/server/db/custom-types';
import { getClient } from '$lib/server/nadir-agent/client';
export const serverInfo = query(v.string(),async (machineId) => {
import { parsePackageUpdates, throwNadirError } from './utils';
export const serverInfo = query(v.string(), async (machineId) => {
const {
locals: { user },
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
@@ -39,31 +41,32 @@ 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);
try {
const { data } = await nadir.GET('/api/audit', { params: { query: { limit } } });
return data?.entries ?? [];
} catch {
return [];
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);
try {
const { data } = await nadir.GET('/api/audit', { params: { query: { limit } } });
return data?.entries ?? [];
} catch {
return [];
}
}
});
);
let latestCache: { at: number; tag: null | string } | null = null;
export const latestAgentRelease = query(async () => {
if (latestCache && Date.now() - latestCache.at < 60_000) return latestCache.tag;
try {
const r = await fetch(
env.REPOSITORY_URL
);
const r = await fetch(env.REPOSITORY_URL);
const tag = r.ok ? (((await r.json()) as { tag_name?: string }).tag_name ?? null) : null;
latestCache = { at: Date.now(), tag };
return tag;
@@ -72,9 +75,9 @@ export const latestAgentRelease = query(async () => {
}
});
export const updateAgent = command(v.string(),async (machineId) => {
export const updateAgent = command(v.string(), async (machineId) => {
const {
locals: { user },
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
@@ -83,11 +86,11 @@ export const updateAgent = command(v.string(),async (machineId) => {
if (!token) error(500, { message: m.errors_generic() });
const nadir = getClient(machine.address, token);
const { error: err } = await nadir.POST('/api/update');
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
if (err) throwNadirError(err);
});
export const systemDetails = query(v.string(),async (machineId,) => {
export const systemDetails = query(v.string(), async (machineId) => {
const {
locals: { user },
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
const machine = await db.query.machines.findFirst({ where: { id: machineId } });
@@ -97,11 +100,17 @@ export const systemDetails = query(v.string(),async (machineId,) => {
const nadir = getClient(machine.address, token);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { token: _, ...safe } = machine;
const updatesPromise = nadir
.GET('/api/packages/updates', { parseAs: 'text' })
.then(({ data, response }) => parsePackageUpdates(data, response))
.catch(() => null);
const [time, locale, dns, updates] = await Promise.all([
nadir.GET('/api/system/time').catch(() => ({ data: undefined })),
nadir.GET('/api/system/locale').catch(() => ({ data: undefined })),
nadir.GET('/api/networking/dns').catch(() => ({ data: undefined })),
nadir.GET('/api/packages/updates').catch(() => ({ data: undefined }))
updatesPromise
]);
return {
$db: safe,
@@ -115,8 +124,6 @@ export const systemDetails = query(v.string(),async (machineId,) => {
timezone: time.data.timezone
}
: null,
updates: updates.data
? { count: updates.data.packages?.length ?? 0, manager: updates.data.manager }
: null
updates: updates ? { count: updates.packages?.length ?? 0, manager: updates.manager } : null
};
});
+158
View File
@@ -0,0 +1,158 @@
import { error } from '@sveltejs/kit';
import { command, query } from '$app/server';
import { v } from '$lib';
import { nadirForMachine, 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 { data, error: err } = await nadir.GET('/api/services');
if (err) throwNadirError(err);
return data;
});
export const getServiceStatus = query(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/services/{unit}', {
params: { path: { unit } }
});
if (err) throwNadirError(err);
return data;
}
);
export const getServiceLogs = query(
v.object({
lines: v.optional(v.number()),
machineId: v.string(),
priority: v.optional(v.number()),
since: v.optional(v.string()),
unit: v.string()
}),
async ({ lines, machineId, priority, since, unit }) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/services/{unit}/logs', {
params: { path: { unit }, query: { lines, priority, since } }
});
if (err) throwNadirError(err);
return data;
}
);
export const streamServiceLogs = query.live(
v.object({
machineId: v.string(),
priority: v.optional(v.number()),
since: v.optional(v.string()),
unit: v.string()
}),
async function* ({ machineId, priority, since, unit }) {
const nadir = await nadirForMachine(machineId);
const { error: err, response } = await nadir.GET('/api/services/{unit}/logs/stream', {
params: { path: { unit }, query: { priority, since } },
parseAs: 'stream'
});
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();
}
}
);
export const enableService = command(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/services/{unit}/enable', {
params: { path: { unit } }
});
if (err) throwNadirError(err);
await listServices(machineId).refresh();
await getServiceStatus({ machineId, unit }).refresh();
}
);
export const disableService = command(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/services/{unit}/disable', {
params: { path: { unit } }
});
if (err) throwNadirError(err);
await listServices(machineId).refresh();
await getServiceStatus({ machineId, unit }).refresh();
}
);
export const startService = command(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/services/{unit}/start', {
params: { path: { unit } }
});
if (err) throwNadirError(err);
await listServices(machineId).refresh();
await getServiceStatus({ machineId, unit }).refresh();
}
);
export const stopService = command(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/services/{unit}/stop', {
params: { path: { unit } }
});
if (err) throwNadirError(err);
await listServices(machineId).refresh();
await getServiceStatus({ machineId, unit }).refresh();
}
);
export const restartService = command(
v.object({ machineId: v.string(), unit: v.string() }),
async ({ machineId, unit }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/services/{unit}/restart', {
params: { path: { unit } }
});
if (err) throwNadirError(err);
await listServices(machineId).refresh();
await getServiceStatus({ machineId, unit }).refresh();
}
);
+50
View File
@@ -0,0 +1,50 @@
import { command, query } from '$app/server';
import { v } from '$lib';
import { nadirForMachine, throwNadirError } from './utils';
export const listMounts = query(v.string(), async (machineId) => {
const 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 { data, error: err } = await nadir.GET('/api/storage/fstab');
if (!data) throwNadirError(err);
return data.entries ?? [];
});
export const addMount = command(
v.object({
device: v.string(),
dump: v.optional(v.number()),
fstype: v.string(),
machineId: v.string(),
mountpoint: v.string(),
options: v.optional(v.string()),
pass: v.optional(v.number())
}),
async ({ machineId, ...body }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/storage/mounts', { body });
if (err) throwNadirError(err);
await listMounts(machineId).refresh();
await listFstab(machineId).refresh();
}
);
export const removeMount = command(
v.object({ machineId: v.string(), mountpoint: v.string() }),
async ({ machineId, mountpoint }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.DELETE('/api/storage/mounts', {
params: { query: { mountpoint } }
});
if (err) throwNadirError(err);
await listMounts(machineId).refresh();
await listFstab(machineId).refresh();
}
);
+129 -95
View File
@@ -7,132 +7,166 @@ 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);
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);
}
export const systemTime = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/system/time');
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
return data;
const 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 { data, error: err } = await nadir.GET('/api/system/locale');
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
return data;
const 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 { data } = await nadir.GET('/api/system/timezones');
return data?.timezones ?? [];
const 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 { data } = await nadir.GET('/api/system/locales');
return data?.locales ?? [];
const nadir = await nadirForMachine(machineId);
const { data } = await nadir.GET('/api/system/locales');
return data?.locales ?? [];
});
export const setTimezone = command(v.object({machineId:v.string(), timezone:v.string()}), async ({machineId,timezone}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/timezone', { body: { timezone } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await systemTime(machineId).refresh();
await systemDetails(machineId).refresh();
});
export const setTimezone = command(
v.object({ machineId: v.string(), timezone: v.string() }),
async ({ machineId, timezone }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/timezone', { body: { timezone } });
if (err) throwNadirError(err);
await systemTime(machineId).refresh();
await systemDetails(machineId).refresh();
}
);
export const setNtp = command(v.object({enabled:v.boolean(), machineId:v.string()}), async ({enabled,machineId}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/ntp', { body: { enabled } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await systemTime(machineId).refresh();
await systemDetails(machineId).refresh();
});
export const setNtp = command(
v.object({ enabled: v.boolean(), machineId: v.string() }),
async ({ enabled, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/ntp', { body: { enabled } });
if (err) throwNadirError(err);
await systemTime(machineId).refresh();
await systemDetails(machineId).refresh();
}
);
export const setTime = command(v.object({machineId:v.string(), time:v.string()}), async ({machineId,time}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/time', { body: { time } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await systemTime(machineId).refresh();
});
export const setTime = command(
v.object({ machineId: v.string(), time: v.string() }),
async ({ machineId, time }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/time', { body: { time } });
if (err) throwNadirError(err);
await systemTime(machineId).refresh();
}
);
export const systemHostname = query(v.string(), async (machineId) => {
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/system/hostname');
if (!data) throw error(err?.status || 500, { message: err?.detail || m.errors_generic() });
return data;
const nadir = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/system/hostname');
console.log(err)
if (!data) throwNadirError(err);
return data;
});
export const setHostname = command(v.object({hostname:v.string(), machineId:v.string()}), async ({hostname,machineId}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/hostname', { body: { hostname } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await systemHostname(machineId).refresh();
});
export const setHostname = command(
v.object({ hostname: v.string(), machineId: v.string() }),
async ({ hostname, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/hostname', { body: { hostname } });
if (err) throwNadirError(err);
await systemHostname(machineId).refresh();
}
);
export const listKeymaps = query(v.string(), async (machineId) => {
const 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 ?? '' };
const 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 ?? '' };
});
export const setKeymap = command(v.object({keymap: v.string(),machineId:v.string()}), async ({keymap,machineId}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/keymap', { body: { keymap } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await systemLocale(machineId).refresh();
await systemDetails(machineId).refresh();
});
export const powerOff = command(v.object({machineId:v.string(), when:v.optional(v.string(),"")}), async ({machineId,when}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/poweroff', { body: { when } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
});
export const reboot = command(v.object({machineId:v.string(), when:v.optional(v.string(), '')}), async ({machineId,when}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/reboot', { body: { when } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
});
export const setLocale = command(
v.object({
lang: v.string(),
language: v.optional(v.string()),
machineId:v.string()
}),
async ({ lang,language , machineId }) => {
export const setKeymap = command(
v.object({ keymap: v.string(), machineId: v.string() }),
async ({ keymap, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/locale', {
body: { lang, language: language || undefined }
});
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
const { error: err } = await nadir.POST('/api/system/keymap', { body: { keymap } });
if (err) throwNadirError(err);
await systemLocale(machineId).refresh();
await systemDetails(machineId).refresh();
}
);
export const powerOff = command(
v.object({ machineId: v.string(), when: v.optional(v.string(), '') }),
async ({ machineId, when }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/poweroff', { body: { when } });
if (err) throwNadirError(err);
}
);
export const generateLocale = command(v.object({locale:v.string(), machineId:v.string()}), async ({locale,machineId}) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/locale/generate', { body: { locale } });
if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() });
await listLocales(machineId).refresh();
export const reboot = command(
v.object({ machineId: v.string(), when: v.optional(v.string(), '') }),
async ({ machineId, when }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/reboot', { body: { when } });
if (err) throwNadirError(err);
}
);
export const setLocale = command(
v.object({
lang: v.string(),
language: v.optional(v.string()),
machineId: v.string()
}),
async ({ lang, language, machineId }) => {
const nadir = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/locale', {
body: { lang, language: language || undefined }
});
if (err) throwNadirError(err);
await systemLocale(machineId).refresh();
await systemDetails(machineId).refresh();
}
);
export const generateLocale = command(v.object({ locale: v.string(), machineId: v.string() }), async ({ locale, machineId }) => {
const 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 { 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 { data, error: err } = await nadir.GET('/api/whoami');
if (err) throwNadirError(err);
return data;
});
+74 -17
View File
@@ -1,18 +1,75 @@
import { error } from "@sveltejs/kit";
import { getRequestEvent } from "$app/server";
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 { error } from '@sveltejs/kit';
import { getRequestEvent } from '$app/server';
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';
export const nadirForMachine = async(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);
}
export type Package = { name: string; version: string };
export type PackageList = { manager: string; packages: Package[] };
export const nadirForMachine = async (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);
};
export function parsePackageUpdates(data: unknown, response: Response): PackageList {
const raw = (typeof data === 'string' ? data : '').trim();
const ct = response.headers.get('content-type') ?? '';
if (ct.includes('json') || raw.startsWith('{')) {
try {
return JSON.parse(raw) as PackageList;
} catch {
return { manager: '', packages: [] } satisfies PackageList;
}
}
// SSE: walk blocks, prefer a `done` event carrying { manager, packages };
// otherwise harvest individual packages from per-line events.
const out: PackageList = { manager: '', packages: [] };
for (const block of raw.split('\n\n')) {
const trimmed = block.trim();
if (!trimmed) continue;
let eventName = '';
let dataStr = '';
for (const line of trimmed.split('\n')) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
else if (line.startsWith('data:')) dataStr = line.slice(5).trim();
}
if (!dataStr) continue;
try {
const parsed = JSON.parse(dataStr);
if (eventName === 'done' && Array.isArray(parsed.packages)) {
return { manager: parsed.manager ?? out.manager, packages: parsed.packages };
}
if (typeof parsed.manager === 'string') out.manager = parsed.manager;
if (typeof parsed.name === 'string') {
out.packages.push({ name: parsed.name, version: parsed.version ?? '' });
}
} catch {
// non-JSON data lines ignored
}
}
return out;
}
export function throwNadirError(err: { detail?: string; status?: number; } | null | undefined): never {
const status = err?.status || 500;
let message = err?.detail;
if (status === 403 || message === 'forbidden') {
message = m.forbidden();
} else if (status === 401 || message === 'unauthorized') {
message = m.errors_unauthenticated();
} else if (!message) {
message = m.errors_generic();
}
throw error(status, { message });
}
+4 -4
View File
@@ -1,10 +1,10 @@
import { createClient } from '@libsql/client';
import { env } from '$lib/const/schema';
import { drizzle } from 'drizzle-orm/libsql';
import { Database } from 'bun:sqlite';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import * as schema from './schema';
import { relations } from './schema';
const client = createClient({ url: env.DATABASE_URL });
const sqlite = new Database(env.DATABASE_URL);
export const db = drizzle({ client, relations, schema });
export const db = drizzle({ client: sqlite, relations, schema });
+163 -4
View File
@@ -243,7 +243,11 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/**
* Get an interface's current configuration
* @description Returns the IfaceConfig the backend currently has for this interface (method, address/prefix, gateway, DNS, IPv6). Same schema as PUT /api/networking/interfaces/{name}, so the frontend can prefill an edit form from this response directly. Returns 501 when no backend was detected (nmcli / networkd / ifupdown).
*/
get: operations["networking-get-interface"];
/**
* Apply interface configuration
* @description Replaces the interface's IPv4 configuration. The change is applied immediately but starts a rollback timer — if not confirmed within the timeout (default 60s), the prior configuration is automatically restored. This prevents lock-yourself-out mistakes on remote hosts.
@@ -431,6 +435,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/packages/upgrade/{name}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Upgrade a single package (streamed)
* @description Upgrades the named package to its latest version, streaming the package manager's output live. apt uses `install --only-upgrade` so the package must already be installed; dnf/pacman handle this natively.
*/
post: operations["packages-upgrade-one"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/packages/{name}": {
parameters: {
query?: never;
@@ -1413,7 +1437,7 @@ export interface components {
* @example wheel
*/
name: string;
/** @description True for system groups (g 1000) */
/** @description True for system groups (gid < 1000) */
system: boolean;
};
HealthOutputBody: {
@@ -2300,7 +2324,7 @@ export interface components {
* @example /bin/bash
*/
shell: string;
/** @description True for system accounts (u 1000) */
/** @description True for system accounts (uid < 1000) */
system: boolean;
/**
* Format: int64
@@ -3176,6 +3200,65 @@ export interface operations {
};
};
};
"networking-get-interface": {
parameters: {
query?: never;
header?: never;
path: {
/** @description Interface name */
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IfaceConfig"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
};
};
"networking-apply-config": {
parameters: {
query?: never;
@@ -3935,6 +4018,71 @@ export interface operations {
};
};
};
"packages-upgrade-one": {
parameters: {
query?: never;
header?: never;
path: {
/** @description Package to upgrade */
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/event-stream": ({
data: components["schemas"]["PkgDoneEvent"];
/**
* @description The event name.
* @constant
*/
event: "done";
/** @description The event ID. */
id?: number;
/** @description The retry time in milliseconds. */
retry?: number;
} | {
data: components["schemas"]["PkgErrorEvent"];
/**
* @description The event name.
* @constant
*/
event: "error";
/** @description The event ID. */
id?: number;
/** @description The retry time in milliseconds. */
retry?: number;
} | {
data: components["schemas"]["PkgOutputEvent"];
/**
* @description The event name.
* @constant
*/
event: "output";
/** @description The event ID. */
id?: number;
/** @description The retry time in milliseconds. */
retry?: number;
})[];
};
};
/** @description Error */
default: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
};
};
"packages-remove": {
parameters: {
query?: never;
@@ -6461,7 +6609,9 @@ export interface operations {
whoami: {
parameters: {
query?: never;
header?: never;
header?: {
Authorization?: string;
};
path?: never;
cookie?: {
nadir_session_id?: string;
@@ -6496,6 +6646,15 @@ export interface operations {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
/** @description Too Many Requests */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ErrorModel"];
};
};
/** @description Internal Server Error */
500: {
headers: {
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import HomeIcon from '@lucide/svelte/icons/home';
import LockIcon from '@lucide/svelte/icons/lock';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import SearchXIcon from '@lucide/svelte/icons/search-x';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import { goto, invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import * as Empty from '$lib/components/ui/empty';
import { m } from '$lib/paraglide/messages';
const status = $derived(page.status);
const message = $derived(page.error?.message ?? '');
const kind = $derived(
status === 404 ? 'not_found' : status === 401 || status === 403 ? 'unauthorized' : 'generic'
);
const title = $derived(
kind === 'not_found'
? m.error_not_found_title()
: kind === 'unauthorized'
? m.error_unauthorized_title()
: m.error_generic_title()
);
const description = $derived(
kind === 'not_found'
? m.error_not_found_description()
: kind === 'unauthorized'
? m.error_unauthorized_description()
: m.error_generic_description()
);
const Icon = $derived(
kind === 'not_found' ? SearchXIcon : kind === 'unauthorized' ? LockIcon : TriangleAlertIcon
);
</script>
<PageMeta {title} {description} noIndex />
<div class="mx-auto flex w-full max-w-2xl flex-col gap-4 p-6">
<Empty.Root class="border">
<Empty.Header>
<Empty.Media variant="icon" class="size-12">
<Icon class="size-6" />
</Empty.Media>
<Empty.Title>{title}</Empty.Title>
<Empty.Description>{description}</Empty.Description>
</Empty.Header>
<Empty.Content class="gap-3">
<div class="text-muted-foreground text-xs font-mono">
{m.error_code_label({ status })}
</div>
{#if message}
<details class="w-full max-w-lg text-left">
<summary class="text-muted-foreground cursor-pointer text-xs">
{m.error_details()}
</summary>
<pre
class="bg-muted text-foreground mt-2 max-h-64 overflow-auto rounded-md border p-3 text-xs whitespace-pre-wrap">{message}</pre>
</details>
{/if}
<div class="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline" onclick={() => invalidateAll()}>
<RefreshCwIcon class="size-4" />
{m.error_action_retry()}
</Button>
<Button variant="outline" onclick={() => history.back()}>
<ArrowLeftIcon class="size-4" />
{m.error_action_back()}
</Button>
<Button onclick={() => goto(resolve('/'))}>
<HomeIcon class="size-4" />
{m.error_action_home()}
</Button>
</div>
</Empty.Content>
</Empty.Root>
</div>
+16 -6
View File
@@ -1,23 +1,31 @@
<script lang="ts">
import './layout.css';
import type { Pathname } from '$app/types';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import favicon from '$lib/assets/favicon.svg';
import AppControls from '$lib/components/blocks/app-controls.svelte';
import Breadcrumbs from '$lib/components/blocks/breadcrumbs/breadcrumbs.svelte';
import AppSidebar from '$lib/components/blocks/sidebar/app-sidebar.svelte';
import TerminalDialog from '$lib/components/blocks/terminal-dialog.svelte';
import { Separator } from '$lib/components/ui/separator';
import * as Sidebar from '$lib/components/ui/sidebar';
import { Toaster } from '$lib/components/ui/sonner';
import { locales, localizeHref } from '$lib/paraglide/runtime';
import { getUser } from '$lib/remotes/auth.remote';
import { ModeWatcher } from 'mode-watcher';
import { setContext } from 'svelte';
let { children } = $props();
const user = $derived(getUser());
let showSidebar =$derived( (cU:null|User)=> cU && (page.url.pathname.startsWith('/dashboard')||page.url.pathname.startsWith('/admin')))
let showSidebar = $derived(
(cU: null | User) =>
cU && (page.url.pathname.startsWith('/dashboard') || page.url.pathname.startsWith('/admin'))
);
class TerminalState {
open = $state(false);
}
const terminalState = new TerminalState();
setContext('terminalState', terminalState);
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
@@ -25,7 +33,7 @@
<Sidebar.Provider style="--sidebar-width: 350px;">
{@const currentUser = await user}
{@const show = currentUser && showSidebar(currentUser)}
{#if show }
{#if show}
<AppSidebar user={currentUser} />
{/if}
<Sidebar.Inset>
@@ -49,9 +57,11 @@
<div style="display:none">
{#each locales as locale (locale)}
<a href={resolve(localizeHref(page.url.pathname, { locale }) as Pathname)}>{locale}</a>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={localizeHref(page.url.pathname, { locale })}>{locale}</a>
{/each}
</div>
<ModeWatcher />
<Toaster />
<TerminalDialog />
+6
View File
@@ -1,2 +1,8 @@
<script lang="ts">
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { m } from '$lib/paraglide/messages';
</script>
<PageMeta title={m.seo_title_root()} description={m.seo_desc_root()} noIndex />
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { m } from '$lib/paraglide/messages';
</script>
<PageMeta title={m.seo_title_admin_config()} description={m.seo_desc_admin_config()} />
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { m } from '$lib/paraglide/messages';
</script>
<PageMeta title={m.seo_title_admin_config()} description={m.seo_desc_admin_config()} />
+4 -2
View File
@@ -9,6 +9,7 @@
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { createUserSchema, inviteUserSchema, updateUserSchema } from '$lib/auth/schemas';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
@@ -194,9 +195,10 @@
}
</script>
<PageMeta title={m.seo_title_admin_users()} description={m.seo_desc_admin_users()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight flex gap-2 items-center truncate">
@@ -353,7 +355,7 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} class="rounded-s-none border-s px-2" aria-label="more">
<Button {...props} class="rounded-s-none border-s px-2" aria-label={m.more()}>
<ChevronDownIcon class="size-4" />
</Button>
{/snippet}
@@ -0,0 +1,34 @@
import { error } from '@sveltejs/kit';
import { auth } from '$lib/auth/server';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, platform, request }) => {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session) {
error(401, 'Unauthorized');
}
const machineId = params.machineId;
if (
request.headers.get('connection')?.toLowerCase().includes('upgrade') &&
request.headers.get('upgrade')?.toLowerCase() === 'websocket'
) {
if (platform?.server && platform?.request) {
const upgraded = await platform.server.upgrade(platform.request, {
data: { machineId }
});
if (upgraded) {
return new Response(null, { status: 101 });
}
}
error(500, 'Upgrade failed');
}
error(400, 'Expected websocket upgrade request');
};
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Orbit } from '@lucide/svelte';
import Orbit from '@lucide/svelte/icons/orbit';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
@@ -11,7 +11,7 @@
</script>
<div class="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10 relative z-10">
<div class={['flex w-full flex-col gap-6', wide ? 'max-w-2xl' : 'max-w-sm']}>
<div class={['flex w-full flex-col gap-6', wide ? 'max-w-3xl' : 'max-w-sm']}>
<a href={resolve('/')} class="flex items-center gap-2 self-center font-medium">
<div class="text-primary flex size-6 items-center justify-center rounded-md">
<Orbit class="size-6" />
+2
View File
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { getAuthClient } from '$lib/auth/client';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
@@ -34,6 +35,7 @@
}
</script>
<PageMeta title={m.seo_title_auth_2fa()} description={m.seo_desc_auth_2fa()} noIndex />
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.two_factor_title()}</Card.Title>
@@ -1,6 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { resetRequestSchema } from '$lib/auth/schemas';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
@@ -12,6 +13,11 @@
const id = $props.id();
</script>
<PageMeta
title={m.seo_title_auth_forgot_password()}
description={m.seo_desc_auth_forgot_password()}
noIndex
/>
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.forgot_password_title()}</Card.Title>
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { resetPasswordSchema } from '$lib/auth/schemas';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
@@ -13,6 +14,11 @@
const token = page.url.searchParams.get('token') ?? '';
</script>
<PageMeta
title={m.seo_title_auth_reset_password()}
description={m.seo_desc_auth_reset_password()}
noIndex
/>
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.reset_password_title()}</Card.Title>
+2
View File
@@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { getAuthClient } from '$lib/auth/client';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
@@ -62,6 +63,7 @@
}
</script>
<PageMeta title={m.seo_title_auth_setup_2fa()} description={m.seo_desc_auth_setup_2fa()} noIndex />
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">{m.setup_2fa_title()}</Card.Title>
+2
View File
@@ -3,6 +3,7 @@
import { env } from '$env/dynamic/public';
import { getAuthClient } from '$lib/auth/client';
import { loginSchema } from '$lib/auth/schemas';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
@@ -19,6 +20,7 @@
const providers = $derived(await getOAuthProviders());
</script>
<PageMeta title={m.seo_title_auth_sign_in()} description={m.seo_desc_auth_sign_in()} noIndex />
<div class={cn('flex flex-col gap-6')}>
<Card.Root>
<Card.Header class="text-center">
+2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { registerSchema } from '$lib/auth/schemas';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
@@ -12,6 +13,7 @@
const id = $props.id();
</script>
<PageMeta title={m.seo_title_auth_sign_up()} description={m.seo_desc_auth_sign_up()} noIndex />
{#if register.result?.email}
<Card.Root>
<Card.Header class="text-center">
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { m } from '$lib/paraglide/messages';
</script>
<PageMeta title={m.seo_title_dashboard()} description={m.seo_desc_dashboard()} />
+35 -27
View File
@@ -1,21 +1,19 @@
<script lang="ts">
import {
Activity,
Clock,
ClockAlert,
Cpu,
Ellipsis,
Globe,
MemoryStick,
Pause,
Pencil,
Play,
RefreshCw,
Server,
Trash2,
User,
X
} from '@lucide/svelte';
import Activity from '@lucide/svelte/icons/activity';
import Clock from '@lucide/svelte/icons/clock';
import ClockAlert from '@lucide/svelte/icons/clock-alert';
import Cpu from '@lucide/svelte/icons/cpu';
import Ellipsis from '@lucide/svelte/icons/ellipsis';
import Globe from '@lucide/svelte/icons/globe';
import MemoryStick from '@lucide/svelte/icons/memory-stick';
import Pause from '@lucide/svelte/icons/pause';
import Pencil from '@lucide/svelte/icons/pencil';
import Play from '@lucide/svelte/icons/play';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Server from '@lucide/svelte/icons/server';
import Trash2 from '@lucide/svelte/icons/trash-2';
import User from '@lucide/svelte/icons/user';
import X from '@lucide/svelte/icons/x';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
@@ -35,6 +33,7 @@
import StoragePanel from '$lib/components/dashboard/storage-panel.svelte';
import SystemPanel from '$lib/components/dashboard/system-panel.svelte';
import TemperaturePanel from '$lib/components/dashboard/temperature-panel.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { ButtonGroup } from '$lib/components/ui/button-group';
@@ -49,7 +48,12 @@
import { Spinner } from '$lib/components/ui/spinner';
import { machineDeleteSchema, machineEditSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import { deleteMachine, listMachines, updateMachine } from '$lib/remotes/machines.remote';
import {
deleteMachine,
listMachines,
machineHealth,
updateMachine
} from '$lib/remotes/machines.remote';
import {
auditLog,
latestAgentRelease,
@@ -59,11 +63,12 @@
} from '$lib/remotes/server.remote';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!)
const machineId = $derived(page.params.machineId!);
const info = $derived(serverInfo(machineId));
const audit = $derived(auditLog({limit:undefined,machineId}));
const audit = $derived(auditLog({ limit: undefined, machineId }));
const details = $derived(systemDetails(machineId));
const latest = $derived(latestAgentRelease());
const machineName = $derived(info.current?.$db.name ?? machineId);
const intervals = $derived([
{ label: m.dashboard_interval_second(), value: '1' },
{ label: m.dashboard_interval_5s(), value: '5' },
@@ -80,7 +85,9 @@
const poll = async () => {
await serverInfo(machineId).refresh();
await auditLog({limit:undefined, machineId}).refresh();
await auditLog({ limit: undefined, machineId }).refresh();
// keep the sidebar dot in sync with what the dashboard sees
void machineHealth(machineId).refresh();
};
const refreshAll = async () => {
await poll();
@@ -88,7 +95,7 @@
};
let polling = $state(true);
$effect(() => {
if (!polling || intervalMs <= 0) return;
const id = setInterval(poll, intervalMs);
@@ -101,6 +108,7 @@
const formId = $props.id();
</script>
<PageMeta title={machineName} description={m.seo_desc_machine_detail()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<svelte:boundary>
{#snippet failed(err)}
@@ -113,7 +121,7 @@
<Trunk>
<Card.Root class="relative m-auto z-20">
<Card.Content>
<Empty.Root class="max-w-2xl">
<Empty.Root class="max-w-3xl">
<Empty.Header>
<p class="text-destructive font-mono text-sm">{m.machine_offline_code()}</p>
<Empty.Title>{m.machine_offline_title({ name: raw.$db.name ?? '' })}</Empty.Title>
@@ -210,7 +218,7 @@
)}
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight flex gap-2 items-center truncate">
@@ -387,8 +395,8 @@
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
/>
</div>
<div class="grid items-center gap-4 justify-center grid-cols-1 lg:grid-cols-3 p-4 py-2">
<div class="col-span-2 w-full">
<div class="grid items-center gap-4 justify-center grid-cols-1 lg:grid-cols-3 p-4 py-2">
<div class="col-span-2 w-full h-full">
<StoragePanel items={sys.disks ?? []} />
</div>
@@ -519,7 +527,7 @@
</AlertDialog.Content>
</AlertDialog.Root>
{#snippet pending()}
<Spinner class="size-24 m-auto"/>
<Spinner class="size-24 m-auto" />
{/snippet}
</svelte:boundary>
</div>
@@ -0,0 +1,64 @@
<script lang="ts">
import GlobeIcon from '@lucide/svelte/icons/globe';
import NetworkIcon from '@lucide/svelte/icons/network';
import RouteIcon from '@lucide/svelte/icons/route';
import ServerIcon from '@lucide/svelte/icons/server';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
const machineId = $derived(page.params.machineId!);
</script>
<PageMeta title={m.seo_title_networking()} description={m.seo_desc_networking()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_networking()}</h1>
<div class="grid gap-4 sm:grid-cols-2">
<a href={resolve('/dashboard/[machineId]/networking/interfaces', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<NetworkIcon class="size-5" />
<Card.Title>{m.nav_networking_interfaces()}</Card.Title>
</div>
<Card.Description>{m.nav_networking_interfaces_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/networking/routes', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<RouteIcon class="size-5" />
<Card.Title>{m.nav_networking_routes()}</Card.Title>
</div>
<Card.Description>{m.nav_networking_routes_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/networking/hosts', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<ServerIcon class="size-5" />
<Card.Title>{m.nav_networking_hosts()}</Card.Title>
</div>
<Card.Description>{m.nav_networking_hosts_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/networking/dns', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<GlobeIcon class="size-5" />
<Card.Title>{m.nav_networking_dns()}</Card.Title>
</div>
<Card.Description>{m.nav_networking_dns_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
</div>
</div>
@@ -0,0 +1,50 @@
<script lang="ts">
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { getDns } from '$lib/remotes/networking.remote';
const machineId = $derived(pageState.params.machineId!);
const dns = $derived(getDns(machineId));
</script>
<PageMeta title={m.seo_title_networking_dns()} description={m.seo_desc_networking_dns()} />
<div class="mx-auto flex w-full max-w-2xl flex-col gap-4 p-4">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_networking_dns()}</h1>
<p class="text-muted-foreground text-sm">{m.networking_dns_description()}</p>
</div>
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => dns.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
</div>
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_dns_servers()}</Card.Title>
<Card.Description>{m.networking_dns_per_interface_hint()}</Card.Description>
</Card.Header>
<Card.Content>
{#if dns.loading && !dns.current}
<div class="text-muted-foreground py-2 text-sm"></div>
{:else if !dns.current?.length}
<div class="text-muted-foreground py-2 text-sm">{m.networking_no_dns()}</div>
{:else}
<ul class="flex flex-col gap-1 font-mono text-sm">
{#each dns.current as s, i (s + i)}
<li class="bg-muted/40 rounded px-3 py-1.5">{s}</li>
{/each}
</ul>
{/if}
</Card.Content>
</Card.Root>
</div>
@@ -0,0 +1,345 @@
<script lang="ts">
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { deleteHost, listHosts, upsertHost } from '$lib/remotes/networking.remote';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
type Host = { hostnames: null | string[]; ip: string };
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const hosts = $derived(listHosts(machineId));
const pageSize = new PersistedState<number>('networking.hosts.pageSize', 25);
let page = $state(1);
let search = $state('');
let editOpen = $state(false);
let editForm = $state({ hostnames: '', ip: '' });
let editingExisting = $state(false);
let deleteOpen = $state(false);
let deleting = $state<Host | null>(null);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
type SortKey = 'hostnames' | 'ip';
let sort = $state<{ dir: 'asc' | 'desc'; key: SortKey }>({ dir: 'asc', key: 'ip' });
function toggleSort(key: SortKey) {
if (sort.key === key) sort = { dir: sort.dir === 'asc' ? 'desc' : 'asc', key };
else sort = { dir: 'asc', key };
}
const filtered = $derived.by(() => {
const list = (hosts.current ?? []) as Host[];
const q = search.trim().toLowerCase();
const matched = !q
? [...list]
: list.filter(
(h) =>
h.ip.toLowerCase().includes(q) ||
(h.hostnames ?? []).some((n) => n.toLowerCase().includes(q))
);
matched.sort((a, b) => {
const av = sort.key === 'ip' ? a.ip : ((a.hostnames ?? [])[0] ?? '');
const bv = sort.key === 'ip' ? b.ip : ((b.hostnames ?? [])[0] ?? '');
const cmp = av.localeCompare(bv);
return sort.dir === 'asc' ? cmp : -cmp;
});
return matched;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) page = totalPages;
});
function openAdd() {
editingExisting = false;
editForm = { hostnames: '', ip: '' };
editOpen = true;
}
function openEdit(h: Host) {
editingExisting = true;
editForm = { hostnames: (h.hostnames ?? []).join(' '), ip: h.ip };
editOpen = true;
}
async function doSave() {
const ip = editForm.ip.trim();
const hostnames = editForm.hostnames.split(/\s+/).filter(Boolean);
if (!ip || !hostnames.length) return;
try {
await upsertHost({ hostnames, ip, machineId });
toast.success(m.networking_host_saved());
editOpen = false;
} catch (e) {
handleError(e);
}
}
async function doDelete() {
if (!deleting) return;
try {
await deleteHost({ ip: deleting.ip, machineId });
toast.success(m.networking_host_removed());
deleteOpen = false;
deleting = null;
} catch (e) {
handleError(e);
}
}
</script>
<PageMeta title={m.seo_title_networking_hosts()} description={m.seo_desc_networking_hosts()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="truncate text-2xl font-semibold tracking-tight">{m.nav_networking_hosts()}</h1>
<p class="text-muted-foreground truncate text-sm">{m.networking_hosts_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => hosts.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
<ListFilterIcon class="size-4" />
{m.users_filter()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
</div>
<div class="flex items-center justify-between p-2">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="net-hosts-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="net-hosts-rpp-{id}">
{#each [10, 25, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={openAdd}>
<PlusIcon class="size-4" />
{m.networking_host_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.networking_hosts_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {hosts.current?.length ?? 0}
</span>
</div>
{#snippet sortHead(key: SortKey, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key)}
>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
{/snippet}
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-56">{@render sortHead('ip', m.networking_col_ip())}</Table.Head>
<Table.Head>{@render sortHead('hostnames', m.networking_col_hostnames())}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if hosts.loading && !hosts.current}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center">
{m.networking_no_hosts()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as h,i (h.ip,i)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{h.ip}</Table.Cell>
<Table.Cell class="flex flex-wrap gap-1">
{#each h.hostnames ?? [] as n (n)}
<Badge variant="outline" class="font-mono">{n}</Badge>
{/each}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(h)}>{m.edit()}</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = h;
deleteOpen = true;
}}
>
{m.networking_host_remove()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="mt-auto flex items-center justify-end gap-4 p-4">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
<Dialog.Root bind:open={editOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{editingExisting ? m.networking_host_edit() : m.networking_host_add()}
</Dialog.Title>
<Dialog.Description>{m.networking_host_add_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doSave();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="h-ip-{id}">{m.networking_col_ip()}</Label>
<Input
id="h-ip-{id}"
bind:value={editForm.ip}
placeholder="192.168.1.10"
required
readonly={editingExisting}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="h-names-{id}">{m.networking_col_hostnames()}</Label>
<Input
id="h-names-{id}"
bind:value={editForm.hostnames}
placeholder="server server.local"
required
/>
</div>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (editOpen = false)}>
{m.cancel()}
</Button>
<Button type="submit">{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
{m.networking_host_remove_title({ ip: deleting?.ip ?? '' })}
</AlertDialog.Title>
<AlertDialog.Description>{m.networking_host_remove_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.networking_host_remove()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,391 @@
<script lang="ts">
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import CheckIcon from '@lucide/svelte/icons/check';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {
confirmChange,
getPending,
linkDown,
linkUp,
listInterfaces,
rollbackChange
} from '$lib/remotes/networking.remote';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const interfaces = $derived(listInterfaces(machineId));
const pending = $derived(getPending(machineId));
const pageSize = new PersistedState<number>('networking.interfaces.pageSize', 25);
let page = $state(1);
let search = $state('');
let busy = $state<null | string>(null);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
async function run(key: string, fn: () => Promise<unknown>, success: string) {
busy = key;
try {
await fn();
toast.success(success);
} catch (e) {
handleError(e);
} finally {
busy = null;
}
}
type SortKey = 'mac' | 'mtu' | 'name' | 'state';
let sort = $state<{ dir: 'asc' | 'desc'; key: SortKey }>({ dir: 'asc', key: 'name' });
function toggleSort(key: SortKey) {
if (sort.key === key) sort = { dir: sort.dir === 'asc' ? 'desc' : 'asc', key };
else sort = { dir: 'asc', key };
}
const filtered = $derived.by(() => {
const list = interfaces.current ?? [];
const q = search.trim().toLowerCase();
const matched = !q
? [...list]
: list.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.mac.toLowerCase().includes(q) ||
(i.ipv4 ?? []).some((a) => a.toLowerCase().includes(q)) ||
(i.ipv6 ?? []).some((a) => a.toLowerCase().includes(q))
);
matched.sort((a, b) => {
const av = a[sort.key];
const bv = b[sort.key];
const cmp =
typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return sort.dir === 'asc' ? cmp : -cmp;
});
return matched;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) page = totalPages;
});
</script>
<PageMeta
title={m.seo_title_networking_interfaces()}
description={m.seo_desc_networking_interfaces()}
/>
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="truncate text-2xl font-semibold tracking-tight">
{m.nav_networking_interfaces()}
</h1>
<p class="text-muted-foreground truncate text-sm">
{m.networking_interfaces_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => {
interfaces.refresh();
pending.refresh();
}}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
<ListFilterIcon class="size-4" />
{m.users_filter()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
</div>
<div class="flex items-center justify-between p-2">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="net-iface-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="net-iface-rpp-{id}">
{#each [10, 25, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
{#if pending.current}
<div
class="mx-4 mb-2 flex items-center justify-between gap-3 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm"
>
<span>
{m.networking_pending_banner({
iface: pending.current.interface,
seconds: pending.current.seconds_remaining
})}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={!!busy}
onclick={() =>
run(
'rollback',
() => rollbackChange({ machineId, name: pending.current!.interface }),
m.networking_rolled_back()
)}
>
<RotateCcwIcon class="size-4" />
{m.networking_rollback()}
</Button>
<Button
size="sm"
disabled={!!busy}
onclick={() =>
run(
'confirm',
() => confirmChange({ machineId, name: pending.current!.interface }),
m.networking_confirmed()
)}
>
<CheckIcon class="size-4" />
{m.networking_confirm()}
</Button>
</div>
</div>
{/if}
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.networking_interfaces_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {interfaces.current?.length ?? 0}
</span>
</div>
{#snippet sortHead(key: SortKey, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key)}
>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
{/snippet}
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{@render sortHead('name', m.networking_col_name())}</Table.Head>
<Table.Head class="w-24"
>{@render sortHead('state', m.networking_col_state())}</Table.Head
>
<Table.Head>{m.networking_col_ipv4()}</Table.Head>
<Table.Head>{m.networking_col_ipv6()}</Table.Head>
<Table.Head>{@render sortHead('mac', m.networking_col_mac())}</Table.Head>
<Table.Head class="w-20">{@render sortHead('mtu', m.networking_col_mtu())}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if interfaces.loading && !interfaces.current}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">
{m.networking_no_interfaces()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as i (i.name)}
<Table.Row>
<Table.Cell class="font-mono text-xs font-medium">
<a
href={resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
machineId,
name: i.name
})}
class="text-primary hover:underline"
>
{i.name}
</a>
</Table.Cell>
<Table.Cell>
{#if i.state === 'up'}
<Badge
class="border-0 bg-emerald-500/15 capitalize text-emerald-700 dark:text-emerald-400"
>{i.state}</Badge
>
{:else if i.state === 'down'}
<Badge
class="border-0 bg-zinc-500/15 capitalize text-zinc-700 dark:text-zinc-400"
>{i.state}</Badge
>
{:else}
<Badge variant="outline" class="capitalize">{i.state}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
<span class="block truncate" title={(i.ipv4 ?? []).join(', ')}>
{(i.ipv4 ?? []).join(', ') || '—'}
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground max-w-56 font-mono text-xs">
<span class="block truncate" title={(i.ipv6 ?? []).join(', ')}>
{(i.ipv6 ?? []).join(', ') || '—'}
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mac}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{i.mtu}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() =>
goto(
resolve('/dashboard/[machineId]/networking/interfaces/[name]', {
machineId,
name: i.name
})
)}
>
{m.networking_details()}
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() =>
goto(
resolve(
'/dashboard/[machineId]/networking/interfaces/[name]/configure',
{ machineId, name: i.name }
)
)}
>
{m.networking_configure()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
disabled={!!busy || i.state === 'up'}
onclick={() =>
run(
`up-${i.name}`,
() => linkUp({ machineId, name: i.name }),
m.networking_link_up_done({ name: i.name })
)}
>
{m.networking_link_up()}
</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
disabled={!!busy || i.state === 'down'}
onclick={() =>
run(
`down-${i.name}`,
() => linkDown({ machineId, name: i.name }),
m.networking_link_down_done({ name: i.name })
)}
>
{m.networking_link_down()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="mt-auto flex items-center justify-end gap-4 p-4">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
@@ -0,0 +1,159 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import PencilIcon from '@lucide/svelte/icons/pencil';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { getInterfaceConfig } from '$lib/remotes/networking.remote';
const name = $derived(pageState.params.name!);
const machineId = $derived(pageState.params.machineId!);
const config = $derived(getInterfaceConfig({ machineId, name }).current);
const v4Badge = $derived(config?.method === 'dhcp' ? 'DHCP' : 'Static');
const v6Badge = $derived(
config?.ipv6?.method === 'auto'
? 'SLAAC'
: config?.ipv6?.method === 'static'
? 'Static'
: config?.ipv6?.method === 'ignore'
? 'Disabled'
: '—'
);
</script>
<PageMeta title={name} description={m.seo_desc_networking_interfaces()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<div class="flex items-start gap-3">
<Button
variant="ghost"
size="icon"
href={resolve('/dashboard/[machineId]/networking/interfaces', { machineId })}
title={m.error_action_back()}
>
<ArrowLeftIcon class="size-4" />
</Button>
<div class="flex min-w-0 flex-1 items-start justify-between gap-2">
<div class="flex flex-col gap-0.5">
<h1 class="font-mono text-2xl font-semibold tracking-tight">{name}</h1>
<p class="text-muted-foreground text-sm">{m.networking_interfaces_description()}</p>
</div>
{#if config}
<Button
href={resolve('/dashboard/[machineId]/networking/interfaces/[name]/configure', {
machineId,
name
})}
>
<PencilIcon class="size-4" />
{m.networking_configure()}
</Button>
{/if}
</div>
</div>
{#if config}
<!-- IPv4 -->
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between gap-2">
<Card.Title>{m.networking_ipv4_section()}</Card.Title>
<Badge variant="outline">{v4Badge}</Badge>
</Card.Header>
<Card.Content class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.machine_address()}</span>
<span class="font-mono text-sm">{config.address || '—'}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.prefix()}</span>
<span class="font-mono text-sm">{config.prefix ?? '—'}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.networking_col_gateway()}</span>
<span class="font-mono text-sm">{config.gateway || '—'}</span>
</div>
</Card.Content>
</Card.Root>
<!-- IPv6 -->
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between gap-2">
<Card.Title>{m.networking_ipv6_section()}</Card.Title>
<Badge variant="outline">{v6Badge}</Badge>
</Card.Header>
{#if config.ipv6}
<Card.Content class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.machine_address()}</span>
<span class="font-mono text-sm">{config.ipv6.address || '—'}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.prefix()}</span>
<span class="font-mono text-sm">{config.ipv6.prefix ?? '—'}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs">{m.networking_col_gateway()}</span>
<span class="font-mono text-sm">{config.ipv6.gateway || '—'}</span>
</div>
</Card.Content>
{:else}
<Card.Content>
<p class="text-muted-foreground text-sm"></p>
</Card.Content>
{/if}
</Card.Root>
<!-- DNS -->
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_dns_section()}</Card.Title>
</Card.Header>
<Card.Content>
{#if config.dns?.length}
<div class="flex flex-col gap-1.5">
{#each config.dns as server, i (i)}
<span class="font-mono text-sm">{server}</span>
{/each}
</div>
{:else}
<p class="text-muted-foreground text-sm"></p>
{/if}
</Card.Content>
</Card.Root>
<!-- Routes -->
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_routes_section()}</Card.Title>
</Card.Header>
<Card.Content>
{#if config.routes?.length}
<div class="flex flex-col gap-1.5">
{#each config.routes as route, i (i)}
<div class="grid grid-cols-[1fr_1fr] gap-2 font-mono text-sm">
<span>{route.destination}</span>
<span class="text-muted-foreground">{route.gateway}</span>
</div>
{/each}
</div>
{:else}
<p class="text-muted-foreground text-sm"></p>
{/if}
</Card.Content>
</Card.Root>
<!-- Rollback -->
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_rollback_seconds()}</Card.Title>
</Card.Header>
<Card.Content>
<p class="font-mono text-sm">{config.rollback_seconds ?? 60}s</p>
</Card.Content>
</Card.Root>
{/if}
</div>
@@ -0,0 +1,355 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import PlusIcon from '@lucide/svelte/icons/plus';
import TrashIcon from '@lucide/svelte/icons/trash-2';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import { applyInterfaceConfig, getInterfaceConfig } from '$lib/remotes/networking.remote';
import { toast } from 'svelte-sonner';
const id = $props.id();
const name = $derived(pageState.params.name!);
const machineId = $derived(pageState.params.machineId!);
const config = $derived(getInterfaceConfig({ machineId, name }).current);
let v4Method = $state<'dhcp' | 'static'>('dhcp');
let v4 = $state({ address: '', gateway: '', prefix: 24 });
let v6Method = $state<'auto' | 'ignore' | 'static'>('auto');
let v6 = $state({ address: '', gateway: '', prefix: 64 });
let dns = $state<string[]>(['']);
let routes = $state<{ destination: string; gateway: string }[]>([]);
let rollbackSeconds = $state(60);
let confirmOpen = $state(false);
let submitting = $state(false);
$effect(() => {
const c = config;
if (!c) return;
v4Method = c.method;
v4 = { address: c.address ?? '', gateway: c.gateway ?? '', prefix: c.prefix ?? 24 };
if (c.ipv6) {
v6Method = c.ipv6.method;
v6 = {
address: c.ipv6.address ?? '',
gateway: c.ipv6.gateway ?? '',
prefix: c.ipv6.prefix ?? 64
};
}
dns = c.dns?.length ? [...c.dns] : [''];
routes = c.routes?.length ? c.routes.map((r) => ({ ...r })) : [];
rollbackSeconds = c.rollback_seconds ?? 60;
});
function addDns() {
dns = [...dns, ''];
}
function removeDns(i: number) {
dns = dns.filter((_, j) => j !== i);
}
function addRoute() {
routes = [...routes, { destination: '', gateway: '' }];
}
function removeRoute(i: number) {
routes = routes.filter((_, j) => j !== i);
}
const valid = $derived.by(() => {
if (v4Method === 'static' && !v4.address.trim()) return false;
if (v6Method === 'static' && !v6.address.trim()) return false;
for (const r of routes) {
if (!r.destination.trim() || !r.gateway.trim()) return false;
}
return true;
});
async function submit() {
submitting = true;
try {
const cleanedDns = dns.map((s) => s.trim()).filter(Boolean);
await applyInterfaceConfig({
address: v4Method === 'static' ? v4.address.trim() : undefined,
dns: cleanedDns.length ? cleanedDns : undefined,
gateway: v4Method === 'static' && v4.gateway.trim() ? v4.gateway.trim() : undefined,
ipv6: {
address: v6Method === 'static' ? v6.address.trim() : undefined,
gateway: v6Method === 'static' && v6.gateway.trim() ? v6.gateway.trim() : undefined,
method: v6Method,
prefix: v6Method === 'static' ? v6.prefix : undefined
},
machineId,
method: v4Method,
name,
prefix: v4Method === 'static' ? v4.prefix : undefined,
rollback_seconds: rollbackSeconds || undefined,
routes: routes.length ? routes : undefined
});
toast.success(m.networking_apply_done());
await goto(resolve('/dashboard/[machineId]/networking/interfaces', { machineId }));
} catch (e) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
} finally {
submitting = false;
confirmOpen = false;
}
}
</script>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<div class="flex items-start gap-3">
<Button
variant="ghost"
size="icon"
href={resolve('/dashboard/[machineId]/networking/interfaces', { machineId })}
title={m.error_action_back()}
>
<ArrowLeftIcon class="size-4" />
</Button>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="font-mono text-2xl font-semibold tracking-tight">{name}</h1>
<p class="text-muted-foreground text-sm">{m.networking_configure_description()}</p>
</div>
</div>
{#if config}
<form
onsubmit={(e) => {
e.preventDefault();
if (valid) confirmOpen = true;
}}
class="flex flex-col gap-4"
>
<!-- IPv4 -->
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_ipv4_section()}</Card.Title>
<Card.Description>{m.networking_ipv4_section_hint()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<RadioGroup.Root bind:value={v4Method} class="grid grid-cols-2 gap-2">
<Label
class="border-input has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 flex cursor-pointer items-center gap-2 rounded-md border p-3 font-normal"
>
<RadioGroup.Item value="dhcp" />
{m.networking_method_dhcp()}
</Label>
<Label
class="border-input has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 flex cursor-pointer items-center gap-2 rounded-md border p-3 font-normal"
>
<RadioGroup.Item value="static" />
{m.networking_method_static()}
</Label>
</RadioGroup.Root>
{#if v4Method === 'static'}
<Separator />
<div class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_6rem]">
<div class="flex flex-col gap-1.5">
<Label for="v4-addr-{id}">{m.machine_address()}</Label>
<Input
id="v4-addr-{id}"
bind:value={v4.address}
placeholder="192.168.1.10"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v4-prefix-{id}">{m.prefix()}</Label>
<Input
id="v4-prefix-{id}"
type="number"
min="0"
max="32"
bind:value={v4.prefix}
required
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v4-gw-{id}">{m.networking_col_gateway()} {m.optional()}</Label>
<Input id="v4-gw-{id}" bind:value={v4.gateway} placeholder="192.168.1.1" />
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- IPv6 -->
<Card.Root>
<Card.Header>
<Card.Title>{m.networking_ipv6_section()}</Card.Title>
<Card.Description>{m.networking_ipv6_section_hint()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<RadioGroup.Root bind:value={v6Method} class="grid grid-cols-3 gap-2">
<Label
class="border-input has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 flex cursor-pointer items-center gap-2 rounded-md border p-3 font-normal"
>
<RadioGroup.Item value="auto" />
{m.networking_method_slaac()}
</Label>
<Label
class="border-input has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 flex cursor-pointer items-center gap-2 rounded-md border p-3 font-normal"
>
<RadioGroup.Item value="static" />
{m.networking_method_static()}
</Label>
<Label
class="border-input has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 flex cursor-pointer items-center gap-2 rounded-md border p-3 font-normal"
>
<RadioGroup.Item value="ignore" />
{m.networking_method_ignore()}
</Label>
</RadioGroup.Root>
{#if v6Method === 'static'}
<Separator />
<div class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_6rem]">
<div class="flex flex-col gap-1.5">
<Label for="v6-addr-{id}">{m.machine_address()}</Label>
<Input
id="v6-addr-{id}"
bind:value={v6.address}
placeholder="2001:db8::10"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v6-prefix-{id}">{m.prefix()}</Label>
<Input
id="v6-prefix-{id}"
type="number"
min="0"
max="128"
bind:value={v6.prefix}
required
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v6-gw-{id}">{m.networking_col_gateway()} {m.optional()}</Label>
<Input id="v6-gw-{id}" bind:value={v6.gateway} placeholder="2001:db8::1" />
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- DNS -->
<Card.Root>
<Card.Header class="flex flex-row items-start justify-between gap-2">
<div>
<Card.Title>{m.networking_dns_section()}</Card.Title>
<Card.Description>{m.networking_dns_section_hint()}</Card.Description>
</div>
<Button type="button" variant="outline" size="sm" onclick={addDns}>
<PlusIcon class="size-4" />
{m.networking_dns_add()}
</Button>
</Card.Header>
<Card.Content class="flex flex-col gap-2">
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#each dns as _, i (i)}
<div class="flex items-center gap-2">
<Input bind:value={dns[i]} placeholder={m.networking_dns_placeholder()} />
<Button
type="button"
variant="ghost"
size="icon"
class="size-9 shrink-0"
onclick={() => removeDns(i)}
>
<TrashIcon class="size-4" />
</Button>
</div>
{/each}
</Card.Content>
</Card.Root>
<!-- Routes -->
<Card.Root>
<Card.Header class="flex flex-row items-start justify-between gap-2">
<div>
<Card.Title>{m.networking_routes_section()}</Card.Title>
<Card.Description>{m.networking_routes_section_hint()}</Card.Description>
</div>
<Button type="button" variant="outline" size="sm" onclick={addRoute}>
<PlusIcon class="size-4" />
{m.networking_route_add()}
</Button>
</Card.Header>
<Card.Content class="flex flex-col gap-2">
{#if !routes.length}
<p class="text-muted-foreground text-sm"></p>
{/if}
{#each routes as r, i (i)}
<div class="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_1fr_auto]">
<Input
bind:value={r.destination}
placeholder={m.networking_route_destination()}
required
/>
<Input bind:value={r.gateway} placeholder={m.networking_route_gateway()} required />
<Button
type="button"
variant="ghost"
size="icon"
class="size-9 shrink-0"
onclick={() => removeRoute(i)}
>
<TrashIcon class="size-4" />
</Button>
</div>
{/each}
</Card.Content>
</Card.Root>
<!-- Rollback + submit -->
<Card.Root>
<Card.Content class="flex flex-col gap-3 py-4 sm:flex-row sm:items-end sm:justify-between">
<div class="flex flex-col gap-1.5">
<Label for="rollback-{id}">{m.networking_rollback_seconds()}</Label>
<Input
id="rollback-{id}"
type="number"
min="0"
max="600"
class="w-32"
bind:value={rollbackSeconds}
/>
<p class="text-muted-foreground text-xs">{m.networking_rollback_seconds_hint()}</p>
</div>
<Button type="submit" disabled={!valid || submitting}>{m.networking_apply()}</Button>
</Card.Content>
</Card.Root>
</form>
{/if}
</div>
<AlertDialog.Root bind:open={confirmOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.networking_apply_confirm_title()}</AlertDialog.Title>
<AlertDialog.Description>
{m.networking_apply_confirm_body({ seconds: rollbackSeconds || 60 })}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={submit} disabled={submitting}>
{m.networking_apply()}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,216 @@
<script lang="ts">
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listRoutes } from '$lib/remotes/networking.remote';
import { PersistedState } from 'runed';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const routes = $derived(listRoutes(machineId));
const pageSize = new PersistedState<number>('networking.routes.pageSize', 25);
let page = $state(1);
let search = $state('');
type SortKey = 'destination' | 'gateway' | 'interface' | 'metric';
let sort = $state<{ dir: 'asc' | 'desc'; key: SortKey }>({ dir: 'asc', key: 'destination' });
function toggleSort(key: SortKey) {
if (sort.key === key) sort = { dir: sort.dir === 'asc' ? 'desc' : 'asc', key };
else sort = { dir: 'asc', key };
}
const filtered = $derived.by(() => {
const list = routes.current ?? [];
const q = search.trim().toLowerCase();
const matched = !q
? [...list]
: list.filter(
(r) =>
r.destination.toLowerCase().includes(q) ||
(r.gateway ?? '').toLowerCase().includes(q) ||
r.interface.toLowerCase().includes(q)
);
matched.sort((a, b) => {
const av = a[sort.key];
const bv = b[sort.key];
const cmp =
typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av ?? '').localeCompare(String(bv ?? ''));
return sort.dir === 'asc' ? cmp : -cmp;
});
return matched;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) page = totalPages;
});
</script>
<PageMeta title={m.seo_title_networking_routes()} description={m.seo_desc_networking_routes()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="bg-background sticky top-15 z-20 mb-2 flex flex-col gap-2 p-4 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="truncate text-2xl font-semibold tracking-tight">{m.nav_networking_routes()}</h1>
<p class="text-muted-foreground truncate text-sm">{m.networking_routes_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => routes.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
<ListFilterIcon class="size-4" />
{m.users_filter()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_display()}</h3>
</div>
<div class="flex items-center justify-between p-2">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="net-routes-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="net-routes-rpp-{id}">
{#each [10, 25, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.networking_routes_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {routes.current?.length ?? 0}
</span>
</div>
{#snippet sortHead(key: SortKey, label: string)}
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(key)}
>
{label}
{#if sort.key === key}
{#if sort.dir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-40" />
{/if}
</button>
{/snippet}
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head
>{@render sortHead('destination', m.networking_col_destination())}</Table.Head
>
<Table.Head>{@render sortHead('gateway', m.networking_col_gateway())}</Table.Head>
<Table.Head>{@render sortHead('interface', m.networking_col_interface())}</Table.Head>
<Table.Head>{m.networking_col_source()}</Table.Head>
<Table.Head class="w-20"
>{@render sortHead('metric', m.networking_col_metric())}</Table.Head
>
</Table.Row>
</Table.Header>
<Table.Body>
{#if routes.loading && !routes.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
{m.networking_no_routes()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as r, i (r.destination + '\0' + r.interface + i)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{r.destination}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.gateway ?? '—'}
</Table.Cell>
<Table.Cell class="font-mono text-xs">{r.interface}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.source ?? '—'}
</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">
{r.metric ?? '—'}
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="mt-auto flex items-center justify-end gap-4 p-4">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
@@ -0,0 +1,40 @@
<script lang="ts">
import ArrowUpCircleIcon from '@lucide/svelte/icons/arrow-up-circle';
import PackageIcon from '@lucide/svelte/icons/package';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
const machineId = $derived(page.params.machineId!);
</script>
<PageMeta title={m.seo_title_packages()} description={m.seo_desc_packages()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_packages()}</h1>
<div class="grid gap-4 sm:grid-cols-2">
<a href={resolve('/dashboard/[machineId]/packages/installed', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<PackageIcon class="size-5" />
<Card.Title>{m.nav_packages_installed()}</Card.Title>
</div>
<Card.Description>{m.nav_packages_installed_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/packages/updates', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<ArrowUpCircleIcon class="size-5" />
<Card.Title>{m.nav_packages_updates()}</Card.Title>
</div>
<Card.Description>{m.nav_packages_updates_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
</div>
</div>
@@ -0,0 +1,436 @@
<script lang="ts">
import AlertCircleIcon from '@lucide/svelte/icons/alert-circle';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpCircleIcon from '@lucide/svelte/icons/arrow-up-circle';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
import {
listInstalledPackages,
listPackageUpdates,
streamPackageAction
} from '$lib/remotes/packages.remote';
import { PersistedState } from 'runed';
type Package = { name: string; version: string };
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const dataResource = $derived(listInstalledPackages(machineId));
const updatesResource = $derived(listPackageUpdates(machineId));
const manager = $derived(dataResource.current?.manager ?? '');
const packages = $derived((dataResource.current?.packages ?? []) as Package[]);
// name → new version, for the per-row "update available" badge.
const updatesMap = $derived(
new Map(
((updatesResource.current?.packages ?? []) as Package[]).map((p) => [p.name, p.version])
)
);
const pageSize = new PersistedState<number>('packages.installed.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
let sortDir = $state<'asc' | 'desc'>('asc');
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
function toggleSort() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
page = 1;
}
const filtered = $derived.by(() => {
const q = debouncedSearch.trim().toLowerCase();
let list = [...packages];
if (q) {
list = list.filter((p) => p.name.toLowerCase().includes(q));
}
list.sort((a, b) => {
const comp = a.name.localeCompare(b.name);
return sortDir === 'asc' ? comp : -comp;
});
return list;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
// Dialogs
let createOpen = $state(false);
let installName = $state('');
let deleteOpen = $state(false);
let deleting = $state<null | Package>(null);
// Terminal Stream Console
let consoleOpen = $state(false);
let consoleTitle = $state('');
let consoleLogs = $state<string[]>([]);
let consoleStatus = $state<'failed' | 'running' | 'success'>('running');
let consoleEl = $state<HTMLDivElement | null>(null);
$effect(() => {
if (consoleLogs.length && consoleEl) {
consoleEl.scrollTop = consoleEl.scrollHeight;
}
});
async function runAction(action: 'install' | 'remove' | 'upgrade', name: string) {
consoleOpen = true;
consoleLogs = [];
consoleStatus = 'running';
consoleTitle =
action === 'install'
? m.packages_install_started({ name })
: action === 'upgrade'
? m.packages_update_started({ name })
: m.packages_remove_started({ name });
try {
const stream = streamPackageAction({ action, machineId, name });
for await (const chunk of stream) {
if (chunk.event === 'output') {
consoleLogs = [...consoleLogs, chunk.data.line];
} else if (chunk.event === 'error') {
consoleLogs = [...consoleLogs, m.packages_stream_error({ message: chunk.data.message })];
consoleStatus = 'failed';
} else if (chunk.event === 'done') {
if (chunk.data.success) {
consoleLogs = [
...consoleLogs,
`\n${action === 'install' ? m.packages_install_success({ name }) : action === 'upgrade' ? m.packages_update_success({ name }) : m.packages_remove_success({ name })}`
];
consoleStatus = 'success';
} else {
consoleLogs = [...consoleLogs, `\n${chunk.data.error ? m.packages_stream_error({ message: chunk.data.error }) : m.packages_stream_failed()}`];
consoleStatus = 'failed';
}
}
}
} catch (err) {
consoleLogs = [...consoleLogs, `\n${m.packages_stream_connection_error({ message: (err as Error).message || `${err}` })}`];
consoleStatus = 'failed';
} finally {
await dataResource.refresh();
await updatesResource.refresh();
}
}
const runUpdate = (name: string) => runAction('upgrade', name);
async function doInstall() {
const name = installName.trim();
if (!name) return;
createOpen = false;
installName = '';
await runAction('install', name);
}
async function doRemove() {
if (!deleting) return;
const name = deleting.name;
deleteOpen = false;
deleting = null;
await runAction('remove', name);
}
</script>
<PageMeta title={m.seo_title_packages_installed()} description={m.seo_desc_packages_installed()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_packages_installed()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.nav_packages_installed_desc()}
{#if manager}({m.packages_manager_label()}: {manager}){/if}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => dataResource.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-4" align="end">
<div class="flex flex-col gap-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="pkg-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="pkg-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.packages_install()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.packages_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {packages.length}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-8"></Table.Head>
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
{m.packages_col_name()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head>{m.packages_col_version()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if dataResource.loading && !dataResource.current}
<Table.Row>
<Table.Cell colspan={4} class="text-muted-foreground py-8 text-center">
<Loader2Icon class="size-4 animate-spin inline" />
</Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={4} class="text-muted-foreground py-8 text-center"
>{m.packages_no_packages()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as p (p.name)}
{@const newVersion = updatesMap.get(p.name)}
<Table.Row>
<Table.Cell class="w-8">
{#if newVersion}
<Tooltip.Root>
<Tooltip.Trigger>
<AlertCircleIcon class="text-amber-500 size-4" />
</Tooltip.Trigger>
<Tooltip.Content side="top">
{m.packages_update_available({ from: p.version, to: newVersion })}
</Tooltip.Content>
</Tooltip.Root>
{/if}
</Table.Cell>
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if newVersion}
<DropdownMenu.Item onclick={() => runUpdate(p.name)}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = p;
deleteOpen = true;
}}>{m.delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.packages_install()}</Dialog.Title>
<Dialog.Description>{m.packages_install_desc()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doInstall();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="pkg-name-{id}">{m.packages_col_name()}</Label>
<Input id="pkg-name-{id}" bind:value={installName} placeholder="e.g. htop" required />
</div>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit">{m.packages_install_button()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title
>{m.packages_remove_confirm_title({ name: deleting?.name ?? '' })}</AlertDialog.Title
>
<AlertDialog.Description>
{m.packages_remove_confirm_desc({ name: deleting?.name ?? '' })}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action
onclick={doRemove}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>{m.delete()}</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Stream Console Modal -->
<Dialog.Root bind:open={consoleOpen}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<TerminalIcon class="size-5" />
{consoleTitle}
</Dialog.Title>
<Dialog.Description>{m.packages_terminal_desc()}</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-3 mt-2">
<div
bind:this={consoleEl}
class="bg-zinc-950 font-mono text-zinc-100 text-xs p-4 rounded-md h-96 overflow-y-auto whitespace-pre-wrap select-text leading-relaxed border border-zinc-800 shadow-inner"
>
{#each consoleLogs as log, i (i)}
{log + '\n'}
{/each}
{#if consoleStatus === 'running'}
<Loader2Icon class="size-3 animate-spin inline text-zinc-500" />
{/if}
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if consoleStatus === 'running'}
<Badge variant="secondary" class="animate-pulse">{m.dashboard_syncing()}</Badge>
{:else}
<Badge variant={consoleStatus === 'success' ? 'default' : 'destructive'}>
{consoleStatus.toUpperCase()}
</Badge>
{/if}
</div>
<Button disabled={consoleStatus === 'running'} onclick={() => (consoleOpen = false)}>
{m.finish()}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,319 @@
<script lang="ts">
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpCircleIcon from '@lucide/svelte/icons/arrow-up-circle';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listPackageUpdates, streamPackageAction } from '$lib/remotes/packages.remote';
import { PersistedState } from 'runed';
type Package = { name: string; version: string };
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const dataResource = $derived(listPackageUpdates(machineId));
const manager = $derived(dataResource.current?.manager ?? '');
const packages = $derived((dataResource.current?.packages ?? []) as Package[]);
const pageSize = new PersistedState<number>('packages.updates.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
let sortDir = $state<'asc' | 'desc'>('asc');
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
function toggleSort() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
page = 1;
}
const filtered = $derived.by(() => {
const q = debouncedSearch.trim().toLowerCase();
let list = [...packages];
if (q) {
list = list.filter((p) => p.name.toLowerCase().includes(q));
}
list.sort((a, b) => {
const comp = a.name.localeCompare(b.name);
return sortDir === 'asc' ? comp : -comp;
});
return list;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
// Terminal Stream Console
let consoleOpen = $state(false);
let consoleLogs = $state<string[]>([]);
let consoleStatus = $state<'failed' | 'running' | 'success'>('running');
let consoleEl = $state<HTMLDivElement | null>(null);
$effect(() => {
if (consoleLogs.length && consoleEl) {
consoleEl.scrollTop = consoleEl.scrollHeight;
}
});
// Empty `name` = upgrade all; otherwise upgrades a single package.
async function doUpgrade(name = '') {
consoleOpen = true;
consoleLogs = [];
consoleStatus = 'running';
try {
const stream = streamPackageAction({
action: 'upgrade',
machineId,
name: name || undefined
});
for await (const chunk of stream) {
if (chunk.event === 'output') {
consoleLogs = [...consoleLogs, chunk.data.line];
} else if (chunk.event === 'error') {
consoleLogs = [...consoleLogs, `ERROR: ${chunk.data.message}`];
consoleStatus = 'failed';
} else if (chunk.event === 'done') {
if (chunk.data.success) {
consoleLogs = [...consoleLogs, `\n${m.packages_upgrade_success()}`];
consoleStatus = 'success';
} else {
consoleLogs = [...consoleLogs, `\nERROR: ${chunk.data.error || 'Operation failed.'}`];
consoleStatus = 'failed';
}
}
}
} catch (err) {
consoleLogs = [...consoleLogs, `\nConnection Error: ${(err as Error).message || err}`];
consoleStatus = 'failed';
} finally {
await dataResource.refresh();
}
}
</script>
<PageMeta title={m.seo_title_packages_updates()} description={m.seo_desc_packages_updates()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_packages_updates()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.nav_packages_updates_desc()}
{#if manager}({m.packages_manager_label()}: {manager}){/if}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => dataResource.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-4" align="end">
<div class="flex flex-col gap-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="pkg-up-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="pkg-up-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => doUpgrade()} disabled={!packages.length}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_upgrade_all()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.packages_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {packages.length}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
{m.packages_col_name()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head>{m.packages_col_version()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if dataResource.loading && !dataResource.current}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center">
<Loader2Icon class="size-4 animate-spin inline" />
</Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={3} class="text-muted-foreground py-8 text-center"
>{m.packages_no_updates()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as p (p.name)}
<Table.Row>
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => doUpgrade(p.name)}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
<!-- Stream Console Modal -->
<Dialog.Root bind:open={consoleOpen}>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<TerminalIcon class="size-5" />
{m.packages_upgrade_all()}
</Dialog.Title>
<Dialog.Description>{m.packages_terminal_desc()}</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-3 mt-2">
<div
bind:this={consoleEl}
class="bg-zinc-950 max-w-lg font-mono text-zinc-100 text-xs p-4 rounded-md h-96 overflow-y-auto select-text whitespace-pre-wrap leading-relaxed border border-zinc-800 shadow-inner"
>
{#each consoleLogs as log, i (i)}
{log + '\n'}
{/each}
{#if consoleStatus === 'running'}
<Loader2Icon class="size-3 animate-spin inline text-zinc-500" />
{/if}
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if consoleStatus === 'running'}
<Badge variant="secondary" class="animate-pulse">{m.dashboard_syncing()}</Badge>
{:else}
<Badge variant={consoleStatus === 'success' ? 'default' : 'destructive'}>
{consoleStatus.toUpperCase()}
</Badge>
{/if}
</div>
<Button disabled={consoleStatus === 'running'} onclick={() => (consoleOpen = false)}>
{m.finish()}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,428 @@
<script lang="ts">
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listServices } from '$lib/remotes/services.remote';
import { PersistedState } from 'runed';
type ServiceUnit = {
active: string;
description: string;
load: string;
sub: string;
unit: string;
};
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const servicesResource = $derived(listServices(machineId));
const services = $derived((servicesResource.current?.services ?? []) as ServiceUnit[]);
const pageSize = new PersistedState<number>('services.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
let sortDir = $state<'asc' | 'desc'>('asc');
// Active State Filter
let filterActive = $state({
active: true,
failed: true,
inactive: true,
other: true
});
// Load State Filter
let filterLoad = $state({
error: true,
loaded: true,
masked: true,
notFound: true
});
// Sub State Filter
let filterSub = $state({
dead: true,
exited: true,
other: true,
running: true
});
const activeFilterCount = $derived(
(Object.values(filterActive).some((v) => !v) ? 1 : 0) +
(Object.values(filterLoad).some((v) => !v) ? 1 : 0) +
(Object.values(filterSub).some((v) => !v) ? 1 : 0)
);
function resetFilters() {
filterActive = { active: true, failed: true, inactive: true, other: true };
filterLoad = { error: true, loaded: true, masked: true, notFound: true };
filterSub = { dead: true, exited: true, other: true, running: true };
page = 1;
}
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
function toggleSort() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
page = 1;
}
const filtered = $derived.by(() => {
const q = debouncedSearch.trim().toLowerCase();
let list = [...services];
// Text Search
if (q) {
list = list.filter(
(s) => s.unit.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
);
}
// Active State Filter
list = list.filter((s) => {
const activeCat =
s.active === 'active'
? 'active'
: s.active === 'inactive'
? 'inactive'
: s.active === 'failed'
? 'failed'
: 'other';
return filterActive[activeCat];
});
// Load State Filter
list = list.filter((s) => {
const loadCat =
s.load === 'loaded'
? 'loaded'
: s.load === 'masked'
? 'masked'
: s.load === 'not-found'
? 'notFound'
: s.load === 'error'
? 'error'
: 'loaded';
return filterLoad[loadCat as keyof typeof filterLoad] ?? true;
});
// Sub State Filter
list = list.filter((s) => {
const subCat =
s.sub === 'running'
? 'running'
: s.sub === 'exited'
? 'exited'
: s.sub === 'dead'
? 'dead'
: 'other';
return filterSub[subCat as keyof typeof filterSub] ?? true;
});
// Sorting
list.sort((a, b) => {
const comp = a.unit.localeCompare(b.unit);
return sortDir === 'asc' ? comp : -comp;
});
return list;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) {
page = totalPages;
}
});
</script>
<PageMeta title={m.seo_title_services()} description={m.seo_desc_services()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.services_title()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.services_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => servicesResource.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0" align="end">
<div class="border-b p-3 flex items-center justify-between">
<h3 class="text-sm font-semibold">{m.services_filter_title()}</h3>
{#if activeFilterCount > 0}
<Button variant="ghost" size="sm" class="h-auto p-1 text-xs" onclick={resetFilters}>
{m.cancel()}
</Button>
{/if}
</div>
<div class="flex flex-col gap-4 p-4">
<!-- Active State -->
<div class="flex flex-col gap-2">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_active_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.active} />
{m.services_filter_active()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.inactive} />
{m.services_filter_inactive()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.failed} />
{m.services_filter_failed()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
<!-- Load State -->
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_load_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.loaded} />
{m.services_filter_loaded()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.masked} />
{m.services_filter_masked()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.notFound} />
{m.services_filter_not_found()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.error} />
{m.services_filter_error()}
</Label>
</div>
</div>
<!-- Sub State -->
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_sub_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.running} />
{m.services_filter_running()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.exited} />
{m.services_filter_exited()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.dead} />
{m.services_filter_dead()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
<!-- Rows per page -->
<div class="flex items-center justify-between border-t pt-3">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="services-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="services-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.services_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {services.length}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
{m.services_col_unit()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head class="w-24">{m.services_col_active()}</Table.Head>
<Table.Head class="w-24">{m.services_col_sub()}</Table.Head>
<Table.Head class="w-24">{m.services_col_load()}</Table.Head>
<Table.Head>{m.services_col_description()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if servicesResource.loading && !servicesResource.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
<Loader2Icon class="size-4 animate-spin inline" />
</Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center">
{m.services_no_services()}
</Table.Cell>
</Table.Row>
{:else}
{#each pageRows as s (s.unit)}
<Table.Row>
<Table.Cell class="font-medium font-mono text-xs max-w-[20rem]">
<a
href={resolve('/dashboard/[machineId]/services/[name]', {
machineId,
name: s.unit
})}
class="block truncate text-primary hover:underline"
title={s.unit}
>
{s.unit}
</a>
</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs max-w-[20rem] w-full">
<span class="block truncate" title={s.description}>
{s.description}
</span>
</Table.Cell>
<Table.Cell>
{#if s.active === 'active'}
<Badge
class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else if s.active === 'inactive'}
<Badge
class="bg-zinc-500/15 text-zinc-700 dark:text-zinc-400 hover:bg-zinc-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else}
<Badge
class="bg-rose-500/15 text-rose-700 dark:text-rose-400 hover:bg-rose-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{/if}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">
{s.sub}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">
{s.load}
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.users_page_of({ page, total: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
</div>
</div>
@@ -0,0 +1,576 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import ChevronsLeftIcon from '@lucide/svelte/icons/chevrons-left';
import ChevronsRightIcon from '@lucide/svelte/icons/chevrons-right';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import PlayIcon from '@lucide/svelte/icons/play';
import PowerIcon from '@lucide/svelte/icons/power';
import PowerOffIcon from '@lucide/svelte/icons/power-off';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import RotateCwIcon from '@lucide/svelte/icons/rotate-cw';
import SearchXIcon from '@lucide/svelte/icons/search-x';
import SquareIcon from '@lucide/svelte/icons/square';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Empty from '$lib/components/ui/empty';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { m } from '$lib/paraglide/messages';
import {
disableService,
enableService,
getServiceLogs,
getServiceStatus,
restartService,
startService,
stopService,
streamServiceLogs
} from '$lib/remotes/services.remote';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
const name = $derived(pageState.params.name!);
const machineId = $derived(pageState.params.machineId!);
const statusResource = $derived(getServiceStatus({ machineId, unit: name }));
let busy = $state<null | string>(null);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
async function run(
action: 'disable' | 'enable' | 'restart' | 'start' | 'stop',
fn: () => Promise<unknown>,
successMsg: string
) {
busy = action;
try {
await fn();
toast.success(successMsg);
} catch (e) {
handleError(e);
} finally {
busy = null;
}
}
const id = $props.id();
const lines = new PersistedState<number>('services.logs.lines', 500);
const priority = new PersistedState<number>('services.logs.priority', 7);
const since = new PersistedState<string>('services.logs.since', '');
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
let logsPage = $state(1);
const logsPageSize = new PersistedState<number>('services.logs.pageSize', 100);
const live = new PersistedState<boolean>('services.logs.live', false);
const autoscroll = new PersistedState<boolean>('services.logs.autoscroll', true);
let scrollEl = $state<HTMLDivElement | null>(null);
const logsResource = $derived(
getServiceLogs({
lines: lines.current,
machineId,
priority: priority.current,
since: since.current || undefined,
unit: name
})
);
const snapshot = $derived(logsResource.current?.entries ?? []);
// Live tail buffer (capped at 5000)
let liveBuffer = $state<{ message: string; priority: number; time: string }[]>([]);
$effect(() => {
if (!live.current) return;
// ponytail: replay last snapshot as seed when live starts, then append SSE events
liveBuffer = [...snapshot];
const iter = streamServiceLogs({
machineId,
priority: priority.current,
since: since.current || undefined,
unit: name
})[Symbol.asyncIterator]();
let cancelled = false;
(async () => {
try {
while (!cancelled) {
const { done, value } = await iter.next();
if (done) break;
if (value.event === 'log' && typeof value.data === 'object') {
liveBuffer = [...liveBuffer.slice(-4999), value.data];
} else if (value.event === 'error') {
const msg =
typeof value.data === 'object' && 'message' in value.data
? (value.data as { message: string }).message
: String(value.data);
toast.error(msg || m.errors_generic());
}
}
} catch (err) {
console.error(err);
}
})();
return () => {
cancelled = true;
iter.return?.();
};
});
const entries = $derived(live.current ? liveBuffer : snapshot);
$effect(() => {
// Track entry count so autoscroll fires whenever new lines arrive
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
entries.length;
if (autoscroll.current && scrollEl) {
queueMicrotask(() => scrollEl?.scrollTo({ top: scrollEl.scrollHeight }));
}
});
const filteredLogs = $derived.by(() => {
const q = debouncedSearch.trim().toLowerCase();
if (!q) return entries;
return entries.filter((e) => e.message.toLowerCase().includes(q));
});
const totalLogPages = $derived(
Math.max(1, Math.ceil(filteredLogs.length / logsPageSize.current))
);
const pageLogs = $derived(
filteredLogs.slice((logsPage - 1) * logsPageSize.current, logsPage * logsPageSize.current)
);
$effect(() => {
if (logsPage > totalLogPages) logsPage = totalLogPages;
});
function onLogSearch(e: Event) {
search = (e.target as HTMLInputElement).value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
logsPage = 1;
}, 200);
}
function priorityClass(p: number) {
if (p <= 3) return 'text-rose-600 dark:text-rose-400';
if (p === 4) return 'text-amber-600 dark:text-amber-400';
if (p === 5) return 'text-sky-600 dark:text-sky-400';
return 'text-muted-foreground';
}
function badgeClass(state: string) {
if (state === 'active' || state === 'enabled' || state === 'loaded' || state === 'running')
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-0 capitalize';
if (state === 'inactive' || state === 'disabled' || state === 'dead' || state === 'exited')
return 'bg-zinc-500/15 text-zinc-700 dark:text-zinc-400 border-0 capitalize';
if (state === 'static' || state === 'masked')
return 'bg-amber-500/15 text-amber-700 dark:text-amber-400 border-0 capitalize';
return 'bg-rose-500/15 text-rose-700 dark:text-rose-400 border-0 capitalize';
}
</script>
<PageMeta
title={`${name} · ${m.seo_title_services()}`}
description={m.seo_desc_services_detail()}
/>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<svelte:boundary>
{#snippet failed(err, reset)}
{@const e = err as { body?: { message?: string }; message?: string; status?: number }}
{@const status = e.status ?? 0}
{@const notFound = status === 404}
<Empty.Root class="border">
<Empty.Header>
<Empty.Media variant="icon" class="size-12">
{#if notFound}
<SearchXIcon class="size-6" />
{:else}
<TriangleAlertIcon class="size-6" />
{/if}
</Empty.Media>
<Empty.Title>
{notFound ? m.error_not_found_title() : m.error_generic_title()}
</Empty.Title>
<Empty.Description>
<span class="font-mono">{name}</span><br />
{notFound ? m.error_not_found_description() : m.error_generic_description()}
</Empty.Description>
</Empty.Header>
<Empty.Content class="gap-3">
{#if status}
<div class="text-muted-foreground text-xs font-mono">
{m.error_code_label({ status })}
</div>
{/if}
{#if e.body?.message || e.message}
<details class="w-full max-w-lg text-left">
<summary class="text-muted-foreground cursor-pointer text-xs">
{m.error_details()}
</summary>
<pre
class="bg-muted text-foreground mt-2 max-h-64 overflow-auto rounded-md border p-3 text-xs whitespace-pre-wrap">{e
.body?.message ?? e.message}</pre>
</details>
{/if}
<div class="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline" onclick={reset}>
<RefreshCwIcon class="size-4" />
{m.error_action_retry()}
</Button>
<Button href={resolve('/dashboard/[machineId]/services', { machineId })}>
<ArrowLeftIcon class="size-4" />
{m.nav_services()}
</Button>
</div>
</Empty.Content>
</Empty.Root>
{/snippet}
{@const s = await statusResource}
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight font-mono truncate" title={s.unit}>
{s.unit}
</h1>
<p class="text-muted-foreground text-sm truncate" title={s.description}>
{s.description}
</p>
</div>
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => statusResource.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
</div>
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-col lg:flex-row gap-4 lg:sticky lg:top-20 lg:self-start">
<Card.Root class="w-full">
<Card.Header>
<Card.Title>{m.services_details_title()}</Card.Title>
</Card.Header>
<Card.Content>
<dl class="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-3 text-sm">
<dt class="text-muted-foreground">{m.services_details_active_state()}</dt>
<dd><Badge class={badgeClass(s.active_state)}>{s.active_state}</Badge></dd>
<dt class="text-muted-foreground">{m.services_details_sub_state()}</dt>
<dd><Badge class={badgeClass(s.sub_state)}>{s.sub_state}</Badge></dd>
<dt class="text-muted-foreground">{m.services_details_load_state()}</dt>
<dd><Badge class={badgeClass(s.load_state)}>{s.load_state}</Badge></dd>
<dt class="text-muted-foreground">{m.services_details_unit_file_state()}</dt>
<dd><Badge class={badgeClass(s.unit_file_state)}>{s.unit_file_state}</Badge></dd>
</dl>
</Card.Content>
</Card.Root>
<Card.Root class="w-full">
<Card.Content class="grid grid-cols-2 gap-2 py-4">
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.active_state === 'active'}
onclick={() =>
run(
'start',
() => startService({ machineId, unit: s.unit }),
m.services_action_started({ name: s.unit })
)}
>
{#if busy === 'start'}
<Loader2Icon class="size-4 animate-spin" />
{:else}
<PlayIcon class="size-4" />
{/if}
{m.services_action_start()}
</Button>
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.active_state !== 'active'}
onclick={() =>
run(
'stop',
() => stopService({ machineId, unit: s.unit }),
m.services_action_stopped({ name: s.unit })
)}
>
{#if busy === 'stop'}
<Loader2Icon class="size-4 animate-spin" />
{:else}
<SquareIcon class="size-4" />
{/if}
{m.services_action_stop()}
</Button>
<Button
variant="outline"
class="col-span-2 justify-start"
disabled={!!busy}
onclick={() =>
run(
'restart',
() => restartService({ machineId, unit: s.unit }),
m.services_action_restarted({ name: s.unit })
)}
>
{#if busy === 'restart'}
<Loader2Icon class="size-4 animate-spin" />
{:else}
<RotateCwIcon class="size-4" />
{/if}
{m.services_action_restart()}
</Button>
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.unit_file_state === 'enabled' || s.unit_file_state === 'static'}
onclick={() =>
run(
'enable',
() => enableService({ machineId, unit: s.unit }),
m.services_action_enabled({ name: s.unit })
)}
>
{#if busy === 'enable'}
<Loader2Icon class="size-4 animate-spin" />
{:else}
<PowerIcon class="size-4" />
{/if}
{m.services_action_enable()}
</Button>
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.unit_file_state !== 'enabled'}
onclick={() =>
run(
'disable',
() => disableService({ machineId, unit: s.unit }),
m.services_action_disabled({ name: s.unit })
)}
>
{#if busy === 'disable'}
<Loader2Icon class="size-4 animate-spin" />
{:else}
<PowerOffIcon class="size-4" />
{/if}
{m.services_action_disable()}
</Button>
</Card.Content>
</Card.Root>
</div>
<Card.Root class="flex flex-col min-h-0">
<Card.Header class="flex flex-row items-center justify-between gap-2">
<Card.Title class="flex items-center gap-2">
{m.services_logs_title()}
{#if live.current}
<span
class="inline-flex items-center gap-1.5 text-xs font-normal text-emerald-600 dark:text-emerald-400"
>
<span class="relative flex size-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"
></span>
<span class="relative inline-flex size-2 rounded-full bg-emerald-500"></span>
</span>
{m.services_logs_live_streaming()}
</span>
{/if}
</Card.Title>
<div class="flex items-center gap-3">
<Label class="flex items-center gap-2 text-sm font-normal">
<Switch bind:checked={autoscroll.current} />
{m.services_logs_autoscroll()}
</Label>
<Label class="flex items-center gap-2 text-sm font-normal">
<Switch bind:checked={live.current} />
{m.services_logs_live()}
</Label>
{#if live.current}
<Button variant="outline" size="sm" onclick={() => (liveBuffer = [])}>
{m.services_logs_clear()}
</Button>
{:else}
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => logsResource.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
{/if}
</div>
</Card.Header>
<Card.Content class="flex flex-col gap-3">
<div class="flex flex-wrap items-end gap-2">
<div class="flex flex-col gap-1">
<Label for="logs-lines-{id}" class="text-xs">{m.services_logs_lines()}</Label>
<Input
id="logs-lines-{id}"
type="number"
min="1"
max="10000"
class="h-9 w-24"
value={lines.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1 && n <= 10000) lines.current = n;
}}
/>
</div>
<div class="flex flex-col gap-1">
<Label for="logs-priority-{id}" class="text-xs">{m.services_logs_priority()}</Label>
<select
id="logs-priority-{id}"
class="border-input bg-background h-9 w-36 rounded-md border px-2 text-sm"
value={priority.current}
onchange={(e) => (priority.current = Number((e.target as HTMLSelectElement).value))}
>
<option value={0}>{m.syslog_emerg()}</option>
<option value={1}>{m.syslog_alert()}</option>
<option value={2}>{m.syslog_crit()}</option>
<option value={3}>{m.syslog_err()}</option>
<option value={4}>{m.syslog_warning()}</option>
<option value={5}>{m.syslog_notice()}</option>
<option value={6}>{m.syslog_info()}</option>
<option value={7}>{m.syslog_debug()}</option>
</select>
</div>
<div class="flex flex-col gap-1">
<Label for="logs-since-{id}" class="text-xs">{m.services_logs_since()}</Label>
<select
id="logs-since-{id}"
class="border-input bg-background h-9 w-44 rounded-md border px-2 text-sm"
value={since.current}
onchange={(e) => (since.current = (e.target as HTMLSelectElement).value)}
>
<option value="">{m.services_logs_since_all()}</option>
<option value="15 minutes ago">{m.services_logs_since_15m()}</option>
<option value="1 hour ago">{m.services_logs_since_1h()}</option>
<option value="6 hours ago">{m.services_logs_since_6h()}</option>
<option value="today">{m.services_logs_since_today()}</option>
<option value="yesterday">{m.services_logs_since_yesterday()}</option>
</select>
</div>
<div class="flex min-w-48 flex-1 flex-col gap-1">
<Label for="logs-search-{id}" class="text-xs"
>{m.services_logs_search_placeholder()}</Label
>
<Input
id="logs-search-{id}"
placeholder={m.services_logs_search_placeholder()}
value={search}
oninput={onLogSearch}
/>
</div>
</div>
<div class="text-muted-foreground flex items-center justify-between text-xs">
<span>{filteredLogs.length} / {entries.length}</span>
</div>
<div
bind:this={scrollEl}
class="bg-muted/30 max-h-144 min-h-80 overflow-auto rounded-md border"
>
{#if logsResource.loading && !logsResource.current}
<div class="text-muted-foreground flex items-center justify-center py-8">
<Loader2Icon class="size-4 animate-spin" />
</div>
{:else if !pageLogs.length}
<div class="text-muted-foreground py-8 text-center text-sm">
{m.services_logs_empty()}
</div>
{:else}
<ul class="divide-border divide-y font-mono text-xs">
{#each pageLogs as e, i (e.time + '\0' + i)}
<li class="hover:bg-muted/50 flex gap-3 px-3 py-1.5">
<span class="text-muted-foreground shrink-0 tabular-nums">{e.time}</span>
<span class="shrink-0 tabular-nums {priorityClass(e.priority)}"
>[{e.priority}]</span
>
<span class="break-all whitespace-pre-wrap">{e.message}</span>
</li>
{/each}
</ul>
{/if}
</div>
<div class="flex items-center justify-end gap-2">
<Input
type="number"
min="10"
max="1000"
class="h-8 w-20"
value={logsPageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 10) {
logsPageSize.current = n;
logsPage = 1;
}
}}
/>
<span class="text-muted-foreground text-sm">
{m.users_page_of({ page: logsPage, total: totalLogPages })}
</span>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
title={m.users_prev()}
disabled={logsPage <= 1}
onclick={() => (logsPage = 1)}
>
<ChevronsLeftIcon class="size-4" />
</Button>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
title={m.users_prev()}
disabled={logsPage <= 1}
onclick={() => (logsPage -= 1)}
>
<ChevronLeftIcon class="size-4" />
</Button>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
title={m.users_next()}
disabled={logsPage >= totalLogPages}
onclick={() => (logsPage += 1)}
>
<ChevronRightIcon class="size-4" />
</Button>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
title={m.users_next()}
disabled={logsPage >= totalLogPages}
onclick={() => (logsPage = totalLogPages)}
>
<ChevronsRightIcon class="size-4" />
</Button>
</div>
</Card.Content>
</Card.Root>
</div>
</svelte:boundary>
</div>
@@ -0,0 +1,40 @@
<script lang="ts">
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
import ListIcon from '@lucide/svelte/icons/list';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
const machineId = $derived(page.params.machineId!);
</script>
<PageMeta title={m.seo_title_storage()} description={m.seo_desc_storage()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_storage()}</h1>
<div class="grid gap-4 sm:grid-cols-2">
<a href={resolve('/dashboard/[machineId]/storage/mounts', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<HardDriveIcon class="size-5" />
<Card.Title>{m.nav_storage_mounts()}</Card.Title>
</div>
<Card.Description>{m.nav_storage_mounts_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/storage/fstab', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<ListIcon class="size-5" />
<Card.Title>{m.nav_storage_fstab()}</Card.Title>
</div>
<Card.Description>{m.nav_storage_fstab_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
</div>
</div>
@@ -0,0 +1,255 @@
<script lang="ts">
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listFstab, removeMount } from '$lib/remotes/storage.remote';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
type FstabEntry = {
device: string;
dump: number;
fstype: string;
mountpoint: string;
options: string;
pass: number;
};
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const fstab = $derived(listFstab(machineId));
const pageSize = new PersistedState<number>('storage.fstab.pageSize', 10);
let page = $state(1);
let search = $state('');
const filtered = $derived.by(() => {
const all = (fstab.current ?? []) as FstabEntry[];
const q = search.trim().toLowerCase();
return all
.filter(
(e) =>
!q ||
e.device.toLowerCase().includes(q) ||
e.mountpoint.toLowerCase().includes(q) ||
e.fstype.toLowerCase().includes(q)
)
.sort((a, b) => a.mountpoint.localeCompare(b.mountpoint));
});
const paginated = $derived.by(() => {
const start = (page - 1) * pageSize.current;
const end = start + pageSize.current;
return filtered.slice(start, end);
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
let deleteOpen = $state(false);
let deleting = $state<FstabEntry | null>(null);
async function doDelete() {
if (!deleting) return;
try {
await removeMount({ machineId, mountpoint: deleting.mountpoint });
toast.success(m.storage_mount_removed());
deleteOpen = false;
deleting = null;
} catch (e) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
}
</script>
<PageMeta title={m.seo_title_storage_fstab()} description={m.seo_desc_storage_fstab()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_storage_fstab()}</h1>
<p class="text-muted-foreground text-sm truncate">{m.storage_fstab_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => fstab.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-2 p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="rpp-presets-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="rpp-presets-{id}">
{#each [10, 20, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.storage_mounts_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {fstab.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
<Table.Head>{m.storage_col_device()}</Table.Head>
<Table.Head>{m.storage_col_fstype()}</Table.Head>
<Table.Head>{m.storage_col_options()}</Table.Head>
<Table.Head class="text-right">{m.storage_col_dump()}</Table.Head>
<Table.Head class="text-right">{m.storage_col_pass()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if fstab.loading && !fstab.current}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
>{m.storage_no_fstab()}</Table.Cell
>
</Table.Row>
{:else}
{#each paginated as e, i (e.mountpoint + '\0' + e.device + i)}
<Table.Row>
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
<span class="block truncate" title={e.mountpoint}>{e.mountpoint}</span>
</Table.Cell>
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={e.device}>{e.device}</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{e.fstype}</Badge></Table.Cell>
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={e.options}>{e.options}</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground text-right tabular-nums"
>{e.dump}</Table.Cell
>
<Table.Cell class="text-muted-foreground text-right tabular-nums"
>{e.pass}</Table.Cell
>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = e;
deleteOpen = true;
}}>{m.storage_mount_remove()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title
>{m.storage_mount_remove_title({
mountpoint: deleting?.mountpoint ?? ''
})}</AlertDialog.Title
>
<AlertDialog.Description>{m.storage_mount_remove_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.storage_mount_remove()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,378 @@
<script lang="ts">
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { addMount, listFstab, listMounts, removeMount } from '$lib/remotes/storage.remote';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
type Mount = { device: string; fstype: string; mountpoint: string; options: string };
// Kernel/virtual filesystems most people don't want to see by default.
const PSEUDO_FSTYPES = new Set([
'autofs',
'binfmt_misc',
'bpf',
'cgroup',
'cgroup2',
'configfs',
'debugfs',
'devpts',
'devtmpfs',
'fusectl',
'hugetlbfs',
'mqueue',
'nsfs',
'proc',
'pstore',
'ramfs',
'securityfs',
'sysfs',
'tmpfs',
'tracefs'
]);
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const mounts = $derived(listMounts(machineId));
const fstab = $derived(listFstab(machineId));
const pageSize = new PersistedState<number>('storage.mounts.pageSize', 10);
let page = $state(1);
let search = $state('');
const filtersStore = new PersistedState('storage.mounts.filters', { showPseudo: false });
let filters = $state({ ...filtersStore.current });
$effect(() => {
filtersStore.current = filters;
});
const activeFilterCount = $derived(filters.showPseudo ? 1 : 0);
// Mountpoints present in /etc/fstab — used to flag which active mounts are persistent.
const persistent = $derived(new Set((fstab.current ?? []).map((e) => e.mountpoint)));
const filtered = $derived.by(() => {
const all = (mounts.current ?? []) as Mount[];
const q = search.trim().toLowerCase();
return all
.filter((mt) => filters.showPseudo || !PSEUDO_FSTYPES.has(mt.fstype))
.filter(
(mt) =>
!q ||
mt.device.toLowerCase().includes(q) ||
mt.mountpoint.toLowerCase().includes(q) ||
mt.fstype.toLowerCase().includes(q)
)
.sort((a, b) => a.mountpoint.localeCompare(b.mountpoint));
});
const paginated = $derived.by(() => {
const start = (page - 1) * pageSize.current;
const end = start + pageSize.current;
return filtered.slice(start, end);
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
let createOpen = $state(false);
let createForm = $state({ device: '', fstype: '', mountpoint: '', options: 'defaults' });
let deleteOpen = $state(false);
let deleting = $state<Mount | null>(null);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
async function doCreate() {
try {
await addMount({
device: createForm.device.trim(),
fstype: createForm.fstype.trim(),
machineId,
mountpoint: createForm.mountpoint.trim(),
options: createForm.options.trim() || undefined
});
toast.success(m.storage_mount_added());
createOpen = false;
createForm = { device: '', fstype: '', mountpoint: '', options: 'defaults' };
} catch (e) {
handleError(e);
}
}
async function doDelete() {
if (!deleting) return;
try {
await removeMount({ machineId, mountpoint: deleting.mountpoint });
toast.success(m.storage_mount_removed());
deleteOpen = false;
deleting = null;
} catch (e) {
handleError(e);
}
}
</script>
<PageMeta title={m.seo_title_storage_mounts()} description={m.seo_desc_storage_mounts()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.nav_storage_mounts()}</h1>
<p class="text-muted-foreground text-sm truncate">{m.storage_mounts_description()}</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => mounts.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.storage_filter_show_pseudo()}</span>
<small>{m.storage_filter_show_pseudo_hint()}</small>
</div>
<Checkbox
checked={filters.showPseudo}
onCheckedChange={(v) => {
filters.showPseudo = !!v;
page = 1;
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="rpp-presets-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="rpp-presets-{id}">
{#each [10, 20, 50, 100] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.storage_mount_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.storage_mounts_search_placeholder()}
value={search}
oninput={(e) => {
search = e.currentTarget.value;
page = 1;
}}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {mounts.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{m.storage_col_mountpoint()}</Table.Head>
<Table.Head>{m.storage_col_device()}</Table.Head>
<Table.Head>{m.storage_col_fstype()}</Table.Head>
<Table.Head>{m.storage_col_options()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if mounts.loading && !mounts.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !filtered.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"
>{m.storage_no_mounts()}</Table.Cell
>
</Table.Row>
{:else}
{#each paginated as mt, i (mt.mountpoint + '\0' + mt.device + i)}
<Table.Row>
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
<span class="flex items-center gap-2">
<span class="block truncate" title={mt.mountpoint}>{mt.mountpoint}</span>
{#if persistent.has(mt.mountpoint)}
<Badge variant="secondary">{m.storage_badge_fstab()}</Badge>
{/if}
</span>
</Table.Cell>
<Table.Cell class="max-w-[16rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={mt.device}>{mt.device}</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{mt.fstype}</Badge></Table.Cell>
<Table.Cell class="max-w-[20rem] text-muted-foreground font-mono text-xs">
<span class="block truncate" title={mt.options}>{mt.options}</span>
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = mt;
deleteOpen = true;
}}>{m.storage_mount_remove()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">
{m.pagination_page_of({ page, pages: totalPages })}
</span>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.pagination_previous()}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.pagination_next()}
</Button>
</div>
</div>
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.storage_mount_add()}</Dialog.Title>
<Dialog.Description>{m.storage_mount_add_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doCreate();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="sm-device-{id}">{m.storage_col_device()}</Label>
<Input
id="sm-device-{id}"
bind:value={createForm.device}
placeholder="/dev/sdb1"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-mountpoint-{id}">{m.storage_col_mountpoint()}</Label>
<Input
id="sm-mountpoint-{id}"
bind:value={createForm.mountpoint}
placeholder="/mnt/data"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-fstype-{id}">{m.storage_col_fstype()}</Label>
<Input id="sm-fstype-{id}" bind:value={createForm.fstype} placeholder="ext4" required />
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-options-{id}">{m.storage_col_options()}</Label>
<Input id="sm-options-{id}" bind:value={createForm.options} placeholder="defaults" />
</div>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit">{m.storage_mount_add()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title
>{m.storage_mount_remove_title({
mountpoint: deleting?.mountpoint ?? ''
})}</AlertDialog.Title
>
<AlertDialog.Description>{m.storage_mount_remove_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.storage_mount_remove()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -1,21 +1,46 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import ClockIcon from '@lucide/svelte/icons/clock';
import FingerprintIcon from '@lucide/svelte/icons/fingerprint';
import GlobeIcon from '@lucide/svelte/icons/globe';
import PowerIcon from '@lucide/svelte/icons/power';
import ServerIcon from '@lucide/svelte/icons/server';
import {resolve} from '$app/paths'
import TerminalIcon from '@lucide/svelte/icons/terminal';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
const base = $derived(`/dashboard/${page.params.machineId}/system`);
import { m } from '$lib/paraglide/messages';
import { getUser } from '$lib/remotes/auth.remote';
import { getWhoami } from '$lib/remotes/system.remote';
import { getContext } from 'svelte';
const machineId = $derived(page.params.machineId!);
const userResource = $derived(getUser());
const whoamiResource = $derived(getWhoami(machineId));
const canShowTerminal = $derived(
userResource.current?.role === 'admin' && whoamiResource.current?.username === 'root'
);
const terminalState = getContext<{ open: boolean }>('terminalState');
</script>
<PageMeta title={m.seo_title_system()} description={m.seo_desc_system()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system()}</h1>
<div class="grid gap-4 sm:grid-cols-2">
<a href={resolve(`${base}/date-time` as Pathname)} class="block">
<a href={resolve('/dashboard/[machineId]/system/nadir', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<FingerprintIcon class="size-5" />
<Card.Title>{m.nav_system_nadir()}</Card.Title>
</div>
<Card.Description>{m.nav_system_nadir_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve('/dashboard/[machineId]/system/date-time', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
@@ -26,7 +51,7 @@ import { m } from '$lib/paraglide/messages';
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/hostname` as Pathname)} class="block">
<a href={resolve('/dashboard/[machineId]/system/hostname', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
@@ -37,7 +62,7 @@ import { m } from '$lib/paraglide/messages';
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/localization` as Pathname)} class="block">
<a href={resolve('/dashboard/[machineId]/system/localization', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
@@ -48,7 +73,7 @@ import { m } from '$lib/paraglide/messages';
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/power` as Pathname)} class="block">
<a href={resolve('/dashboard/[machineId]/system/power', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
@@ -59,5 +84,21 @@ import { m } from '$lib/paraglide/messages';
</Card.Header>
</Card.Root>
</a>
{#if canShowTerminal}
<button
onclick={() => (terminalState.open = true)}
class="block text-start cursor-pointer h-full"
>
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<TerminalIcon class="size-5" />
<Card.Title>{m.nav_system_terminal()}</Card.Title>
</div>
<Card.Description>{m.nav_system_terminal_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</button>
{/if}
</div>
</div>
@@ -2,6 +2,7 @@
import CheckIcon from '@lucide/svelte/icons/check';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Command from '$lib/components/ui/command';
@@ -19,7 +20,7 @@
} from '$lib/remotes/system.remote';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const machineId = $derived(page.params.machineId!);
const time = $derived(systemTime(machineId));
const tzs = $derived(listTimezones(machineId));
@@ -39,13 +40,14 @@
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
} finally {
saving = false;
}
}
</script>
<PageMeta title={m.seo_title_system_datetime()} description={m.seo_desc_system_datetime()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_datetime()}</h1>
@@ -97,7 +99,7 @@
onSelect={async () => {
tzOpen = false;
if (z === t.timezone) return;
await withSaving(() => setTimezone({machineId,timezone:z}));
await withSaving(() => setTimezone({ machineId, timezone: z }));
}}
>
<CheckIcon
@@ -125,7 +127,7 @@
<Switch
checked={t.ntp}
disabled={saving || !t.can_ntp}
onCheckedChange={(v) => withSaving(() => setNtp({enabled:v, machineId}))}
onCheckedChange={(v) => withSaving(() => setNtp({ enabled: v, machineId }))}
/>
</Card.Content>
</Card.Root>
@@ -143,7 +145,7 @@
const fd = new FormData(e.currentTarget);
const v = String(fd.get('time') ?? '').trim();
if (!v) return;
await withSaving(() => setTime({machineId,time:rfc3339FromLocal(v)}));
await withSaving(() => setTime({ machineId, time: rfc3339FromLocal(v) }));
}}
>
<div class="flex grow flex-col gap-1.5">
@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
@@ -8,16 +9,18 @@
import { setHostname, systemHostname } from '$lib/remotes/system.remote';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const machineId = $derived(page.params.machineId!);
const host = $derived(systemHostname(machineId));
const formId = $props.id();
let saving = $state(false);
// ponytail: hostname syntax is RFC1123 — letters, digits, hyphen, max 63 chars per label.
const HOSTNAME_RE = /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const HOSTNAME_RE =
/^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
</script>
<PageMeta title={m.seo_title_system_hostname()} description={m.seo_desc_system_hostname()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_hostname()}</h1>
@@ -48,10 +51,11 @@
if (v === h.hostname) return;
saving = true;
try {
await setHostname({hostname:v,machineId,});
await setHostname({ hostname: v, machineId });
toast.success(m.saved());
} catch (err) {
toast.error((err as Error).message || 'Error');
console.log(err);
toast.error((err as { body?: { message?: string } })?.body?.message || m.errors_generic());
} finally {
saving = false;
}
@@ -64,7 +68,7 @@
name="hostname"
value={h.hostname}
required
pattern="[A-Za-z0-9.-]+"
pattern="[A-Za-z0-9.\-]+"
maxlength={253}
/>
</div>
@@ -4,6 +4,7 @@
import PlusIcon from '@lucide/svelte/icons/plus';
import XIcon from '@lucide/svelte/icons/x';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
@@ -57,7 +58,7 @@
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
} finally {
saving = false;
}
@@ -65,7 +66,7 @@
async function handleSetLanguage() {
const l = await locale;
await setLocale({ lang: l.lang,language: langList.join(':'), machineId });
await setLocale({ lang: l.lang, language: langList.join(':'), machineId });
serverLanguage = '';
}
@@ -75,11 +76,15 @@
toast.error(m.system_locale_generate_invalid());
return;
}
await generateLocale({locale:loc,machineId});
await generateLocale({ locale: loc, machineId });
newLocale = '';
}
</script>
<PageMeta
title={m.seo_title_system_localization()}
description={m.seo_desc_system_localization()}
/>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_localization()}</h1>
@@ -125,7 +130,9 @@
onSelect={() => {
open = false;
if (loc !== l.lang)
pick(() => setLocale({ lang: loc, language: l.language || undefined,machineId }));
pick(() =>
setLocale({ lang: loc, language: l.language || undefined, machineId })
);
}}
>
<CheckIcon
@@ -245,7 +252,7 @@
value={km}
onSelect={() => {
kmOpen = false;
if (km !== l.vc_keymap) pick(() => setKeymap({keymap:km,machineId}));
if (km !== l.vc_keymap) pick(() => setKeymap({ keymap: km, machineId }));
}}
>
<CheckIcon
@@ -0,0 +1,208 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import CheckIcon from '@lucide/svelte/icons/check';
import CompassIcon from '@lucide/svelte/icons/compass';
import CpuIcon from '@lucide/svelte/icons/cpu';
import FingerprintIcon from '@lucide/svelte/icons/fingerprint';
import HardDriveIcon from '@lucide/svelte/icons/hard-drive';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import MonitorCogIcon from '@lucide/svelte/icons/monitor-cog';
import NetworkIcon from '@lucide/svelte/icons/network';
import PackageIcon from '@lucide/svelte/icons/package';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
import UsersIcon from '@lucide/svelte/icons/users';
import XIcon from '@lucide/svelte/icons/x';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { getModules, getWhoami } from '$lib/remotes/system.remote';
const machineId = $derived(page.params.machineId!);
const modulesResource = $derived(getModules(machineId));
const whoamiResource = $derived(getWhoami(machineId));
const dataPromise = $derived(Promise.all([modulesResource, whoamiResource]));
let refreshing = $state(false);
async function handleRefresh() {
refreshing = true;
try {
await Promise.all([modulesResource.refresh(), whoamiResource.refresh()]);
} finally {
refreshing = false;
}
}
function getModuleIcon(id: string) {
switch (id) {
case 'networking':
return NetworkIcon;
case 'packages':
return PackageIcon;
case 'services':
return CpuIcon;
case 'storage':
return HardDriveIcon;
case 'system':
return MonitorCogIcon;
case 'users':
return UsersIcon;
default:
return CompassIcon;
}
}
function hasPermission(
moduleId: string,
perm: string,
userPerms: Record<string, null | string[]>
) {
if (userPerms['*']) return true;
const modPerms = userPerms[moduleId];
if (!modPerms) return false;
return modPerms.includes('*') || modPerms.includes(perm);
}
</script>
<PageMeta title={m.seo_title_system_nadir()} description={m.seo_desc_system_nadir()} />
<div class="mx-auto flex w-full max-w-4xl flex-col gap-6 p-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<Button
variant="outline"
size="icon"
href={resolve('/dashboard/[machineId]/system', { machineId })}
title={m.error_action_back()}
>
<ArrowLeftIcon class="size-4" />
</Button>
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight">{m.seo_title_system_nadir()}</h1>
<p class="text-muted-foreground text-sm">{m.nav_system_nadir_desc()}</p>
</div>
</div>
<Button variant="outline" size="icon" onclick={handleRefresh} disabled={refreshing}>
<RefreshCwIcon class="size-4 {refreshing ? 'animate-spin' : ''}" />
</Button>
</div>
<svelte:boundary>
{#snippet failed(err)}
<Card.Root class="border-destructive/20 bg-destructive/5">
<Card.Content class="text-destructive py-6 font-mono text-sm">
{(err as Error).message || m.errors_generic()}
</Card.Content>
</Card.Root>
{/snippet}
{#await dataPromise}
<div class="flex items-center justify-center py-20">
<Loader2Icon class="text-muted-foreground size-8 animate-spin" />
</div>
{:then [modulesData, whoamiData]}
{@const hasGlobalWildcard = !!whoamiData.permissions['*']}
<div class="grid gap-6 md:grid-cols-3">
<!-- Whoami Details Card -->
<Card.Root class="relative overflow-hidden md:col-span-1 border border-border bg-linear-to-br from-card/85 to-card/50 shadow-lg backdrop-blur-md">
<!-- Visual Accent Gradient -->
<div class="absolute -right-16 -top-16 size-32 rounded-full bg-emerald-500/10 blur-3xl"></div>
<Card.Header class="pb-3">
<div class="flex items-center gap-2 text-emerald-500">
<FingerprintIcon class="size-5" />
<Card.Title class="text-sm font-semibold tracking-wide uppercase">{m.system_nadir_username()}</Card.Title>
</div>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<div class="flex flex-col">
<span class="text-2xl font-bold tracking-tight break-all">{whoamiData.username}</span>
<span class="text-muted-foreground mt-1 text-xs font-mono">Agent Identity</span>
</div>
<div class="border-t pt-3 space-y-2">
<span class="text-muted-foreground text-xs font-medium block uppercase tracking-wider">{m.system_nadir_permissions()}</span>
{#if hasGlobalWildcard}
<div class="flex items-center gap-2 rounded-md bg-emerald-500/10 p-2 text-xs border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 font-medium">
<ShieldCheckIcon class="size-4 shrink-0" />
<span>Administrator (Full Access)</span>
</div>
{:else}
<div class="flex items-center gap-2 rounded-md bg-sky-500/10 p-2 text-xs border border-sky-500/20 text-sky-600 dark:text-sky-400 font-medium">
<ShieldAlertIcon class="size-4 shrink-0" />
<span>Restricted Module Access</span>
</div>
{/if}
</div>
</Card.Content>
</Card.Root>
<!-- Module List / Grid -->
<div class="flex flex-col gap-4 md:col-span-2">
<h2 class="text-lg font-medium tracking-tight flex items-center gap-2">
<span>{m.system_nadir_modules()}</span>
<Badge variant="secondary" class="font-mono text-xs">{modulesData?.modules?.length ?? 0}</Badge>
</h2>
<div class="grid gap-4 sm:grid-cols-2">
{#each modulesData?.modules ?? [] as mod (mod.id)}
{@const Icon = getModuleIcon(mod.id)}
{@const hasModuleWildcard = !hasGlobalWildcard && whoamiData.permissions[mod.id]?.includes('*')}
<Card.Root class="group transition-all duration-300 hover:-translate-y-0.5 hover:shadow-md border border-border/80 hover:border-emerald-500/30">
<Card.Header class="pb-3 flex flex-row items-start justify-between gap-2 space-y-0">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-muted/40 p-2 text-muted-foreground group-hover:text-emerald-500 group-hover:bg-emerald-500/10 transition-colors">
<Icon class="size-4"/>
</div>
<div class="flex flex-col gap-0.5">
<Card.Title class="text-sm font-semibold tracking-tight leading-none capitalize">{mod.name}</Card.Title>
<span class="text-muted-foreground text-[10px] font-mono leading-none">{mod.id}</span>
</div>
</div>
{#if hasGlobalWildcard || hasModuleWildcard}
<Badge class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-0 p-1 rounded-full" title="Full access">
<ShieldCheckIcon class="size-3.5" />
</Badge>
{/if}
</Card.Header>
<Card.Content class="flex flex-col gap-3">
<div class="flex flex-wrap gap-1.5">
{#if mod.permissions && mod.permissions.length > 0}
{#each mod.permissions as perm (perm)}
{@const ok = hasPermission(mod.id, perm, whoamiData.permissions)}
<Badge
variant={ok ? 'default' : 'outline'}
class="text-[10px] py-0.5 px-2 font-mono transition-all flex items-center gap-1 leading-none rounded-full
{ok
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-0 hover:bg-emerald-500/20'
: 'bg-muted/10 text-muted-foreground hover:bg-muted/20 border-dashed border-border/70'}"
>
{#if ok}
<CheckIcon class="size-2.5 shrink-0" />
{:else}
<XIcon class="size-2.5 shrink-0" />
{/if}
{perm}
</Badge>
{/each}
{:else}
<span class="text-muted-foreground text-xs italic">{m.system_nadir_no_permissions()}</span>
{/if}
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
</div>
</div>
{/await}
</svelte:boundary>
</div>
@@ -2,6 +2,7 @@
import PowerIcon from '@lucide/svelte/icons/power';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { page } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { buttonVariants } from '$lib/components/ui/button';
@@ -13,14 +14,16 @@
let pending = $state<'off' | 'reboot' | null>(null);
let busy = $state(false);
const machineId = $derived(page.params.machineId!)
const machineId = $derived(page.params.machineId!);
async function run(action: 'off' | 'reboot') {
busy = true;
try {
await (action === 'off' ? powerOff({machineId,when:''}) : reboot({machineId,when:''}));
await (action === 'off'
? powerOff({ machineId, when: '' })
: reboot({ machineId, when: '' }));
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
} finally {
busy = false;
pending = null;
@@ -28,6 +31,7 @@
}
</script>
<PageMeta title={m.seo_title_system_power()} description={m.seo_desc_system_power()} />
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_power()}</h1>
@@ -58,7 +62,9 @@
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
{pending === 'off' ? m.system_power_confirm_poweroff_title() : m.system_power_confirm_reboot_title()}
{pending === 'off'
? m.system_power_confirm_poweroff_title()
: m.system_power_confirm_reboot_title()}
</AlertDialog.Title>
<AlertDialog.Description>{m.system_power_confirm_description()}</AlertDialog.Description>
</AlertDialog.Header>
@@ -1,6 +1,4 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
@@ -9,6 +7,7 @@
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
@@ -69,9 +68,7 @@
$effect(() => {
filtersStore.current = filters;
});
const activeFilterCount = $derived(
(filters.showSystem ? 1 : 0) + (filters.shellOnly ? 1 : 0)
);
const activeFilterCount = $derived((filters.showSystem ? 1 : 0) + (filters.shellOnly ? 1 : 0));
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
@@ -117,9 +114,7 @@
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(
filtered.slice((page - 1) * pageSize.current, page * pageSize.current)
);
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
// Dialogs
let createOpen = $state(false);
@@ -154,7 +149,13 @@
});
toast.success(m.users_created());
createOpen = false;
createForm = { comment: '', create_home: true, shell: '/bin/bash', system: false, username: '' };
createForm = {
comment: '',
create_home: true,
shell: '/bin/bash',
system: false,
username: ''
};
} catch (e) {
handleError(e);
}
@@ -165,7 +166,9 @@
try {
await deletePamUser({
machineId,
remove_home: removeHome, username: deleting.username });
remove_home: removeHome,
username: deleting.username
});
toast.success(m.users_deleted());
deleteOpen = false;
deleting = null;
@@ -178,9 +181,11 @@
async function doSetPassword() {
if (!pwUser || !pwValue) return;
try {
await setPamUserPassword({
await setPamUserPassword({
machineId,
password: pwValue, username: pwUser.username });
password: pwValue,
username: pwUser.username
});
toast.success(m.saved());
pwOpen = false;
pwUser = null;
@@ -191,9 +196,10 @@
}
</script>
<PageMeta title={m.seo_title_users()} description={m.seo_desc_users()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.users_nav_title()}</h1>
@@ -220,10 +226,10 @@
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.users_filter_show_system()}</span>
<small>{m.users_filter_system_uid_hint()}</small>
</div>
<div class="flex flex-col">
<span>{m.users_filter_show_system()}</span>
<small>{m.users_filter_system_uid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
@@ -296,7 +302,7 @@
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: 'UID' }, { key: 'shell', label: 'Shell' }] as col (col.key)}
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: m.users_col_uid() }, { key: 'shell', label: m.users_create_field_shell() }] as col (col.key)}
<Table.Head>
<button
type="button"
@@ -337,11 +343,14 @@
{#each pageRows as u (u.username)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve(`/dashboard/${pageState.params.machineId}/users/${u.username}` as Pathname)}
class="hover:underline">{u.username}</a
>
</Table.Cell>
<a
href={resolve('/dashboard/[machineId]/users/[username]', {
machineId,
username: u.username
})}
class="hover:underline">{u.username}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.uid}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.shell}</Table.Cell>
<Table.Cell>{u.comment || '—'}</Table.Cell>
@@ -398,12 +407,7 @@
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onclick={() => (page += 1)}
>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
@@ -449,10 +453,7 @@
{m.users_create_field_create_home()}
</label>
<label class="flex items-center gap-2 text-sm">
<Checkbox
checked={createForm.system}
onCheckedChange={(v) => (createForm.system = !!v)}
/>
<Checkbox checked={createForm.system} onCheckedChange={(v) => (createForm.system = !!v)} />
{m.users_create_field_system()}
</label>
<Dialog.Footer class="mt-2">
@@ -468,7 +469,8 @@
<Dialog.Root bind:open={pwOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.users_set_password_title({ username: pwUser?.username ?? '' })}</Dialog.Title>
<Dialog.Title>{m.users_set_password_title({ username: pwUser?.username ?? '' })}</Dialog.Title
>
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
</Dialog.Header>
<form
@@ -495,10 +497,10 @@
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.users_delete_title({ username: deleting?.username ?? '' })}</AlertDialog.Title>
<AlertDialog.Description
>{m.users_delete_description()}</AlertDialog.Description
<AlertDialog.Title
>{m.users_delete_title({ username: deleting?.username ?? '' })}</AlertDialog.Title
>
<AlertDialog.Description>{m.users_delete_description()}</AlertDialog.Description>
</AlertDialog.Header>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={removeHome} />
@@ -1,11 +1,10 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import KeyIcon from '@lucide/svelte/icons/key';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
@@ -28,20 +27,21 @@
const id = $props.id();
const username = $derived(pageState.params.username!);
const machineId = $derived(pageState.params.machineId!);
const user = $derived(getPamUser({machineId,username,}));
const user = $derived(getPamUser({ machineId, username }));
const groups = $derived(listPamGroups(machineId));
const primary = $derived(
(groups.current ?? []).find((g) => g.gid === (user.current?.gid ?? -1))
);
const primary = $derived((groups.current ?? []).find((g) => g.gid === (user.current?.gid ?? -1)));
const supplementary = $derived(
(groups.current ?? []).filter((g) => g.members?.includes(username)).map((g) => g.name)
);
let editing = $state(false);
const selected = new SvelteSet<string>()
const selected = new SvelteSet<string>();
$effect(() => {
if (!editing) {selected.clear(); selected.union(new Set(supplementary))}
if (!editing) {
selected.clear();
selected.union(new Set(supplementary));
}
});
const dirty = $derived.by(() => {
const cur = new Set(supplementary);
@@ -65,8 +65,8 @@
const next = new SvelteSet(selected);
if (on) next.add(name);
else next.delete(name);
selected.clear()
selected.union(next)
selected.clear();
selected.union(next);
}
async function saveGroups() {
@@ -85,7 +85,7 @@
async function doSetPassword() {
if (!pwValue) return;
try {
await setPamUserPassword({ machineId,password: pwValue, username });
await setPamUserPassword({ machineId, password: pwValue, username });
toast.success(m.saved());
pwOpen = false;
pwValue = '';
@@ -96,15 +96,16 @@
async function doDelete() {
try {
await deletePamUser({ machineId,remove_home: removeHome, username });
await deletePamUser({ machineId, remove_home: removeHome, username });
toast.success(m.users_deleted());
await goto(resolve(`/dashboard/${machineId}/users` as Pathname));
await goto(resolve('/dashboard/[machineId]/users', { machineId }));
} catch (e) {
handleError(e);
}
}
</script>
<PageMeta title={`${username} · ${m.seo_title_users()}`} description={m.seo_desc_users_detail()} />
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<svelte:boundary>
{#snippet failed(err)}
@@ -118,8 +119,8 @@
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
{u.username}
{#if u.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge variant="outline"
>{m.users_type_user()}</Badge
{#if u.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge
variant="outline">{m.users_type_user()}</Badge
>{/if}
</h1>
<p class="text-muted-foreground text-sm">{u.comment || m.users_no_gecos()}</p>
@@ -142,7 +143,7 @@
</Card.Header>
<Card.Content>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-muted-foreground">UID</dt>
<dt class="text-muted-foreground">{m.users_col_uid()}</dt>
<dd class="font-mono">{u.uid}</dd>
<dt class="text-muted-foreground">{m.users_primary_gid()}</dt>
<dd class="font-mono">
@@ -175,8 +176,8 @@
variant="outline"
onclick={() => {
editing = false;
selected.clear()
selected.union(new Set(supplementary))
selected.clear();
selected.union(new Set(supplementary));
}}>{m.cancel()}</Button
>
<Button onclick={saveGroups} disabled={saving || !dirty}>{m.save()}</Button>
@@ -187,7 +188,9 @@
{#if !editing}
<div class="flex flex-wrap gap-1.5">
{#if primary}
<Badge variant="default" title="Primary group">{primary.name} {m.users_group_primary_badge()}</Badge>
<Badge variant="default" title={m.users_primary_group()}
>{primary.name} {m.users_group_primary_badge()}</Badge
>
{/if}
{#each supplementary as name (name)}
<Badge variant="outline">{name}</Badge>
@@ -1,6 +1,4 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
@@ -8,7 +6,8 @@
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import {page as pageState} from '$app/state'
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
@@ -32,7 +31,7 @@
type SortBy = 'gid' | 'members' | 'name';
type Dir = 'asc' | 'desc';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!)
const machineId = $derived(pageState.params.machineId!);
const groups = $derived(listPamGroups(machineId));
const pageSize = new PersistedState<number>('pam.groups.pageSize', 25);
@@ -96,9 +95,7 @@
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(
filtered.slice((page - 1) * pageSize.current, page * pageSize.current)
);
const pageRows = $derived(filtered.slice((page - 1) * pageSize.current, page * pageSize.current));
let createOpen = $state(false);
let createForm = $state({ gid: '', name: '', system: false });
@@ -129,7 +126,7 @@
async function doDelete() {
if (!deleting) return;
try {
await deletePamGroup({group:deleting.name, machineId});
await deletePamGroup({ group: deleting.name, machineId });
toast.success(m.groups_deleted());
deleteOpen = false;
deleting = null;
@@ -139,9 +136,10 @@
}
</script>
<PageMeta title={m.seo_title_users_groups()} description={m.seo_desc_users_groups()} />
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.groups_nav_title()}</h1>
@@ -168,10 +166,10 @@
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.groups_filter_show_system()}</span>
<small>{m.groups_filter_system_gid_hint()}</small>
</div>
<div class="flex flex-col">
<span>{m.groups_filter_show_system()}</span>
<small>{m.groups_filter_system_gid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
@@ -229,7 +227,7 @@
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: 'GID' }, { key: 'members', label: 'Members' }] as col (col.key)}
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: m.groups_col_gid() }, { key: 'members', label: m.groups_col_members() }] as col (col.key)}
<Table.Head>
<button
type="button"
@@ -249,7 +247,7 @@
</button>
</Table.Head>
{/each}
<Table.Head>Type</Table.Head>
<Table.Head>{m.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
@@ -268,11 +266,14 @@
{#each pageRows as g (g.name)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve(`/dashboard/${pageState.params.machineId}/users/groups/${g.name}` as Pathname)}
class="hover:underline">{g.name}</a
>
</Table.Cell>
<a
href={resolve('/dashboard/[machineId]/users/groups/[group]', {
group: g.name,
machineId
})}
class="hover:underline">{g.name}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{g.gid}</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">
{#if g.members?.length}
@@ -328,12 +329,7 @@
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onclick={() => (page += 1)}
>
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
{m.users_next()}
</Button>
</div>
@@ -364,14 +360,11 @@
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="cg-gid-{id}">GID (optional)</Label>
<Label for="cg-gid-{id}">{m.groups_gid_optional()}</Label>
<Input id="cg-gid-{id}" type="number" min="0" bind:value={createForm.gid} />
</div>
<label class="flex items-center gap-2 text-sm">
<Checkbox
checked={createForm.system}
onCheckedChange={(v) => (createForm.system = !!v)}
/>
<Checkbox checked={createForm.system} onCheckedChange={(v) => (createForm.system = !!v)} />
{m.groups_create_field_system()}
</label>
<Dialog.Footer class="mt-2">
@@ -388,9 +381,7 @@
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.groups_delete_title({ name: deleting?.name ?? '' })}</AlertDialog.Title>
<AlertDialog.Description
>{m.groups_delete_description()}</AlertDialog.Description
>
<AlertDialog.Description>{m.groups_delete_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
@@ -1,12 +1,11 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import PlusIcon from '@lucide/svelte/icons/plus';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import XIcon from '@lucide/svelte/icons/x';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
@@ -42,9 +41,7 @@
);
function supplementaryOf(username: string): string[] {
return (groups.current ?? [])
.filter((g) => g.members?.includes(username))
.map((g) => g.name);
return (groups.current ?? []).filter((g) => g.members?.includes(username)).map((g) => g.name);
}
let addOpen = $state(false);
@@ -61,7 +58,7 @@
busy = username;
try {
const next = [...new Set([...supplementaryOf(username), groupName])];
await setPamUserGroups({ groups: next,machineId, username });
await setPamUserGroups({ groups: next, machineId, username });
toast.success(m.groups_member_added({ username }));
addOpen = false;
addQuery = '';
@@ -76,7 +73,7 @@
busy = username;
try {
const next = supplementaryOf(username).filter((g) => g !== groupName);
await setPamUserGroups({ groups: next,machineId, username });
await setPamUserGroups({ groups: next, machineId, username });
toast.success(m.groups_member_removed({ username }));
} catch (e) {
handleError(e);
@@ -87,9 +84,9 @@
async function doDelete() {
try {
await deletePamGroup({group:groupName,machineId,});
await deletePamGroup({ group: groupName, machineId });
toast.success(m.groups_deleted());
await goto(resolve(`/dashboard/${machineId}/users/groups` as Pathname));
await goto(resolve('/dashboard/[machineId]/users/groups', { machineId }));
} catch (e) {
handleError(e);
}
@@ -101,6 +98,10 @@
});
</script>
<PageMeta
title={`${groupName} · ${m.seo_title_users_groups()}`}
description={m.seo_desc_users_groups_detail()}
/>
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
{#if groups.loading && !groups.current}
<Card.Root>
@@ -108,15 +109,17 @@
</Card.Root>
{:else if !group}
<Card.Root>
<Card.Content class="text-destructive py-6">{m.groups_not_found({ name: groupName })}</Card.Content>
<Card.Content class="text-destructive py-6"
>{m.groups_not_found({ name: groupName })}</Card.Content
>
</Card.Root>
{:else}
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
{group.name}
{#if group.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge variant="outline"
>{m.users_type_user()}</Badge
{#if group.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge
variant="outline">{m.users_type_user()}</Badge
>{/if}
</h1>
<p class="text-muted-foreground text-sm font-mono">gid {group.gid}</p>
@@ -132,7 +135,7 @@
<div>
<Card.Title>{m.groups_members_title()}</Card.Title>
<Card.Description>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html m.groups_members_description({ gid: group.gid })}
</Card.Description>
</div>
@@ -143,13 +146,18 @@
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<div>
<Label class="mb-1.5 block text-xs">{m.groups_supplementary_label({ count: members.length })}</Label>
<Label class="mb-1.5 block text-xs"
>{m.groups_supplementary_label({ count: members.length })}</Label
>
{#if members.length}
<div class="flex flex-wrap gap-1.5">
{#each members as name (name)}
<Badge variant="outline" class="gap-1 pe-1">
<a
href={resolve(`/dashboard/${machineId}/users/${name}` as Pathname)}
href={resolve('/dashboard/[machineId]/users/[username]', {
machineId,
username: name
})}
class="hover:underline">{name}</a
>
<button
@@ -157,7 +165,7 @@
class="hover:bg-muted rounded-sm p-0.5 disabled:opacity-50"
disabled={busy === name}
onclick={() => removeMember(name)}
aria-label="Remove {name}"
aria-label={m.groups_remove_member_aria({ name })}
>
<XIcon class="size-3" />
</button>
@@ -170,13 +178,18 @@
</div>
<div>
<Label class="mb-1.5 block text-xs">{m.groups_primary_label({ count: primaryMembers.length })}</Label>
<Label class="mb-1.5 block text-xs"
>{m.groups_primary_label({ count: primaryMembers.length })}</Label
>
{#if primaryMembers.length}
<div class="flex flex-wrap gap-1.5">
{#each primaryMembers as name (name)}
<Badge variant="default">
<a
href={resolve(`/dashboard/${machineId}/users/${name}` as Pathname)}
href={resolve('/dashboard/[machineId]/users/[username]', {
machineId,
username: name
})}
class="hover:underline">{name}</a
>
</Badge>
@@ -232,9 +245,7 @@
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.groups_delete_title({ name: groupName })}</AlertDialog.Title>
<AlertDialog.Description
>{m.groups_delete_description()}</AlertDialog.Description
>
<AlertDialog.Description>{m.groups_delete_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
+70
View File
@@ -0,0 +1,70 @@
import type { RequestHandler } from './$types';
const repo = process.env.NADIR_RELEASE_REPO ?? '__NADIR_RELEASE_REPO__';
function escapeShell(str: string) {
return str.replace(/(["\\$`])/g, '\\$1');
}
const RELEASE_REPO = escapeShell(repo);
const script = `#!/bin/sh
set -e
RELEASE_REPO="${RELEASE_REPO}"
do_install() {
if [ "$(id -u)" -ne 0 ]; then
echo "must be root" >&2
exit 1
fi
command -v curl >/dev/null 2>&1 || {
echo "curl required" >&2
exit 1
}
case "$(uname -m)" in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
*) echo "unsupported architecture" >&2; exit 1 ;;
esac
# safer repo parsing (no awk assumptions)
host=$(echo "$RELEASE_REPO" | sed -E 's#(https?://[^/]+).*#\\1#')
path=$(echo "$RELEASE_REPO" | sed -E 's#https?://[^/]+/##')
api="$host/api/v1/repos/$path/releases/latest"
echo "querying $api ..."
asset_url=$(curl -fsSL "$api" \
| grep -o '"browser_download_url":"[^"]*linux-'"$arch"'"' \
| head -n1 \
| cut -d'"' -f4)
if [ -z "$asset_url" ]; then
echo "no asset found" >&2
exit 1
fi
echo "downloading $asset_url ..."
curl -fL "$asset_url" -o /usr/local/bin/nadir.tmp
mv /usr/local/bin/nadir.tmp /usr/local/bin/nadir
chmod +x /usr/local/bin/nadir
/usr/local/bin/nadir install
echo "done"
}
do_install
`;
export const GET: RequestHandler = () => {
return new Response(script, {
headers: {
'Cache-Control': 'no-store',
'Content-Type': 'text/x-shellscript; charset=utf-8'
}
});
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { paraglideVitePlugin } from '@inlang/paraglide-js';
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import adapter from 'svelte-adapter-bun';
import { defineConfig } from 'vite';
export default defineConfig({