433 lines
14 KiB
Svelte
433 lines
14 KiB
Svelte
<script lang="ts">
|
||
import Input from '$ui/components/primitives/Input.svelte';
|
||
import Field from '$ui/components/primitives/Field.svelte';
|
||
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 { stripGameColorCodes } from '$ui/log-text.js';
|
||
import Tabs from './primitives/Tabs.svelte';
|
||
import Button from './primitives/Button.svelte';
|
||
import Freshness from './Freshness.svelte';
|
||
import RelativeTime from './RelativeTime.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>>({});
|
||
let checkedAt = $state<Record<string, number>>({});
|
||
|
||
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 = {};
|
||
checkedAt = {};
|
||
refreshError = '';
|
||
copyStatus = '';
|
||
},
|
||
onLoading: (value) => {
|
||
loading = value;
|
||
},
|
||
onError: (message) => {
|
||
refreshError = message;
|
||
},
|
||
onData: (key, body) => {
|
||
data = { ...data, [key]: body };
|
||
checkedAt = { ...checkedAt, [key]: Date.now() };
|
||
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 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 ?? [])
|
||
.map(stripGameColorCodes)
|
||
.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)} · File changed <RelativeTime
|
||
value={current.mtime}
|
||
/></span
|
||
>
|
||
{/if}
|
||
<label class="auto">
|
||
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
|
||
Auto-refresh
|
||
</label>
|
||
{#if !autoRefresh}<button
|
||
class="refresh"
|
||
onclick={() => refresh.refresh()}
|
||
disabled={loading}
|
||
>
|
||
{loading ? 'Refreshing…' : 'Refresh'}
|
||
</button>{/if}
|
||
</div>
|
||
</div>
|
||
<Freshness
|
||
updatedAt={checkedAt[active] ?? null}
|
||
paused={!autoRefresh}
|
||
intervalLabel={`Updates every ${pollMs / 1000} seconds`}
|
||
error={refreshError ? 'Log updates interrupted.' : ''}
|
||
onRetry={() => void refresh.refresh()}
|
||
retryDisabled={loading}
|
||
/>
|
||
<div class="log-tools">
|
||
<Field class="log-search" label="Search loaded lines">
|
||
<Input type="search" bind:value={query} placeholder="Find text in this log" /></Field
|
||
>
|
||
<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 raw 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.' : ''}
|
||
<details><summary>Technical details</summary>{refreshError}</details>
|
||
</div>{/if}
|
||
{#if !current && !refreshError}
|
||
<div class="empty">
|
||
{loading
|
||
? 'Loading log…'
|
||
: autoRefresh
|
||
? 'Waiting for the first log update.'
|
||
: '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-tools :global(.log-search) {
|
||
display: grid;
|
||
gap: 6px;
|
||
flex: 1;
|
||
min-width: 180px;
|
||
font-size: var(--text-xs);
|
||
color: var(--text-secondary);
|
||
}
|
||
.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-subtle);
|
||
padding-bottom: 8px;
|
||
}
|
||
|
||
.right {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
.meta {
|
||
color: var(--text-tertiary);
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.auto {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: var(--text-tertiary);
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-xs);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.auto input {
|
||
accent-color: var(--accent-primary);
|
||
}
|
||
.refresh {
|
||
background: var(--surface);
|
||
color: var(--text-primary);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-sm);
|
||
padding: 6px 12px;
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-sm);
|
||
cursor: pointer;
|
||
}
|
||
.refresh:hover:not(:disabled) {
|
||
border-color: var(--accent-primary);
|
||
box-shadow: var(--focus-ring);
|
||
}
|
||
.refresh:disabled {
|
||
opacity: 0.4;
|
||
cursor: default;
|
||
}
|
||
|
||
.body {
|
||
margin: 0;
|
||
padding: 16px 18px;
|
||
background: var(--surface-sunken);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-secondary);
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-sm);
|
||
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-body);
|
||
font-size: var(--text-sm);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.empty {
|
||
color: var(--text-tertiary);
|
||
background: var(--surface);
|
||
border: 1px dashed var(--border-subtle);
|
||
}
|
||
.empty code {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.error {
|
||
color: var(--state-danger);
|
||
background: rgba(255, 48, 80, 0.08);
|
||
border: 1px solid rgba(255, 48, 80, 0.25);
|
||
}
|
||
.foot {
|
||
color: var(--text-tertiary);
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-xs);
|
||
text-align: right;
|
||
}
|
||
@media (max-width: 640px) {
|
||
.right {
|
||
width: 100%;
|
||
margin-left: 0;
|
||
flex-wrap: wrap;
|
||
}
|
||
}
|
||
</style>
|