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