fix: some improvement and docs
This commit is contained in:
+865
-680
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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()} • {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 — 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">
|
||||
{ "status": "healthy", "version": "v1.2.0", "uptime": "142d 18h" }
|
||||
</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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+13
-6
@@ -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 })}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 <proxy-wg-ip> -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>
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user