diff --git a/db.sqlite b/db.sqlite index 1e30078..b28cb84 100644 Binary files a/db.sqlite and b/db.sqlite differ diff --git a/messages/en.json b/messages/en.json index 5bb2a2a..2ad012b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3,6 +3,19 @@ "account": "Account", "already_have_account": "Already have an account?", "appname": "NadiЯ", + "agent_updates":"Agent outdated", + "agent_update_started":"Updating agent... ({from} → {to})", + "agent_update_success":"Agent updated to {version}", + "agent_update_failed":"Agent update did not complete - check `nadir logs` on the host", + "machine_offline_title":"{name} is offline", + "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_code":"Error 502", + "machine_offline_node_you":"You", + "machine_offline_node_proxy":"Web UI", + "machine_offline_node_dest":"Agent", + "machine_offline_status_connected":"CONNECTED", + "machine_offline_status_unreachable":"UNREACHABLE", + "machine_offline_details":"Show error details", "back_to_login": "Back to login", "backup_code": "Backup code", "backup_codes_notice": "Store these somewhere safe. Each code works once if you lose your device.", diff --git a/src/lib/components/blocks/trunk/trunk.svelte b/src/lib/components/blocks/trunk/trunk.svelte index 98934c9..77de090 100644 --- a/src/lib/components/blocks/trunk/trunk.svelte +++ b/src/lib/components/blocks/trunk/trunk.svelte @@ -190,6 +190,8 @@ width: 100%; height: 100%; overflow: hidden; + display:grid; + place-content:center } .trunk :global(canvas) { display: block; diff --git a/src/lib/components/dashboard/system-panel.svelte b/src/lib/components/dashboard/system-panel.svelte index e68f335..d664cd4 100644 --- a/src/lib/components/dashboard/system-panel.svelte +++ b/src/lib/components/dashboard/system-panel.svelte @@ -7,6 +7,7 @@ import type { SystemDetails } from './types'; + import { Badge } from '../ui/badge'; import { ghz } from './format'; let { @@ -36,8 +37,10 @@ {#snippet section(heading: string, body: Snippet)}
- {heading} - {@render body()} + {heading} +
+ {@render body()} +
{/snippet} diff --git a/src/lib/components/ui/badge/badge.svelte b/src/lib/components/ui/badge/badge.svelte index b928c31..0ddf945 100644 --- a/src/lib/components/ui/badge/badge.svelte +++ b/src/lib/components/ui/badge/badge.svelte @@ -13,7 +13,7 @@ '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-border 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' } } diff --git a/src/lib/machines/schema.ts b/src/lib/machines/schema.ts index 2c2866a..4ff7a37 100644 --- a/src/lib/machines/schema.ts +++ b/src/lib/machines/schema.ts @@ -4,8 +4,11 @@ import { m } from '$lib/paraglide/messages'; const nonEmpty = v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty())); const address = v.pipe( - v.optional(v.string(), 'http://127.0.0.1:9999'), - v.transform((s) => s.trim() || 'http://127.0.0.1:9999'), + v.optional(v.string(), 'https://127.0.0.1:9999'), + v.transform((s) => { + const t = s.trim() || 'https://127.0.0.1:9999'; + return /^https?:\/\//i.test(t) ? t : `https://${t}`; + }), v.url(m.errors_address_invalid()) ); @@ -17,7 +20,11 @@ export const machineSchema = v.object({ address, name: nonEmpty, token: nonEmpty export const machineEditSchema = v.object({ address: v.pipe( v.optional(v.string(), ''), - v.transform((s) => s.trim()) + v.transform((s) => { + const t = s.trim(); + if (!t) return t; + return /^https?:\/\//i.test(t) ? t : `https://${t}`; + }) ), id: nonEmpty, name: v.pipe( diff --git a/src/lib/remotes/server.remote.ts b/src/lib/remotes/server.remote.ts index 0598a9d..e410b24 100644 --- a/src/lib/remotes/server.remote.ts +++ b/src/lib/remotes/server.remote.ts @@ -1,5 +1,5 @@ import { error } from '@sveltejs/kit'; -import { getRequestEvent, query } from '$app/server'; +import { command, getRequestEvent, query } from '$app/server'; import { v } from '$lib'; import { m } from '$lib/paraglide/messages'; import { db } from '$lib/server/db'; @@ -18,12 +18,25 @@ export const serverInfo = query(async () => { if (!token) error(500, { message: m.errors_generic() }); const nadir = getClient(machine.address, token); - const { data, error: err } = await nadir.GET('/api/system/info'); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { token: _, ...safe } = machine; - if (data) return { ...data, $db: safe }; - - throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + try { + const [info, health] = await Promise.all([ + nadir.GET('/api/system/info'), + nadir.GET('/api/health') + ]); + const { data, error: err } = info; + if (data) + return { + ...data, + $agent_version: (health.data as { version?: string } | undefined)?.version ?? null, + $db: safe, + $offline: null + }; + return { $db: safe, $offline: err.detail || m.errors_generic() }; + } catch (e) { + return { $db: safe, $offline: (e as Error)?.message || m.errors_generic() }; + } }); export const auditLog = query(v.optional(v.number(), 20), async (limit) => { @@ -37,10 +50,12 @@ export const auditLog = query(v.optional(v.number(), 20), async (limit) => { const token = decryptValue(machine.token); if (!token) error(500, { message: m.errors_generic() }); const nadir = getClient(machine.address, token); - const { data, error: err } = await nadir.GET('/api/audit', { params: { query: { limit } } }); - if (data) return data.entries ?? []; - console.error(err); - throw error(err.status || 500, { message: err.detail || m.errors_generic() }); + try { + const { data } = await nadir.GET('/api/audit', { params: { query: { limit } } }); + return data?.entries ?? []; + } catch { + return []; + } }); // ponytail: in-memory cache, 10 min; switch to Redis/KV if we ever run >1 node @@ -59,6 +74,21 @@ export const latestAgentRelease = query(async () => { } }); +export const updateAgent = command(async () => { + const { + locals: { user }, + params: { machineId } + } = getRequestEvent(); + if (!user) error(401, { message: m.errors_unauthenticated() }); + const machine = await db.query.machines.findFirst({ where: { id: machineId } }); + if (!machine) error(404, { message: m.errors_not_found() }); + const token = decryptValue(machine.token); + if (!token) error(500, { message: m.errors_generic() }); + const nadir = getClient(machine.address, token); + const { error: err } = await nadir.POST('/api/update'); + if (err) throw error(err.status || 500, { message: err.detail || m.errors_generic() }); +}); + export const systemDetails = query(async () => { const { locals: { user }, @@ -70,14 +100,14 @@ export const systemDetails = query(async () => { const token = decryptValue(machine.token); if (!token) error(500, { message: m.errors_generic() }); const nadir = getClient(machine.address, token); - const [time, locale, dns, updates] = await Promise.all([ - nadir.GET('/api/system/time'), - nadir.GET('/api/system/locale'), - nadir.GET('/api/networking/dns'), - nadir.GET('/api/packages/updates') - ]); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { token: _, ...safe } = machine; + 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 })) + ]); return { $db: safe, dns: dns.data?.servers ?? null, diff --git a/src/lib/server/nadir-agent/client.ts b/src/lib/server/nadir-agent/client.ts index 9206ea5..2321a43 100644 --- a/src/lib/server/nadir-agent/client.ts +++ b/src/lib/server/nadir-agent/client.ts @@ -1,16 +1,16 @@ -import { getRequestEvent } from '$app/server'; import createClient from 'openapi-fetch'; import type { paths } from './schema'; +// Use the global fetch (NOT event.fetch): SvelteKit's event.fetch forwards the +// browser request's Origin header to outbound calls, which trips the agent's +// CSRF same-origin check on POST/PUT/DELETE. Server-to-server fetch has no +// Origin, so the agent treats it as a non-browser caller and lets writes through. const getClient = (baseUrl: string, token: string) => { - const { fetch } = getRequestEvent(); return createClient({ - baseUrl: baseUrl, - fetch, - headers: { - authorization: `Bearer ${token}` - } + baseUrl, + fetch: globalThis.fetch, + headers: { authorization: `Bearer ${token}` } }); }; diff --git a/src/lib/server/nadir-agent/schema.d.ts b/src/lib/server/nadir-agent/schema.d.ts index 52757dc..d4e6ce5 100644 --- a/src/lib/server/nadir-agent/schema.d.ts +++ b/src/lib/server/nadir-agent/schema.d.ts @@ -948,6 +948,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Update nadir to the latest release + * @description Equivalent to running `sudo nadir update` on the host: queries server.release_repo for the latest release, downloads the binary matching the host's architecture, atomically replaces the running binary, and restarts the systemd unit. Returns 202 immediately; the service restart drops in-flight connections, so poll /api/health to confirm the new version is up. Requires the wildcard admin role. + */ + post: operations["meta-update"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users": { parameters: { query?: never; @@ -1380,6 +1400,11 @@ export interface components { * @example ok */ status: string; + /** + * @description Application version + * @example 1.0.0 + */ + version: string; }; HostEntry: { /** @@ -5792,6 +5817,53 @@ export interface operations { }; }; }; + "meta-update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusOutputBody"]; + }; + }; + /** @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 Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; "users-list": { parameters: { query?: never; diff --git a/src/routes/dashboard/[machineId]/+page.svelte b/src/routes/dashboard/[machineId]/+page.svelte index 4d56bde..d106eb2 100644 --- a/src/routes/dashboard/[machineId]/+page.svelte +++ b/src/routes/dashboard/[machineId]/+page.svelte @@ -2,17 +2,23 @@ import { Activity, Clock, + ClockAlert, Cpu, Ellipsis, + Globe, MemoryStick, Pause, Pencil, Play, RefreshCw, - Trash2 + Server, + Trash2, + User, + X } from '@lucide/svelte'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; + import Trunk from '$lib/components/blocks/trunk/trunk.svelte'; import ActivityPanel from '$lib/components/dashboard/activity-panel.svelte'; import CpuHeatmap from '$lib/components/dashboard/cpu-heatmap.svelte'; import { @@ -30,8 +36,11 @@ import TemperaturePanel from '$lib/components/dashboard/temperature-panel.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'; + import * as Card from '$lib/components/ui/card'; import * as Dialog from '$lib/components/ui/dialog'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; + import * as Empty from '$lib/components/ui/empty'; import * as Field from '$lib/components/ui/field'; import { Input } from '$lib/components/ui/input'; import { Progress } from '$lib/components/ui/progress'; @@ -43,7 +52,8 @@ auditLog, latestAgentRelease, serverInfo, - systemDetails + systemDetails, + updateAgent } from '$lib/remotes/server.remote'; import { toast } from 'svelte-sonner'; @@ -87,6 +97,7 @@ let editOpen = $state(false); let deleteOpen = $state(false); + let updating = $state(false); const formId = $props.id(); @@ -96,180 +107,311 @@ {console.log('errore', err)} {/snippet} - {@const sys = await info} - {@const log = await audit} - {@const d = await details} - {@const latestTag = await latest} - {@const agentVersion = (sys as unknown as { agent_version?: string }).agent_version} - {@const outdated = !!(agentVersion && latestTag && agentVersion !== latestTag)} - {@const memPct = pct(sys.memory.used_bytes, sys.memory.total_bytes)} - {@const loadPct = Math.round((sys.load.load1 / sys.cpu.logical_cpus) * 100)} - {@const swapPct = pct( - sys.memory.swap_total_bytes - sys.memory.swap_free_bytes, - sys.memory.swap_total_bytes - )} - -
-
-

{sys.$db.name}

-

- {sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address} -

+ {@const raw = await info} + {#if raw.$offline !== null} +
+ + + + + +

{m.machine_offline_code()}

+ {m.machine_offline_title({ name: raw.$db.name ?? '' })} + + {m.machine_offline_description({ address: raw.$db.address })} + +
+ +
+
+
+ +
+
{m.machine_offline_node_you()}
+
+ {m.machine_offline_status_connected()} +
+
+
+
+
+
+
+ +
+
{m.machine_offline_node_proxy()}
+
+ {m.machine_offline_status_connected()} +
+
+
+
+
+ +
+
+
+
+ +
+
{m.machine_offline_node_dest()}
+
+ {m.machine_offline_status_unreachable()} +
+
+
+
+ {m.machine_offline_details()} +
{raw.$offline}
+
+
+ + + +
+
+
+
+
+
-
- - {intervalLabel} - - {#each intervals as opt (opt.value)} - {opt.label} - {/each} - - - {#if syncInterval === 'custom'} -
- -
- {/if} - - - - - {#snippet child({ props })} - - {/snippet} - - - (editOpen = true)}> - - {m.edit()} - - (deleteOpen = true)}> - - {m.delete()} - - - -
-
+ {:else} + {@const sys = raw} + {@const log = await audit} + {@const d = await details} + {@const latestTag = await latest} + {@const agentVersion = sys.$agent_version} + {@const outdated = !!(agentVersion && latestTag && agentVersion !== latestTag)} + {@const memPct = pct(sys.memory.used_bytes, sys.memory.total_bytes)} + {@const loadPct = Math.round((sys.load.load1 / sys.cpu.logical_cpus) * 100)} + {@const swapPct = pct( + sys.memory.swap_total_bytes - sys.memory.swap_free_bytes, + sys.memory.swap_total_bytes + )} -
- - - - - {#snippet extra()} -
+

+ {sys.$db.name} +

+

+ {sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address} +

+
+
+ + + {intervalLabel} + + {#each intervals as opt (opt.value)} + {opt.label} + {/each} + + + {#if syncInterval === 'custom'} +
+ +
+ {/if} + +
+
- - {#if sys.memory.swap_total_bytes > 0} + + + + + + {#snippet child({ props })} + + {/snippet} + + + (editOpen = true)}> + + {m.edit()} + + (deleteOpen = true)}> + + {m.delete()} + + + + + + {#if outdated} + + {/if} +
+
+ +
+ + + + + {#snippet extra()}
- {gb(sys.memory.swap_total_bytes - sys.memory.swap_free_bytes)} / {gb( - sys.memory.swap_total_bytes - )} - {m.dashboard_swap({ swapPct })} + {gb(sys.memory.used_bytes)} / {gb(sys.memory.total_bytes)}
- - {/if} - {/snippet} -
+ + {#if sys.memory.swap_total_bytes > 0} +
+ {gb(sys.memory.swap_total_bytes - sys.memory.swap_free_bytes)} / {gb( + sys.memory.swap_total_bytes + )} + {m.dashboard_swap({ swapPct })} +
+ + {/if} + {/snippet} +
- + - -
-
-
- + +
+
+
+ +
+ +
- -
+
+ + + +
-
- - - -
- -
- -
+
+ +
+ {/if} @@ -297,7 +439,7 @@ } })} > - + {m.machine_name()} @@ -305,7 +447,7 @@ id="edit-name-{formId}" placeholder={m.machine_name_placeholder()} {...updateMachine.fields.name.as('text')} - value={sys.$db.name} + value={raw.$db.name} required /> {#each updateMachine.fields.name.issues() as issue, i (`${issue}-${i}`)} @@ -318,7 +460,7 @@ id="edit-address-{formId}" placeholder={m.machine_address_placeholder()} {...updateMachine.fields.address.as('text')} - value={sys.$db.address} + value={raw.$db.address} /> {#each updateMachine.fields.address.issues() as issue, i (`${issue}-${i}`)} {issue.message} @@ -348,7 +490,7 @@ {m.machine_delete_title()} - {m.machine_delete_confirm({ name: sys.$db.name ?? '' })} + {m.machine_delete_confirm({ name: raw.$db.name ?? '' })} @@ -368,7 +510,7 @@ } })} > - + {m.delete()} @@ -378,3 +520,27 @@ + +