updated update

This commit is contained in:
2026-06-22 19:14:44 +02:00
parent 8a81eb2634
commit 96a92603a0
10 changed files with 489 additions and 196 deletions
+334 -168
View File
@@ -2,17 +2,23 @@
import {
Activity,
Clock,
ClockAlert,
Cpu,
Ellipsis,
Globe,
MemoryStick,
Pause,
Pencil,
Play,
RefreshCw,
Trash2
Server,
Trash2,
User,
X
} from '@lucide/svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Trunk from '$lib/components/blocks/trunk/trunk.svelte';
import ActivityPanel from '$lib/components/dashboard/activity-panel.svelte';
import CpuHeatmap from '$lib/components/dashboard/cpu-heatmap.svelte';
import {
@@ -30,8 +36,11 @@
import TemperaturePanel from '$lib/components/dashboard/temperature-panel.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { ButtonGroup } from '$lib/components/ui/button-group';
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Empty from '$lib/components/ui/empty';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Progress } from '$lib/components/ui/progress';
@@ -43,7 +52,8 @@
auditLog,
latestAgentRelease,
serverInfo,
systemDetails
systemDetails,
updateAgent
} from '$lib/remotes/server.remote';
import { toast } from 'svelte-sonner';
@@ -87,6 +97,7 @@
let editOpen = $state(false);
let deleteOpen = $state(false);
let updating = $state(false);
const formId = $props.id();
</script>
@@ -96,180 +107,311 @@
{console.log('errore', err)}
{/snippet}
{@const sys = await info}
{@const log = await audit}
{@const d = await details}
{@const latestTag = await latest}
{@const agentVersion = (sys as unknown as { agent_version?: string }).agent_version}
{@const outdated = !!(agentVersion && latestTag && agentVersion !== latestTag)}
{@const memPct = pct(sys.memory.used_bytes, sys.memory.total_bytes)}
{@const loadPct = Math.round((sys.load.load1 / sys.cpu.logical_cpus) * 100)}
{@const swapPct = pct(
sys.memory.swap_total_bytes - sys.memory.swap_free_bytes,
sys.memory.swap_total_bytes
)}
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{sys.$db.name}</h1>
<p class="text-muted-foreground text-sm truncate">
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address}
</p>
{@const raw = await info}
{#if raw.$offline !== null}
<div class="flex grow items-center justify-center p-8 relative">
<Trunk>
<Card.Root class="relative m-auto z-20">
<Card.Content>
<Empty.Root class="max-w-2xl">
<Empty.Header>
<p class="text-destructive font-mono text-sm">{m.machine_offline_code()}</p>
<Empty.Title>{m.machine_offline_title({ name: raw.$db.name ?? '' })}</Empty.Title>
<Empty.Description>
{m.machine_offline_description({ address: raw.$db.address })}
</Empty.Description>
</Empty.Header>
<Empty.Content>
<div class="flex w-full items-start justify-center gap-2 py-4 sm:gap-4">
<div class="flex flex-col items-center gap-2">
<div
class="bg-muted text-foreground flex size-14 items-center justify-center rounded-lg"
>
<User class="size-6" />
</div>
<div class="text-xs font-medium">{m.machine_offline_node_you()}</div>
<div class="text-[10px] font-mono tracking-wider text-emerald-500">
{m.machine_offline_status_connected()}
</div>
</div>
<div class="relative mt-6 h-1 flex-1 self-start">
<div class="custom-dash custom-dash-ok h-1 w-full"></div>
</div>
<div class="flex flex-col items-center gap-2">
<div
class="bg-muted text-foreground flex size-14 items-center justify-center rounded-lg"
>
<Server class="size-6" />
</div>
<div class="text-xs font-medium">{m.machine_offline_node_proxy()}</div>
<div class="text-[10px] font-mono tracking-wider text-emerald-500">
{m.machine_offline_status_connected()}
</div>
</div>
<div class="relative mt-6 h-1 flex-1 self-start">
<div class="custom-dash custom-dash-bad h-1 w-full"></div>
<div
class="bg-card text-destructive absolute top-1/2 left-1/2 flex size-5.5 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full"
>
<X class="size-4" />
</div>
</div>
<div class="flex flex-col items-center gap-2">
<div
class="bg-muted text-foreground flex size-14 items-center justify-center rounded-lg"
>
<Globe class="size-6" />
</div>
<div class="text-xs font-medium">{m.machine_offline_node_dest()}</div>
<div class="text-destructive text-[10px] font-mono tracking-wider">
{m.machine_offline_status_unreachable()}
</div>
</div>
</div>
<details class="text-muted-foreground w-full max-w-full text-xs">
<summary class="cursor-pointer select-none"
>{m.machine_offline_details()}</summary
>
<pre
class="bg-muted mt-2 max-w-full overflow-auto rounded-md p-3 whitespace-pre-wrap text-center">{raw.$offline}</pre>
</details>
<div class="flex flex-wrap gap-2 justify-center">
<Button onclick={() => info.refresh()}>
<RefreshCw class="size-4" />
{m.dashboard_refresh()}
</Button>
<Button variant="outline" onclick={() => (editOpen = true)}>
<Pencil class="size-4" />
{m.edit()}
</Button>
<Button variant="outline" onclick={() => (deleteOpen = true)}>
<Trash2 class="size-4" />
{m.delete()}
</Button>
</div>
</Empty.Content>
</Empty.Root>
</Card.Content>
</Card.Root>
</Trunk>
</div>
<div class="flex flex-wrap items-center lg:justify-end gap-2 py-1">
<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" />
</div>
{/if}
<Button
variant="outline"
size="icon"
class="h-8 w-8"
onclick={() => (polling = !polling)}
aria-label={polling ? m.dashboard_pause() : m.dashboard_resume()}
>
{#if polling}
<Pause class="size-4" />
{:else}
<Play class="size-4" />
{/if}
</Button>
<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>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="icon"
class="h-8 w-8 relative"
aria-label={m.machine_actions()}
>
<Ellipsis class="size-4" />
{#if outdated}
<span
class="absolute top-1 right-1 size-2 rounded-full bg-red-500 ring-2 ring-background"
title={`nadir-agent ${agentVersion}${latestTag}`}
></span>
{/if}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onSelect={() => (editOpen = true)}>
<Pencil class="size-4" />
{m.edit()}
</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onSelect={() => (deleteOpen = true)}>
<Trash2 class="size-4" />
{m.delete()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
{:else}
{@const sys = raw}
{@const log = await audit}
{@const d = await details}
{@const latestTag = await latest}
{@const agentVersion = sys.$agent_version}
{@const outdated = !!(agentVersion && latestTag && agentVersion !== latestTag)}
{@const memPct = pct(sys.memory.used_bytes, sys.memory.total_bytes)}
{@const loadPct = Math.round((sys.load.load1 / sys.cpu.logical_cpus) * 100)}
{@const swapPct = pct(
sys.memory.swap_total_bytes - sys.memory.swap_free_bytes,
sys.memory.swap_total_bytes
)}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 px-4 py-2">
<!-- CPU Heatmap (replaces the old CPU KpiCard) -->
<KpiCard
label={m.dashboard_cpu()}
icon={Cpu}
value={sys.cpu.model}
detail={m.dashboard_logical_cores({ cores: sys.cpu.logical_cpus })}
/>
<KpiCard
label={m.dashboard_memory()}
icon={MemoryStick}
value="{memPct}%"
valueClass="tabular-nums {usageText(memPct)}"
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
>
{#snippet extra()}
<div
class="text-muted-foreground flex items-baseline justify-between text-xs tabular-nums"
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight flex gap-2 items-center truncate">
{sys.$db.name}
</h1>
<p class="text-muted-foreground text-sm truncate">
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address}
</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" />
</div>
{/if}
<Button
variant="outline"
size="icon"
class="h-8 w-8"
onclick={() => (polling = !polling)}
aria-label={polling ? m.dashboard_pause() : m.dashboard_resume()}
>
{#if polling}
<Pause class="size-4!" />
{:else}
<Play class="size-4!" />
{/if}
</Button>
</ButtonGroup>
<Button
variant="outline"
class="h-8 px-2 sm:px-3"
onclick={refreshAll}
aria-label={m.dashboard_refresh()}
>
<span>{gb(sys.memory.used_bytes)} / {gb(sys.memory.total_bytes)}</span>
</div>
<Progress value={memPct} class={usageBar(memPct)} />
{#if sys.memory.swap_total_bytes > 0}
<RefreshCw class="size-4!" />
<span class="hidden sm:inline">{m.dashboard_refresh()}</span>
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="icon"
class="h-8 w-8 relative"
aria-label={m.machine_actions()}
>
<Ellipsis class="size-4!" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onSelect={() => (editOpen = true)}>
<Pencil class="size-4!" />
{m.edit()}
</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onSelect={() => (deleteOpen = true)}>
<Trash2 class="size-4!" />
{m.delete()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
</DropdownMenu.Content>
</DropdownMenu.Root>
{#if outdated}
<Button
variant="secondary"
title={`${m.agent_updates()}: ${agentVersion}${latestTag}`}
disabled={updating}
onclick={async () => {
updating = true;
const from = agentVersion ?? '?';
const to = latestTag ?? '?';
try {
await updateAgent();
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) {
await new Promise((r) => setTimeout(r, 3000));
await info.refresh().catch(() => {});
const next = await info;
const v = next.$offline === null ? next.$agent_version : null;
if (v && v !== from) {
toast.success(m.agent_update_success({ version: v }));
await refreshAll();
done = true;
break;
}
}
if (!done) toast.error(m.agent_update_failed());
} catch (e) {
toast.error(
(e as { body?: { message?: string } })?.body?.message || m.errors_generic()
);
} finally {
updating = false;
}
}}
>
<ClockAlert class="size-4!" />
</Button>
{/if}
</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) -->
<KpiCard
label={m.dashboard_cpu()}
icon={Cpu}
value={sys.cpu.model}
detail={m.dashboard_logical_cores({ cores: sys.cpu.logical_cpus })}
/>
<KpiCard
label={m.dashboard_memory()}
icon={MemoryStick}
value="{memPct}%"
valueClass="tabular-nums {usageText(memPct)}"
>
{#snippet extra()}
<div
class="text-muted-foreground flex items-baseline justify-between text-xs tabular-nums"
>
<span
>{gb(sys.memory.swap_total_bytes - sys.memory.swap_free_bytes)} / {gb(
sys.memory.swap_total_bytes
)}</span
>
<span>{m.dashboard_swap({ swapPct })}</span>
<span>{gb(sys.memory.used_bytes)} / {gb(sys.memory.total_bytes)}</span>
</div>
<Progress value={swapPct} class={usageBar(swapPct)} />
{/if}
{/snippet}
</KpiCard>
<Progress value={memPct} class={usageBar(memPct)} />
{#if sys.memory.swap_total_bytes > 0}
<div
class="text-muted-foreground flex items-baseline justify-between text-xs tabular-nums"
>
<span
>{gb(sys.memory.swap_total_bytes - sys.memory.swap_free_bytes)} / {gb(
sys.memory.swap_total_bytes
)}</span
>
<span>{m.dashboard_swap({ swapPct })}</span>
</div>
<Progress value={swapPct} class={usageBar(swapPct)} />
{/if}
{/snippet}
</KpiCard>
<KpiCard
label={m.dashboard_load_average()}
icon={Activity}
value={sys.load.load1.toFixed(2)}
valueClass="tabular-nums {usageText(loadPct)}"
detail={m.dashboard_load_detail({
cores: sys.cpu.logical_cpus,
load15: sys.load.load15.toFixed(2),
load5: sys.load.load5.toFixed(2),
loadPct
})}
/>
<KpiCard
label={m.dashboard_load_average()}
icon={Activity}
value={sys.load.load1.toFixed(2)}
valueClass="tabular-nums {usageText(loadPct)}"
detail={m.dashboard_load_detail({
cores: sys.cpu.logical_cpus,
load15: sys.load.load15.toFixed(2),
load5: sys.load.load5.toFixed(2),
loadPct
})}
/>
<KpiCard
label={m.dashboard_uptime()}
icon={Clock}
value={uptime(sys.uptime_seconds)}
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
/>
</div>
<div class="grid items-center gap-4 justify-center lg:grid-cols-3 p-4 py-2">
<div class="col-span-2">
<StoragePanel items={sys.disks ?? []} />
<KpiCard
label={m.dashboard_uptime()}
icon={Clock}
value={uptime(sys.uptime_seconds)}
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
/>
</div>
<div class="grid items-center gap-4 justify-center lg:grid-cols-3 p-4 py-2">
<div class="col-span-2">
<StoragePanel items={sys.disks ?? []} />
</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>
<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 lg: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="grid items-start gap-4 lg: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>
<div class="px-4 py-2 pb-4">
<ActivityPanel items={log} />
</div>
{/if}
<Dialog.Root bind:open={editOpen}>
<Dialog.Content>
@@ -297,7 +439,7 @@
}
})}
>
<input type="hidden" name="id" value={sys.$db.id} />
<input type="hidden" name="id" value={raw.$db.id} />
<Field.Group>
<Field.Field>
<Field.Label for="edit-name-{formId}">{m.machine_name()}</Field.Label>
@@ -305,7 +447,7 @@
id="edit-name-{formId}"
placeholder={m.machine_name_placeholder()}
{...updateMachine.fields.name.as('text')}
value={sys.$db.name}
value={raw.$db.name}
required
/>
{#each updateMachine.fields.name.issues() as issue, i (`${issue}-${i}`)}
@@ -318,7 +460,7 @@
id="edit-address-{formId}"
placeholder={m.machine_address_placeholder()}
{...updateMachine.fields.address.as('text')}
value={sys.$db.address}
value={raw.$db.address}
/>
{#each updateMachine.fields.address.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
@@ -348,7 +490,7 @@
<AlertDialog.Header>
<AlertDialog.Title>{m.machine_delete_title()}</AlertDialog.Title>
<AlertDialog.Description>
{m.machine_delete_confirm({ name: sys.$db.name ?? '' })}
{m.machine_delete_confirm({ name: raw.$db.name ?? '' })}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
@@ -368,7 +510,7 @@
}
})}
>
<input type="hidden" name="id" value={sys.$db.id} />
<input type="hidden" name="id" value={raw.$db.id} />
<AlertDialog.Action type="submit" disabled={!!deleteMachine.pending}>
{m.delete()}
</AlertDialog.Action>
@@ -378,3 +520,27 @@
</AlertDialog.Root>
</svelte:boundary>
</div>
<style>
.custom-dash {
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>