Unify UI workflows and streamline fleet management

This commit is contained in:
2026-09-18 14:00:28 -04:00
parent 9cf0bd87f4
commit f54cac8c50
63 changed files with 1706 additions and 1286 deletions
+39 -66
View File
@@ -1,5 +1,9 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { tick, untrack } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import Tabs from './primitives/Tabs.svelte';
import Button from './primitives/Button.svelte';
const uid = $props.id();
interface TabDef {
key: string; // matches the log key in KNOWN_LOGS
@@ -32,27 +36,33 @@
// Default to the first tab once props are populated; don't overwrite
// the user's later selection.
$effect(() => {
if (!active && tabs.length > 0) active = tabs[0].key;
if (!tabs.some(tab => tab.key === active)) active = tabs[0]?.key ?? '';
});
let loading = $state(false);
let autoRefresh = $state(true);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let refreshError = $state('');
const pending = new Set<string>();
let bodyEl = $state<HTMLPreElement | null>(null);
let stickToBottom = $state(true);
async function fetchLog(key: string): Promise<void> {
loading = true;
const currentServer = server;
const requestKey = currentServer + ':' + key;
if (pending.has(requestKey)) return;
pending.add(requestKey); loading = true;
try {
const res = await fetch(
`/api/servers/${encodeURIComponent(server)}/logs/${encodeURIComponent(key)}?lines=${lines}`
);
const body = (await res.json()) as LogResponse;
data = { ...data, [key]: body };
if (!res.ok || !body.ok) throw new Error(body.reason ?? 'Could not refresh log.');
if (server === currentServer) { data = { ...data, [key]: body }; if (key === active) refreshError = ''; }
} catch (err) {
data = { ...data, [key]: { ok: false, reason: (err as Error).message } };
if (server === currentServer && key === active) refreshError = (err as Error).message;
} finally {
loading = false;
if (stickToBottom) {
pending.delete(requestKey);
if (server === currentServer && key === active) loading = false;
if (stickToBottom && server === currentServer && key === active) {
await tick();
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
}
@@ -67,37 +77,18 @@
stickToBottom = nearBottom;
}
function switchTab(key: string): void {
active = key;
if (!data[key]) void fetchLog(key);
}
function schedulePoll(): void {
if (pollTimer) clearInterval(pollTimer);
if (autoRefresh) {
pollTimer = setInterval(() => {
if (active) void fetchLog(active);
}, pollMs);
}
}
function toggleAuto(): void {
autoRefresh = !autoRefresh;
schedulePoll();
}
// Initial load
function toggleAuto() { autoRefresh = !autoRefresh; }
$effect(() => { server; data = {}; refreshError = ''; });
$effect(() => {
if (active && !data[active]) void fetchLog(active);
});
// Reschedule when autoRefresh or active changes
$effect(() => {
schedulePoll();
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
server;
const key = active;
const auto = autoRefresh;
const interval = pollMs;
if (!key) return;
return untrack(() => {
if (auto) return pollVisible(() => fetchLog(key), interval);
void fetchLog(key);
});
});
function ago(iso: string | null | undefined): string {
@@ -122,11 +113,7 @@
<div class="log-panel">
<div class="tabs">
{#each tabs as t (t.key)}
<button class="tab" class:on={active === t.key} onclick={() => switchTab(t.key)}>
{t.label}
</button>
{/each}
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} bind:value={active} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
<div class="right">
{#if current?.mtime}
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
@@ -141,18 +128,20 @@
</div>
</div>
{#if !current}
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
{#if refreshError}<div class="ui-error" role="alert">{refreshError} {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => fetchLog(active)} disabled={loading}>Retry</Button></div>{/if}
{#if !current && !refreshError}
<div class="empty">loading…</div>
{:else if !current.ok}
{:else if current && !current.ok}
<div class="error">error: {current.reason ?? 'unknown'}</div>
{:else if current.missing}
{:else if current?.missing}
<div class="empty">
log file not written yet
{#if current.path}<br /><code>{current.path}</code>{/if}
</div>
{:else if !current.lines || current.lines.length === 0}
{:else if current && (!current.lines || current.lines.length === 0)}
<div class="empty">log is empty</div>
{:else}
{:else if current?.lines}
<pre class="body" bind:this={bodyEl} onscroll={onScroll}>{current.lines.join('\n')}</pre>
{#if current.truncated}
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
@@ -160,6 +149,7 @@
<div class="foot">{current.lines.length} line{current.lines.length === 1 ? '' : 's'}</div>
{/if}
{/if}
</div>
</div>
<style>
@@ -176,24 +166,7 @@
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.tab {
background: transparent;
color: var(--ink-300);
border: 1px solid transparent;
border-radius: var(--r-sm) var(--r-sm) 0 0;
padding: 8px 14px;
font-family: var(--font-mono);
font-size: var(--text-mono);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.tab:hover { color: var(--cream); background: var(--bg-card); }
.tab.on {
color: var(--cream);
background: var(--bg-card);
border-color: var(--border);
border-bottom-color: var(--bg-card);
}
.right {
margin-left: auto;
display: flex;