|
|
|
@@ -0,0 +1,138 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { onMount } from 'svelte';
|
|
|
|
|
import { beforeNavigate, goto } from '$app/navigation';
|
|
|
|
|
import { page, navigating } from '$app/stores';
|
|
|
|
|
import type { PageData } from './$types';
|
|
|
|
|
import { operationKindLabels, operationView, type OperationsOverview } from '$lib/operations-overview.js';
|
|
|
|
|
import { contextHref } from '$lib/navigation-context.js';
|
|
|
|
|
import { pollVisible } from '$ui/poll.js';
|
|
|
|
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
|
|
|
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
|
|
|
|
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
|
|
|
|
import Button from '$ui/components/primitives/Button.svelte';
|
|
|
|
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
|
|
|
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
|
|
|
|
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
|
|
|
|
|
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
|
|
|
let snapshot = $state<{ source: PageData; overview: OperationsOverview } | null>(null);
|
|
|
|
|
let refreshing = $state(false);
|
|
|
|
|
let error = $state('');
|
|
|
|
|
let request: AbortController | null = null;
|
|
|
|
|
let sequence = 0;
|
|
|
|
|
let search = $state('');
|
|
|
|
|
let kind = $state('');
|
|
|
|
|
let limit = $state(25);
|
|
|
|
|
const overview = $derived(snapshot?.source === data ? snapshot.overview : data.overview);
|
|
|
|
|
const view = $derived(operationView($page.url.searchParams.get('view')));
|
|
|
|
|
const tabs = $derived([
|
|
|
|
|
{ id: 'attention', label: 'Needs attention', badge: overview.counts.attention },
|
|
|
|
|
{ id: 'running', label: 'Running', badge: overview.counts.running },
|
|
|
|
|
{ id: 'recent', label: 'Recent', badge: overview.counts.recent }
|
|
|
|
|
]);
|
|
|
|
|
const visible = $derived(overview.items.filter(item => item.view === view && (!kind || item.kind === kind)
|
|
|
|
|
&& [item.title, item.detail, item.status, ...item.resources.map(resource => resource.label)].join(' ').toLowerCase().includes(search.trim().toLowerCase())));
|
|
|
|
|
$effect(() => {
|
|
|
|
|
search = $page.url.searchParams.get('q') ?? '';
|
|
|
|
|
const value = $page.url.searchParams.get('kind') ?? '';
|
|
|
|
|
kind = Object.hasOwn(operationKindLabels, value) ? value : '';
|
|
|
|
|
limit = Math.max(25, Math.min(10000, Number($page.url.searchParams.get('limit')) || 25));
|
|
|
|
|
});
|
|
|
|
|
function contextUrl(nextView = view) {
|
|
|
|
|
const url = new URL($page.url);
|
|
|
|
|
for (const [key, value] of Object.entries({ view: nextView, q: search, kind, limit: limit > 25 ? String(limit) : '' })) {
|
|
|
|
|
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
|
|
|
|
|
}
|
|
|
|
|
return url;
|
|
|
|
|
}
|
|
|
|
|
const origin = $derived(contextUrl());
|
|
|
|
|
function saveContext(nextView = view, reset = false) {
|
|
|
|
|
if (reset) limit = 25;
|
|
|
|
|
void goto(contextUrl(nextView), { replaceState: nextView === view, noScroll: true, keepFocus: true });
|
|
|
|
|
}
|
|
|
|
|
function cancel() { sequence++; request?.abort(); request = null; refreshing = false; }
|
|
|
|
|
async function refresh(signal?: AbortSignal) {
|
|
|
|
|
if (refreshing || $navigating) return;
|
|
|
|
|
const current = ++sequence, source = data;
|
|
|
|
|
const controller = new AbortController(); request = controller; refreshing = true;
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/operations', { cache: 'no-store', signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000), ...(signal ? [signal] : [])]) });
|
|
|
|
|
if (!response.ok) throw new Error('Could not refresh Operations. Showing the last successful update.');
|
|
|
|
|
const result: OperationsOverview = await response.json();
|
|
|
|
|
if (current === sequence && source === data && !signal?.aborted) { snapshot = { source, overview: result }; error = ''; }
|
|
|
|
|
} catch (cause) { if (current === sequence && !signal?.aborted && !controller.signal.aborted) error = (cause as Error).message; }
|
|
|
|
|
finally { if (current === sequence) { request = null; refreshing = false; } }
|
|
|
|
|
}
|
|
|
|
|
beforeNavigate(cancel);
|
|
|
|
|
onMount(() => { const stop = pollVisible(refresh, 10000, false); return () => { stop(); cancel(); }; });
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<svelte:head><title>Operations · Electric Cherry</title></svelte:head>
|
|
|
|
|
<section class="page">
|
|
|
|
|
<SectionHeader title="Operations" subtitle="Follow fleet changes, review blockers, and return to the work that needs you.">
|
|
|
|
|
{#snippet actions()}<Button disabled={refreshing || !!$navigating} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
|
|
|
|
|
</SectionHeader>
|
|
|
|
|
<Tabs {tabs} value={view} onchange={value => saveContext(operationView(value), true)} ariaLabel="Operations views" idPrefix="operations-tabs" panelId="operations-panel" />
|
|
|
|
|
<form class="filters" onsubmit={event => { event.preventDefault(); saveContext(view, true); }}>
|
|
|
|
|
<label>Find work<input type="search" bind:value={search} placeholder="Search servers, hosts, or work…" /></label>
|
|
|
|
|
<label>Workflow<select bind:value={kind} onchange={event => { kind = event.currentTarget.value; saveContext(view, true); }}><option value="">All workflows</option>{#each Object.entries(operationKindLabels) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
|
|
|
|
|
<Button type="submit">Search</Button>
|
|
|
|
|
{#if search || kind}<Button variant="ghost" onclick={() => { search = ''; kind = ''; saveContext(view, true); }}>Clear filters</Button>{/if}
|
|
|
|
|
</form>
|
|
|
|
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
|
|
|
|
<div class="freshness">Updated {$timeFormat.dateTime(overview.generatedAt)} · Refreshes automatically</div>
|
|
|
|
|
<div id="operations-panel" role="tabpanel" aria-labelledby={`operations-tabs-${view}`} class="rows">
|
|
|
|
|
{#if !visible.length}
|
|
|
|
|
<EmptyState title={search || kind ? 'No matching work' : view === 'attention' ? 'Nothing needs attention' : view === 'running' ? 'No work running' : 'No recent results'} description={view === 'attention' && !search && !kind ? 'Queued work and normal waits appear under Running.' : 'Choose another view or workflow to explore its activity.'} />
|
|
|
|
|
{:else}
|
|
|
|
|
<p class="result-count" role="status">{visible.length} {visible.length === 1 ? 'item' : 'items'} · Related operations are grouped together.</p>
|
|
|
|
|
{#each visible.slice(0, limit) as item (item.key)}
|
|
|
|
|
<article class:attention={item.view === 'attention'}>
|
|
|
|
|
<div class="row-heading"><div><span class="eyebrow">{operationKindLabels[item.kind]}</span><h2><a href={contextHref(item.href, origin)}>{item.title}</a></h2></div><Badge variant={item.view === 'attention' ? 'warning' : item.view === 'running' ? 'info' : 'neutral'}>{item.status}</Badge></div>
|
|
|
|
|
<p>{item.detail}</p>
|
|
|
|
|
{#if item.progress}<strong class="progress">{item.progress}</strong>{/if}
|
|
|
|
|
<div class="resources">{#each item.resources.slice(0, 4) as resource}{#if resource.href}<a href={contextHref(resource.href, origin)}>{resource.label}</a>{:else}<span>{resource.label}</span>{/if}{/each}{#if item.resources.length > 4}<span>+{item.resources.length - 4} more below</span>{/if}</div>
|
|
|
|
|
{#if item.related.length}<div class="resources">{#each item.related as related}<a href={contextHref(related.href, origin)}>{related.label}</a>{/each}</div>{/if}
|
|
|
|
|
<div class="row-footer"><time datetime={item.updatedAt}>{$timeFormat.dateTime(item.updatedAt)}</time><Button size="sm" href={contextHref(item.href, origin)}>{item.action} →</Button></div>
|
|
|
|
|
{#if item.children.length || item.resources.length > 4}<details>
|
|
|
|
|
<summary>{item.children.length ? `${item.children.length} related operations` : `${item.resources.length} affected resources`}</summary>
|
|
|
|
|
{#if item.resources.length > 4}<div class="resources">{#each item.resources as resource}{#if resource.href}<a href={contextHref(resource.href, origin)}>{resource.label}</a>{:else}<span>{resource.label}</span>{/if}{/each}</div>{/if}
|
|
|
|
|
<ul>{#each item.children.slice(0, 25) as child (child.key)}<li><a href={contextHref(child.href, origin)}>{child.title}</a><span>{child.status}</span><p>{child.detail}</p></li>{/each}</ul>
|
|
|
|
|
{#if item.children.length > 25}<a href={contextHref(item.href, origin)}>View all {item.children.length} related operations in the workflow →</a>{/if}
|
|
|
|
|
</details>{/if}
|
|
|
|
|
</article>
|
|
|
|
|
{/each}
|
|
|
|
|
<ListFooter total={visible.length} bind:limit onchange={() => saveContext()} label="items" />
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<p class="history-note">Running workflows stay visible. Recent activity includes up to {overview.recentLimit} finished records per workflow from the last {overview.recentDays} days. Full history remains on each workflow page. Routine successful agent checks are available in <a href="/hosts/operations?filter=routine">Host operation history</a>.</p>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.page,.rows { display:grid; gap:20px; min-width:0; }
|
|
|
|
|
.filters { display:flex; flex-wrap:wrap; align-items:end; gap:12px; }
|
|
|
|
|
label { display:grid; gap:8px; font-size:var(--text-sm); color:var(--text-secondary); }
|
|
|
|
|
label:first-child { flex:1; min-width:min(250px,100%); }
|
|
|
|
|
input,select { width:100%; min-height:42px; padding:10px 12px; background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); color:var(--text-primary); font:inherit; }
|
|
|
|
|
.freshness,.result-count,.history-note,time { font-size:var(--text-xs); color:var(--text-secondary); }
|
|
|
|
|
.result-count,.history-note { margin:0; line-height:1.7; }
|
|
|
|
|
article { min-width:0; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); padding:20px; }
|
|
|
|
|
article.attention { border-left:3px solid var(--state-warning); }
|
|
|
|
|
.row-heading,.row-footer { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px; }
|
|
|
|
|
.row-heading { align-items:flex-start; }
|
|
|
|
|
.eyebrow { font-size:var(--text-xs); color:var(--text-secondary); }
|
|
|
|
|
h2 { margin:6px 0 0; font-size:var(--text-lg); }
|
|
|
|
|
h2 a { color:var(--text-primary); text-decoration:none; }
|
|
|
|
|
article p { font-size:var(--text-sm); color:var(--text-secondary); line-height:1.65; }
|
|
|
|
|
.progress { font-size:var(--text-sm); }
|
|
|
|
|
.resources { display:flex; flex-wrap:wrap; gap:8px 16px; margin-top:12px; font-size:var(--text-sm); }
|
|
|
|
|
.resources span { color:var(--text-secondary); }
|
|
|
|
|
.row-footer { margin-top:16px; }
|
|
|
|
|
details { margin-top:16px; padding-top:16px; border-top:1px solid var(--border-subtle); font-size:var(--text-sm); }
|
|
|
|
|
summary { cursor:pointer; }
|
|
|
|
|
ul { list-style:none; padding:0; margin:16px 0 0; display:grid; gap:16px; }
|
|
|
|
|
li { min-width:0; padding:12px; background:var(--surface-sunken); border-radius:var(--radius-sm); }
|
|
|
|
|
li > span { display:block; margin-top:6px; color:var(--text-secondary); font-size:var(--text-xs); }
|
|
|
|
|
li p { margin-bottom:0; }
|
|
|
|
|
@media (max-width:600px) { article { padding:16px; } .filters label { flex:1 1 100%; } .row-footer { align-items:flex-start; } }
|
|
|
|
|
</style>
|