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

276 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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><details class="log-actions"><summary>More</summary><div class="action-items"><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></details></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">Couldn’t load logs. {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => refresh.refresh()} disabled={loading}>Retry</Button><details><summary>Technical details</summary>{refreshError}</details></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, .action-items, .follow-state { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.log-actions { position: relative; font-size: var(--text-sm); }
summary { cursor: pointer; }
.action-items { position: absolute; right: 0; z-index: 3; padding: 12px; min-width: 160px; background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); }
.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>