@@ -0,0 +1,183 @@
< script lang = "ts" >
import { untrack } from 'svelte' ;
import { page } from '$app/stores' ;
import { goto } from '$app/navigation' ;
import { activityTypes , activitySources , activityStates , type ActivityPage , type ActivityQuery , type ActivitySource } from '$lib/activity.js' ;
import { contextHref } from '$lib/navigation-context.js' ;
import { activityApiHref , activityHistoryHref , activityQuery , activityTimeLabel } from '$ui/activity-history.js' ;
import { pollVisible } from '$ui/poll.js' ;
import { serverTitle } from '$ui/server-labels.js' ;
import { fleet } from '$ui/stores/fleet.js' ;
import { uiNow } from '$ui/stores/servers.js' ;
import { timeFormat , timeZone } from '$ui/stores/time-zone.js' ;
import Button from './primitives/Button.svelte' ;
import Dialog from './primitives/Dialog.svelte' ;
import EmptyState from './primitives/EmptyState.svelte' ;
import StatusBadge from './primitives/StatusBadge.svelte' ;
import Freshness from './Freshness.svelte' ;
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte' ;
let { server , source , initial } : { server? : string ; source? : ActivitySource ; initial? : ActivityPage } = $props ();
const id = $props . id ();
const headingId = ` ${ id } -activity-heading` ;
let data = $state < ActivityPage | undefined >( untrack (() => initial ));
let loading = $state ( ! untrack (() => initial ));
let error = $state ( '' );
let retry = $state ( 0 );
let filtersOpen = $state ( false );
let checkedAt = $state < number | null >( untrack (() => initial ? Date . now () : null ));
let results = $state < HTMLOListElement >();
let search = $state ( untrack (() => activityQuery ( $page . url , { server , source }). q ?? '' ));
let pendingUrl : URL | undefined ;
let navigationId = 0 ;
const filters = $derived ( activityQuery ( $page . url , { server , source }));
const apiUrl = $derived ( activityApiHref ( $page . url , { server , source }));
let loadedUrl = $state ( untrack (() => initial ? apiUrl : '' ));
const filterCount = $derived ([ filters . type , filters . state , source === undefined ? filters . source : '' , server === undefined ? filters . server : '' , filters . host , filters . days , filters . q ]. filter ( Boolean ). length );
const serverOptions = $derived . by (() => {
const names = new Set ([... $fleet . servers . map ( item => item . name ), ...( data ? . rows . flatMap ( row => row . server ? [ row . server ] : []) ?? [])]);
if ( filters . server ) names . add ( filters . server );
return [... names ]. sort (( a , b ) => serverTitle ( a ). localeCompare ( serverTitle ( b )));
});
const hostOptions = $derived . by (() => {
const hosts = new Map < string , string >();
for ( const item of $fleet . servers ) if ( item . host ) hosts . set ( item . host . id , item . host . name );
for ( const row of data ? . rows ?? []) if ( row . hostId ) hosts . set ( row . hostId , row . hostName ?? 'Historical host' );
if ( filters . host && ! hosts . has ( filters . host )) hosts . set ( filters . host , 'Selected host' );
return [... hosts ]. sort (( a , b ) => a [ 1 ]. localeCompare ( b [ 1 ]));
});
$effect (() => { search = filters . q ?? '' ; });
$effect (() => {
const report = initial ;
if ( report ) untrack (() => { data = report ; loadedUrl = apiUrl ; loading = false ; error = '' ; checkedAt = Date . now (); });
});
$effect (() => {
const url = apiUrl ;
retry ;
untrack (() => { if ( loadedUrl !== url ) loading = true ; error = '' ; });
return pollVisible ( async signal => {
try {
const response = await fetch ( url , { signal , cache : 'no-store' });
if ( ! response . ok ) throw new Error ( 'Could not load activity.' );
const body : ActivityPage = await response . json ();
if ( signal . aborted || apiUrl !== url ) return ;
// Keep a focused operation link in place when newer rows arrive.
if ( loadedUrl !== url || ! results ? . contains ( document . activeElement )) { data = body ; checkedAt = Date . now (); }
loadedUrl = url ; loading = false ; error = '' ;
} catch ( cause ) {
if ( signal . aborted || apiUrl !== url ) return ;
error = cause instanceof Error ? cause . message : 'Could not load activity.' ;
loading = false ;
return false ;
}
}, 10 _000 , untrack (() => loadedUrl !== url || retry > 0 ));
});
function update ( changes : Partial < Record < keyof ActivityQuery , string >>) {
const href = activityHistoryHref ( pendingUrl ?? $page . url , changes );
pendingUrl = new URL ( href , $page . url );
const id = ++ navigationId ;
void goto ( href , { noScroll : true , keepFocus : true })
. catch (() => { error = 'Could not update activity filters. Please try again.' ; })
. finally (() => { if ( id === navigationId ) pendingUrl = undefined ; });
}
function reset() { update ({ type : '' , state : '' , source : '' , server : '' , host : '' , days : '' , q : '' }); }
function submitSearch() { update ({ q : search.trim () }); }
</ script >
< section class = "activity-log" aria-labelledby = { headingId } >
< h2 class = "sr-only" id = { headingId } > Activity history </ h2 >
{ # snippet filterFields ( suffix : string )}
< div class = "filters" >
< label for = { `activity-type ${ suffix } ` } > Type<select id = { `activity-type ${ suffix } ` } value= { filters . type ?? '' } onchange = { event => update ({ type : event . currentTarget . value })} > <option value = "" > All types</ option > { #each Object . entries ( activityTypes ) as [ value , label ]} < option { value } > { label } </ option > { /each } </ select ></ label >
< label for = { `activity-state ${ suffix } ` } > State<select id = { `activity-state ${ suffix } ` } value= { filters . state ?? '' } onchange = { event => update ({ state : event.currentTarget.value })} > <option value = "" > All states</ option > { #each Object . entries ( activityStates ) as [ value , label ]} < option { value } > { label } </ option > { /each } </ select ></ label >
{ #if source === undefined } < label for = { `activity-source ${ suffix } ` } > Source<select id = { `activity-source ${ suffix } ` } value= { filters . source ?? '' } onchange = { event => update ({ source : event.currentTarget.value })} > <option value = "" > All sources</ option > { #each Object . entries ( activitySources ) as [ value , label ]} < option { value } > { label } </ option > { /each } </ select ></ label > { /if }
{ #if server === undefined } < label for = { `activity-server ${ suffix } ` } > Server<select id = { `activity-server ${ suffix } ` } value= { filters . server ?? '' } onchange = { event => update ({ server : event.currentTarget.value })} > <option value = "" > All servers</ option > { #each serverOptions as name } < option value = { name } > { serverTitle ( name )} </option > { /each } </ select ></ label > { /if }
< label for = { `activity-host ${ suffix } ` } > Host<select id = { `activity-host ${ suffix } ` } value= { filters . host ?? '' } onchange = { event => update ({ host : event.currentTarget.value })} > <option value = "" > All hosts</ option > { #each hostOptions as [ value , label ]} < option { value } > { label } </ option > { /each } </ select ></ label >
< label for = { `activity-days ${ suffix } ` } > Date range < select id = { `activity-days ${ suffix } ` } value= { filters . days ?? 0 } onchange = { event => update ({ days : event.currentTarget.value })} > <option value = "0" > All retained history</ option >< option value = "1" > Last 24 hours</ option >< option value = "7" > Last 7 days</ option >< option value = "30" > Last 30 days</ option >< option value = "90" > Last 90 days</ option > { #if filters . days && ! [ 1 , 7 , 30 , 90 ]. includes ( filters . days )} < option value = { filters . days } > Last { filters . days } days </ option > { /if } </ select ></ label >
< form class = "search" role = "search" onsubmit = { event => { event . preventDefault (); submitSearch (); }} > <label for = { `activity-search ${ suffix } ` } > Search activity < input id = { `activity-search ${ suffix } ` } type="search" bind:value = { search } maxlength="200" placeholder = "Server, host, or activity…" /></ label >< Button type = "submit" size = "sm" > Search</ Button ></ form >
</ div >
{ /snippet }
< div class = "desktop-filters" > { @render filterFields ( '' )} </ div >
< div class = "mobile-filters" >< span > { data ? . total ?? '—' } { data ? . total === 1 ? 'activity' : 'activities' }{ source ? ` · $ { activitySources [ source ]} ` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · $ { filterCount } ` : ''}</Button></div>
<Dialog bind:open={filtersOpen} title="Activity filters" description="Choose the activity to show." drawer>
{@render filterFields('-mobile')}
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => { if (search.trim() !== (filters.q ?? '')) submitSearch(); filtersOpen = false; }}>Show results</Button>{/snippet}
</Dialog>
<div class="context"><span>Newest first · Times in {$timeZone}</span>{#if filterCount}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
{#if error}<div class="error" role="alert"><span>{error}{loadedUrl === apiUrl && data ? ' Showing the last successful update.' : ''}</span><Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
{#if loading}<EmptyState title="Loading activity…" />
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Activity unavailable" description="Retry to load this selection." />
{:else if data?.rows.length}
<ol class="activity-list" bind:this={results} aria-label="Newest activity first">
{#each data.rows as row (row.id)}
<li class="activity-row">
<div class="event"><h3><a href={contextHref(row.href, $page.url)} aria-label={` $ { row . title } $ { row . server ? ` · $ { serverTitle ( row . server )} ` : row.hostName ? ` · $ { row . hostName } ` : ''} · ${ $timeFormat . period ( row . at ) } ` } > { row . title }</ a ></ h3 >{ #if row . detail }< p >{ row . detail }</ p >{ /if }
< div class = "resources" >{ #if row . server && server === undefined }< HistoricalServerIdentity name = { row . server } deletedAt = { row . serverDeletedAt } href = { contextHref ( `/servers/$ { encodeURIComponent ( row . server )} `, $page.url)} />{/if}
{#if row.hostName}<span class="host-name">{row.hostName}</span>{/if}
</div>
</div>
<div class="outcome"><StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time datetime={row.at} title={$timeFormat.dateTime(row.at)} aria-label={$timeFormat.dateTime(row.at)}>{activityTimeLabel(row.at, $timeZone, $uiNow)}</time>
<span class="sources">{(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')}</span>
</div>
</li>
{/each}
</ol>
{:else if !error}<EmptyState title={filterCount ? 'No matching activity' : 'No activity recorded yet'} description={filterCount ? 'Try another type, state, server, host, or date range.' : 'Server operations, file changes, maintenance, and host work appear here.'}>
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
</EmptyState>{/if}
{#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)}
<div class="pagination"><span>{data.rows.length ? ` $ { data . offset + 1 } – $ { data . offset + data . rows . length } of $ { data . total } ` : 'No activities on this page' }</ span >
< label > Per page < select aria-label = "Activities per page" value = { data . limit } onchange = { event => update ({ limit : event.currentTarget.value })} > { #each [... new Set ([ 25 , 50 , 100 , data . limit ])]. sort (( a , b ) => a - b ) as size }< option value = { size } > { size }</ option >{ /each }</ select ></ label >
{ #if data . total > data . limit || data . offset > 0 }< nav aria-label = "Activity pages" >< Button size = "sm" disabled = { data . offset === 0 } href = { activityHistoryHref ( $page . url , { offset : String ( Math . max ( 0 , data . offset - data . limit )) })} > Previous </ Button >< Button size = "sm" disabled = { data . offset + data . limit >= data . total } href = { activityHistoryHref ( $page . url , { offset : String ( data . offset + data . limit ) })} > Next </ Button ></ nav >{ /if }
</ div >
{ /if }
</ section >
< style >
. activity - log { display : grid ; gap : var ( -- space - 4 ); min - width : 0 ; }
. filters { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 150 px , 1 fr )); align - items : end ; gap : var ( -- space - 3 ); }
label { display : grid ; gap : 6px ; color : var ( -- text - secondary ); font - size : var ( -- text - xs ); min - width : 0 ; }
select , input { width : 100 % ; min - height : 40px ; padding : 8px 10 px ; border : 1px solid var ( -- border - control ); border - radius : var ( -- radius - md ); background : var ( -- surface - sunken ); color : var ( -- text - primary ); font : inherit ; font - size : var ( -- text - sm ); }
. search { grid - column : span 2 ; display : flex ; align - items : end ; gap : var ( -- space - 2 ); min - width : 0 ; }
. search label { flex : 1 ; }
. context , . pagination { display : flex ; align - items : center ; flex - wrap : wrap ; gap : var ( -- space - 2 ) var ( -- space - 4 ); font - size : var ( -- text - xs ); color : var ( -- text - secondary ); }
. context > : last - child { margin - left : auto ; }
. text - button { padding : 4px 0 ; border : 0 ; background : none ; color : var ( -- accent - primary - hover ); cursor : pointer ; font : inherit ; }
. activity - list { padding : 0 ; margin : 0 ; list - style : none ; border : 1px solid var ( -- border - subtle ); border - radius : var ( -- radius - md ); background : var ( -- surface ); overflow : hidden ; }
. activity - row { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) minmax ( 180 px , auto ); gap : var ( -- space - 3 ); padding : var ( -- space - 4 ); }
. activity - row + . activity - row { border - top : 1px solid var ( -- border - subtle ); }
. activity - row :focus - within { background : var ( -- surface - raised ); }
h3 , p { margin : 0 ; }
h3 { font - size : var ( -- text - sm ); font - weight : var ( -- weight - medium ); }
a { color : var ( -- accent - primary - hover ); text - decoration : none ; }
a :hover { text - decoration : underline ; }
a :focus - visible , button :focus - visible { outline : 2px solid var ( -- accent - primary ); outline - offset : 3px ; }
. event { min - width : 0 ; overflow - wrap : anywhere ; }
. event p { margin - top : var ( -- space - 1 ); color : var ( -- text - secondary ); font - size : var ( -- text - xs ); line - height : var ( -- leading - body ); }
. resources { display : flex ; flex - wrap : wrap ; align - items : baseline ; gap : var ( -- space - 1 ) var ( -- space - 3 ); margin - top : var ( -- space - 2 ); font - size : var ( -- text - xs ); }
. host - name , . sources , time { color : var ( -- text - secondary ); font - size : var ( -- text - xs ); }
. outcome { display : flex ; flex - direction : column ; align - items : flex - end ; gap : var ( -- space - 2 ); text - align : right ; }
time { font - variant - numeric : tabular - nums ; }
. sources { max - width : 32ch ; overflow - wrap : anywhere ; }
. pagination { justify - content : space - between ; }
. pagination label { display : flex ; align - items : center ; gap : var ( -- space - 2 ); }
. pagination select { width : auto ; min - height : 36px ; }
. pagination nav { display : flex ; gap : var ( -- space - 2 ); }
. error { display : flex ; align - items : center ; flex - wrap : wrap ; gap : var ( -- space - 3 ); color : var ( -- state - warning ); font - size : var ( -- text - sm ); }
. mobile - filters { display : none ; }
. sr - only { position : absolute ; width : 1px ; height : 1px ; padding : 0 ; margin : - 1 px ; overflow : hidden ; clip - path : inset ( 50 % ); white - space : nowrap ; border : 0 ; }
@media ( max - width : 640px ) {
. desktop - filters { display : none ; }
. mobile - filters { display : flex ; align - items : center ; justify - content : space - between ; gap : var ( -- space - 3 ); font - size : var ( -- text - sm ); }
. filters { grid - template - columns : minmax ( 0 , 1 fr ); }
. search { grid - column : auto ; }
. context { gap : var ( -- space - 1 ) var ( -- space - 2 ); }
. context > : last - child { margin - left : 0 ; }
. activity - row { grid - template - columns : minmax ( 0 , 1 fr ); padding : var ( -- space - 3 ); gap : var ( -- space - 2 ); }
. outcome { flex - direction : row ; flex - wrap : wrap ; align - items : center ; text - align : left ; }
. sources { max - width : none ; }
. pagination { gap : var ( -- space - 3 ); }
. pagination nav { margin - left : auto ; }
}
</ style >