133 lines
9.9 KiB
Svelte
133 lines
9.9 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy } from 'svelte';
|
|
import type { ProvisioningDetail, ProvisioningEvent } from '$lib/provisioning-detail.js';
|
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
|
import { isProvisioningEventPage } from '$ui/provisioning-presentation.js';
|
|
import Button from './primitives/Button.svelte';
|
|
|
|
let { detail }: { detail: ProvisioningDetail } = $props();
|
|
let open = $state(false);
|
|
let query = $state('');
|
|
let order = $state<'newest' | 'oldest'>('newest');
|
|
let appliedQuery = $state('');
|
|
let appliedOrder = $state<'newest' | 'oldest'>('newest');
|
|
let entries = $state<ProvisioningEvent[]>([]);
|
|
let frozenRecent = $state<ProvisioningEvent[] | null>(null);
|
|
let total = $state(0);
|
|
let baselineTotal = $state<number | null>(null);
|
|
let watermark = $state<number | null>(null);
|
|
let cursor = $state<number | null>(null);
|
|
let loaded = $state(false);
|
|
let loading = $state(false);
|
|
let error = $state('');
|
|
let requestNumber = 0;
|
|
let controller: AbortController | null = null;
|
|
let disposed = false;
|
|
const recent = $derived((frozenRecent ?? detail.events).slice().sort((a, b) => b.id - a.id).slice(0, 5));
|
|
const newEvents = $derived(baselineTotal === null ? 0 : Math.max(0, detail.activity.total - baselineTotal));
|
|
|
|
async function load(more = false) {
|
|
if (more && (loading || cursor === null)) return;
|
|
controller?.abort();
|
|
controller = new AbortController();
|
|
const currentController = controller;
|
|
const request = ++requestNumber;
|
|
const id = detail.deployment.id;
|
|
const current = () => !disposed && request === requestNumber && detail.deployment.id === id;
|
|
loading = true; error = '';
|
|
const requestedQuery = more ? appliedQuery : query;
|
|
const requestedOrder = more ? appliedOrder : order;
|
|
const params = new URLSearchParams({ order: requestedOrder, limit: '25' });
|
|
if (requestedQuery) params.set('query', requestedQuery);
|
|
if (more && cursor !== null && watermark !== null) {
|
|
params.set('cursor', String(cursor));
|
|
params.set('watermark', String(watermark));
|
|
}
|
|
let timedOut = false;
|
|
const timeout = setTimeout(() => { timedOut = true; currentController.abort(); }, 15_000);
|
|
try {
|
|
const response = await fetch(`/api/provisioning/${encodeURIComponent(id)}/events?${params}`, { signal: currentController.signal, cache: 'no-store' });
|
|
if (!response.ok) throw new Error('Could not load activity history.');
|
|
const body: unknown = await response.json();
|
|
if (!isProvisioningEventPage(body)) throw new Error('The activity response was incomplete.');
|
|
if (!current()) return;
|
|
entries = more ? [...entries, ...body.events.filter(event => !entries.some(previous => previous.id === event.id))] : body.events;
|
|
total = body.total; watermark = body.watermark; cursor = body.nextCursor; loaded = true;
|
|
if (!more) { baselineTotal = body.totalEvents; appliedQuery = requestedQuery; appliedOrder = requestedOrder; }
|
|
} catch (reason) {
|
|
if (current() && (!currentController.signal.aborted || timedOut)) error = timedOut ? 'Activity refresh timed out. Try again.' : reason instanceof Error ? reason.message : 'Could not load activity history.';
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
if (current()) loading = false;
|
|
}
|
|
}
|
|
function toggle(event: Event) {
|
|
const expanded = (event.currentTarget as HTMLDetailsElement).open;
|
|
open = expanded;
|
|
if (expanded) {
|
|
frozenRecent = detail.events.slice();
|
|
if (!loaded && !loading) void load();
|
|
} else frozenRecent = null;
|
|
}
|
|
function jumpToLatest() {
|
|
query = appliedQuery; order = 'newest';
|
|
frozenRecent = detail.events.slice();
|
|
void load();
|
|
}
|
|
onDestroy(() => { disposed = true; requestNumber++; controller?.abort(); });
|
|
</script>
|
|
|
|
<section aria-labelledby="activity-title" class="activity">
|
|
<div class="heading"><div><span class="eyebrow">Activity</span><h2 id="activity-title">Recent updates</h2></div><span class="count">{detail.activity.total} event{detail.activity.total === 1 ? '' : 's'}</span></div>
|
|
<p class="muted">{open ? 'Recent updates stay in place while you read the full history.' : 'Newest first · updates automatically'}</p>
|
|
{#if recent.length}<ol class="timeline recent">
|
|
{#each recent as event (event.id)}<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
|
|
</ol>{:else}<p class="empty">No detailed activity was recorded. The recorded outcome and timestamps are preserved above.</p>{/if}
|
|
|
|
<details class="history" ontoggle={toggle}>
|
|
<summary>Full activity history <span>{detail.activity.total} events</span></summary>
|
|
<div class="history-content">
|
|
{#if newEvents > 0}<div class="new-events" role="status"><span>{newEvents} new event{newEvents === 1 ? '' : 's'} since this view was loaded.</span><Button size="sm" onclick={jumpToLatest}>Jump to latest</Button></div>{/if}
|
|
<form class="tools" onsubmit={event => { event.preventDefault(); void load(); }}>
|
|
<label class="search">Search activity<input type="search" bind:value={query} placeholder="Find a stage or message" /></label>
|
|
<label>Order<select bind:value={order} onchange={() => { void load(); }}><option value="newest">Newest first</option><option value="oldest">Oldest first</option></select></label>
|
|
<Button type="submit" disabled={loading}>Search</Button>
|
|
</form>
|
|
{#if error}<div class="history-error" role="alert"><p>{error} {loaded ? 'Your previous history view is still shown.' : ''}</p><Button size="sm" onclick={() => load()}>Retry history</Button></div>{/if}
|
|
{#if loaded}<p class="muted">{appliedOrder === 'newest' ? 'Newest first' : 'Oldest first'}{appliedQuery ? ` · Matching “${appliedQuery}”` : ' · All messages'}</p>{/if}
|
|
{#if loading && !loaded}<p class="muted" role="status">Loading activity…</p>
|
|
{:else if loaded && !entries.length}<p class="empty">{appliedQuery ? 'No activity matches this search.' : 'No detailed activity recorded.'}</p>
|
|
{:else}<ol class="timeline">
|
|
{#each entries as event (event.id)}<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
|
|
</ol>{/if}
|
|
{#if loaded}<div class="footer"><span>Showing {entries.length} of {total} matching events</span>{#if cursor !== null}<Button size="sm" loading={loading} disabled={loading} onclick={() => load(true)}>Show more activity</Button>{/if}</div>{/if}
|
|
</div>
|
|
</details>
|
|
</section>
|
|
|
|
<style>
|
|
.activity { display: grid; gap: var(--space-4); min-width: 0; }
|
|
.heading { display: flex; justify-content: space-between; gap: var(--space-4); align-items: end; flex-wrap: wrap; }
|
|
.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: var(--space-2); }
|
|
h2 { margin: 0; font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
|
|
p { margin: 0; line-height: 1.65; overflow-wrap: anywhere; font-size: var(--text-sm); }
|
|
.muted, .count, time, .footer { font-size: var(--text-xs); color: var(--text-secondary); }
|
|
.empty { padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-secondary); }
|
|
.timeline { list-style: none; margin: 0 0 0 var(--space-1); padding: 0 0 0 var(--space-5); border-left: 1px solid var(--border-strong); }
|
|
.timeline li { padding: 0 0 var(--space-5); position: relative; }.timeline li:last-child { padding-bottom: 0; }
|
|
.timeline li::before { content: ''; position: absolute; top: 6px; left: -24px; width: 7px; height: 7px; background: var(--border-control); border-radius: var(--radius-pill); }
|
|
.recent li:first-child::before { background: var(--accent-primary); }.timeline p { margin-top: var(--space-1); }
|
|
.history { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
|
|
summary { cursor: pointer; padding: var(--space-5); font-size: var(--text-sm); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
|
summary span { margin-left: var(--space-2); color: var(--text-tertiary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
|
|
summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -3px; border-radius: var(--radius-sm); }
|
|
.history-content { padding: 0 var(--space-5) var(--space-5); display: grid; gap: var(--space-5); }
|
|
.tools { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); }.tools label { display: grid; gap: var(--space-2); font-size: var(--text-xs); color: var(--text-secondary); }.search { flex: 1 1 160px; }
|
|
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 10px; font: inherit; min-height: 40px; width: 100%; }
|
|
input:focus-visible, select:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 2px; }
|
|
.new-events { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }.new-events span { flex: 1; }
|
|
.footer { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); flex-wrap: wrap; line-height: 1.6; }
|
|
.history-error { display: grid; justify-items: start; gap: var(--space-3); color: var(--state-danger); }
|
|
@media (max-width: 600px) { summary { padding: var(--space-4); }.history-content { padding: 0 var(--space-4) var(--space-4); }.tools { align-items: stretch; }.tools .search { flex-basis: 100%; } }
|
|
</style>
|