Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -11,8 +11,8 @@
|
||||
const uid = $props.id();
|
||||
|
||||
interface TabDef {
|
||||
key: string; // matches the log key in KNOWN_LOGS
|
||||
label: string; // human-facing tab name
|
||||
key: string; // matches the log key in KNOWN_LOGS
|
||||
label: string; // human-facing tab name
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -23,16 +23,21 @@
|
||||
}
|
||||
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 ?? '');
|
||||
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);
|
||||
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') ?? ''; });
|
||||
onMount(() => {
|
||||
query = $page.url.searchParams.get('logQuery') ?? '';
|
||||
});
|
||||
let loading = $state(false);
|
||||
let autoRefresh = $state(true);
|
||||
let refreshError = $state('');
|
||||
@@ -41,15 +46,25 @@
|
||||
let stickToBottom = $state(true);
|
||||
|
||||
const refresh = createLogRefresh({
|
||||
onReset: () => { data = {}; checkedAt = {}; refreshError = ''; copyStatus = ''; },
|
||||
onLoading: value => { loading = value; },
|
||||
onError: message => { refreshError = message; },
|
||||
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;
|
||||
if (server === currentServer && active === key && stickToBottom && !query && bodyEl)
|
||||
bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -68,32 +83,51 @@
|
||||
refresh.configure({ server, key: active, lines }, autoRefresh, pollMs);
|
||||
}
|
||||
async function jumpToLatest() {
|
||||
query = ''; stickToBottom = true;
|
||||
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
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.'; }
|
||||
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');
|
||||
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();
|
||||
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;
|
||||
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, 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;
|
||||
parts.push({ text: line.slice(start), match: false });
|
||||
return parts;
|
||||
}
|
||||
$effect(() => {
|
||||
const target = { server, key: active, lines };
|
||||
@@ -111,67 +145,193 @@
|
||||
}
|
||||
|
||||
let current = $derived(data[active] ?? null);
|
||||
const visibleLines = $derived((current?.lines ?? []).map(stripGameColorCodes).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
|
||||
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" />
|
||||
<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>
|
||||
<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}
|
||||
{#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"><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 raw tail</Button></div></details></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">
|
||||
<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 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 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 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}
|
||||
{/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-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;
|
||||
@@ -207,7 +367,9 @@
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.auto input { accent-color: var(--accent-primary); }
|
||||
.auto input {
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
.refresh {
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
@@ -222,7 +384,10 @@
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.refresh:disabled { opacity: 0.4; cursor: default; }
|
||||
.refresh:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
@@ -239,7 +404,8 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.empty, .error {
|
||||
.empty,
|
||||
.error {
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
font-family: var(--font-body);
|
||||
@@ -267,6 +433,10 @@
|
||||
text-align: right;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.right { width: 100%; margin-left: 0; flex-wrap: wrap; }
|
||||
.right {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user