Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { tick, untrack, onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from '$ui/toast.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import Tabs from './primitives/Tabs.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
@@ -30,14 +33,15 @@
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
let active = $state<string>('');
|
||||
const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? '');
|
||||
let data = $state<Record<string, LogResponse | null>>({});
|
||||
|
||||
// Default to the first tab once props are populated; don't overwrite
|
||||
// the user's later selection.
|
||||
$effect(() => {
|
||||
if (!tabs.some(tab => tab.key === active)) active = tabs[0]?.key ?? '';
|
||||
});
|
||||
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('');
|
||||
@@ -62,7 +66,7 @@
|
||||
} finally {
|
||||
pending.delete(requestKey);
|
||||
if (server === currentServer && key === active) loading = false;
|
||||
if (stickToBottom && server === currentServer && key === active) {
|
||||
if (stickToBottom && !query && server === currentServer && key === active) {
|
||||
await tick();
|
||||
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
}
|
||||
@@ -78,6 +82,33 @@
|
||||
}
|
||||
|
||||
function toggleAuto() { autoRefresh = !autoRefresh; }
|
||||
async function jumpToLatest() {
|
||||
query = ''; stickToBottom = true;
|
||||
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
}
|
||||
async function copy(value: string, message: string) {
|
||||
try { await navigator.clipboard.writeText(value); toast(message); }
|
||||
catch { toast('Could not copy. Select the log text to copy manually.', { variant: 'danger' }); }
|
||||
}
|
||||
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(() => { server; data = {}; refreshError = ''; });
|
||||
$effect(() => {
|
||||
server;
|
||||
@@ -109,24 +140,27 @@
|
||||
}
|
||||
|
||||
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}))} bind:value={active} 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)} · updated {ago(current.mtime)}</span>
|
||||
{/if}
|
||||
<label class="auto">
|
||||
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
|
||||
auto {pollMs / 1000}s
|
||||
Auto-refresh · {pollMs / 1000}s
|
||||
</label>
|
||||
<button class="refresh" onclick={() => fetchLog(active)} disabled={loading}>
|
||||
{loading ? '…' : 'refresh'}
|
||||
{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>
|
||||
<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={() => fetchLog(active)} disabled={loading}>Retry</Button></div>{/if}
|
||||
@@ -142,7 +176,9 @@
|
||||
{:else if current && (!current.lines || current.lines.length === 0)}
|
||||
<div class="empty">log is empty</div>
|
||||
{:else if current?.lines}
|
||||
<pre class="body" bind:this={bodyEl} onscroll={onScroll}>{current.lines.join('\n')}</pre>
|
||||
<!-- 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}
|
||||
@@ -153,6 +189,12 @@
|
||||
</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; }
|
||||
.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;
|
||||
|
||||
Reference in New Issue
Block a user