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}
+
+ {m.dashboard_refresh()}
+
+
+
+ {#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 @@
+
+