324 lines
11 KiB
Svelte
324 lines
11 KiB
Svelte
<script lang="ts">
|
|
import Select from '$ui/components/primitives/Select.svelte';
|
|
import Input from '$ui/components/primitives/Input.svelte';
|
|
import Field from '$ui/components/primitives/Field.svelte';
|
|
import { timestampISO } from '$lib/time-zone.js';
|
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
|
import { onDestroy } from 'svelte';
|
|
import type { ProvisioningDetail, ProvisioningEvent } from '$lib/provisioning-detail.js';
|
|
import { countLabel } from '$ui/plural.js';
|
|
import { isProvisioningEventPage } from '$ui/provisioning-presentation.js';
|
|
import Button from './primitives/Button.svelte';
|
|
|
|
let { detail }: { detail: ProvisioningDetail } = $props();
|
|
let query = $state('');
|
|
let order = $state<'newest' | 'oldest'>('newest');
|
|
let appliedQuery = $state('');
|
|
let appliedOrder = $state<'newest' | 'oldest'>('newest');
|
|
let entries = $state<ProvisioningEvent[]>([]);
|
|
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 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;
|
|
if (expanded && !loaded && !loading) void load();
|
|
}
|
|
function jumpToLatest() {
|
|
query = appliedQuery;
|
|
order = 'newest';
|
|
void load();
|
|
}
|
|
onDestroy(() => {
|
|
disposed = true;
|
|
requestNumber++;
|
|
controller?.abort();
|
|
});
|
|
</script>
|
|
|
|
<section aria-labelledby="activity-title" class="activity">
|
|
<h2 id="activity-title">Activity log</h2>
|
|
<details class="history" ontoggle={toggle}>
|
|
<summary
|
|
>Full activity history <span>{countLabel(detail.activity.total, 'event')}</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();
|
|
}}
|
|
>
|
|
<Field class="search" label="Search activity">
|
|
<Input
|
|
type="search"
|
|
bind:value={query}
|
|
placeholder="Find a stage or message"
|
|
/></Field
|
|
>
|
|
<Field label="Order">
|
|
<Select
|
|
bind:value={order}
|
|
onchange={() => {
|
|
void load();
|
|
}}
|
|
><option value="newest">Newest first</option><option value="oldest"
|
|
>Oldest first</option
|
|
></Select
|
|
></Field
|
|
>
|
|
<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 class="time" datetime={timestampISO(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}
|
|
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;
|
|
}
|
|
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,
|
|
.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);
|
|
}
|
|
.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 :global(.search) {
|
|
flex: 1 1 160px;
|
|
}
|
|
|
|
.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 :global(.search) {
|
|
flex-basis: 100%;
|
|
}
|
|
}
|
|
</style>
|