fix: some improvement and docs

This commit is contained in:
2026-06-25 23:11:32 +02:00
parent 20ac1445ca
commit 8414c26bd4
68 changed files with 9965 additions and 7993 deletions
BIN
View File
Binary file not shown.
+865 -680
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -17,7 +17,8 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
event.locals.user = session.user;
} else if (
!event.url.pathname.startsWith('/auth') &&
!event.url.pathname.startsWith('/api/auth')
!event.url.pathname.startsWith('/api/auth') &&
event.url.pathname !== '/install.sh'
) {
redirect(307, '/auth/sign-in');
}
@@ -39,6 +40,7 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
.filter(Boolean);
if (!roles.includes('admin')) redirect(307, '/dashboard');
}
return svelteKitHandler({ auth, building, event, resolve });
};
@@ -40,7 +40,7 @@
let listEl: HTMLDivElement | undefined = $state();
let items: { address: string; id: string; name: null | string }[] = $state([]);
let reordering = $state(false);
let inst = $state<null|Swapy>(null);
let inst = $state<null | Swapy>(null);
onDestroy(() => inst?.destroy());
@@ -86,7 +86,7 @@
untrack(() => {
const cur = [...items.map((i) => i.id)].sort().join('|');
const next = [...data.items.map((i) => i.id)].sort().join('|');
if (cur !== next) {
if (cur !== next) {
items = data.items;
reordering = false;
inst?.destroy();
@@ -115,9 +115,7 @@
open = false;
} catch (error) {
console.error(error);
toast.error(
extractErrorMessage(error) ?? m.errors_generic()
);
toast.error(extractErrorMessage(error) ?? m.errors_generic());
}
})}
>
@@ -36,10 +36,7 @@
function stripAnsi(text: string): string {
const E = String.fromCharCode(27);
const B = String.fromCharCode(7);
const control = new RegExp(
`[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]`,
'g'
);
const control = new RegExp(`[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]`, 'g');
return text
.replace(new RegExp(`${E}\\[[0-9;<=>?]*[a-zA-Z]`, 'g'), '')
.replace(new RegExp(`${E}\\][0-9;]*[^${E}]*(?:${E}\\\\|${B})`, 'g'), '')
@@ -121,9 +118,7 @@
view = 'terminal';
} catch (err) {
view = 'form';
toast.error(
extractErrorMessage(err) ?? m.terminal_ssh_failed()
);
toast.error(extractErrorMessage(err) ?? m.terminal_ssh_failed());
localStorage.removeItem(storageKey);
} finally {
loadingSaved = false;
@@ -171,9 +166,7 @@
view = 'terminal';
} catch (err) {
view = 'form';
toast.error(
extractErrorMessage(err) ?? m.terminal_ssh_failed()
);
toast.error(extractErrorMessage(err) ?? m.terminal_ssh_failed());
}
}
@@ -279,9 +272,7 @@
} catch (err) {
if (active) {
sessionEnded = true;
toast.error(
extractErrorMessage(err) ?? m.errors_generic()
);
toast.error(extractErrorMessage(err) ?? m.errors_generic());
}
}
})();
@@ -311,11 +302,14 @@
});
</script>
<Dialog.Root bind:open={terminalState.open} onOpenChange={(o) => { if (!o && sessionActive) return; if (!o) handleClose(); }}>
<Dialog.Content
class="sm:max-w-2xl sm:min-w-max -h-[500px]!"
showCloseButton={!sessionActive}
<Dialog.Root
bind:open={terminalState.open}
onOpenChange={(o) => {
if (!o && sessionActive) return;
if (!o) handleClose();
}}
>
<Dialog.Content class="sm:max-w-2xl sm:min-w-max -h-[500px]!" showCloseButton={!sessionActive}>
{#if view === 'form'}
<Dialog.Header>
<div class="flex items-center gap-3">
@@ -333,12 +327,16 @@
<div class="grid gap-5 px-4 py-3">
<div class="space-y-3">
<p class="text-xs font-medium tracking-wide text-muted-foreground uppercase">{m.terminal_section_connection()}</p>
<p class="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{m.terminal_section_connection()}
</p>
<div class="grid grid-cols-5 gap-3">
<div class="col-span-3 space-y-1.5">
<Label for="term-user" class="text-xs">{m.username()}</Label>
<div class="relative">
<UserIcon class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<UserIcon
class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2"
/>
<Input
id="term-user"
class="pl-8 h-9 text-sm"
@@ -356,8 +354,10 @@
<div class="space-y-3">
<div>
<p class="text-xs font-medium tracking-wide text-muted-foreground uppercase">{m.terminal_section_auth()}</p>
</div>
<p class="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{m.terminal_section_auth()}
</p>
</div>
{#if hasSaved && !loadingSaved}
<div class="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2">
@@ -389,13 +389,17 @@
>
<Tabs.List class="h-9 px-0">
<Tabs.Trigger value="password" class="text-xs h-9 px-3">{m.password()}</Tabs.Trigger>
<Tabs.Trigger value="key" class="text-xs h-9 px-3">{m.terminal_auth_private_key()}</Tabs.Trigger>
<Tabs.Trigger value="key" class="text-xs h-9 px-3"
>{m.terminal_auth_private_key()}</Tabs.Trigger
>
</Tabs.List>
<div class="mt-3">
<Tabs.Content value="password" class="space-y-1.5">
<Label for="term-password" class="text-xs">{m.password()}</Label>
<div class="relative">
<KeyRoundIcon class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<KeyRoundIcon
class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2"
/>
<Input
id="term-password"
class="pl-8 h-9 text-sm"
@@ -418,10 +422,7 @@
</Tabs.Root>
<div class="flex items-center gap-2 pt-1">
<Checkbox
id="term-save"
bind:checked={saveCredential}
/>
<Checkbox id="term-save" bind:checked={saveCredential} />
<Label for="term-save" class="text-xs text-muted-foreground cursor-pointer">
{m.terminal_remember_credential()}
</Label>
@@ -438,7 +439,6 @@
{m.terminal_connect()}
</Button>
</Dialog.Footer>
{:else if view === 'connecting'}
<div class="flex flex-col items-center justify-center gap-4 py-16 min-h-125">
<Spinner class="size-6 text-muted-foreground" />
@@ -449,47 +449,57 @@
</p>
</div>
</div>
{:else}
<div class="flex flex-col min-h-125">
<div class="flex items-center justify-between px-4 py-2.5">
<div class="flex items-center gap-2.5">
<div class="flex size-7 items-center justify-center rounded-md border bg-muted">
<TerminalIcon class="size-3.5 text-muted-foreground" />
</div>
<div class="flex items-center gap-2 text-xs font-medium leading-none">
{m.nav_system_terminal()}
<Separator orientation="vertical" class="h-3" />
<span class="font-mono text-muted-foreground text-[11px] tabular-nums">
{username}@{port}
</span>
</div>
</div>
</div>
<div
class="relative flex-1 min-h-0 w-full bg-zinc-950 rounded-b-lg border-x border-b border-zinc-800 overflow-hidden"
>
<div
bind:this={containerElement}
class="w-full h-full p-1.5"
></div>
{#if sessionEnded}
<div class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 border-t border-zinc-800 bg-zinc-950/90 px-3 py-2.5">
<span class="text-xs text-muted-foreground">
{capturedHistory.length > 2_000_000 ? m.terminal_history_truncated() : m.terminal_session_ended()}
</span>
<div class="flex items-center gap-1.5">
<Button size="sm" variant="ghost" class="h-7 text-xs px-2" onclick={() => handleClose()}>
{m.terminal_discard()}
</Button>
<Button size="sm" variant="secondary" class="h-7 text-xs px-2" onclick={downloadHistory}>
{m.terminal_download_history()}
</Button>
<div class="flex flex-col min-h-125">
<div class="flex items-center justify-between px-4 py-2.5">
<div class="flex items-center gap-2.5">
<div class="flex size-7 items-center justify-center rounded-md border bg-muted">
<TerminalIcon class="size-3.5 text-muted-foreground" />
</div>
<div class="flex items-center gap-2 text-xs font-medium leading-none">
{m.nav_system_terminal()}
<Separator orientation="vertical" class="h-3" />
<span class="font-mono text-muted-foreground text-[11px] tabular-nums">
{username}@{port}
</span>
</div>
</div>
{/if}
</div>
<div
class="relative flex-1 min-h-0 w-full bg-zinc-950 rounded-b-lg border-x border-b border-zinc-800 overflow-hidden"
>
<div bind:this={containerElement} class="w-full h-full p-1.5"></div>
{#if sessionEnded}
<div
class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 border-t border-zinc-800 bg-zinc-950/90 px-3 py-2.5"
>
<span class="text-xs text-muted-foreground">
{capturedHistory.length > 2_000_000
? m.terminal_history_truncated()
: m.terminal_session_ended()}
</span>
<div class="flex items-center gap-1.5">
<Button
size="sm"
variant="ghost"
class="h-7 text-xs px-2"
onclick={() => handleClose()}
>
{m.terminal_discard()}
</Button>
<Button
size="sm"
variant="secondary"
class="h-7 text-xs px-2"
onclick={downloadHistory}
>
{m.terminal_download_history()}
</Button>
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -14,7 +14,7 @@
title={m.dashboard_recent_activity()}
icon={History}
{items}
pageSize={10}
pageSize={25}
initialDir="desc"
columns={[
{ get: (e) => e.time, key: 'time', label: m.dashboard_col_time() },
@@ -84,12 +84,12 @@
</div>
</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(60px,1fr))] gap-2 w-full">
<div class="grid grid-cols-[repeat(auto-fill,minmax(80px,1fr))] gap-2 w-full">
{#each cores as c (c.core)}
<div
class="{getCoreColorClass(
c.usage_pct
)} group/square aspect-square rounded-[2px] border border-border transition-all duration-150 hover:z-10 hover:border-2 hover:border-primary flex flex-col"
)} group/square aspect-square rounded-md border border-border transition-all duration-150 hover:z-10 hover:border-2 hover:border-primary flex flex-col"
>
<div
class="group-hover/square:opacity-100 opacity-0 transition-all flex justify-center items-center gap-0 aspect-square"
@@ -65,14 +65,12 @@
} = $props();
const id = $props.id();
// svelte-ignore state_referenced_locally
const pageSize = new PersistedState<number>(pageSizeKey, defaultPageSize);
const totalPages = $derived(Math.max(1, Math.ceil(items.length / pageSize.current)));
const paginated = $derived(
items.slice((page - 1) * pageSize.current, page * pageSize.current)
);
const paginated = $derived(items.slice((page - 1) * pageSize.current, page * pageSize.current));
$effect(() => {
if (page > totalPages) page = totalPages;
+14
View File
@@ -15,6 +15,20 @@ export function uptime(seconds: number) {
// first IPv4 (no colon), address only
export const ipv4 = (addrs: null | string[]) => addrs?.find((a) => !a.includes(':'))?.split('/')[0];
// Strip lspci noise from GPU model strings
export function trimGpuName(name: string): string {
return name
.replace(/^VGA compatible controller \[\w+\]:\s*/, '')
.replace(
/^(?:Advanced Micro Devices, Inc\. \[AMD\/ATI\]|NVIDIA Corporation|Intel Corporation)\s*/,
''
)
.replace(/\s*\[\w+:\w+\]\s*/g, ' ')
.replace(/\s*\(rev\s+\w+\)\s*/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
// TZ pinned to UTC so SSR (Node host TZ) and first client paint produce identical
// strings — the locale comes from paraglide, so it matches whatever the user picked.
// ponytail: swap timeZone to the browser's TZ post-mount if you want local time.
@@ -0,0 +1,74 @@
<script lang="ts">
import Gpu from '@lucide/svelte/icons/gpu';
import * as Card from '$lib/components/ui/card';
import { Progress } from '$lib/components/ui/progress';
import { m } from '$lib/paraglide/messages';
import { gb, pct, trimGpuName, usageBar, usageText } from './format';
let {
gpus
}: {
gpus: {
device_id: string;
driver: string;
mem_utilization_pct: number;
memory_total_bytes: number;
memory_used_bytes: number;
model: string;
utilization_pct: number;
vendor: string;
}[];
} = $props();
</script>
<Card.Root class="h-full">
<Card.Header>
<div class="flex items-center gap-2">
<span
class="bg-muted text-muted-foreground flex size-7 items-center justify-center rounded-md"
>
<Gpu class="size-4" />
</span>
<Card.Title class="leading-1">{m.dashboard_gpu()}</Card.Title>
</div>
</Card.Header>
<Card.Content class="flex flex-col grow gap-0">
{#each gpus as gpu, i (gpu.vendor + gpu.device_id)}
{@const memPct = pct(gpu.memory_used_bytes, gpu.memory_total_bytes)}
<div
class="flex flex-col gap-1.5 {i > 0 ? 'border-border/60 border-t pt-3' : ''} {i <
gpus.length - 1
? 'pb-3'
: ''}"
>
<div class="flex items-baseline justify-between gap-2 text-sm">
<span class="font-medium truncate dmax-w-56" title={gpu.model}
>{trimGpuName(gpu.model)}</span
>
<span class="text-muted-foreground font-mono text-xs">{gpu.driver}</span>
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">{m.dashboard_gpu_compute()}</span>
<span class="tabular-nums font-medium {usageText(gpu.utilization_pct)}"
>{gpu.utilization_pct}%</span
>
</div>
<Progress value={gpu.utilization_pct} class={usageBar(gpu.utilization_pct)} />
</div>
{#if gpu.memory_total_bytes > 0}
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">{m.dashboard_gpu_vram()}</span>
<span class="tabular-nums font-medium {usageText(memPct)}"
>{gb(gpu.memory_used_bytes)} / {gb(gpu.memory_total_bytes)}</span
>
</div>
<Progress value={memPct} class={usageBar(memPct)} />
</div>
{/if}
</div>
{/each}
</Card.Content>
</Card.Root>
@@ -16,6 +16,7 @@
title={m.dashboard_storage()}
icon={HardDrive}
{items}
pageSize={5}
initialDir="desc"
columns={[
{ get: (d) => pct(d.used_bytes, d.total_bytes), key: 'usage', label: m.dashboard_col_usage() },
@@ -27,7 +27,7 @@
opts: { class: string; mono: boolean } = { class: '', mono: false }
)}
<div class="flex items-baseline justify-between gap-3 text-sm">
<span class="text-muted-foreground text-xs">{label}</span>
<span class="text-muted-foreground text-xs truncate max-w-[30ch]" title={label}>{label}</span>
<span
class="truncate text-right {opts.mono ? 'font-mono text-xs' : 'font-medium'} {opts.class ??
''}">{value}</span
@@ -8,19 +8,17 @@
import { tempText } from './format';
let { items }: { items: Temp[] } = $props();
const numbered = $derived(items.map((t, i) => ({ ...t, n: i + 1 })));
</script>
<DataPanel
title={m.dashboard_temperatures()}
icon={Thermometer}
items={numbered}
{items}
initialDir="desc"
pageSize={9}
pageSize={7}
columns={[
{ get: (t) => t.celsius, key: 'temp', label: m.dashboard_col_temp() },
{ get: (t) => t.label, key: 'sensor', label: m.dashboard_col_sensor() }
{ get: (t) => t.chip, key: 'chip', label: m.dashboard_col_sensor() }
]}
>
{#snippet row(temp, i)}
@@ -29,8 +27,11 @@
? 'border-border/60 border-t'
: ''}"
>
<span class="text-muted-foreground">{temp.label} #{temp.n}</span>
<span class="font-semibold tabular-nums {tempText(temp.celsius)}"
<div class="flex items-baseline gap-1.5 min-w-0">
<span class="text-muted-foreground font-mono text-xs">{temp.chip}</span>
<span class="truncate">{temp.label}</span>
</div>
<span class="font-semibold tabular-nums shrink-0 {tempText(temp.celsius)}"
>{temp.celsius.toFixed(1)} °C</span
>
</div>
+1 -1
View File
@@ -30,4 +30,4 @@ export type SystemDetails = {
updates: { count: number; manager: string } | null;
};
export type Temp = { celsius: number; label: string };
export type Temp = { celsius: number; chip: string; label: string };
@@ -16,7 +16,7 @@
bind:ref
data-slot="field-label"
class={cn(
'has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit leading-snug',
'has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
className
)}
+17 -1
View File
@@ -31,6 +31,22 @@ const EnvSchema = v.object({
SMTP_USER: v.pipe(v.optional(v.string()))
});
const env = v.parse(EnvSchema, Bun.env);
const env = (() => {
const rawEnv = typeof Bun !== 'undefined' ? Bun.env : process.env;
// Mock defaults during build analysis when environment variables are not injected
const parsed = v.safeParse(EnvSchema, rawEnv);
if (parsed.issues) {
const fallbackEnv = {
CRYPTO_SECRET:
rawEnv.CRYPTO_SECRET || 'build_time_mock_crypto_secret_for_sveltekit_build_analysis_step',
REPOSITORY_URL:
rawEnv.REPOSITORY_URL ||
'https://tea.urania.dev/api/v1/repos/urania/nadir-agent/releases/latest',
...rawEnv
};
return v.parse(EnvSchema, fallbackEnv);
}
return parsed.output;
})();
export { env };
+8
View File
@@ -71,6 +71,14 @@ export const machineHealth = query.batch(v.string(), async (machineIds) => {
return (id: string) => online.get(id) ?? false;
});
export const allMachines = query(async () => {
const rows = await db
.select({ address: machines.address, id: machines.id, name: machines.name })
.from(machines)
.orderBy(sql`${machines.order} IS NULL`, asc(machines.order), asc(machines.name));
return rows;
});
export const getMachine = query(v.string(), async (id) => {
const row = await db
.select({ address: machines.address, id: machines.id, name: machines.name })
+4 -1
View File
@@ -77,7 +77,10 @@ export const streamPackageAction = query.live(
}
for await (const chunk of parseSseStream(response)) {
yield chunk as { data: { error: string; line: string; message: string; success: boolean }; event: string };
yield chunk as {
data: { error: string; line: string; message: string; success: boolean };
event: string;
};
}
}
);
+10 -7
View File
@@ -65,7 +65,7 @@ export const setTime = command(
export const systemHostname = query(v.string(), async (machineId) => {
const { client: nadir } = await nadirForMachine(machineId);
const { data, error: err } = await nadir.GET('/api/system/hostname');
console.log(err)
console.log(err);
if (!data) throwNadirError(err);
return data;
});
@@ -133,12 +133,15 @@ export const setLocale = command(
}
);
export const generateLocale = command(v.object({ locale: v.string(), machineId: v.string() }), async ({ locale, machineId }) => {
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/locale/generate', { body: { locale } });
if (err) throwNadirError(err);
await listLocales(machineId).refresh();
});
export const generateLocale = command(
v.object({ locale: v.string(), machineId: v.string() }),
async ({ locale, machineId }) => {
const { client: nadir } = await nadirForMachine(machineId);
const { error: err } = await nadir.POST('/api/system/locale/generate', { body: { locale } });
if (err) throwNadirError(err);
await listLocales(machineId).refresh();
}
);
export const getModules = query(v.string(), async (machineId) => {
const { client: nadir } = await nadirForMachine(machineId);
+36 -42
View File
@@ -4,15 +4,15 @@ import { v } from '$lib';
import { env } from '$lib/const/schema';
import { m } from '$lib/paraglide/messages';
import { db } from '$lib/server/db';
import { machines } from '$lib/server/db/schema';
import { decryptValue, encrypt } from '$lib/server/db/custom-types';
import { machines } from '$lib/server/db/schema';
import {
closeSession,
createSession,
getSession,
resizeSession,
writeToSession,
type SessionAuth
type SessionAuth,
writeToSession
} from '$lib/server/terminal/session';
import { eq } from 'drizzle-orm';
@@ -48,36 +48,33 @@ export const openSshTerminal = command(
}
);
export const streamSshTerminal = query.live(
v.string(),
async function* (sessionId: string) {
const session = getSession(sessionId);
if (!session) {
yield { data: 'SSH connection failed or session expired', event: 'error' };
return;
}
if (session.outputStream.locked) {
throw error(409, 'Terminal stream is already being consumed');
}
const reader = session.outputStream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield { data: value, event: 'output' };
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Terminal connection lost';
yield { data: message, event: 'error' };
} finally {
reader.releaseLock();
closeSession(sessionId);
}
export const streamSshTerminal = query.live(v.string(), async function* (sessionId: string) {
const session = getSession(sessionId);
if (!session) {
yield { data: 'SSH connection failed or session expired', event: 'error' };
return;
}
);
if (session.outputStream.locked) {
throw error(409, 'Terminal stream is already being consumed');
}
const reader = session.outputStream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield { data: value, event: 'output' };
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Terminal connection lost';
yield { data: message, event: 'error' };
} finally {
reader.releaseLock();
closeSession(sessionId);
}
});
export const writeSshTerminal = command(
v.object({ data: v.string(), sessionId: v.string() }),
@@ -100,16 +97,13 @@ export const closeSshTerminal = command(v.string(), async (sessionId: string) =>
return { ok: true };
});
export const encryptCredential = command(
v.object({ data: v.string() }),
async ({ data }) => {
const {
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
return { encrypted: encrypt(data, env.CRYPTO_SECRET) };
}
);
export const encryptCredential = command(v.object({ data: v.string() }), async ({ data }) => {
const {
locals: { user }
} = getRequestEvent();
if (!user) error(401, { message: m.errors_unauthenticated() });
return { encrypted: encrypt(data, env.CRYPTO_SECRET) };
});
export const decryptCredential = command(
v.object({ encrypted: v.string() }),
+1 -1
View File
@@ -10,13 +10,13 @@ import {
import { auth } from '$lib/auth/server';
import { env } from '$lib/const/schema';
import { m } from '$lib/paraglide/messages';
import { extractErrorMessage } from './utils';
import { db } from '$lib/server/db';
import {
session as sessionTable,
user as userTable,
verification as verificationTable
} from '$lib/server/db/auth-schema';
import { extractErrorMessage } from '$lib/utils';
import { and, asc, count, desc, eq, gt, gte, inArray, like, lte, max, or, sql } from 'drizzle-orm';
type Role = 'admin' | 'user';
+4 -3
View File
@@ -6,7 +6,7 @@ import { decryptValue } from '$lib/server/db/custom-types';
import { getClient } from '$lib/server/nadir-agent/client';
export type AgentError = {
body?: { code?: string; message?: string; };
body?: { code?: string; message?: string };
detail?: string;
message?: string;
status?: number;
@@ -15,7 +15,6 @@ export type Package = { name: string; version: string };
export type PackageList = { manager: string; packages: Package[] };
export const nadirForMachine = async (machineId: string) => {
const {
locals: { user }
@@ -107,7 +106,9 @@ export async function* parseSseStream(response: Response): AsyncGenerator<SseEve
}
}
export function throwNadirError(err: { detail?: string; status?: number; } | null | undefined): never {
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') {
+11 -11
View File
@@ -10,6 +10,17 @@ export function decryptValue(encryptedValue: null | string | undefined): null |
return decrypt(encryptedValue, env.CRYPTO_SECRET);
}
export function encrypt(value: string, secret: string) {
const key = getKey(secret);
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
return Buffer.concat([iv, encrypted]).toString('base64');
}
export function encryptedText(name: string, options: { autoDecrypt: boolean }) {
const { autoDecrypt } = options;
@@ -49,17 +60,6 @@ function decrypt(value: string, secret: string) {
return decrypted.toString('utf8');
}
export function encrypt(value: string, secret: string) {
const key = getKey(secret);
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
return Buffer.concat([iv, encrypted]).toString('base64');
}
function getKey(secret: string) {
return crypto.createHash('sha256').update(secret).digest();
}
+6654 -6661
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -167,15 +167,27 @@ export function writeToSession(sessionId: string, data: string): void {
function safeClose(ctrl: null | ReadableStreamDefaultController<string>) {
if (!ctrl) return;
try { ctrl.close(); } catch { /* controller already closed */ }
try {
ctrl.close();
} catch {
/* controller already closed */
}
}
function safeEnqueue(ctrl: null | ReadableStreamDefaultController<string>, data: string) {
if (!ctrl) return;
try { ctrl.enqueue(data); } catch { /* controller already closed */ }
try {
ctrl.enqueue(data);
} catch {
/* controller already closed */
}
}
function safeError(ctrl: null | ReadableStreamDefaultController<string>, err: Error) {
if (!ctrl) return;
try { ctrl.error(err); } catch { /* controller already errored */ }
try {
ctrl.error(err);
} catch {
/* controller already errored */
}
}
+13 -2
View File
@@ -14,9 +14,20 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function extractErrorMessage(err: unknown): string | undefined {
if (!err || typeof err !== 'object') return undefined;
const a = err as AgentError;
return a.body?.message ?? a.message ?? a.detail ?? undefined;
}
}
export function hasPermission(
moduleId: string,
perm: 'read' | 'root' | 'write',
userPerms: null | Record<string, null | string[]> | undefined
): boolean {
if (!userPerms) return false;
if (userPerms['*']) return true;
const modPerms = userPerms[moduleId];
if (!modPerms) return false;
return modPerms.includes('*') || modPerms.includes(perm);
}
+1 -1
View File
@@ -40,7 +40,7 @@
</script>
<PageMeta {title} {description} noIndex />
<div class="mx-auto flex w-full max-w-2xl flex-col gap-4 p-6">
<div class="m-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">
+2 -3
View File
@@ -16,9 +16,8 @@
let { children } = $props();
const user = $derived(getUser());
const showSidebar =
(cU: null | User) =>
cU && (page.url.pathname.startsWith('/dashboard') || page.url.pathname.startsWith('/admin'))
const showSidebar = (cU: null | User) =>
cU && (page.url.pathname.startsWith('/dashboard') || page.url.pathname.startsWith('/admin'));
class TerminalState {
open = $state(false);
+455 -3
View File
@@ -1,8 +1,460 @@
<script lang="ts">
import ActivityIcon from '@lucide/svelte/icons/activity';
import ClockIcon from '@lucide/svelte/icons/clock';
import CpuIcon from '@lucide/svelte/icons/cpu';
import GlobeIcon from '@lucide/svelte/icons/globe';
import HardDrive from '@lucide/svelte/icons/hard-drive';
import MemoryStickIcon from '@lucide/svelte/icons/memory-stick';
import MonitorIcon from '@lucide/svelte/icons/monitor';
import MoonIcon from '@lucide/svelte/icons/moon';
import Orbit from '@lucide/svelte/icons/orbit';
import ServerIcon from '@lucide/svelte/icons/server';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import SunIcon from '@lucide/svelte/icons/sun';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import UsersIcon from '@lucide/svelte/icons/users';
import { resolve } from '$app/paths';
import { env } from '$env/dynamic/public';
import KpiCard from '$lib/components/dashboard/kpi-card.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button, buttonVariants } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { CopyButton } from '$lib/components/ui/copy-button';
import { m } from '$lib/paraglide/messages';
import { getLocale, locales, setLocale } from '$lib/paraglide/runtime';
import { setMode, userPrefersMode } from 'mode-watcher';
const current = $derived(getLocale());
const names = $derived(new Intl.DisplayNames([current], { type: 'language' }));
const labelOf = (l: string) => names.of(l) ?? l;
// Resolve PUBLIC_NADIR_URL with default fallback
const nadirHost = $derived(env.PUBLIC_NADIR_URL || 'nadir.urania.dev');
const installCommand = $derived(`curl -fsSL https://${nadirHost}/install.sh | sudo sh`);
const dockerCommand = 'docker run -d -p 3000:3000 urania/nadir-frontend';
const features = $derived([
{
desc: m.landing_features_dashboard_desc(),
icon: CpuIcon,
title: m.landing_features_dashboard()
},
{
desc: m.landing_features_services_desc(),
icon: ServerIcon,
title: m.landing_features_services()
},
{
desc: m.landing_features_users_desc(),
icon: UsersIcon,
title: m.landing_features_users()
},
{
desc: m.landing_features_networking_desc(),
icon: GlobeIcon,
title: m.landing_features_networking()
},
{
desc: m.landing_features_storage_desc(),
icon: HardDrive,
title: m.landing_features_storage()
},
{
desc: m.landing_features_terminal_desc(),
icon: TerminalIcon,
title: m.landing_features_terminal()
}
]);
</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>
<PageMeta title={m.seo_title_root()} description={m.seo_desc_root()} />
<!-- Top Navigation -->
<header
class="bg-background/80 border-b border-border/60 fixed top-0 z-50 flex w-full items-center gap-4 px-6 py-4 backdrop-blur-md"
>
<a href={resolve('/')} class="flex items-center gap-2.5 font-semibold text-foreground">
<Orbit class="text-primary size-5 animate-pulse" />
<span class="font-bold text-lg tracking-tight">{m.appname()}</span>
</a>
<div class="ms-auto flex items-center gap-3">
<a href={resolve('/auth/sign-in')} class={buttonVariants({ size: 'sm', variant: 'ghost' })}>
{m.login()}
</a>
<a href={resolve('/auth/sign-up')} class={buttonVariants({ size: 'sm', variant: 'default' })}>
{m.landing_hero_cta_start()}
</a>
</div>
</header>
<main class="min-h-screen pt-16 text-foreground antialiased selection:bg-primary/20">
<!-- Hero Section -->
<section class="border-b border-border bg-linear-to-b from-background to-muted/20">
<div class="mx-auto max-w-6xl px-6 py-20 md:py-28">
<div class="grid grid-cols-1 gap-12 md:grid-cols-2 md:items-center">
<div class="flex flex-col items-start text-left">
<Badge
variant="outline"
class="mb-5 border-primary/30 px-3 py-1 font-mono text-xs text-primary shadow-sm"
>
{m.landing_tech_opensource()} &bull; {m.landing_tech_selfhosted()}
</Badge>
<h1
class="mb-4 text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl text-balance leading-none"
>
{m.landing_hero_title()}
</h1>
<p class="text-muted-foreground mb-8 max-w-lg text-lg leading-relaxed text-pretty">
{m.landing_hero_tagline()}
</p>
<div class="flex flex-wrap gap-3">
<a href="#get-started" class={buttonVariants({ size: 'lg' })}>
{m.landing_hero_cta_start()}
</a>
<a
href="https://tea.urania.dev/urania/nadir-webui"
target="_blank"
rel="noreferrer"
class={buttonVariants({ size: 'lg', variant: 'outline' })}
>
{m.landing_hero_cta_github()}
</a>
</div>
</div>
<!-- Visual Dominant Hook (Editorial Mock Dashboard) -->
<div
class="relative w-full min-w-max rounded-xl border border-border bg-background/50 p-4 shadow-2xl backdrop-blur-sm"
>
<div class="flex items-center gap-1.5 border-b border-border pb-3 mb-4">
<span class="size-3 rounded-full bg-red-500/80"></span>
<span class="size-3 rounded-full bg-yellow-500/80"></span>
<span class="size-3 rounded-full bg-green-500/80"></span>
<span class="text-xs text-muted-foreground font-mono ml-2"
>nadir-webui &mdash; dashboard</span
>
</div>
<!-- Preview Stack of KPI Cards -->
<div
class="relative h-42 w-full mb-4 flex items-center justify-center select-none overflow-visible"
>
<!-- Card.Root 4 (Uptime) -->
<div
class="absolute w-[280px] scale-90 translate-x-[-75px] -translate-y-3 opacity-40 origin-center pointer-events-none z-10"
>
<KpiCard
label={m.dashboard_uptime()}
icon={ClockIcon}
value="142d 18h"
detail="since boot"
/>
</div>
<!-- Card.Root 3 (Load Average) -->
<div
class="absolute w-[280px] scale-90 translate-x-[-25px] -translate-y-1 opacity-95 origin-center pointer-events-none z-20"
>
<KpiCard
label={m.dashboard_load_average()}
icon={ActivityIcon}
value="4.12"
detail="64 logical cores"
/>
</div>
<!-- Card.Root 2 (Memory) -->
<div
class="absolute w-[280px] scale-90 translate-x-[25px] translate-y-1 opacity-97 origin-center pointer-events-none z-30"
>
<KpiCard
label={m.dashboard_memory()}
icon={MemoryStickIcon}
value="25.6%"
detail="32.8 / 128 GB"
/>
</div>
<!-- Card.Root 1 (CPU) -->
<div
class="absolute w-[280px] scale-90 translate-x-[75px] translate-y-3 opacity-100 origin-center shadow-2xl z-40"
>
<KpiCard
label={m.dashboard_cpu()}
icon={CpuIcon}
value="2.80 GHz"
detail="AMD EPYC 7763"
/>
</div>
</div>
<div
class="border border-border min-w-max rounded-lg overflow-hidden bg-black/90 p-4 text-xs font-mono text-emerald-400"
>
<div class="text-muted-foreground mb-1">// Querying nadir-agent metrics...</div>
<div>$ curl -fsSL https://{nadirHost}/api/health</div>
<div class="text-white mt-1 whitespace-nowrap">
&#123; "status": "healthy", "version": "v1.2.0", "uptime": "142d 18h" &#125;
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Dual-Architecture Fused Presentation -->
<section class="border-b border-border bg-background py-16 md:py-24" id="get-started">
<div class="mx-auto max-w-6xl px-6">
<div class="mb-16 max-w-2xl text-balance text-left">
<h2 class="mb-4 text-3xl font-bold tracking-tight md:text-4xl">
{m.landing_arch_title()}
</h2>
<p class="text-muted-foreground text-lg text-pretty">
{m.landing_arch_desc()}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Frontend Card.Root -->
<Card.Root class="border-border bg-muted/5 flex flex-col justify-between overflow-hidden">
<Card.Header class="pb-4">
<Badge class="w-fit mb-2" variant="secondary">Frontend</Badge>
<Card.Title class="text-xl">{m.landing_arch_frontend_title()}</Card.Title>
<Card.Description class="text-pretty">
{m.landing_arch_frontend_desc()}
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<div
class="font-mono text-xs text-muted-foreground bg-muted border border-border rounded-lg p-3 flex items-center justify-between gap-4"
>
<span class="overflow-x-auto whitespace-nowrap">{dockerCommand}</span>
<CopyButton text={dockerCommand} size="sm" variant="ghost" />
</div>
</Card.Content>
</Card.Root>
<!-- Backend Card.Root -->
<Card.Root class="border-border bg-muted/5 flex flex-col justify-between overflow-hidden">
<Card.Header class="pb-4">
<Badge class="w-fit mb-2" variant="secondary">Backend</Badge>
<Card.Title class="text-xl">{m.landing_arch_backend_title()}</Card.Title>
<Card.Description class="text-pretty">
{m.landing_arch_backend_desc()}
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<div
class="font-mono text-xs text-muted-foreground bg-muted border border-border rounded-lg p-3 flex items-center justify-between gap-4"
>
<span class="overflow-x-auto whitespace-nowrap">{installCommand}</span>
<CopyButton text={installCommand} size="sm" variant="ghost" />
</div>
</Card.Content>
</Card.Root>
</div>
<!-- Security Architecture Note -->
<div
class="mt-8 border border-amber-500/20 bg-amber-500/5 rounded-xl p-6 text-sm leading-relaxed text-pretty"
>
<div
class="flex items-center gap-2 text-amber-600/95 font-bold mb-2 font-mono text-xs uppercase tracking-wider"
>
<ShieldAlertIcon class="size-4 shrink-0 text-amber-500" />
<span>{m.landing_security_architecture_title()}</span>
</div>
<p class="text-muted-foreground">{m.landing_security_architecture_desc()}</p>
</div>
</div>
</section>
<!-- Features Grid -->
<section class="border-b border-border bg-muted/10 py-16 md:py-24">
<div class="mx-auto max-w-6xl px-6">
<div class="mb-16 max-w-2xl text-left">
<h2 class="mb-4 text-3xl font-bold tracking-tight md:text-4xl">
{m.landing_features_title()}
</h2>
<p class="text-muted-foreground text-lg text-pretty">
{m.landing_features_desc()}
</p>
</div>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each features as feature (feature.title)}
<div
class="border border-border/80 bg-background rounded-xl p-6 shadow-sm hover:shadow-md transition-all flex flex-col gap-3"
>
<div class="flex items-center gap-3 text-primary">
<feature.icon class="size-5 shrink-0" stroke-width="2" />
<h3 class="font-bold text-foreground text-base tracking-tight">{feature.title}</h3>
</div>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty">{feature.desc}</p>
</div>
{/each}
</div>
</div>
</section>
<!-- Quickstart Walkthrough -->
<section class="py-16 md:py-24 bg-background">
<div class="mx-auto max-w-4xl px-6">
<div class="mb-16 text-left">
<h2 class="mb-4 text-3xl font-bold tracking-tight md:text-4xl">
{m.landing_how_title()}
</h2>
<p class="text-muted-foreground text-lg text-pretty">
{m.landing_how_desc()}
</p>
</div>
<div class="flex flex-col gap-12">
<!-- Step 1 -->
<div class="flex flex-col md:flex-row gap-6 items-start">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-mono font-bold text-base shadow-md"
>
1
</div>
<div class="flex-1 flex flex-col gap-4">
<div>
<h3 class="font-bold text-lg mb-1">{m.landing_how_step1_title()}</h3>
<p class="text-muted-foreground text-sm text-pretty leading-relaxed">
{m.landing_how_step1_desc()}
</p>
</div>
<div
class="font-mono text-xs text-muted-foreground bg-muted border border-border rounded-lg p-4 flex items-center justify-between gap-4"
>
<span class="overflow-x-auto whitespace-nowrap">{dockerCommand}</span>
<CopyButton text={dockerCommand} size="sm" variant="ghost" />
</div>
</div>
</div>
<!-- Step 2 -->
<div class="flex flex-col md:flex-row gap-6 items-start">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-mono font-bold text-base shadow-md"
>
2
</div>
<div class="flex-1 flex flex-col gap-4">
<div>
<h3 class="font-bold text-lg mb-1">{m.landing_how_step2_title()}</h3>
<p class="text-muted-foreground text-sm text-pretty leading-relaxed">
{m.landing_how_step2_desc()}
</p>
</div>
<div
class="font-mono text-xs text-muted-foreground bg-muted border border-border rounded-lg p-4 flex items-center justify-between gap-4"
>
<span class="overflow-x-auto whitespace-nowrap">{installCommand}</span>
<CopyButton text={installCommand} size="sm" variant="ghost" />
</div>
<div
class="flex items-start gap-2 border border-amber-500/20 bg-amber-500/5 rounded-lg p-3 text-xs text-amber-600/90 leading-relaxed font-mono"
>
<ShieldAlertIcon class="size-4 shrink-0 text-amber-500 mt-0.5" />
<div>
<strong>Security note:</strong>
{m.landing_security_note().replace('Security note:', '').trim()}
</div>
</div>
</div>
</div>
<!-- Step 3 -->
<div class="flex flex-col md:flex-row gap-6 items-start">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-mono font-bold text-base shadow-md"
>
3
</div>
<div class="flex-1">
<h3 class="font-bold text-lg mb-1">{m.landing_how_step3_title()}</h3>
<p class="text-muted-foreground text-sm text-pretty leading-relaxed">
{m.landing_how_step3_desc()}
</p>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- Footer -->
<footer class="bg-muted border-t border-border py-12">
<div class="mx-auto flex max-w-6xl flex-col items-center gap-6 px-6">
<a href={resolve('/')} class="flex items-center gap-2 font-semibold">
<Orbit class="text-primary size-5" />
<span class="font-bold text-lg tracking-tight">{m.appname()}</span>
</a>
<p class="text-muted-foreground text-balance text-center text-sm max-w-md">
{m.landing_footer_subtitle()}
</p>
<nav class="flex items-center gap-6 text-sm">
<a
href="https://tea.urania.dev/urania/nadir-webui"
target="_blank"
rel="noreferrer"
class="text-muted-foreground hover:text-foreground transition-colors"
>
{m.landing_footer_gitea()}
</a>
<a
href="https://tea.urania.dev/urania/nadir-agent"
target="_blank"
rel="noreferrer"
class="text-muted-foreground hover:text-foreground transition-colors"
>
{m.docs_arch_card_agent_title()}
</a>
<a
href={resolve('/docs')}
target="_blank"
rel="noreferrer"
class="text-muted-foreground hover:text-foreground transition-colors"
>
{m.landing_footer_docs()}
</a>
</nav>
<div class="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
class="gap-1.5 text-muted-foreground"
onclick={() => {
const all = locales;
const cur = current ?? all[0];
const idx = all.indexOf(cur);
if (idx < 0) return;
const next = all[(idx + 1) % all.length];
if (next) setLocale(next);
}}
>
<GlobeIcon class="size-4" />
<span>{labelOf(current)}</span>
</Button>
<Button
variant="ghost"
size="sm"
class="text-muted-foreground"
aria-label={m.theme()}
onclick={() =>
setMode(
userPrefersMode.current === 'light'
? 'dark'
: userPrefersMode.current === 'dark'
? 'system'
: 'light'
)}
>
{#if userPrefersMode.current === 'light'}
<SunIcon class="size-4" />
{:else if userPrefersMode.current === 'dark'}
<MoonIcon class="size-4" />
{:else}
<MonitorIcon class="size-4" />
{/if}
</Button>
</div>
</div>
</footer>
+4 -4
View File
@@ -28,7 +28,9 @@
</Card.Header>
<Card.Content>
{#if requestReset.result?.sent}
<Button href={resolve("/auth/sign-in")} variant="outline" class="w-full">{m.back_to_login()}</Button>
<Button href={resolve('/auth/sign-in')} variant="outline" class="w-full"
>{m.back_to_login()}</Button
>
{:else}
<form
oninput={() => requestReset.validate()}
@@ -37,9 +39,7 @@
await submit();
} catch (error) {
console.error(error);
toast.error(
extractErrorMessage(error) ?? m.errors_generic()
);
toast.error(extractErrorMessage(error) ?? m.errors_generic());
}
})}
>
+1 -3
View File
@@ -36,9 +36,7 @@
await submit();
} catch (error) {
console.error(error);
toast.error(
extractErrorMessage(error) ?? m.errors_generic()
);
toast.error(extractErrorMessage(error) ?? m.errors_generic());
}
})}
>
+2 -4
View File
@@ -11,8 +11,8 @@
import { Input } from '$lib/components/ui/input/index.js';
import { m } from '$lib/paraglide/messages';
import { getOAuthProviders, login } from '$lib/remotes/auth.remote';
import { cn } from '$lib/utils.js';
import { extractErrorMessage } from '$lib/utils';
import { cn } from '$lib/utils.js';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -36,9 +36,7 @@
await submit();
} catch (error) {
console.error(error);
toast.error(
extractErrorMessage(error) ?? m.errors_generic()
);
toast.error(extractErrorMessage(error) ?? m.errors_generic());
}
})}
>
+4 -4
View File
@@ -24,7 +24,9 @@
>
</Card.Header>
<Card.Footer>
<Button href={resolve("/auth/sign-in")} variant="outline" class="w-full">{m.back_to_login()}</Button>
<Button href={resolve('/auth/sign-in')} variant="outline" class="w-full"
>{m.back_to_login()}</Button
>
</Card.Footer>
</Card.Root>
{:else}
@@ -41,9 +43,7 @@
await submit();
} catch (error) {
console.error(error);
toast.error(
extractErrorMessage(error) ?? m.errors_generic()
);
toast.error(extractErrorMessage(error) ?? m.errors_generic());
}
})}
>
+360
View File
@@ -1,6 +1,366 @@
<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 MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import * as Table from '$lib/components/ui/table';
import { machineDeleteSchema, machineEditSchema, machineSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import {
addMachine,
allMachines,
deleteMachine,
listMachines,
machineHealth,
updateMachine
} from '$lib/remotes/machines.remote';
import { extractErrorMessage } from '$lib/utils';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
type Machine = { address: string; id: string; name: null | string };
type SortBy = 'address' | 'name';
type Dir = 'asc' | 'desc';
const id = $props.id();
const machines = $derived(allMachines());
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
const sortStore = new PersistedState<{ sortBy: SortBy; sortDir: Dir }>('machines.sort', {
sortBy: 'name',
sortDir: 'asc'
});
let sortBy = $state<SortBy>(sortStore.current.sortBy);
let sortDir = $state<Dir>(sortStore.current.sortDir);
$effect(() => {
sortStore.current = { sortBy, sortDir };
});
let page = $state(1);
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
function toggleSort(col: SortBy) {
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else {
sortBy = col;
sortDir = 'asc';
}
page = 1;
}
const filtered = $derived.by(() => {
const all = (machines.current ?? []) as Machine[];
const q = debouncedSearch.trim().toLowerCase();
const out = q
? all.filter((m) => m.name?.toLowerCase().includes(q) || m.address.toLowerCase().includes(q))
: all;
const dir = sortDir === 'asc' ? 1 : -1;
out.sort((a, b) => {
const av = a[sortBy] ?? '';
const bv = b[sortBy] ?? '';
return av < bv ? -1 * dir : av > bv ? 1 * dir : 0;
});
return out;
});
function handleError(e: unknown) {
console.error(e);
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
let addOpen = $state(false);
let editingMachine = $state<Machine | null>(null);
let editOpen = $state(false);
let deletingMachine = $state<Machine | null>(null);
let deleteOpen = $state(false);
async function afterMutate() {
allMachines().refresh();
listMachines({ page: 1, search: '' }).refresh();
}
</script>
<PageMeta title={m.seo_title_dashboard()} description={m.seo_desc_dashboard()} />
<DataTable
title={m.dashboard()}
description={m.dashboard_machines_description()}
searchPlaceholder={m.machine_search_placeholder()}
emptyMessage={m.machine_none()}
items={filtered}
loading={machines.loading}
pageSizeKey="machines.pageSize"
defaultPageSize={25}
pageSizePresets={[10, 25, 50, 100]}
bind:search
onsearchinput={onSearchInput}
onrefresh={() => machines.refresh()}
bind:page
i18n={{
display: m.dashboard_filter_display(),
filterTitle: m.dashboard_filter_title(),
next: () => m.dashboard_next(),
pageOf: (p) => m.dashboard_page_of(p),
previous: () => m.dashboard_prev(),
rowsPerPage: m.dashboard_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (addOpen = true)}>
<PlusIcon class="size-4" />
{m.machine_add()}
</Button>
{/snippet}
{#snippet columns()}
{#each [{ key: 'name', label: m.machine_name() }, { key: 'address', label: m.machine_address() }] as col (col.key)}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(col.key as SortBy)}
>
{col.label}
{#if sortBy === col.key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-50" />
{/if}
</button>
</Table.Head>
{/each}
<Table.Head>{m.dashboard_col_status()}</Table.Head>
<Table.Head class="w-12 text-right">{m.dashboard_col_actions()}</Table.Head>
{/snippet}
{#snippet row(machine: Machine)}
{@const health = machineHealth(machine.id).current}
<Table.Cell class="font-medium">
<a href={resolve('/dashboard/[machineId]', { machineId: machine.id })} class="hover:underline"
>{machine.name ?? machine.id.slice(0, 8)}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{machine.address}</Table.Cell>
<Table.Cell>
<span
class="inline-flex items-center gap-1.5 text-xs font-medium {health
? 'text-emerald-500'
: 'text-destructive'}"
>
<span class="size-1.5 rounded-full {health ? 'bg-emerald-500' : 'bg-destructive'}"></span>
{health ? m.machine_online() : m.machine_offline()}
</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
onclick={() => {
editingMachine = machine;
editOpen = true;
}}>{m.machine_edit()}</DropdownMenu.Item
>
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deletingMachine = machine;
deleteOpen = true;
}}>{m.delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/snippet}
</DataTable>
<Dialog.Root bind:open={addOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.machine_add()}</Dialog.Title>
<Dialog.Description>{m.machine_add_description()}</Dialog.Description>
</Dialog.Header>
<form
oninput={() => addMachine.validate()}
{...addMachine.preflight(machineSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.machine_add());
addOpen = false;
await afterMutate();
} catch (e) {
handleError(e);
}
})}
>
<Field.Group>
<Field.Field>
<Field.Label for="add-name-{id}">{m.machine_name()}</Field.Label>
<Input
id="add-name-{id}"
placeholder={m.machine_name_placeholder()}
{...addMachine.fields.name.as('text')}
required
/>
{#each addMachine.fields.name.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="add-address-{id}">{m.machine_address()}</Field.Label>
<Input
id="add-address-{id}"
placeholder={m.machine_address_placeholder()}
{...addMachine.fields.address.as('text')}
/>
{#each addMachine.fields.address.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="add-token-{id}">{m.machine_token()}</Field.Label>
<Input
id="add-token-{id}"
placeholder={m.machine_token_placeholder()}
{...addMachine.fields.token.as('password')}
required
/>
{#each addMachine.fields.token.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Button type="submit" disabled={!!addMachine.pending}>{m.machine_save()}</Button>
</Field.Group>
</form>
</Dialog.Content>
</Dialog.Root>
{#if editingMachine}
{@const editForm = updateMachine.for(editingMachine.id)}
<Dialog.Root bind:open={editOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.machine_edit()}</Dialog.Title>
<Dialog.Description>{m.machine_edit_description()}</Dialog.Description>
</Dialog.Header>
<form
oninput={() => editForm.validate()}
{...editForm.preflight(machineEditSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.machine_edit());
editOpen = false;
editingMachine = null;
await afterMutate();
} catch (e) {
handleError(e);
}
})}
>
<input type="hidden" name="id" value={editingMachine.id} />
<Field.Group>
<Field.Field>
<Field.Label for="edit-name-{id}">{m.machine_name()}</Field.Label>
<Input
id="edit-name-{id}"
placeholder={m.machine_name_placeholder()}
{...editForm.fields.name.as('text')}
value={editingMachine.name ?? ''}
required
/>
{#each editForm.fields.name.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="edit-address-{id}">{m.machine_address()}</Field.Label>
<Input
id="edit-address-{id}"
placeholder={m.machine_address_placeholder()}
{...editForm.fields.address.as('text')}
value={editingMachine.address}
/>
{#each editForm.fields.address.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="edit-token-{id}">{m.machine_token()}</Field.Label>
<Input
id="edit-token-{id}"
placeholder={m.machine_token_placeholder()}
{...editForm.fields.token.as('password')}
/>
<Field.Description>{m.machine_token_keep()}</Field.Description>
{#each editForm.fields.token.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Button type="submit" disabled={!!editForm.pending}>{m.machine_save_edit()}</Button>
</Field.Group>
</form>
</Dialog.Content>
</Dialog.Root>
{/if}
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.machine_delete_title()}</AlertDialog.Title>
<AlertDialog.Description>
{m.machine_delete_confirm({ name: deletingMachine?.name ?? '' })}
</AlertDialog.Description>
</AlertDialog.Header>
<form
{...deleteMachine.preflight(machineDeleteSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.machine_delete_title());
deleteOpen = false;
deletingMachine = null;
await afterMutate();
} catch (e) {
handleError(e);
}
})}
>
<input type="hidden" name="id" value={deletingMachine?.id} />
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action type="submit" disabled={!!deleteMachine.pending}>
{m.delete()}
</AlertDialog.Action>
</AlertDialog.Footer>
</form>
</AlertDialog.Content>
</AlertDialog.Root>
+73 -82
View File
@@ -6,9 +6,7 @@
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';
@@ -23,11 +21,13 @@
import {
fmtDateTime,
gb,
ghz,
pct,
uptime,
usageBar,
usageText
} from '$lib/components/dashboard/format';
import GpuCard from '$lib/components/dashboard/gpu-card.svelte';
import KpiCard from '$lib/components/dashboard/kpi-card.svelte';
import NetworkPanel from '$lib/components/dashboard/network-panel.svelte';
import StoragePanel from '$lib/components/dashboard/storage-panel.svelte';
@@ -36,13 +36,14 @@
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';
import * as Card from '$lib/components/ui/card';
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 * as Empty from '$lib/components/ui/empty';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
import { Progress } from '$lib/components/ui/progress';
import * as Select from '$lib/components/ui/select';
import { Spinner } from '$lib/components/ui/spinner';
@@ -230,43 +231,47 @@
</p>
</div>
<div class="flex flex-wrap items-center lg:justify-end gap-2 py-1">
<ButtonGroup>
<Select.Root type="single" bind:value={syncInterval}>
<Select.Trigger class="h-8 w-auto grow lg:w-35">{intervalLabel}</Select.Trigger>
<Select.Content>
{#each intervals as opt (opt.value)}
<Select.Item value={opt.value}>{opt.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if syncInterval === 'custom'}
<div class="flex items-center gap-1.5">
<Input type="number" min="1" bind:value={customSec} class="h-8 w-16" />
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="h-8 w-auto">
{intervalLabel}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-56" align="end">
<div class="grid gap-3">
<label class="flex items-center gap-2 text-sm">
<Checkbox checked={polling} onCheckedChange={(v: boolean) => (polling = v)} />
{m.dashboard_auto_refresh()}
</label>
{#if polling}
<Select.Root type="single" bind:value={syncInterval}>
<Select.Trigger class="h-8 w-full">{intervalLabel}</Select.Trigger>
<Select.Content>
{#each intervals as opt (opt.value)}
<Select.Item value={opt.value}>{opt.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if syncInterval === 'custom'}
<Input type="number" min="1" bind:value={customSec} class="h-8 w-full" />
{/if}
{/if}
</div>
{/if}
</Popover.Content>
</Popover.Root>
{#if !polling}
<Button
variant="outline"
size="icon"
class="h-8 w-8"
onclick={() => (polling = !polling)}
aria-label={polling ? m.dashboard_pause() : m.dashboard_resume()}
class="h-8 px-2 sm:px-3"
onclick={refreshAll}
aria-label={m.dashboard_refresh()}
>
{#if polling}
<Pause class="size-4!" />
{:else}
<Play class="size-4!" />
{/if}
<RefreshCw class="size-4!" />
<span class="hidden sm:inline">{m.dashboard_refresh()}</span>
</Button>
</ButtonGroup>
<Button
variant="outline"
class="h-8 px-2 sm:px-3"
onclick={refreshAll}
aria-label={m.dashboard_refresh()}
>
<RefreshCw class="size-4!" />
<span class="hidden sm:inline">{m.dashboard_refresh()}</span>
</Button>
{/if}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
@@ -306,8 +311,6 @@
try {
await updateAgent(machineId);
toast.info(m.agent_update_started({ from, to }));
// Agent returns 202 and runs update in background; poll until the
// reported version flips or we give up.
const deadline = Date.now() + 60_000;
let done = false;
while (Date.now() < deadline) {
@@ -324,9 +327,7 @@
}
if (!done) toast.error(m.agent_update_failed());
} catch (e) {
toast.error(
extractErrorMessage(e) ?? m.errors_generic()
);
toast.error(extractErrorMessage(e) ?? m.errors_generic());
} finally {
updating = false;
}
@@ -338,14 +339,20 @@
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 px-4 py-2">
<!-- CPU Heatmap (replaces the old CPU KpiCard) -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 px-4 py-0">
<KpiCard
label={m.dashboard_cpu()}
icon={Cpu}
value={sys.cpu.model}
detail={m.dashboard_logical_cores({ cores: sys.cpu.logical_cpus })}
/>
value={ghz(sys.cpu.current_mhz)}
valueClass="tabular-nums {usageText(pct(sys.cpu.current_mhz, sys.cpu.max_mhz))}"
detail={sys.cpu.model}
>
{#snippet extra()}
<div class="text-muted-foreground text-xs">
{m.dashboard_logical_cores({ cores: sys.cpu.logical_cpus })}
</div>
{/snippet}
</KpiCard>
<KpiCard
label={m.dashboard_memory()}
@@ -396,29 +403,27 @@
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 h-full">
<div class="grid gap-4 lg:grid-cols-2 h-full items-start p-4">
<div class="flex flex-col grow gap-4">
<CpuHeatmap
cpuUsage={sys.load.cpu_usage}
cpuModel={sys.cpu.model}
logicalCpus={sys.cpu.logical_cpus}
minMhz={sys.cpu.min_mhz}
maxMhz={sys.cpu.max_mhz}
currentMhz={sys.cpu.current_mhz}
/>
{#if sys.gpus?.length}
<GpuCard gpus={sys.gpus} />
{/if}
<StoragePanel items={sys.disks ?? []} />
<ActivityPanel items={log} />
</div>
<div class="flex flex-col gap-4 lg:sticky lg:top-34">
<SystemPanel os={sys.os} cpu={sys.cpu} details={d} />
<NetworkPanel items={sys.network_interfaces ?? []} />
<TemperaturePanel items={sys.temperatures ?? []} />
</div>
<CpuHeatmap
cpuUsage={sys.load.cpu_usage}
cpuModel={sys.cpu.model}
logicalCpus={sys.cpu.logical_cpus}
minMhz={sys.cpu.min_mhz}
maxMhz={sys.cpu.max_mhz}
currentMhz={sys.cpu.current_mhz}
/>
</div>
<div class="grid items-start gap-4 xl:grid-cols-3 p-4 py-2">
<SystemPanel os={sys.os} cpu={sys.cpu} details={d} />
<NetworkPanel items={sys.network_interfaces ?? []} />
<TemperaturePanel items={sys.temperatures ?? []} />
</div>
<div class="px-4 py-2 pb-4">
<ActivityPanel items={log} />
</div>
{/if}
@@ -442,9 +447,7 @@
toast.success(m.machine_edit());
} catch (error) {
console.error(error);
toast.error(
extractErrorMessage(error) ?? m.errors_generic()
);
toast.error(extractErrorMessage(error) ?? m.errors_generic());
}
})}
>
@@ -513,9 +516,7 @@
goto(resolve('/dashboard'));
} catch (error) {
console.error(error);
toast.error(
extractErrorMessage(error) ?? m.errors_generic()
);
toast.error(extractErrorMessage(error) ?? m.errors_generic());
}
})}
>
@@ -538,21 +539,11 @@
background-image: linear-gradient(to right, currentColor 50%, transparent 0%);
background-size: 10px 2px;
background-repeat: repeat-x;
animation: custom-flow 1s linear infinite;
}
.custom-dash-ok {
color: var(--color-emerald-500, #10b981);
}
.custom-dash-bad {
color: var(--destructive, #ef4444);
animation-duration: 2s;
}
@keyframes custom-flow {
from {
background-position: 0 0;
}
to {
background-position: 10px 0;
}
}
</style>
@@ -13,7 +13,7 @@
</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">
<div class="mx-auto flex w-full max-w-4xl 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">
@@ -12,7 +12,7 @@
</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="mx-auto flex w-full max-w-4xl 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>
@@ -17,7 +17,8 @@
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { deleteHost, listHosts, upsertHost } from '$lib/remotes/networking.remote';
import { extractErrorMessage } from '$lib/utils';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
type Host = { hostnames: null | string[]; ip: string };
@@ -25,6 +26,8 @@
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const hosts = $derived(listHosts(machineId));
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('networking', 'write', whoami.current?.permissions));
let search = $state('');
let editOpen = $state(false);
@@ -136,16 +139,16 @@
bind:search
onrefresh={() => hosts.refresh()}
i18n={{
display: m.users_filter_display(),
filterTitle: m.users_filter(),
next: () => m.pagination_next(),
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={openAdd}>
<Button onclick={openAdd} disabled={!canWrite}>
<PlusIcon class="size-4" />
{m.networking_host_add()}
</Button>
@@ -172,9 +175,12 @@
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(h)}>{m.edit()}</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => openEdit(h)} disabled={!canWrite}
>{m.edit()}</DropdownMenu.Item
>
<DropdownMenu.Item
variant="destructive"
disabled={!canWrite}
onclick={() => {
deleting = h;
deleteOpen = true;
@@ -23,12 +23,15 @@
listInterfaces,
rollbackChange
} from '$lib/remotes/networking.remote';
import { extractErrorMessage } from '$lib/utils';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(pageState.params.machineId!);
const interfaces = $derived(listInterfaces(machineId));
const pending = $derived(getPending(machineId));
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('networking', 'write', whoami.current?.permissions));
let search = $state('');
let busy = $state<null | string>(null);
@@ -86,49 +89,47 @@
title={m.seo_title_networking_interfaces()}
description={m.seo_desc_networking_interfaces()}
/>
<div class="mx-auto flex w-full flex-col gap-0 grow">
{#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>
{#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 || !canWrite}
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 || !canWrite}
onclick={() =>
run(
'confirm',
() => confirmChange({ machineId, name: pending.current!.interface }),
m.networking_confirmed()
)}
>
<CheckIcon class="size-4" />
{m.networking_confirm()}
</Button>
</div>
{/if}
</div>
</div>
{/if}
{#snippet sortHead(key: string, label: string)}
<button
@@ -165,11 +166,11 @@
pending.refresh();
}}
i18n={{
display: m.users_filter_display(),
filterTitle: m.users_filter(),
next: () => m.pagination_next(),
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
@@ -196,13 +197,11 @@
</Table.Cell>
<Table.Cell>
{#if i.state === 'up'}
<Badge
class="border-0 bg-emerald-500/15 capitalize text-emerald-700 dark:text-emerald-400"
<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"
<Badge class="border-0 bg-zinc-500/15 capitalize text-zinc-700 dark:text-zinc-400"
>{i.state}</Badge
>
{:else}
@@ -243,19 +242,20 @@
{m.networking_details()}
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={!canWrite}
onclick={() =>
goto(
resolve(
'/dashboard/[machineId]/networking/interfaces/[name]/configure',
{ machineId, name: i.name }
)
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'}
disabled={!!busy || i.state === 'up' || !canWrite}
onclick={() =>
run(
`up-${i.name}`,
@@ -267,7 +267,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
variant="destructive"
disabled={!!busy || i.state === 'down'}
disabled={!!busy || i.state === 'down' || !canWrite}
onclick={() =>
run(
`down-${i.name}`,
@@ -9,10 +9,14 @@
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { getInterfaceConfig } from '$lib/remotes/networking.remote';
import { getWhoami } from '$lib/remotes/system.remote';
import { hasPermission } from '$lib/utils';
const name = $derived(pageState.params.name!);
const machineId = $derived(pageState.params.machineId!);
const config = $derived(getInterfaceConfig({ machineId, name }).current);
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('networking', 'write', whoami.current?.permissions));
const v4Badge = $derived(config?.method === 'dhcp' ? 'DHCP' : 'Static');
const v6Badge = $derived(
@@ -27,7 +31,7 @@
</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="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<div class="flex items-start gap-3">
<Button
variant="ghost"
@@ -44,6 +48,7 @@
</div>
{#if config}
<Button
disabled={!canWrite}
href={resolve('/dashboard/[machineId]/networking/interfaces/[name]/configure', {
machineId,
name
@@ -63,19 +68,15 @@
<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>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-muted-foreground">{m.machine_address()}</dt>
<dd class="font-mono">{config.address || '—'}</dd>
<dt class="text-muted-foreground">{m.prefix()}</dt>
<dd class="font-mono">{config.prefix ?? '—'}</dd>
<dt class="text-muted-foreground">{m.networking_col_gateway()}</dt>
<dd class="font-mono">{config.gateway || '—'}</dd>
</dl>
</Card.Content>
</Card.Root>
@@ -86,19 +87,15 @@
<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>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-muted-foreground">{m.machine_address()}</dt>
<dd class="font-mono">{config.ipv6.address || '—'}</dd>
<dt class="text-muted-foreground">{m.prefix()}</dt>
<dd class="font-mono">{config.ipv6.prefix ?? '—'}</dd>
<dt class="text-muted-foreground">{m.networking_col_gateway()}</dt>
<dd class="font-mono">{config.ipv6.gateway || '—'}</dd>
</dl>
</Card.Content>
{:else}
<Card.Content>
@@ -14,7 +14,8 @@
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import { applyInterfaceConfig, getInterfaceConfig } from '$lib/remotes/networking.remote';
import { extractErrorMessage } from '$lib/utils';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -22,6 +23,8 @@
const machineId = $derived(pageState.params.machineId!);
const config = $derived(getInterfaceConfig({ machineId, name }).current);
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('networking', 'write', whoami.current?.permissions));
let v4Method = $state<'dhcp' | 'static'>('dhcp');
let v4 = $state({ address: '', gateway: '', prefix: 24 });
@@ -109,7 +112,7 @@
}
</script>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<div class="flex items-start gap-3">
<Button
variant="ghost"
@@ -254,7 +257,7 @@
<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}>
<Button type="button" variant="outline" size="sm" onclick={addDns} disabled={!canWrite}>
<PlusIcon class="size-4" />
{m.networking_dns_add()}
</Button>
@@ -270,6 +273,7 @@
size="icon"
class="size-9 shrink-0"
onclick={() => removeDns(i)}
disabled={!canWrite}
>
<TrashIcon class="size-4" />
</Button>
@@ -285,7 +289,7 @@
<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}>
<Button type="button" variant="outline" size="sm" onclick={addRoute} disabled={!canWrite}>
<PlusIcon class="size-4" />
{m.networking_route_add()}
</Button>
@@ -308,6 +312,7 @@
size="icon"
class="size-9 shrink-0"
onclick={() => removeRoute(i)}
disabled={!canWrite}
>
<TrashIcon class="size-4" />
</Button>
@@ -331,7 +336,9 @@
/>
<p class="text-muted-foreground text-xs">{m.networking_rollback_seconds_hint()}</p>
</div>
<Button type="submit" disabled={!valid || submitting}>{m.networking_apply()}</Button>
<Button type="submit" disabled={!valid || submitting || !canWrite}
>{m.networking_apply()}</Button
>
</Card.Content>
</Card.Root>
</form>
@@ -348,7 +355,7 @@
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={submit} disabled={submitting}>
<AlertDialog.Action onclick={submit} disabled={submitting || !canWrite}>
{m.networking_apply()}
</AlertDialog.Action>
</AlertDialog.Footer>
@@ -78,11 +78,11 @@
bind:search
onrefresh={() => routes.refresh()}
i18n={{
display: m.users_filter_display(),
filterTitle: m.users_filter(),
next: () => m.pagination_next(),
pageOf: (p) => m.pagination_page_of({ page: p.page, pages: p.pages }),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
@@ -11,7 +11,7 @@
</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">
<div class="mx-auto flex w-full max-w-4xl 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">
@@ -25,6 +25,8 @@
listPackageUpdates,
streamPackageAction
} from '$lib/remotes/packages.remote';
import { getWhoami } from '$lib/remotes/system.remote';
import { hasPermission } from '$lib/utils';
type Package = { name: string; version: string };
@@ -34,6 +36,8 @@
const updatesResource = $derived(listPackageUpdates(machineId));
const manager = $derived(dataResource.current?.manager ?? '');
const packages = $derived((dataResource.current?.packages ?? []) as Package[]);
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('packages', 'write', whoami.current?.permissions));
// name → new version, for the per-row "update available" badge.
const updatesMap = $derived(
new Map(
@@ -117,13 +121,19 @@
];
consoleStatus = 'success';
} else {
consoleLogs = [...consoleLogs, `\n${chunk.data.error ? m.packages_stream_error({ message: chunk.data.error }) : m.packages_stream_failed()}`];
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}` })}`];
consoleLogs = [
...consoleLogs,
`\n${m.packages_stream_connection_error({ message: (err as Error).message || `${err}` })}`
];
consoleStatus = 'failed';
} finally {
await dataResource.refresh();
@@ -173,7 +183,7 @@
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<Button onclick={() => (createOpen = true)} disabled={!canWrite}>
<PlusIcon class="size-4" />
{m.packages_install()}
</Button>
@@ -199,7 +209,7 @@
{/snippet}
{#snippet row(p: Package)}
{@const newVersion = updatesMap.get(p.name)}
<Table.Cell class="font-medium font-mono text-xs">{p.name}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{p.version}</Table.Cell>
<Table.Cell class="w-8">
@@ -225,7 +235,7 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if newVersion}
<DropdownMenu.Item onclick={() => runUpdate(p.name)}>
<DropdownMenu.Item onclick={() => runUpdate(p.name)} disabled={!canWrite}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
@@ -233,6 +243,7 @@
{/if}
<DropdownMenu.Item
variant="destructive"
disabled={!canWrite}
onclick={() => {
deleting = p;
deleteOpen = true;
@@ -15,6 +15,8 @@
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listPackageUpdates, streamPackageAction } from '$lib/remotes/packages.remote';
import { getWhoami } from '$lib/remotes/system.remote';
import { hasPermission } from '$lib/utils';
type Package = { name: string; version: string };
@@ -22,6 +24,8 @@
const dataResource = $derived(listPackageUpdates(machineId));
const manager = $derived(dataResource.current?.manager ?? '');
const packages = $derived((dataResource.current?.packages ?? []) as Package[]);
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('packages', 'write', whoami.current?.permissions));
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
@@ -125,7 +129,7 @@
}}
>
{#snippet actions()}
<Button onclick={() => doUpgrade()} disabled={!packages.length}>
<Button onclick={() => doUpgrade()} disabled={!packages.length || !canWrite}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_upgrade_all()}
</Button>
@@ -161,7 +165,7 @@
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => doUpgrade(p.name)}>
<DropdownMenu.Item onclick={() => doUpgrade(p.name)} disabled={!canWrite}>
<ArrowUpCircleIcon class="size-4" />
{m.packages_update_single()}
</DropdownMenu.Item>
@@ -86,17 +86,37 @@
}
list = list.filter((s) => {
const activeCat =
s.active === 'active' ? 'active' : s.active === 'inactive' ? 'inactive' : s.active === 'failed' ? 'failed' : 'other';
s.active === 'active'
? 'active'
: s.active === 'inactive'
? 'inactive'
: s.active === 'failed'
? 'failed'
: 'other';
return filterActive[activeCat];
});
list = list.filter((s) => {
const loadCat =
s.load === 'loaded' ? 'loaded' : s.load === 'masked' ? 'masked' : s.load === 'not-found' ? 'notFound' : s.load === 'error' ? 'error' : 'loaded';
s.load === 'loaded'
? 'loaded'
: s.load === 'masked'
? 'masked'
: s.load === 'not-found'
? 'notFound'
: s.load === 'error'
? 'error'
: 'loaded';
return filterLoad[loadCat as keyof typeof filterLoad] ?? true;
});
list = list.filter((s) => {
const subCat =
s.sub === 'running' ? 'running' : s.sub === 'exited' ? 'exited' : s.sub === 'dead' ? 'dead' : 'other';
s.sub === 'running'
? 'running'
: s.sub === 'exited'
? 'exited'
: s.sub === 'dead'
? 'dead'
: 'other';
return filterSub[subCat as keyof typeof filterSub] ?? true;
});
list.sort((a, b) => {
@@ -120,14 +140,14 @@
bind:search
onsearchinput={onSearchInput}
onrefresh={() => servicesResource.refresh()}
activeFilterCount={activeFilterCount}
{activeFilterCount}
bind:page
i18n={{
display: m.users_filter_display(),
filterTitle: m.services_filter_title(),
next: () => m.users_next(),
pageOf: (p) => m.users_page_of(p),
previous: () => m.users_prev(),
next: () => m.users_next(),
filterTitle: m.services_filter_title(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
@@ -232,7 +252,7 @@
<Table.Head class="w-24">{m.services_col_load()}</Table.Head>
<Table.Head>{m.services_col_description()}</Table.Head>
{/snippet}
{#snippet row(s: ServiceUnit, i: number)}
{#snippet row(s: ServiceUnit)}
<Table.Cell class="font-medium font-mono text-xs max-w-[20rem]">
<a
href={resolve('/dashboard/[machineId]/services/[name]', {
@@ -4,6 +4,7 @@
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 FilterIcon from '@lucide/svelte/icons/filter';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import PlayIcon from '@lucide/svelte/icons/play';
import PowerIcon from '@lucide/svelte/icons/power';
@@ -22,6 +23,7 @@
import * as Empty from '$lib/components/ui/empty';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import { Switch } from '$lib/components/ui/switch';
import { m } from '$lib/paraglide/messages';
import {
@@ -34,13 +36,16 @@
stopService,
streamServiceLogs
} from '$lib/remotes/services.remote';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const name = $derived(pageState.params.name!);
const machineId = $derived(pageState.params.machineId!);
const statusResource = $derived(getServiceStatus({ machineId, unit: name }));
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('services', 'write', whoami.current?.permissions));
let busy = $state<null | string>(null);
@@ -185,10 +190,15 @@
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">
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<svelte:boundary>
{#snippet failed(err, reset)}
{@const e = err as { body?: { message?: string }; message?: string; status?: number; detail?: string }}
{@const e = err as {
body?: { message?: string };
detail?: string;
message?: string;
status?: number;
}}
{@const status = e.status ?? 0}
{@const notFound = status === 404}
<Empty.Root class="border">
@@ -282,7 +292,7 @@
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.active_state === 'active'}
disabled={!!busy || s.active_state === 'active' || !canWrite}
onclick={() =>
run(
'start',
@@ -300,7 +310,7 @@
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.active_state !== 'active'}
disabled={!!busy || s.active_state !== 'active' || !canWrite}
onclick={() =>
run(
'stop',
@@ -318,7 +328,7 @@
<Button
variant="outline"
class="col-span-2 justify-start"
disabled={!!busy}
disabled={!!busy || !canWrite}
onclick={() =>
run(
'restart',
@@ -336,7 +346,10 @@
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.unit_file_state === 'enabled' || s.unit_file_state === 'static'}
disabled={!!busy ||
s.unit_file_state === 'enabled' ||
s.unit_file_state === 'static' ||
!canWrite}
onclick={() =>
run(
'enable',
@@ -354,7 +367,7 @@
<Button
variant="outline"
class="justify-start"
disabled={!!busy || s.unit_file_state !== 'enabled'}
disabled={!!busy || s.unit_file_state !== 'enabled' || !canWrite}
onclick={() =>
run(
'disable',
@@ -392,6 +405,87 @@
{/if}
</Card.Title>
<div class="flex items-center gap-3">
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
size="icon"
title={m.services_logs_filters()}
{...props}
>
<FilterIcon class="size-4" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-4" align="end">
<div class="flex flex-col gap-3">
<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-full"
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-full 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-full 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 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>
</Popover.Content>
</Popover.Root>
<Label class="flex items-center gap-2 text-sm font-normal">
<Switch bind:checked={autoscroll.current} />
{m.services_logs_autoscroll()}
@@ -417,71 +511,8 @@
</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 class="text-muted-foreground text-xs">
{filteredLogs.length} / {entries.length}
</div>
<div
@@ -11,7 +11,7 @@
</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">
<div class="mx-auto flex w-full max-w-4xl 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">
@@ -10,7 +10,8 @@
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listFstab, removeMount } from '$lib/remotes/storage.remote';
import { extractErrorMessage } from '$lib/utils';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
type FstabEntry = {
@@ -24,6 +25,8 @@
const machineId = $derived(pageState.params.machineId!);
const fstab = $derived(listFstab(machineId));
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('storage', 'write', whoami.current?.permissions));
let search = $state('');
const filtered = $derived.by(() => {
@@ -71,11 +74,11 @@
bind:search
onrefresh={() => fstab.refresh()}
i18n={{
display: m.users_filter_display(),
filterTitle: m.users_filter(),
next: () => m.pagination_next(),
pageOf: (p) => m.pagination_page_of(p),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
@@ -88,7 +91,7 @@
<Table.Head class="text-right">{m.storage_col_pass()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(e: FstabEntry, i: number)}
{#snippet row(e: FstabEntry)}
<Table.Cell class="max-w-[18rem] font-medium font-mono text-xs">
<span class="block truncate" title={e.mountpoint}>{e.mountpoint}</span>
</Table.Cell>
@@ -113,6 +116,7 @@
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
disabled={!canWrite}
onclick={() => {
deleting = e;
deleteOpen = true;
@@ -15,22 +15,42 @@
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 { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type Mount = { device: string; fstype: string; mountpoint: string; options: string };
const PSEUDO_FSTYPES = new Set([
'autofs', 'binfmt_misc', 'bpf', 'cgroup', 'cgroup2', 'configfs', 'debugfs',
'devpts', 'devtmpfs', 'fusectl', 'hugetlbfs', 'mqueue', 'nsfs', 'proc',
'pstore', 'ramfs', 'securityfs', 'sysfs', 'tmpfs', 'tracefs'
'autofs',
'binfmt_misc',
'bpf',
'cgroup',
'cgroup2',
'configfs',
'debugfs',
'devpts',
'devtmpfs',
'fusectl',
'hugetlbfs',
'mqueue',
'nsfs',
'proc',
'pstore',
'ramfs',
'securityfs',
'sysfs',
'tmpfs',
'tracefs'
]);
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const mounts = $derived(listMounts(machineId));
const fstab = $derived(listFstab(machineId));
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('storage', 'write', whoami.current?.permissions));
let search = $state('');
const filtersStore = new PersistedState('storage.mounts.filters', { showPseudo: false });
@@ -110,18 +130,18 @@
pageSizePresets={[10, 20, 50, 100]}
bind:search
onrefresh={() => mounts.refresh()}
activeFilterCount={activeFilterCount}
{activeFilterCount}
i18n={{
display: m.users_filter_display(),
filterTitle: m.users_filter(),
next: () => m.pagination_next(),
pageOf: (p) => m.pagination_page_of(p),
previous: () => m.pagination_previous(),
next: () => m.pagination_next(),
filterTitle: m.users_filter(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<Button onclick={() => (createOpen = true)} disabled={!canWrite}>
<PlusIcon class="size-4" />
{m.storage_mount_add()}
</Button>
@@ -147,7 +167,7 @@
<Table.Head>{m.storage_col_options()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(mt: Mount, i: number)}
{#snippet row(mt: Mount)}
<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>
@@ -175,6 +195,7 @@
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
disabled={!canWrite}
onclick={() => {
deleting = mt;
deleteOpen = true;
@@ -201,7 +222,12 @@
>
<div class="flex flex-col gap-1.5">
<Label for="sm-device-{id}">{m.storage_col_device()}</Label>
<Input id="sm-device-{id}" bind:value={createForm.device} placeholder="/dev/sdb1" required />
<Input
id="sm-device-{id}"
bind:value={createForm.device}
placeholder="/dev/sdb1"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-mountpoint-{id}">{m.storage_col_mountpoint()}</Label>
@@ -26,9 +26,14 @@
</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">
<div class="mx-auto flex w-full max-w-4xl 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">
<div class="col-span-full">
<h2 class="text-muted-foreground text-xs font-semibold uppercase tracking-widest">
{m.system_section_configuration()}
</h2>
</div>
<a href={resolve('/dashboard/[machineId]/system/nadir', { machineId })} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
@@ -40,6 +45,7 @@
</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>
@@ -73,17 +79,6 @@
</Card.Header>
</Card.Root>
</a>
<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">
<PowerIcon class="size-5" />
<Card.Title>{m.nav_system_power()}</Card.Title>
</div>
<Card.Description>{m.nav_system_power_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
{#if canShowTerminal}
<button
onclick={() => (terminalState.open = true)}
@@ -100,5 +95,22 @@
</Card.Root>
</button>
{/if}
<div class="col-span-full">
<h2 class="text-muted-foreground text-xs font-semibold uppercase tracking-widest">
{m.system_section_power()}
</h2>
</div>
<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">
<PowerIcon class="size-5" />
<Card.Title>{m.nav_system_power()}</Card.Title>
</div>
<Card.Description>{m.nav_system_power_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
</div>
</div>
@@ -18,14 +18,16 @@
setTimezone,
systemTime
} from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const time = $derived(systemTime(machineId));
const tzs = $derived(listTimezones(machineId));
const formId = $props.id();
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('system', 'write', whoami.current?.permissions));
let tzOpen = $state(false);
let saving = $state(false);
@@ -49,7 +51,7 @@
</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">
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_datetime()}</h1>
<svelte:boundary>
@@ -64,16 +66,34 @@
<Card.Root>
<Card.Header>
<Card.Title>{m.system_time_current()}</Card.Title>
<Card.Description>{t.time}</Card.Description>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_time_timezone()}</Card.Title>
<Card.Description>{t.timezone}</Card.Description>
</Card.Header>
<Card.Content>
<Card.Content class="flex flex-wrap items-center gap-x-8 gap-y-2 text-sm">
<div class="flex items-baseline gap-2">
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider"
>{m.system_time_timezone()}</span
>
<span class="font-mono font-medium">{t.timezone}</span>
</div>
<div class="flex items-baseline gap-2">
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider"
>{m.system_time_clock()}</span
>
<span class="font-mono tabular-nums">{t.time}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider">NTP</span
>
<Switch
checked={t.ntp}
disabled={saving || !t.can_ntp || !canWrite}
onCheckedChange={(v) => withSaving(() => setNtp({ enabled: v, machineId }))}
/>
</div>
<div class="text-muted-foreground text-xs">
{t.ntp_synchronized ? m.system_time_ntp_synced() : m.system_time_ntp_not_synced()}
</div>
</Card.Content>
<Card.Content class="border-t pt-4">
<Popover.Root bind:open={tzOpen}>
<Popover.Trigger>
{#snippet child({ props })}
@@ -82,6 +102,7 @@
role="combobox"
aria-expanded={tzOpen}
class="w-full justify-between sm:w-80"
disabled={!canWrite}
{...props}
>
{t.timezone}
@@ -116,23 +137,6 @@
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_time_ntp()}</Card.Title>
<Card.Description>{m.system_time_ntp_hint()}</Card.Description>
</Card.Header>
<Card.Content class="flex items-center justify-between gap-4">
<div class="text-muted-foreground text-sm">
{t.ntp_synchronized ? m.system_time_ntp_synced() : m.system_time_ntp_not_synced()}
</div>
<Switch
checked={t.ntp}
disabled={saving || !t.can_ntp}
onCheckedChange={(v) => withSaving(() => setNtp({ enabled: v, machineId }))}
/>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_time_manual()}</Card.Title>
@@ -159,7 +163,7 @@
disabled={t.ntp}
/>
</div>
<Button type="submit" disabled={t.ntp || saving}>{m.save()}</Button>
<Button type="submit" disabled={t.ntp || saving || !canWrite}>{m.save()}</Button>
</form>
</Card.Content>
</Card.Root>
@@ -7,7 +7,8 @@
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import { setHostname, systemHostname } from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
@@ -15,6 +16,8 @@
const host = $derived(systemHostname(machineId));
const formId = $props.id();
let saving = $state(false);
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('system', 'write', whoami.current?.permissions));
// ponytail: hostname syntax is RFC1123 — letters, digits, hyphen, max 63 chars per label.
const HOSTNAME_RE =
@@ -22,7 +25,7 @@
</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">
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_hostname()}</h1>
<svelte:boundary>
@@ -73,7 +76,7 @@
maxlength={253}
/>
</div>
<Button type="submit" disabled={saving}>{m.save()}</Button>
<Button type="submit" disabled={saving || !canWrite}>{m.save()}</Button>
</form>
</Card.Content>
</Card.Root>
@@ -20,10 +20,13 @@
setLocale,
systemLocale
} from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('system', 'write', whoami.current?.permissions));
const locale = $derived(systemLocale(machineId));
const locales = $derived(listLocales(machineId));
@@ -86,7 +89,7 @@
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">
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_localization()}</h1>
<svelte:boundary>
@@ -113,6 +116,7 @@
role="combobox"
aria-expanded={open}
class="w-full justify-between sm:w-80"
disabled={!canWrite}
{...props}
>
{l.lang}
@@ -163,7 +167,7 @@
type="button"
class="hover:bg-muted ms-0.5 rounded-sm p-0.5"
aria-label="Remove"
disabled={saving}
disabled={saving || !canWrite}
onclick={() => (langList = langList.filter((_, j) => j !== i))}
>
<XIcon class="size-3" />
@@ -176,7 +180,7 @@
<Popover.Root bind:open={langOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="outline" size="sm" disabled={saving} {...props}>
<Button variant="outline" size="sm" disabled={saving || !canWrite} {...props}>
<PlusIcon class="size-4" />
{m.system_locale_language_add()}
</Button>
@@ -210,7 +214,7 @@
<div>
<Button
variant="default"
disabled={saving || !languageDirty}
disabled={saving || !languageDirty || !canWrite}
onclick={() => pick(handleSetLanguage)}
>
{m.system_locale_language_button()}
@@ -236,6 +240,7 @@
role="combobox"
aria-expanded={kmOpen}
class="w-full justify-between sm:w-80"
disabled={!canWrite}
{...props}
>
{l.vc_keymap || '—'}
@@ -292,7 +297,7 @@
/>
<Button
variant="default"
disabled={saving || !localeValid}
disabled={saving || !localeValid || !canWrite}
onclick={() => pick(handleGenerate)}
>
{m.system_locale_generate_button()}
@@ -1,5 +1,4 @@
<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';
@@ -14,7 +13,6 @@
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';
@@ -76,17 +74,8 @@
<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>
@@ -110,16 +99,16 @@
</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.Root class="md:col-span-1 border border-border">
<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>
<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">
@@ -129,14 +118,20 @@
</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>
<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">
<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">
<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>
@@ -149,26 +144,41 @@
<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>
<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">
{@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
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>
<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">
<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}
@@ -181,9 +191,9 @@
<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'}"
{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" />
@@ -194,7 +204,9 @@
</Badge>
{/each}
{:else}
<span class="text-muted-foreground text-xs italic">{m.system_nadir_no_permissions()}</span>
<span class="text-muted-foreground text-xs italic"
>{m.system_nadir_no_permissions()}</span
>
{/if}
</div>
</Card.Content>
@@ -6,16 +6,18 @@
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { buttonVariants } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { powerOff, reboot } from '$lib/remotes/system.remote';
import { extractErrorMessage } from '$lib/utils';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
let pending = $state<'off' | 'reboot' | null>(null);
let busy = $state(false);
const machineId = $derived(page.params.machineId!);
const whoami = $derived(getWhoami(machineId));
const canRoot = $derived(hasPermission('system', 'root', whoami.current?.permissions));
async function run(action: 'off' | 'reboot') {
busy = true;
try {
@@ -33,25 +35,20 @@
</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">
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_power()}</h1>
<Card.Root>
<Card.Header>
<Card.Title>{m.nav_system_power()}</Card.Title>
<Card.Description>{m.nav_system_power_desc()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-wrap gap-2">
<Button variant="outline" disabled={busy} onclick={() => (pending = 'reboot')}>
<RotateCcwIcon class="size-4" />
{m.system_power_reboot()}
</Button>
<Button variant="destructive" disabled={busy} onclick={() => (pending = 'off')}>
<PowerIcon class="size-4" />
{m.system_power_poweroff()}
</Button>
</Card.Content>
</Card.Root>
<p class="text-muted-foreground text-sm">{m.nav_system_power_desc()}</p>
<div class="flex flex-wrap gap-2">
<Button variant="outline" disabled={busy || !canRoot} onclick={() => (pending = 'reboot')}>
<RotateCcwIcon class="size-4" />
{m.system_power_reboot()}
</Button>
<Button variant="destructive" disabled={busy || !canRoot} onclick={() => (pending = 'off')}>
<PowerIcon class="size-4" />
{m.system_power_poweroff()}
</Button>
</div>
</div>
<AlertDialog.Root
@@ -24,8 +24,9 @@
listPamUsers,
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type PamUser = {
@@ -43,6 +44,9 @@
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const users = $derived(listPamUsers(machineId));
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('users', 'write', whoami.current?.permissions));
const canRoot = $derived(hasPermission('users', 'root', whoami.current?.permissions));
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
@@ -205,19 +209,19 @@
bind:search
onsearchinput={onSearchInput}
onrefresh={() => users.refresh()}
activeFilterCount={activeFilterCount}
{activeFilterCount}
bind:page
i18n={{
display: m.users_filter_display(),
filterTitle: m.users_filter_title(),
next: () => m.users_next(),
pageOf: (p) => m.users_page_of(p),
previous: () => m.users_prev(),
next: () => m.users_next(),
filterTitle: m.users_filter_title(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<Button onclick={() => (createOpen = true)} disabled={!canWrite && !canRoot}>
<PlusIcon class="size-4" />
{m.users_add()}
</Button>
@@ -273,7 +277,7 @@
<Table.Head>{m.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(u: PamUser, i: number)}
{#snippet row(u: PamUser)}
<Table.Cell class="font-medium">
<a
href={resolve('/dashboard/[machineId]/users/[username]', {
@@ -305,6 +309,7 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
disabled={!canRoot}
onclick={() => {
pwUser = u;
pwValue = '';
@@ -314,6 +319,7 @@
<DropdownMenu.Separator />
<DropdownMenu.Item
variant="destructive"
disabled={!canRoot}
onclick={() => {
deleting = u;
removeHome = false;
@@ -21,7 +21,8 @@
setPamUserGroups,
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { extractErrorMessage } from '$lib/utils';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
import { SvelteSet } from 'svelte/reactivity';
@@ -30,6 +31,8 @@
const machineId = $derived(pageState.params.machineId!);
const user = $derived(getPamUser({ machineId, username }));
const groups = $derived(listPamGroups(machineId));
const whoami = $derived(getWhoami(machineId));
const canRoot = $derived(hasPermission('users', 'root', whoami.current?.permissions));
const primary = $derived((groups.current ?? []).find((g) => g.gid === (user.current?.gid ?? -1)));
const supplementary = $derived(
@@ -127,11 +130,11 @@
<p class="text-muted-foreground text-sm">{u.comment || m.users_no_gecos()}</p>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" onclick={() => (pwOpen = true)}>
<Button variant="outline" onclick={() => (pwOpen = true)} disabled={!canRoot}>
<KeyIcon class="size-4" />
{m.users_action_set_password()}
</Button>
<Button variant="destructive" onclick={() => (deleteOpen = true)}>
<Button variant="destructive" onclick={() => (deleteOpen = true)} disabled={!canRoot}>
<Trash2Icon class="size-4" />
{m.users_delete()}
</Button>
@@ -170,7 +173,9 @@
</Card.Description>
</div>
{#if !editing}
<Button variant="outline" onclick={() => (editing = true)}>{m.edit()}</Button>
<Button variant="outline" onclick={() => (editing = true)} disabled={!canRoot}
>{m.edit()}</Button
>
{:else}
<div class="flex items-center gap-2">
<Button
@@ -19,8 +19,9 @@
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { createPamGroup, deletePamGroup, listPamGroups } from '$lib/remotes/pam-users.remote';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { PersistedState } from 'runed';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
type PamGroup = {
@@ -35,6 +36,9 @@
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const groups = $derived(listPamGroups(machineId));
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('groups', 'write', whoami.current?.permissions));
const canRoot = $derived(hasPermission('groups', 'root', whoami.current?.permissions));
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
@@ -148,19 +152,19 @@
bind:search
onsearchinput={onSearchInput}
onrefresh={() => groups.refresh()}
activeFilterCount={activeFilterCount}
{activeFilterCount}
bind:page
i18n={{
display: m.users_filter_display(),
filterTitle: m.users_filter_title(),
next: () => m.users_next(),
pageOf: (p) => m.users_page_of(p),
previous: () => m.users_prev(),
next: () => m.users_next(),
filterTitle: m.users_filter_title(),
display: m.users_filter_display(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet actions()}
<Button onclick={() => (createOpen = true)}>
<Button onclick={() => (createOpen = true)} disabled={!canWrite && !canRoot}>
<PlusIcon class="size-4" />
{m.groups_add()}
</Button>
@@ -204,7 +208,7 @@
<Table.Head>{m.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
{/snippet}
{#snippet row(g: PamGroup, i: number)}
{#snippet row(g: PamGroup)}
<Table.Cell class="font-medium">
<a
href={resolve('/dashboard/[machineId]/users/groups/[group]', {
@@ -218,7 +222,9 @@
<Table.Cell class="text-muted-foreground text-xs">
{#if g.members?.length}
<span title={g.members.join(', ')}>
{g.members.slice(0, 3).join(', ')}{g.members.length > 3 ? ` +${g.members.length - 3}` : ''}
{g.members.slice(0, 3).join(', ')}{g.members.length > 3
? ` +${g.members.length - 3}`
: ''}
</span>
{:else}
@@ -243,6 +249,7 @@
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
disabled={!canRoot}
onclick={() => {
deleting = g;
deleteOpen = true;
@@ -20,7 +20,8 @@
listPamUsers,
setPamUserGroups
} from '$lib/remotes/pam-users.remote';
import { extractErrorMessage } from '$lib/utils';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
const id = $props.id();
@@ -28,6 +29,9 @@
const groupName = $derived(pageState.params.group!);
const groups = $derived(listPamGroups(machineId));
const users = $derived(listPamUsers(machineId));
const whoami = $derived(getWhoami(machineId));
const canDeleteGroup = $derived(hasPermission('groups', 'root', whoami.current?.permissions));
const canManageMembers = $derived(hasPermission('users', 'root', whoami.current?.permissions));
const group = $derived((groups.current ?? []).find((g) => g.name === groupName));
const members = $derived(group?.members ?? []);
@@ -125,7 +129,7 @@
</h1>
<p class="text-muted-foreground text-sm font-mono">gid {group.gid}</p>
</div>
<Button variant="destructive" onclick={() => (deleteOpen = true)}>
<Button variant="destructive" onclick={() => (deleteOpen = true)} disabled={!canDeleteGroup}>
<Trash2Icon class="size-4" />
{m.users_delete()}
</Button>
@@ -140,7 +144,7 @@
{@html m.groups_members_description({ gid: group.gid })}
</Card.Description>
</div>
<Button variant="outline" onclick={() => (addOpen = true)}>
<Button variant="outline" onclick={() => (addOpen = true)} disabled={!canManageMembers}>
<PlusIcon class="size-4" />
{m.groups_add_member()}
</Button>
@@ -164,7 +168,7 @@
<button
type="button"
class="hover:bg-muted rounded-sm p-0.5 disabled:opacity-50"
disabled={busy === name}
disabled={busy === name || !canManageMembers}
onclick={() => removeMember(name)}
aria-label={m.groups_remove_member_aria({ name })}
>
+115
View File
@@ -0,0 +1,115 @@
<script lang="ts">
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import CpuIcon from '@lucide/svelte/icons/cpu';
import DownloadIcon from '@lucide/svelte/icons/download';
import Orbit from '@lucide/svelte/icons/orbit';
import ScaleIcon from '@lucide/svelte/icons/scale';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
let { children } = $props();
const menuItems = [
{
href: '/docs' as const,
icon: BookOpenIcon,
label: () => m.docs_nav_intro()
},
{
href: '/docs/installation' as const,
icon: DownloadIcon,
label: () => m.docs_nav_installation()
},
{
href: '/docs/architecture' as const,
icon: CpuIcon,
label: () => m.docs_nav_architecture()
},
{
href: '/docs/security' as const,
icon: ShieldAlertIcon,
label: () => m.docs_nav_security()
},
{
href: '/docs/limitations' as const,
icon: ScaleIcon,
label: () => m.docs_nav_limitations()
}
];
</script>
<!-- Top Bar -->
<header
class="bg-background/80 border-b border-border/60 fixed top-0 z-50 flex h-16 w-full items-center gap-4 px-6 backdrop-blur-md"
>
<a href={resolve('/')} class="flex items-center gap-2.5 font-semibold text-foreground">
<Orbit class="text-primary size-5 animate-pulse" />
<span class="font-bold text-lg tracking-tight">{m.appname()}</span>
</a>
<span class="text-muted-foreground/30 text-lg font-light">/</span>
<span class="font-mono text-xs uppercase tracking-widest text-muted-foreground font-semibold"
>{m.docs_title()}</span
>
<div class="ms-auto flex items-center gap-3">
<a
href={resolve('/dashboard')}
class="text-sm font-medium text-muted-foreground hover:text-foreground transition-all flex items-center gap-1.5"
>
<span>{m.docs_go_to_dashboard()}</span>
<ArrowRightIcon class="size-4" />
</a>
</div>
</header>
<div class="flex min-h-screen pt-16 text-foreground antialiased selection:bg-primary/20">
<!-- Sidebar Navigation -->
<aside
class="fixed bottom-0 top-16 z-30 hidden w-64 shrink-0 border-r border-border/60 bg-background md:block"
>
<div class="h-full overflow-y-auto px-4 py-8">
<nav class="flex flex-col gap-1.5">
{#each menuItems as item (item.href)}
{@const active = page.url.pathname === item.href}
<a
href={resolve(item.href)}
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all {active
? 'bg-primary/10 font-semibold text-primary'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}"
>
<item.icon class="size-4 shrink-0" stroke-width={active ? '2.5' : '2'} />
<span>{item.label()}</span>
</a>
{/each}
</nav>
</div>
</aside>
<!-- Main Content Area -->
<div class="flex-1 md:pl-64">
<div class="mx-auto max-w-4xl px-6 py-12 md:py-16">
<!-- Mobile Navigation Header -->
<nav class="mb-8 flex flex-wrap gap-2 border-b border-border/60 pb-6 md:hidden">
{#each menuItems as item (item.href)}
{@const active = page.url.pathname === item.href}
<a
href={resolve(item.href)}
class="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs transition-all {active
? 'bg-primary/10 font-semibold text-primary'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}"
>
<item.icon class="size-3.5" />
<span>{item.label()}</span>
</a>
{/each}
</nav>
<!-- Page Content Injection -->
<article class="prose prose-muted dark:prose-invert max-w-none">
{@render children()}
</article>
</div>
</div>
</div>
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
/* eslint-disable svelte/no-at-html-tags */
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import CpuIcon from '@lucide/svelte/icons/cpu';
import GlobeIcon from '@lucide/svelte/icons/globe';
import HardDrive from '@lucide/svelte/icons/hard-drive';
import Orbit from '@lucide/svelte/icons/orbit';
import ServerIcon from '@lucide/svelte/icons/server';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import UsersIcon from '@lucide/svelte/icons/users';
import { resolve } from '$app/paths';
import { buttonVariants } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
const coreModules = $derived([
{ desc: m.docs_intro_mod_system_desc(), icon: CpuIcon, title: m.nav_system() },
{ desc: m.docs_intro_mod_services_desc(), icon: ServerIcon, title: m.nav_services() },
{ desc: m.docs_intro_mod_users_desc(), icon: UsersIcon, title: m.groups_nav_title() },
{ desc: m.docs_intro_mod_storage_desc(), icon: HardDrive, title: m.nav_storage() },
{ desc: m.docs_intro_mod_networking_desc(), icon: GlobeIcon, title: m.nav_networking() },
{
desc: m.docs_intro_mod_audit_desc(),
icon: ShieldAlertIcon,
title: m.docs_intro_mod_audit_title()
}
]);
</script>
<div class="space-y-10">
<!-- Headline -->
<div class="space-y-4">
<h1 class="text-4xl font-extrabold tracking-tight md:text-5xl leading-none">
{m.docs_intro_title()}
</h1>
<p class="text-muted-foreground text-lg leading-relaxed text-pretty max-w-2xl">
{m.docs_intro_desc()}
</p>
</div>
<!-- Core Architecture Description -->
<div class="border-y border-border/60 py-8 space-y-6">
<div class="flex items-center gap-3 text-primary">
<Orbit class="size-6 animate-pulse" />
<h2 class="text-xl font-bold tracking-tight">{m.docs_intro_how_title()}</h2>
</div>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{@html m.docs_intro_how_desc()}
</p>
</div>
<!-- Features / Modules Grid -->
<div class="space-y-6">
<h2 class="text-2xl font-bold tracking-tight">{m.docs_intro_modules_title()}</h2>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-xl">
{m.docs_intro_modules_desc()}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4">
{#each coreModules as mod (mod.title)}
<div class="border border-border/60 rounded-xl p-5 bg-muted/5 flex gap-4">
<div class="text-primary mt-0.5 shrink-0">
<mod.icon class="size-5" />
</div>
<div class="space-y-1">
<h3 class="font-bold text-foreground text-base">{mod.title}</h3>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty">{mod.desc}</p>
</div>
</div>
{/each}
</div>
</div>
<!-- Next Steps Callout -->
<div
class="bg-primary/5 border border-primary/10 rounded-xl p-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
>
<div class="space-y-1">
<h3 class="font-bold text-foreground text-base">{m.docs_intro_ready_title()}</h3>
<p class="text-muted-foreground text-sm">{m.docs_intro_ready_desc()}</p>
</div>
<a href={resolve('/docs/installation')} class={buttonVariants({ size: 'sm' })}>
<span>{m.docs_intro_ready_cta()}</span>
<ArrowRightIcon class="size-4 ms-1" />
</a>
</div>
</div>
+133
View File
@@ -0,0 +1,133 @@
<script lang="ts">
/* eslint-disable svelte/no-at-html-tags */
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import CpuIcon from '@lucide/svelte/icons/cpu';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
</script>
<div class="space-y-10">
<!-- Headline -->
<div class="space-y-4">
<h1 class="text-4xl font-extrabold tracking-tight md:text-5xl leading-none">
{m.docs_arch_title()}
</h1>
<p class="text-muted-foreground text-lg leading-relaxed text-pretty max-w-2xl">
{m.docs_arch_desc()}
</p>
</div>
<!-- Technical Boundaries -->
<div class="space-y-6">
<div class="flex items-center gap-2.5 text-primary">
<CpuIcon class="size-5" />
<h2 class="text-xl font-bold tracking-tight">{m.docs_arch_bounds_title()}</h2>
</div>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_arch_bounds_desc()}
</p>
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] items-center gap-6 pt-4">
<!-- Web UI Card -->
<Card class="border-border bg-muted/5">
<CardHeader class="pb-3">
<CardTitle class="text-base font-bold">{m.docs_arch_card_webui_title()}</CardTitle>
<CardDescription class="text-sm">{m.docs_arch_card_webui_desc()}</CardDescription>
</CardHeader>
<CardContent class="text-sm text-muted-foreground space-y-2">
<ul class="list-disc pl-4 space-y-1">
<li>{m.docs_arch_card_webui_item1()}</li>
<li>{m.docs_arch_card_webui_item2()}</li>
<li>{m.docs_arch_card_webui_item3()}</li>
</ul>
</CardContent>
</Card>
<!-- Connection arrow / details -->
<div
class="flex flex-row md:flex-col items-center justify-center gap-2 text-muted-foreground min-w-[120px]"
>
<div class="h-px w-full md:w-px md:h-12 bg-border/80"></div>
<div
class="flex flex-col items-center text-center px-3 py-1.5 bg-muted/40 border border-border/50 rounded-full font-mono text-[10px] tracking-wide shrink-0"
>
<span class="text-primary font-semibold">{m.docs_arch_card_flow_title()}</span>
<span>{m.docs_arch_card_flow_desc()}</span>
</div>
<div class="hidden md:block">
<ArrowRightIcon class="size-4 text-muted-foreground/60 animate-pulse" />
</div>
<div class="h-px w-full md:w-px md:h-12 bg-border/80"></div>
</div>
<!-- Agent Backend Card -->
<Card class="border-border bg-muted/5">
<CardHeader class="pb-3">
<CardTitle class="text-base font-bold">{m.docs_arch_card_agent_title()}</CardTitle>
<CardDescription class="text-sm">{m.docs_arch_card_agent_desc()}</CardDescription>
</CardHeader>
<CardContent class="text-sm text-muted-foreground space-y-2">
<ul class="list-disc pl-4 space-y-1">
<li>{m.docs_arch_card_agent_item1()}</li>
<li>{m.docs_arch_card_agent_item2()}</li>
<li>{m.docs_arch_card_agent_item3()}</li>
</ul>
</CardContent>
</Card>
</div>
</div>
<!-- The Self-Contained Go Daemon -->
<div class="space-y-6 border-t border-border/60 pt-8">
<h2 class="text-xl font-bold tracking-tight">{m.docs_arch_daemon_title()}</h2>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_arch_daemon_desc()}
</p>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_arch_daemon_desc2()}
</p>
<ul class="list-disc pl-5 text-muted-foreground text-sm space-y-2 leading-relaxed">
<li>{@html m.docs_arch_daemon_item1()}</li>
<li>{@html m.docs_arch_daemon_item2()}</li>
<li>{@html m.docs_arch_daemon_item3()}</li>
<li>{@html m.docs_arch_daemon_item4()}</li>
</ul>
</div>
<!-- PAM Service Stack -->
<div class="space-y-6 border-t border-border/60 pt-8">
<h2 class="text-xl font-bold tracking-tight">{m.docs_arch_pam_title()}</h2>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{@html m.docs_arch_pam_desc()}
</p>
<pre
class="overflow-x-auto bg-muted border border-border/60 rounded-xl p-4 text-sm font-mono text-muted-foreground">
#%PAM-1.0
auth required pam_unix.so
account required pam_unix.so
</pre>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{@html m.docs_arch_pam_desc2()}
</p>
</div>
<!-- Minisign Signature Verification -->
<div class="space-y-6 border-t border-border/60 pt-8">
<h2 class="text-xl font-bold tracking-tight">{m.docs_arch_minisign_title()}</h2>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_arch_minisign_desc()}
</p>
<ol class="list-decimal pl-5 text-muted-foreground text-sm space-y-2 leading-relaxed">
<li>{@html m.docs_arch_minisign_item1()}</li>
<li>{@html m.docs_arch_minisign_item2()}</li>
<li>{@html m.docs_arch_minisign_item3()}</li>
<li>{@html m.docs_arch_minisign_item4()}</li>
</ol>
</div>
</div>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
/* eslint-disable svelte/no-at-html-tags */
import DownloadIcon from '@lucide/svelte/icons/download';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import TerminalIcon from '@lucide/svelte/icons/terminal';
import { env } from '$env/dynamic/public';
import { CopyButton } from '$lib/components/ui/copy-button';
import { m } from '$lib/paraglide/messages';
const nadirHost = $derived(env.PUBLIC_NADIR_URL || 'nadir.urania.dev');
const installCommand = $derived(`curl -fsSL https://${nadirHost}/install.sh | sudo sh`);
const dockerCommand = 'docker run -d -p 3000:3000 urania/nadir-frontend';
</script>
<div class="space-y-10">
<!-- Headline -->
<div class="space-y-4">
<h1 class="text-4xl font-extrabold tracking-tight md:text-5xl leading-none">
{m.docs_install_title()}
</h1>
<p class="text-muted-foreground text-lg leading-relaxed text-pretty max-w-2xl">
{m.docs_install_desc()}
</p>
</div>
<!-- Section 1: Web UI -->
<div class="space-y-6">
<div class="flex items-center gap-2.5 text-primary">
<DownloadIcon class="size-5" />
<h2 class="text-xl font-bold tracking-tight">{m.docs_install_webui_title()}</h2>
</div>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_install_webui_desc()}
</p>
<div
class="font-mono text-sm text-muted-foreground bg-muted border border-border rounded-lg p-4 flex items-center justify-between gap-4"
>
<span class="overflow-x-auto whitespace-nowrap">{dockerCommand}</span>
<CopyButton text={dockerCommand} size="sm" variant="ghost" />
</div>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty">
{@html m.docs_install_webui_run()}
</p>
</div>
<!-- Section 2: Agent Node -->
<div class="space-y-6 border-t border-border/60 pt-8">
<div class="flex items-center gap-2.5 text-primary">
<TerminalIcon class="size-5" />
<h2 class="text-xl font-bold tracking-tight">{m.docs_install_agent_title()}</h2>
</div>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{@html m.docs_install_agent_desc()}
</p>
<div
class="font-mono text-sm text-muted-foreground bg-muted border border-border rounded-lg p-4 flex items-center justify-between gap-4"
>
<span class="overflow-x-auto whitespace-nowrap">{installCommand}</span>
<CopyButton text={installCommand} size="sm" variant="ghost" />
</div>
<div class="flex flex-col gap-3">
<div
class="flex items-start gap-2.5 border border-red-500/20 bg-red-500/5 rounded-lg p-4 text-sm leading-relaxed text-pretty text-muted-foreground"
>
<ShieldAlertIcon class="size-4 shrink-0 text-red-500 mt-0.5" />
<div class="space-y-1">
<strong
class="text-red-700/90 dark:text-red-500 font-semibold uppercase tracking-wider font-mono text-[10px]"
>{m.docs_install_disclaimer_title()}</strong
>
<p>{@html m.docs_install_disclaimer_desc({ nadirHost })}</p>
</div>
</div>
<div
class="flex items-start gap-2.5 border border-amber-500/20 bg-amber-500/5 rounded-lg p-4 text-sm leading-relaxed text-pretty text-muted-foreground"
>
<ShieldAlertIcon class="size-4 shrink-0 text-amber-500 mt-0.5" />
<div class="space-y-1">
<strong
class="text-amber-700/90 dark:text-amber-500 font-semibold uppercase tracking-wider font-mono text-[10px]"
>{m.docs_install_privilege_title()}</strong
>
<p>{@html m.docs_install_privilege_desc()}</p>
</div>
</div>
</div>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_install_tasks_intro()}
</p>
<ul class="list-disc pl-5 text-muted-foreground text-sm space-y-2 leading-relaxed">
<li>{@html m.docs_install_task_1()}</li>
<li>{@html m.docs_install_task_2()}</li>
<li>{@html m.docs_install_task_3()}</li>
<li>{@html m.docs_install_task_4()}</li>
<li>{@html m.docs_install_task_5()}</li>
<li>{@html m.docs_install_task_6()}</li>
<li>{@html m.docs_install_task_7()}</li>
</ul>
</div>
<!-- Section 3: Connection -->
<div class="space-y-6 border-t border-border/60 pt-8">
<h2 class="text-xl font-bold tracking-tight">{m.docs_install_link_title()}</h2>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{@html m.docs_install_link_desc()}
</p>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_install_link_manual()}
</p>
<div
class="font-mono text-sm text-muted-foreground bg-muted border border-border rounded-lg p-4 flex items-center justify-between gap-4"
>
<span class="overflow-x-auto whitespace-nowrap">sudo nadir token add dashboard</span>
<CopyButton text="sudo nadir token add dashboard" size="sm" variant="ghost" />
</div>
</div>
</div>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
/* eslint-disable svelte/no-at-html-tags */
import { m } from '$lib/paraglide/messages';
</script>
<div class="space-y-10">
<!-- Headline -->
<div class="space-y-4">
<h1 class="text-4xl font-extrabold tracking-tight md:text-5xl leading-none">
{m.docs_limits_title()}
</h1>
<p class="text-muted-foreground text-lg leading-relaxed text-pretty max-w-2xl">
{m.docs_limits_desc()}
</p>
</div>
<!-- System Limitations -->
<div class="space-y-6">
<h2 class="text-xl font-bold tracking-tight">{m.docs_limits_sys_title()}</h2>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_limits_sys_desc()}
</p>
<ul class="list-disc pl-5 text-muted-foreground text-sm space-y-3 leading-relaxed">
<li>{@html m.docs_limits_sys_item1()}</li>
<li>{@html m.docs_limits_sys_item2()}</li>
<li>{@html m.docs_limits_sys_item3()}</li>
<li>{@html m.docs_limits_sys_item4()}</li>
</ul>
</div>
<!-- License Details -->
<div class="space-y-6 border-t border-border/60 pt-8">
<h2 class="text-xl font-bold tracking-tight">{m.docs_limits_license_title()}</h2>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_limits_license_desc()}
</p>
<div
class="bg-muted border border-border/60 rounded-xl p-5 font-mono text-xs text-muted-foreground leading-relaxed"
>
<p class="font-bold text-foreground mb-3">{m.docs_limits_license_mit_title()}</p>
<p class="mb-3">{m.docs_limits_license_mit_copyright()}</p>
<p class="mb-3">{m.docs_limits_license_mit_body1()}</p>
<p class="mb-3">{m.docs_limits_license_mit_body2()}</p>
<p>{m.docs_limits_license_mit_body3()}</p>
</div>
</div>
<!-- Credits -->
<div class="space-y-4 border-t border-border/60 pt-8 text-sm text-muted-foreground">
<h3 class="font-bold text-foreground text-sm">{m.docs_limits_credits_title()}</h3>
<p>{m.docs_limits_credits_desc()}</p>
<ul class="list-disc pl-5 space-y-1">
<li>{@html m.docs_limits_credits_item1()}</li>
<li>{@html m.docs_limits_credits_item2()}</li>
</ul>
</div>
</div>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
/* eslint-disable svelte/no-at-html-tags */
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import { m } from '$lib/paraglide/messages';
</script>
<div class="space-y-10">
<!-- Headline -->
<div class="space-y-4">
<h1 class="text-4xl font-extrabold tracking-tight md:text-5xl leading-none">
{m.docs_security_title()}
</h1>
<p class="text-muted-foreground text-lg leading-relaxed text-pretty max-w-2xl">
{m.docs_security_desc()}
</p>
</div>
<!-- Critical Warning -->
<div
class="flex items-start gap-3 border border-amber-500/20 bg-amber-500/5 rounded-xl p-6 text-sm leading-relaxed text-pretty text-muted-foreground"
>
<ShieldAlertIcon class="size-5 shrink-0 text-amber-500 mt-0.5" />
<div class="space-y-1">
<strong
class="text-amber-700/90 dark:text-amber-500 font-mono text-xs uppercase tracking-wider"
>{m.docs_security_warning_title()}</strong
>
<p>{@html m.docs_security_warning_desc()}</p>
</div>
</div>
<!-- Suggested Deployments -->
<div class="space-y-6 border-t border-border/60 pt-8">
<h2 class="text-xl font-bold tracking-tight">{m.docs_security_deploy_title()}</h2>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_security_deploy_desc()}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4">
<div class="border border-border/60 rounded-xl p-5 bg-muted/5 space-y-2">
<h3 class="font-bold text-foreground text-base">{m.docs_security_deploy_coloc_title()}</h3>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty">
{@html m.docs_security_deploy_coloc_desc()}
</p>
</div>
<div class="border border-border/60 rounded-xl p-5 bg-muted/5 space-y-2">
<h3 class="font-bold text-foreground text-base">{m.docs_security_deploy_vpn_title()}</h3>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty">
{@html m.docs_security_deploy_vpn_desc()}
</p>
</div>
</div>
</div>
<!-- VPN ACL & Firewall -->
<div class="space-y-6 border-t border-border/60 pt-8">
<h2 class="text-xl font-bold tracking-tight">{m.docs_security_acl_title()}</h2>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_security_acl_desc()}
</p>
<ul class="list-disc pl-5 text-muted-foreground text-sm space-y-3 leading-relaxed">
<li>
{@html m.docs_security_acl_tailscale()}
<pre
class="mt-2 bg-muted p-3 rounded-lg overflow-x-auto">"src": ["tag:nadir-ui"], "dst": ["tag:nadir-agent:9999"]</pre>
</li>
<li>{@html m.docs_security_acl_netbird()}</li>
<li>
{@html m.docs_security_acl_iptables()}
<pre
class="mt-2 bg-muted p-3 rounded-lg overflow-x-auto">iptables -A INPUT -i wg0 ! -s &lt;proxy-wg-ip&gt; -p tcp --dport 9999 -j DROP</pre>
</li>
</ul>
</div>
<!-- TLS Configuration -->
<div class="space-y-6 border-t border-border/60 pt-8">
<h2 class="text-xl font-bold tracking-tight">{m.docs_security_tls_title()}</h2>
<p class="text-muted-foreground text-sm leading-relaxed text-pretty max-w-2xl">
{m.docs_security_tls_desc()}
</p>
<ol class="list-decimal pl-5 text-muted-foreground text-sm space-y-3 leading-relaxed">
<li>{@html m.docs_security_tls_proxy_desc()}</li>
<li>{@html m.docs_security_tls_direct_desc()}</li>
<li>{@html m.docs_security_tls_self_desc()}</li>
</ol>
</div>
</div>
+33 -58
View File
@@ -1,69 +1,44 @@
import { env } from '$lib/const/schema';
import type { RequestHandler } from './$types';
const repo = process.env.NADIR_RELEASE_REPO ?? '__NADIR_RELEASE_REPO__';
import installTemplate from './install.sh.tmpl?raw';
function escapeShell(str: string) {
return str.replace(/(["\\$`])/g, '\\$1');
function getReleaseRepo(): string | undefined {
if (process.env.NADIR_RELEASE_REPO) {
return process.env.NADIR_RELEASE_REPO;
}
const repositoryUrl = env.REPOSITORY_URL;
if (repositoryUrl) {
// e.g., https://tea.urania.dev/api/v1/repos/urania/nadir-agent/releases/latest -> https://tea.urania.dev/urania/nadir-agent
const match = repositoryUrl.match(/(https?:\/\/[^/]+)\/api\/v1\/repos\/([^/]+\/[^/]+)/);
if (match) {
return `${match[1]}/${match[2]}`;
}
return repositoryUrl;
}
return undefined;
}
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 = () => {
const releaseRepo = getReleaseRepo();
if (!releaseRepo) {
return new Response('install.sh is disabled: set server.release_repo in config.yaml', {
headers: {
'Content-Type': 'text/plain; charset=utf-8'
},
status: 404
});
}
const script = installTemplate.replace('__NADIR_RELEASE_REPO__', releaseRepo);
return new Response(script, {
headers: {
'Cache-Control': 'no-store',
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
'Content-Type': 'text/x-shellscript; charset=utf-8'
}
});
+112
View File
@@ -0,0 +1,112 @@
#!/bin/sh
set -e
# Nadir bootstrap installer.
#
# Detects the host architecture, fetches the latest release asset from the
# Gitea releases API, and installs it as a systemd service:
#
# curl -fsSL https://<existing-nadir-host>:<port>/install.sh | sudo sh
#
# The binary always comes from the Gitea release (not the source instance),
# so the installed copy is guaranteed to be the latest tagged build,
# independent of whatever version the serving host happens to be running.
#
# Everything is wrapped in do_install and invoked on the last line, the same
# pattern get.docker.com uses: when piped via `curl | sh`, sh executes while
# still receiving bytes from the network. Defining the whole body as a
# function before running anything means sh has fully parsed the script
# before do_install ever runs.
# Where to look for releases. Substituted at request time by the server
# (cmd/server/server.go) so the script knows which Gitea instance owns
# this deployment. Example: https://gitea.example.com/urania/nadir
RELEASE_REPO="__NADIR_RELEASE_REPO__"
do_install() {
if [ "$(id -u)" -ne 0 ]; then
echo "this script must be run as root (try piping through 'sudo sh')" >&2
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "curl is required" >&2
exit 1
fi
case "$(uname -m)" in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
*) echo "unsupported architecture: $(uname -m) (only amd64 and arm64 are built)" >&2; exit 1 ;;
esac
api="$RELEASE_REPO/releases/latest"
api="${api%/}"
# Convert https://host/owner/repo/releases/latest into Gitea API form.
# Splits the URL once on '/' between host and path, inserts /api/v1/repos.
host=$(echo "$RELEASE_REPO" | awk -F/ '{print $1"//"$3}')
path=$(echo "$RELEASE_REPO" | awk -F/ '{print $4"/"$5}')
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 linux-$arch asset found in the latest release at $api" >&2
exit 1
fi
echo "downloading $asset_url ..."
curl -f --progress-bar -L "$asset_url" -o /usr/local/bin/nadir.tmp
asset_name=$(basename "$asset_url")
# Verify SHA-256: download the checksums file published alongside the binary
# and confirm the hash matches. This catches CDN corruption and (together with
# the HTTPS transport) makes tampered binaries detectable.
sums_url="$host/api/v1/repos/$path/releases/latest"
sums_asset_url=$(curl -fsSL "$sums_url" \
| grep -o '"browser_download_url":"[^"]*sha256sums\.txt"' \
| head -n1 \
| cut -d'"' -f4)
if [ -n "$sums_asset_url" ]; then
echo "verifying checksum ..."
curl -fsSL "$sums_asset_url" -o /tmp/nadir-sha256sums.txt
# Extract the expected hash for our asset and compare.
expected=$(grep "$asset_name" /tmp/nadir-sha256sums.txt | awk '{print $1}')
actual=$(sha256sum /usr/local/bin/nadir.tmp | awk '{print $1}')
rm -f /tmp/nadir-sha256sums.txt
if [ -z "$expected" ]; then
echo "warning: sha256sums.txt does not contain a hash for $asset_name" >&2
echo "proceeding without verification" >&2
elif [ "$expected" != "$actual" ]; then
echo "SHA-256 MISMATCH: expected $expected, got $actual" >&2
echo "the downloaded binary may be corrupted or tampered with — aborting" >&2
rm -f /usr/local/bin/nadir.tmp
exit 1
else
echo "checksum OK ($actual)"
fi
else
echo "warning: no sha256sums.txt in release — skipping verification" >&2
fi
mv /usr/local/bin/nadir.tmp /usr/local/bin/nadir
chmod +x /usr/local/bin/nadir
echo "binary installed at /usr/local/bin/nadir"
echo "installing as a systemd service ..."
if [ -c /dev/tty ]; then
/usr/local/bin/nadir install < /dev/tty
else
/usr/local/bin/nadir install
fi
echo
echo "done. check status with: nadir status"
}
do_install