Files
electric-cherry/src/lib/ui/components/LogPanel.svelte
T

273 lines
12 KiB
Svelte

<script lang="ts">
import { tick, untrack, onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { createLogRefresh, type LogResponse } from '$ui/log-refresh.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
label: string; // human-facing tab name
}
interface Props {
server: string;
tabs: TabDef[];
lines?: number;
pollMs?: number;
}
let { server, tabs, lines = 1000, pollMs = 5000 }: Props = $props();
const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? '');
let data = $state<Record<string, LogResponse | null>>({});
function chooseLog(key: string) {
const url = new URL($page.url); url.searchParams.set('log', key);
void goto(url, { noScroll: true, keepFocus: true, replaceState: true });
}
let query = $state('');
onMount(() => { query = $page.url.searchParams.get('logQuery') ?? ''; });
let loading = $state(false);
let autoRefresh = $state(true);
let refreshError = $state('');
let copyStatus = $state('');
let bodyEl = $state<HTMLPreElement | null>(null);
let stickToBottom = $state(true);
const refresh = createLogRefresh({
onReset: () => { data = {}; refreshError = ''; copyStatus = ''; },
onLoading: value => { loading = value; },
onError: message => { refreshError = message; },
onData: (key, body) => {
data = { ...data, [key]: body };
const currentServer = server;
void tick().then(() => {
if (server === currentServer && active === key && stickToBottom && !query && bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
});
}
});
onDestroy(() => refresh.dispose());
function onScroll(): void {
if (!bodyEl) return;
// If the user scrolls up more than one screen, stop auto-scrolling on
// refresh; snap back on when they scroll to the bottom.
const nearBottom = bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight < 40;
stickToBottom = nearBottom;
}
function toggleAuto(event: Event) {
autoRefresh = (event.currentTarget as HTMLInputElement).checked;
refresh.configure({ server, key: active, lines }, autoRefresh, pollMs);
}
async function jumpToLatest() {
query = ''; stickToBottom = true;
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
}
async function copy(value: string, message: string) {
copyStatus = '';
try { await navigator.clipboard.writeText(value); copyStatus = message; }
catch { copyStatus = 'Could not copy. Select the log text or browser address to copy manually.'; }
}
function copyLink() {
const url = new URL(window.location.href); url.searchParams.set('tab', 'logs'); url.searchParams.set('log', active);
if (query) url.searchParams.set('logQuery', query); else url.searchParams.delete('logQuery');
void copy(url.href, 'Log link copied.');
}
function download() {
const blob = new Blob([(current?.lines ?? []).join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a'); link.href = url; link.download = `${server}-${active}.log`; link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function highlight(line: string) {
if (!query) return [{ text: line, match: false }];
const parts: { text: string; match: boolean }[] = []; let start = 0; let index: number;
while ((index = line.toLowerCase().indexOf(query.toLowerCase(), start)) !== -1) {
parts.push({ text: line.slice(start, index), match: false }, { text: line.slice(index, index + query.length), match: true }); start = index + query.length;
}
parts.push({ text: line.slice(start), match: false }); return parts;
}
$effect(() => {
const target = { server, key: active, lines };
const auto = autoRefresh;
const interval = pollMs;
untrack(() => refresh.configure(target, auto, interval));
});
function ago(iso: string | null | undefined): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
function fmtBytes(n: number | undefined): string {
if (n === undefined || n === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(n) / Math.log(1024));
const value = n / Math.pow(1024, i);
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
}
let current = $derived(data[active] ?? null);
const visibleLines = $derived((current?.lines ?? []).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
</script>
<div class="log-panel">
<div class="tabs">
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} value={active} onchange={chooseLog} 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>
{/if}
<label class="auto">
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
Auto-refresh · {pollMs / 1000}s
</label>
<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
{loading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
</div>
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><div class="log-actions"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download tail</Button></div></div>
{#if copyStatus}<p class="copy-status" role="status">{copyStatus}</p>{/if}
<div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div>
<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={() => refresh.refresh()} disabled={loading}>Retry</Button></div>{/if}
{#if !current && !refreshError}
<div class="empty">{loading ? 'loading…' : 'No log loaded. Select Refresh to load it.'}</div>
{:else if current && !current.ok}
<div class="error">error: {current.reason ?? 'unknown'}</div>
{: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 && (!current.lines || current.lines.length === 0)}
<div class="empty">log is empty</div>
{:else if current?.lines}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The log is keyboard-scrollable.) -->
<pre class="body" bind:this={bodyEl} onscroll={onScroll} tabindex="0" aria-label="{active} log contents">{#each visibleLines as line}{#each highlight(line) as part}{#if part.match}<mark>{part.text}</mark>{:else}{part.text}{/if}{/each}{'\n'}{/each}</pre>
{#if query && !visibleLines.length}<p class="empty">No matches in the loaded tail. Try another search or download the tail.</p>{/if}
{#if current.truncated}
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
{:else}
<div class="foot">{current.lines.length} line{current.lines.length === 1 ? '' : 's'}</div>
{/if}
{/if}
</div>
</div>
<style>
.log-tools, .log-actions, .follow-state { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.log-tools { align-items: end; }
.log-search { display: grid; gap: 6px; flex: 1; min-width: 180px; font-size: var(--text-xs); color: var(--text-secondary); }
.log-search input { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 9px 12px; font: inherit; }
.copy-status { margin: 0; font-size: var(--text-xs); color: var(--text-secondary); }
.follow-state { justify-content: space-between; font-size: var(--text-xs); color: var(--text-secondary); }
mark { background: var(--state-warning); color: var(--surface-sunken); }
.log-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.right {
margin-left: auto;
display: flex;
align-items: center;
gap: 14px;
}
.meta {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
}
.auto {
display: flex;
align-items: center;
gap: 6px;
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
cursor: pointer;
user-select: none;
}
.auto input { accent-color: var(--accent); }
.refresh {
background: var(--bg-card);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 6px 12px;
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
}
.refresh:hover:not(:disabled) {
border-color: var(--accent);
box-shadow: var(--accent-glow);
}
.refresh:disabled { opacity: 0.4; cursor: default; }
.body {
margin: 0;
padding: 16px 18px;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--ink-300);
font-family: var(--font-mono);
font-size: var(--text-mono);
line-height: var(--leading-mono);
max-height: 560px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.empty, .error {
padding: 28px 16px;
text-align: center;
font-family: var(--font-mono);
font-size: var(--text-mono);
border-radius: var(--r-sm);
}
.empty {
color: var(--ink-500);
background: var(--bg-nav);
border: 1px dashed var(--border);
}
.empty code {
color: var(--cream-dim);
font-size: var(--text-mono-sm);
}
.error {
color: var(--danger-500);
background: rgba(255, 48, 80, 0.08);
border: 1px solid rgba(255, 48, 80, 0.25);
}
.foot {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
text-align: right;
}
@media (max-width: 640px) {
.right { width: 100%; margin-left: 0; flex-wrap: wrap; }
}
</style>