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