77 lines
5.6 KiB
Svelte
77 lines
5.6 KiB
Svelte
<script lang="ts">
|
|
import { onMount, untrack } from 'svelte';
|
|
import { page } from '$app/stores';
|
|
import { pollVisible } from '$ui/poll.js';
|
|
import { hostListStatus, stableHostOrder } from '$ui/host-overview.js';
|
|
import { countLabel, plural } from '$ui/plural.js';
|
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
|
import Button from '$ui/components/primitives/Button.svelte';
|
|
import AddHostDrawer from '$ui/components/AddHostDrawer.svelte';
|
|
import HostList from '$ui/components/HostList.svelte';
|
|
let { data }: { data: import('./$types').PageData } = $props();
|
|
let hosts = $state(untrack(() => data.hosts));
|
|
let order = $state(untrack(() => stableHostOrder([], data.hosts, data.serverNow)));
|
|
let clockOffset = untrack(() => data.serverNow - Date.now());
|
|
let now = $state(untrack(() => data.serverNow));
|
|
let error = $state(''); let addOpen = $state(false);
|
|
let search = $state(''); let status = $state('');
|
|
let loadSequence = 0;
|
|
const canManage = $derived($page.data.user?.role === 'admin');
|
|
$effect(() => { if (canManage && $page.url.searchParams.get('action') === 'add') addOpen = true; });
|
|
const attention = $derived(hosts.filter(host => ['attention', 'offline'].includes(hostListStatus(host, now).key)).length);
|
|
const ready = $derived(hosts.filter(host => hostListStatus(host, now).key === 'ready').length);
|
|
const filtersVisible = $derived(hosts.length > 5 || !!search || !!status);
|
|
const visible = $derived.by(() => {
|
|
const byId = new Map(hosts.map(host => [host.id, host]));
|
|
return order.flatMap(id => {
|
|
const host = byId.get(id);
|
|
if (!host || !`${host.name} ${host.hostname}`.toLowerCase().includes(search.trim().toLowerCase())) return [];
|
|
const state = hostListStatus(host, now).key;
|
|
if (status && (status === 'attention' ? !['attention', 'offline'].includes(state) : state !== status)) return [];
|
|
return [host];
|
|
});
|
|
});
|
|
async function load(signal?: AbortSignal) {
|
|
const sequence = ++loadSequence;
|
|
try {
|
|
const response = await fetch('/api/hosts', { signal: signal ?? AbortSignal.timeout(15000) });
|
|
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.');
|
|
if (sequence === loadSequence) { hosts = body.hosts; clockOffset = body.serverNow - Date.now(); now = body.serverNow; order = stableHostOrder(order, hosts, now); error = ''; }
|
|
} catch (cause) { if (!signal?.aborted && sequence === loadSequence) error = (cause as Error).message; return false; }
|
|
}
|
|
onMount(() => {
|
|
const stop = pollVisible(load, 30000, false);
|
|
const timer = setInterval(() => now = Date.now() + clockOffset, 15000);
|
|
return () => { stop(); clearInterval(timer); };
|
|
});
|
|
</script>
|
|
|
|
<svelte:head><title>Hosts · Electric Cherry</title></svelte:head>
|
|
<section class="page">
|
|
<SectionHeader title="Hosts">
|
|
{#snippet actions()}<Button href="/capacity?rebalance=1">Rebalance</Button>{#if canManage}<Button variant="primary" onclick={() => addOpen = true}>Add host</Button>{/if}{/snippet}
|
|
</SectionHeader>
|
|
{#if error}<p role="alert" class="ui-error">{error} Showing the last loaded hosts. <Button variant="ghost" size="sm" onclick={() => load()}>Retry</Button></p>{/if}
|
|
{#if hosts.length}
|
|
<div class="summary"><span>{countLabel(hosts.length, 'host')} · {ready === hosts.length ? 'All ready' : `${ready} ready`}</span>{#if attention}<button onclick={() => { status = 'attention'; search = ''; }}>{attention} {plural(attention, 'needs', 'need')} attention</button>{/if}</div>
|
|
{#if filtersVisible}<div class="filters">
|
|
<input type="search" aria-label="Search hosts" placeholder="Search hosts…" bind:value={search} />
|
|
<select aria-label="Filter hosts by status" bind:value={status}><option value="">All statuses</option><option value="attention">Needs attention</option><option value="offline">Offline</option><option value="preparing">Preparing</option><option value="draining">Draining</option><option value="disabled">Disabled</option><option value="deleting">Deleting</option><option value="ready">Ready</option></select>
|
|
{#if search || status}<Button variant="ghost" size="sm" onclick={() => { search = ''; status = ''; }}>Clear filters</Button>{/if}
|
|
</div>{/if}
|
|
{#if visible.length}<HostList hosts={visible} {now} returnTo={$page.url.pathname + $page.url.search} />{:else}<p class="empty">No hosts match these filters.</p>{/if}
|
|
{:else}<p class="empty">No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{/if}
|
|
</section>
|
|
{#if canManage}<AddHostDrawer bind:open={addOpen} onadded={load} />{/if}
|
|
|
|
<style>
|
|
.page { display:grid; gap:20px; }
|
|
.summary,.filters { display:flex; align-items:center; flex-wrap:wrap; gap:12px; }
|
|
.summary,.empty { color:var(--text-secondary); font-size:var(--text-sm); }
|
|
.summary button { background:none; border:0; color:var(--state-danger); cursor:pointer; font:inherit; padding:4px; }
|
|
input,select { box-sizing:border-box; padding:10px 12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:var(--text-sm) var(--font-body); }
|
|
input { flex:1 1 240px; min-width:0; max-width:440px; }
|
|
input:focus-visible,select:focus-visible,button:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
|
.empty { padding:24px; text-align:center; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
|
|
</style>
|