224 lines
7.0 KiB
Svelte
224 lines
7.0 KiB
Svelte
<!-- [DEF:UnifiedReportsPage:Component] -->
|
|
<script>
|
|
/**
|
|
* @TIER: CRITICAL
|
|
* @SEMANTICS: reports, unified, filters, loading, empty, error
|
|
* @PURPOSE: Unified reports page with filtering and resilient UX states for mixed task types.
|
|
* @LAYER: UI
|
|
* @RELATION: DEPENDS_ON -> frontend/src/lib/api/reports.js
|
|
* @RELATION: DEPENDS_ON -> frontend/src/lib/components/reports/ReportsList.svelte
|
|
* @INVARIANT: List state remains deterministic for active filter set.
|
|
*
|
|
* @UX_STATE: Loading -> Skeleton-like block shown; filters visible.
|
|
* @UX_STATE: Ready -> Reports list rendered.
|
|
* @UX_STATE: NoData -> Friendly empty state for total=0 without filters.
|
|
* @UX_STATE: FilteredEmpty -> Filtered empty state with one-click clear.
|
|
* @UX_STATE: Error -> Inline error with retry preserving filters.
|
|
* @UX_FEEDBACK: Filter change reloads list immediately.
|
|
* @UX_RECOVERY: Retry and clear filters actions available.
|
|
*
|
|
* @TEST_CONTRACT Page_Reports ->
|
|
* {
|
|
* required_props: {},
|
|
* optional_props: {},
|
|
* invariants: [
|
|
* "Loads reports on mount using query filters",
|
|
* "Reloads reports automatically upon filter changes",
|
|
* "Loads selected report detail on click"
|
|
* ]
|
|
* }
|
|
* @TEST_FIXTURE init_state -> {}
|
|
* @TEST_EDGE server_error -> shows error block and retry button
|
|
* @TEST_EDGE empty_filtered_list -> displays \"No results matching filters\"
|
|
* @TEST_INVARIANT pagination_and_filtering -> verifies: [init_state]
|
|
*/
|
|
|
|
import { onMount } from "svelte";
|
|
import { t } from "$lib/i18n";
|
|
import { PageHeader } from "$lib/ui";
|
|
import { getReports, getReportDetail } from "$lib/api/reports.js";
|
|
import ReportsList from "$lib/components/reports/ReportsList.svelte";
|
|
import ReportDetailPanel from "$lib/components/reports/ReportDetailPanel.svelte";
|
|
|
|
let loading = true;
|
|
let error = "";
|
|
let collection = null;
|
|
let selectedReport = null;
|
|
let selectedReportDetail = null;
|
|
|
|
let taskType = "all";
|
|
let status = "all";
|
|
let page = 1;
|
|
const pageSize = 20;
|
|
|
|
const TASK_TYPE_OPTIONS = [
|
|
{ value: "all", label: $t.reports?.all_types },
|
|
{ value: "llm_verification", label: "LLM" },
|
|
{ value: "backup", label: $t.nav?.backups },
|
|
{ value: "migration", label: $t.nav?.migration },
|
|
{ value: "documentation", label: "Documentation" },
|
|
];
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: "all", label: $t.reports?.all_statuses },
|
|
{ value: "success", label: "Success" },
|
|
{ value: "failed", label: "Failed" },
|
|
{ value: "in_progress", label: "In progress" },
|
|
{ value: "partial", label: "Partial" },
|
|
];
|
|
|
|
function buildQuery() {
|
|
return {
|
|
page,
|
|
page_size: pageSize,
|
|
task_types: taskType === "all" ? [] : [taskType],
|
|
statuses: status === "all" ? [] : [status],
|
|
sort_by: "updated_at",
|
|
sort_order: "desc",
|
|
};
|
|
}
|
|
|
|
async function loadReports({ silent = false } = {}) {
|
|
try {
|
|
if (!silent) loading = true;
|
|
error = "";
|
|
collection = await getReports(buildQuery());
|
|
if (!selectedReport && collection?.items?.length) {
|
|
selectedReport = collection.items[0];
|
|
selectedReportDetail = await getReportDetail(selectedReport.report_id);
|
|
}
|
|
} catch (e) {
|
|
error = e?.message || "Failed to load reports";
|
|
collection = null;
|
|
} finally {
|
|
if (!silent) loading = false;
|
|
}
|
|
}
|
|
|
|
function hasActiveFilters() {
|
|
return taskType !== "all" || status !== "all";
|
|
}
|
|
|
|
function clearFilters() {
|
|
taskType = "all";
|
|
status = "all";
|
|
page = 1;
|
|
selectedReport = null;
|
|
selectedReportDetail = null;
|
|
loadReports();
|
|
}
|
|
|
|
function onFilterChange() {
|
|
page = 1;
|
|
selectedReport = null;
|
|
selectedReportDetail = null;
|
|
loadReports();
|
|
}
|
|
|
|
async function onSelectReport(event) {
|
|
selectedReport = event.detail.report;
|
|
selectedReportDetail = await getReportDetail(selectedReport.report_id);
|
|
}
|
|
|
|
onMount(() => {
|
|
loadReports();
|
|
});
|
|
</script>
|
|
|
|
<div class="mx-auto w-full max-w-7xl space-y-4">
|
|
<PageHeader
|
|
title={$t.reports?.title}
|
|
subtitle={() => null}
|
|
actions={() => null}
|
|
/>
|
|
|
|
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
|
<div class="grid grid-cols-1 gap-2 md:grid-cols-4">
|
|
<select
|
|
bind:value={taskType}
|
|
on:change={onFilterChange}
|
|
class="rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
|
>
|
|
{#each TASK_TYPE_OPTIONS as option}
|
|
<option value={option.value}>{option.label}</option>
|
|
{/each}
|
|
</select>
|
|
|
|
<select
|
|
bind:value={status}
|
|
on:change={onFilterChange}
|
|
class="rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
|
>
|
|
{#each STATUS_OPTIONS as option}
|
|
<option value={option.value}>{option.label}</option>
|
|
{/each}
|
|
</select>
|
|
|
|
<button
|
|
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
|
on:click={() => loadReports()}
|
|
>
|
|
{$t.common?.refresh}
|
|
</button>
|
|
|
|
<button
|
|
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
|
on:click={clearFilters}
|
|
>
|
|
{$t.reports?.clear_filters}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if loading}
|
|
<div
|
|
class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm"
|
|
>
|
|
{$t.reports?.loading}
|
|
</div>
|
|
{:else if error}
|
|
<div
|
|
class="rounded-xl border border-red-200 bg-red-50 p-4 text-red-700 shadow-sm"
|
|
>
|
|
<p>{error}</p>
|
|
<button
|
|
class="mt-2 inline-flex items-center justify-center rounded-lg border border-red-300 px-3 py-1 text-sm font-medium text-red-700 transition-colors hover:bg-red-100"
|
|
on:click={() => loadReports()}
|
|
>
|
|
{$t.reports?.retry_load || $t.common?.retry}
|
|
</button>
|
|
</div>
|
|
{:else if !collection || collection.total === 0}
|
|
<div
|
|
class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm"
|
|
>
|
|
{$t.reports?.empty}
|
|
</div>
|
|
{:else if collection.items.length === 0 && hasActiveFilters()}
|
|
<div
|
|
class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm"
|
|
>
|
|
<p>{$t.reports?.filtered_empty}</p>
|
|
<button
|
|
class="mt-2 inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-1 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
|
on:click={clearFilters}
|
|
>
|
|
{$t.reports?.clear_filters}
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
<div class="lg:col-span-2">
|
|
<ReportsList
|
|
reports={collection?.items || []}
|
|
selectedReportId={selectedReport?.report_id}
|
|
on:select={onSelectReport}
|
|
/>
|
|
</div>
|
|
<ReportDetailPanel detail={selectedReportDetail} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- [/DEF:UnifiedReportsPage:Component] -->
|