550 lines
19 KiB
Svelte
550 lines
19 KiB
Svelte
<script lang="ts">
|
|
import Activity from '@lucide/svelte/icons/activity';
|
|
import Clock from '@lucide/svelte/icons/clock';
|
|
import ClockAlert from '@lucide/svelte/icons/clock-alert';
|
|
import Cpu from '@lucide/svelte/icons/cpu';
|
|
import Ellipsis from '@lucide/svelte/icons/ellipsis';
|
|
import Globe from '@lucide/svelte/icons/globe';
|
|
import MemoryStick from '@lucide/svelte/icons/memory-stick';
|
|
import Pencil from '@lucide/svelte/icons/pencil';
|
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
|
import Server from '@lucide/svelte/icons/server';
|
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
import User from '@lucide/svelte/icons/user';
|
|
import X from '@lucide/svelte/icons/x';
|
|
import { goto } from '$app/navigation';
|
|
import { resolve } from '$app/paths';
|
|
import { page } from '$app/state';
|
|
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 {
|
|
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';
|
|
import SystemPanel from '$lib/components/dashboard/system-panel.svelte';
|
|
import TemperaturePanel from '$lib/components/dashboard/temperature-panel.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 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';
|
|
import { machineDeleteSchema, machineEditSchema } from '$lib/machines/schema';
|
|
import { m } from '$lib/paraglide/messages';
|
|
import {
|
|
deleteMachine,
|
|
listMachines,
|
|
machineHealth,
|
|
updateMachine
|
|
} from '$lib/remotes/machines.remote';
|
|
import {
|
|
auditLog,
|
|
latestAgentRelease,
|
|
serverInfo,
|
|
systemDetails,
|
|
updateAgent
|
|
} from '$lib/remotes/server.remote';
|
|
import { extractErrorMessage } from '$lib/utils';
|
|
import { toast } from 'svelte-sonner';
|
|
|
|
const machineId = $derived(page.params.machineId!);
|
|
const info = $derived(serverInfo(machineId));
|
|
const audit = $derived(auditLog({ limit: undefined, machineId }));
|
|
const details = $derived(systemDetails(machineId));
|
|
const latest = $derived(latestAgentRelease());
|
|
const machineName = $derived(info.current?.$db.name ?? machineId);
|
|
const intervals = $derived([
|
|
{ label: m.dashboard_interval_second(), value: '1' },
|
|
{ label: m.dashboard_interval_5s(), value: '5' },
|
|
{ label: m.dashboard_interval_10s(), value: '10' },
|
|
{ label: m.dashboard_interval_30s(), value: '30' },
|
|
{ label: m.dashboard_interval_custom(), value: 'custom' }
|
|
]);
|
|
let syncInterval = $state('5');
|
|
let customSec = $state(15);
|
|
const intervalMs = $derived(
|
|
(syncInterval === 'custom' ? customSec : Number(syncInterval)) * 1000
|
|
);
|
|
const intervalLabel = $derived(intervals.find((i) => i.value === syncInterval)?.label);
|
|
|
|
const poll = async () => {
|
|
await serverInfo(machineId).refresh();
|
|
await auditLog({ limit: undefined, machineId }).refresh();
|
|
// keep the sidebar dot in sync with what the dashboard sees
|
|
void machineHealth(machineId).refresh();
|
|
};
|
|
const refreshAll = async () => {
|
|
await poll();
|
|
await systemDetails(machineId).refresh();
|
|
};
|
|
|
|
let polling = $state(true);
|
|
|
|
$effect(() => {
|
|
if (!polling || intervalMs <= 0) return;
|
|
const id = setInterval(poll, intervalMs);
|
|
return () => clearInterval(id);
|
|
});
|
|
|
|
let editOpen = $state(false);
|
|
let deleteOpen = $state(false);
|
|
let updating = $state(false);
|
|
const formId = $props.id();
|
|
</script>
|
|
|
|
<PageMeta title={machineName} description={m.seo_desc_machine_detail()} />
|
|
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
|
<svelte:boundary>
|
|
{#snippet failed(err)}
|
|
{console.log('errore', err)}
|
|
{/snippet}
|
|
|
|
{@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-3xl">
|
|
<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>
|
|
{: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="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 mb-2 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 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.$agent_version} | {sys.$db.address}
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap items-center lg:justify-end gap-2 py-1">
|
|
<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>
|
|
</Popover.Content>
|
|
</Popover.Root>
|
|
{#if !polling}
|
|
<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 })}
|
|
<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(machineId);
|
|
toast.info(m.agent_update_started({ from, to }));
|
|
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(extractErrorMessage(e) ?? 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-0">
|
|
<KpiCard
|
|
label={m.dashboard_cpu()}
|
|
icon={Cpu}
|
|
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()}
|
|
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.used_bytes)} / {gb(sys.memory.total_bytes)}</span>
|
|
</div>
|
|
<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_uptime()}
|
|
icon={Clock}
|
|
value={uptime(sys.uptime_seconds)}
|
|
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
|
|
/>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
{/if}
|
|
|
|
<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={() => updateMachine.validate()}
|
|
{...updateMachine.preflight(machineEditSchema).enhance(async ({ submit }) => {
|
|
try {
|
|
await submit();
|
|
await Promise.all([
|
|
listMachines({ page: 1, search: '' }).refresh(),
|
|
info.refresh(),
|
|
details.refresh()
|
|
]);
|
|
editOpen = false;
|
|
toast.success(m.machine_edit());
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error(extractErrorMessage(error) ?? m.errors_generic());
|
|
}
|
|
})}
|
|
>
|
|
<input type="hidden" name="id" value={raw.$db.id} />
|
|
<Field.Group>
|
|
<Field.Field>
|
|
<Field.Label for="edit-name-{formId}">{m.machine_name()}</Field.Label>
|
|
<Input
|
|
id="edit-name-{formId}"
|
|
placeholder={m.machine_name_placeholder()}
|
|
{...updateMachine.fields.name.as('text')}
|
|
value={raw.$db.name}
|
|
required
|
|
/>
|
|
{#each updateMachine.fields.name.issues() as issue, i (`${issue}-${i}`)}
|
|
<Field.Error>{issue.message}</Field.Error>
|
|
{/each}
|
|
</Field.Field>
|
|
<Field.Field>
|
|
<Field.Label for="edit-address-{formId}">{m.machine_address()}</Field.Label>
|
|
<Input
|
|
id="edit-address-{formId}"
|
|
placeholder={m.machine_address_placeholder()}
|
|
{...updateMachine.fields.address.as('text')}
|
|
value={raw.$db.address}
|
|
/>
|
|
{#each updateMachine.fields.address.issues() as issue, i (`${issue}-${i}`)}
|
|
<Field.Error>{issue.message}</Field.Error>
|
|
{/each}
|
|
</Field.Field>
|
|
<Field.Field>
|
|
<Field.Label for="edit-token-{formId}">{m.machine_token()}</Field.Label>
|
|
<Input
|
|
id="edit-token-{formId}"
|
|
placeholder={m.machine_token_placeholder()}
|
|
{...updateMachine.fields.token.as('password')}
|
|
/>
|
|
<Field.Description>{m.machine_token_keep()}</Field.Description>
|
|
{#each updateMachine.fields.token.issues() as issue, i (`${issue}-${i}`)}
|
|
<Field.Error>{issue.message}</Field.Error>
|
|
{/each}
|
|
</Field.Field>
|
|
<Button type="submit" disabled={!!updateMachine.pending}>{m.machine_save_edit()}</Button
|
|
>
|
|
</Field.Group>
|
|
</form>
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|
|
|
|
<AlertDialog.Root bind:open={deleteOpen}>
|
|
<AlertDialog.Content>
|
|
<AlertDialog.Header>
|
|
<AlertDialog.Title>{m.machine_delete_title()}</AlertDialog.Title>
|
|
<AlertDialog.Description>
|
|
{m.machine_delete_confirm({ name: raw.$db.name ?? '' })}
|
|
</AlertDialog.Description>
|
|
</AlertDialog.Header>
|
|
<AlertDialog.Footer>
|
|
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
|
|
<form
|
|
{...deleteMachine.preflight(machineDeleteSchema).enhance(async ({ submit }) => {
|
|
try {
|
|
await submit();
|
|
await listMachines({ page: 1, search: '' }).refresh();
|
|
deleteOpen = false;
|
|
goto(resolve('/dashboard'));
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error(extractErrorMessage(error) ?? m.errors_generic());
|
|
}
|
|
})}
|
|
>
|
|
<input type="hidden" name="id" value={raw.$db.id} />
|
|
<AlertDialog.Action type="submit" disabled={!!deleteMachine.pending}>
|
|
{m.delete()}
|
|
</AlertDialog.Action>
|
|
</form>
|
|
</AlertDialog.Footer>
|
|
</AlertDialog.Content>
|
|
</AlertDialog.Root>
|
|
{#snippet pending()}
|
|
<Spinner class="size-24 m-auto" />
|
|
{/snippet}
|
|
</svelte:boundary>
|
|
</div>
|
|
|
|
<style>
|
|
.custom-dash {
|
|
background-image: linear-gradient(to right, currentColor 50%, transparent 0%);
|
|
background-size: 10px 2px;
|
|
background-repeat: repeat-x;
|
|
}
|
|
.custom-dash-ok {
|
|
color: var(--color-emerald-500, #10b981);
|
|
}
|
|
.custom-dash-bad {
|
|
color: var(--destructive, #ef4444);
|
|
}
|
|
</style>
|