Files
electric-cherry/src/routes/hosts/+page.svelte
T

201 lines
7.2 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>