Unify UI workflows and streamline fleet management
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user