Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+156
-32
@@ -13,64 +13,188 @@
|
||||
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 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);
|
||||
$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 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 [];
|
||||
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 [];
|
||||
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; }
|
||||
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); };
|
||||
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}
|
||||
{#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 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>
|
||||
<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}
|
||||
<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); }
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user