Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
273 lines
8.9 KiB
Svelte
273 lines
8.9 KiB
Svelte
<script lang="ts">
|
|
import { browser } from "$app/environment";
|
|
import { page } from "$app/state";
|
|
import { onMount } from "svelte";
|
|
import { api } from "$lib/api";
|
|
import { unwrapList } from "$lib/list";
|
|
import { i18n } from "$lib/i18n";
|
|
import {
|
|
formatJobErrorText,
|
|
formatJobStatusLabel,
|
|
formatProcessingTypeLabel,
|
|
isActiveProcessingJob
|
|
} from "$lib/job-status";
|
|
import { notifyError, notifySuccess } from "$lib/notify";
|
|
import type { ListResponse, ProcessingJob } from "$lib/types";
|
|
import { Badge, Progress, buttonClasses } from "$lib/components/ui";
|
|
import { ChevronDown, ChevronUp, Layers } from "@lucide/svelte";
|
|
|
|
const POLL_MS = 2_000;
|
|
const RETRY_MS = 10_000;
|
|
/** Keep the float visible briefly after the last active job finishes (UX + short E2E windows). */
|
|
const LINGER_MS = 6_000;
|
|
|
|
let jobs = $state<ProcessingJob[]>([]);
|
|
let displayJobs = $state<ProcessingJob[]>([]);
|
|
let collapsed = $state(false);
|
|
let timer: number | undefined;
|
|
let lingerTimer: number | undefined;
|
|
let abort: AbortController | null = null;
|
|
let gen = 0;
|
|
/** Jobs observed as active this session — terminal toasts only for transitions we watched. */
|
|
const watchedActiveIds = new Set<string>();
|
|
const toastedTerminalIds = new Set<string>();
|
|
|
|
const activeJobs = $derived(jobs.filter((j) => isActiveProcessingJob(j.status)));
|
|
const visible = $derived(
|
|
displayJobs.length > 0 && !page.url.pathname.startsWith("/processing")
|
|
);
|
|
const headline = $derived(
|
|
displayJobs.length === 1
|
|
? i18n.t("taskIndicator.activeOne")
|
|
: i18n.t("taskIndicator.activeMany", {
|
|
count: Math.max(displayJobs.length, activeJobs.length)
|
|
})
|
|
);
|
|
const previewJobs = $derived(displayJobs.slice(0, 3));
|
|
|
|
function isTerminalSuccess(status: string | null | undefined): boolean {
|
|
const s = (status ?? "").toLowerCase().trim();
|
|
return s === "completed" || s === "success" || s === "done";
|
|
}
|
|
|
|
function isTerminalFailure(status: string | null | undefined): boolean {
|
|
const s = (status ?? "").toLowerCase().trim();
|
|
return s === "failed" || s === "error";
|
|
}
|
|
|
|
function isTerminalCancelled(status: string | null | undefined): boolean {
|
|
const s = (status ?? "").toLowerCase().trim();
|
|
return s === "cancelled" || s === "canceled";
|
|
}
|
|
|
|
/** Fire ai_done / ai_fail toasts when a watched active job reaches a terminal status. */
|
|
function notifyTerminalTransitions(nextJobs: ProcessingJob[]) {
|
|
for (const job of nextJobs) {
|
|
const id = String(job.id ?? "").trim();
|
|
if (!id) continue;
|
|
if (isActiveProcessingJob(job.status)) {
|
|
watchedActiveIds.add(id);
|
|
continue;
|
|
}
|
|
if (!watchedActiveIds.has(id) || toastedTerminalIds.has(id)) {
|
|
if (isTerminalCancelled(job.status) || isTerminalSuccess(job.status) || isTerminalFailure(job.status)) {
|
|
watchedActiveIds.delete(id);
|
|
}
|
|
continue;
|
|
}
|
|
watchedActiveIds.delete(id);
|
|
if (isTerminalCancelled(job.status)) continue;
|
|
toastedTerminalIds.add(id);
|
|
const typeLabel = formatProcessingTypeLabel(job.processing_type ?? job.type);
|
|
const viewAction = { label: i18n.t("taskIndicator.view"), href: "/processing" };
|
|
if (isTerminalSuccess(job.status)) {
|
|
notifySuccess(i18n.t("toast.processing.jobCompleted"), {
|
|
alert: "ai_done",
|
|
description: typeLabel,
|
|
actions: [viewAction],
|
|
duration: 10000
|
|
});
|
|
continue;
|
|
}
|
|
if (isTerminalFailure(job.status)) {
|
|
const detail = formatJobErrorText(job.error);
|
|
notifyError(i18n.t("toast.processing.jobFailed"), {
|
|
alert: "ai_fail",
|
|
description: detail || typeLabel,
|
|
actions: [viewAction],
|
|
duration: 12000
|
|
});
|
|
}
|
|
}
|
|
if (toastedTerminalIds.size > 200) {
|
|
toastedTerminalIds.clear();
|
|
}
|
|
}
|
|
|
|
function syncDisplay(nextJobs: ProcessingJob[]) {
|
|
notifyTerminalTransitions(nextJobs);
|
|
const active = nextJobs.filter((j) => isActiveProcessingJob(j.status));
|
|
if (active.length > 0) {
|
|
displayJobs = active.slice(0, 3);
|
|
if (lingerTimer !== undefined) {
|
|
window.clearTimeout(lingerTimer);
|
|
lingerTimer = undefined;
|
|
}
|
|
return;
|
|
}
|
|
if (displayJobs.length === 0 || lingerTimer !== undefined) return;
|
|
lingerTimer = window.setTimeout(() => {
|
|
displayJobs = [];
|
|
lingerTimer = undefined;
|
|
}, LINGER_MS);
|
|
}
|
|
|
|
function jobProgress(job: ProcessingJob): { pct: number; done: number; total: number } {
|
|
const total = typeof job.total_products === "number" ? job.total_products : 0;
|
|
const done = typeof job.processed_products === "number" ? job.processed_products : 0;
|
|
if (!total) return { pct: 0, done, total: 0 };
|
|
return { pct: Math.min(100, Math.round((done / total) * 100)), done, total };
|
|
}
|
|
|
|
function jobTitle(job: ProcessingJob): string {
|
|
return formatProcessingTypeLabel(job.processing_type ?? job.type);
|
|
}
|
|
|
|
async function refresh(signal?: AbortSignal) {
|
|
const myGen = ++gen;
|
|
try {
|
|
const payload = await api<ListResponse<ProcessingJob>>("/api/processing/jobs?limit=50", {
|
|
signal
|
|
});
|
|
if (myGen !== gen) return true;
|
|
jobs = unwrapList(payload);
|
|
syncDisplay(jobs);
|
|
return true;
|
|
} catch (err) {
|
|
if (myGen !== gen) return true;
|
|
// Aborted fetches are normal on unmount / overlapping ticks — do not enter the
|
|
// slow retry backoff or the indicator stays blank across short-lived jobs.
|
|
const aborted =
|
|
signal?.aborted ||
|
|
(err instanceof DOMException && err.name === "AbortError") ||
|
|
(err instanceof Error && err.name === "AbortError");
|
|
return aborted;
|
|
}
|
|
}
|
|
|
|
function schedule(ms: number) {
|
|
if (timer !== undefined) window.clearInterval(timer);
|
|
timer = window.setInterval(() => {
|
|
void tick();
|
|
}, ms);
|
|
}
|
|
|
|
async function tick() {
|
|
if (document.visibilityState === "hidden") return;
|
|
abort?.abort();
|
|
const ac = new AbortController();
|
|
abort = ac;
|
|
const ok = await refresh(ac.signal);
|
|
if (ac.signal.aborted) return;
|
|
schedule(ok ? POLL_MS : RETRY_MS);
|
|
}
|
|
|
|
onMount(() => {
|
|
if (!browser) return;
|
|
const ac = new AbortController();
|
|
void (async () => {
|
|
const ok = await refresh(ac.signal);
|
|
if (ac.signal.aborted) return;
|
|
schedule(ok ? POLL_MS : RETRY_MS);
|
|
})();
|
|
|
|
const onVis = () => {
|
|
if (document.visibilityState === "visible") void tick();
|
|
};
|
|
document.addEventListener("visibilitychange", onVis);
|
|
const boot = window.setTimeout(() => {
|
|
if (!ac.signal.aborted) void tick();
|
|
}, 750);
|
|
|
|
return () => {
|
|
window.clearTimeout(boot);
|
|
if (lingerTimer !== undefined) window.clearTimeout(lingerTimer);
|
|
ac.abort();
|
|
abort?.abort();
|
|
if (timer !== undefined) window.clearInterval(timer);
|
|
document.removeEventListener("visibilitychange", onVis);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
{#if visible}
|
|
<div
|
|
class="pointer-events-none fixed bottom-4 right-4 z-40 flex w-[min(100vw-2rem,22rem)] flex-col items-stretch gap-2"
|
|
role="status"
|
|
aria-live="polite"
|
|
data-testid="task-status-indicator"
|
|
>
|
|
<div
|
|
class="pointer-events-auto overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-background/90"
|
|
>
|
|
<div class="flex items-start gap-2 border-b border-border px-3 py-2.5">
|
|
<Layers class="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm font-medium text-foreground">{i18n.t("taskIndicator.title")}</p>
|
|
<p class="text-xs text-muted-foreground">{headline}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="rounded-md p-1 text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-expanded={!collapsed}
|
|
aria-label={collapsed ? i18n.t("taskIndicator.expand") : i18n.t("taskIndicator.collapse")}
|
|
onclick={() => (collapsed = !collapsed)}
|
|
>
|
|
{#if collapsed}
|
|
<ChevronUp class="h-3.5 w-3.5" aria-hidden="true" />
|
|
{:else}
|
|
<ChevronDown class="h-3.5 w-3.5" aria-hidden="true" />
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
{#if !collapsed}
|
|
<ul class="divide-y divide-border">
|
|
{#each previewJobs as job (job.id)}
|
|
{@const { pct, done, total } = jobProgress(job)}
|
|
<li class="space-y-1.5 px-3 py-2.5">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<p class="truncate text-xs font-medium">{jobTitle(job)}</p>
|
|
<Badge variant="outline" class="shrink-0 capitalize">
|
|
{formatJobStatusLabel(job.status)}
|
|
</Badge>
|
|
</div>
|
|
{#if total > 0}
|
|
<Progress value={pct} class="" />
|
|
<p class="text-[11px] tabular-nums text-muted-foreground">
|
|
{i18n.t("taskIndicator.progressCounts", { done, total })}
|
|
</p>
|
|
<span class="sr-only">{i18n.t("taskIndicator.progressAria", { pct })}</span>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
<div class="border-t border-border px-3 py-2">
|
|
<a href="/processing" class={buttonClasses("outline", "sm", "w-full justify-center")}>
|
|
{i18n.t("taskIndicator.view")}
|
|
</a>
|
|
</div>
|
|
{:else}
|
|
<div class="px-3 py-2">
|
|
<a
|
|
href="/processing"
|
|
class="text-xs font-medium text-link underline-offset-4 hover:underline"
|
|
>
|
|
{i18n.t("taskIndicator.view")}
|
|
</a>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|