таски готовы
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
let {
|
||||
tasks = [],
|
||||
loading = false,
|
||||
selectedTaskId = null,
|
||||
} = $props();
|
||||
|
||||
|
||||
@@ -54,8 +55,8 @@
|
||||
// @PURPOSE: Dispatches a select event when a task is clicked.
|
||||
// @PRE: taskId is provided.
|
||||
// @POST: 'select' event is dispatched with task ID.
|
||||
function handleTaskClick(taskId: string) {
|
||||
dispatch('select', { id: taskId });
|
||||
function handleTaskClick(task: any) {
|
||||
dispatch('select', { id: task.id, task });
|
||||
}
|
||||
// [/DEF:handleTaskClick:Function]
|
||||
</script>
|
||||
@@ -70,8 +71,8 @@
|
||||
{#each tasks as task (task.id)}
|
||||
<li>
|
||||
<button
|
||||
class="block hover:bg-gray-50 w-full text-left transition duration-150 ease-in-out focus:outline-none"
|
||||
on:click={() => handleTaskClick(task.id)}
|
||||
class="block w-full text-left transition duration-150 ease-in-out focus:outline-none hover:bg-gray-50 {selectedTaskId === task.id ? 'bg-blue-50' : ''}"
|
||||
on:click={() => handleTaskClick(task)}
|
||||
>
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -110,4 +111,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:TaskList:Component] -->
|
||||
<!-- [/DEF:TaskList:Component] -->
|
||||
|
||||
83
frontend/src/lib/api/reports.js
Normal file
83
frontend/src/lib/api/reports.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// [DEF:frontend.src.lib.api.reports:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: frontend, api_client, reports, wrapper
|
||||
// @PURPOSE: Wrapper-based reports API client for list/detail retrieval without direct native fetch usage.
|
||||
// @LAYER: Infra
|
||||
// @RELATION: DEPENDS_ON -> [DEF:api_module]
|
||||
// @INVARIANT: Uses existing api wrapper methods and returns structured errors for UI-state mapping.
|
||||
|
||||
import { api } from '../api.js';
|
||||
|
||||
// [DEF:buildReportQueryString:Function]
|
||||
// @PURPOSE: Build query string for reports list endpoint from filter options.
|
||||
// @PRE: options is an object with optional report query fields.
|
||||
// @POST: Returns URL query string without leading '?'.
|
||||
export function buildReportQueryString(options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options.page != null) params.append('page', String(options.page));
|
||||
if (options.page_size != null) params.append('page_size', String(options.page_size));
|
||||
|
||||
if (Array.isArray(options.task_types) && options.task_types.length > 0) {
|
||||
params.append('task_types', options.task_types.join(','));
|
||||
}
|
||||
if (Array.isArray(options.statuses) && options.statuses.length > 0) {
|
||||
params.append('statuses', options.statuses.join(','));
|
||||
}
|
||||
|
||||
if (options.time_from) params.append('time_from', options.time_from);
|
||||
if (options.time_to) params.append('time_to', options.time_to);
|
||||
if (options.search) params.append('search', options.search);
|
||||
if (options.sort_by) params.append('sort_by', options.sort_by);
|
||||
if (options.sort_order) params.append('sort_order', options.sort_order);
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
// [/DEF:buildReportQueryString:Function]
|
||||
|
||||
// [DEF:normalizeApiError:Function]
|
||||
// @PURPOSE: Convert unknown API exceptions into deterministic UI-consumable error objects.
|
||||
// @PRE: error may be Error/string/object.
|
||||
// @POST: Returns structured error object.
|
||||
export function normalizeApiError(error) {
|
||||
const message =
|
||||
(error && typeof error.message === 'string' && error.message) ||
|
||||
(typeof error === 'string' && error) ||
|
||||
'Failed to load reports';
|
||||
|
||||
return {
|
||||
message,
|
||||
code: 'REPORTS_API_ERROR',
|
||||
retryable: true
|
||||
};
|
||||
}
|
||||
// [/DEF:normalizeApiError:Function]
|
||||
|
||||
// [DEF:getReports:Function]
|
||||
// @PURPOSE: Fetch unified report list using existing request wrapper.
|
||||
// @PRE: valid auth context for protected endpoint.
|
||||
// @POST: Returns parsed payload or structured error for UI-state mapping.
|
||||
export async function getReports(options = {}) {
|
||||
try {
|
||||
const query = buildReportQueryString(options);
|
||||
return await api.fetchApi(`/reports${query ? `?${query}` : ''}`);
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
}
|
||||
// [/DEF:getReports:Function]
|
||||
|
||||
// [DEF:getReportDetail:Function]
|
||||
// @PURPOSE: Fetch one report detail by report_id.
|
||||
// @PRE: reportId is non-empty string; valid auth context.
|
||||
// @POST: Returns parsed detail payload or structured error object.
|
||||
export async function getReportDetail(reportId) {
|
||||
try {
|
||||
return await api.fetchApi(`/reports/${reportId}`);
|
||||
} catch (error) {
|
||||
throw normalizeApiError(error);
|
||||
}
|
||||
}
|
||||
// [/DEF:getReportDetail:Function]
|
||||
|
||||
// [/DEF:frontend.src.lib.api.reports:Module]
|
||||
@@ -58,6 +58,13 @@
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
label: $t.nav?.reports || "REPORTS",
|
||||
icon: "M4 5h16M4 12h16M4 19h10",
|
||||
path: "/reports",
|
||||
subItems: [{ label: $t.nav?.reports || "Reports", path: "/reports" }],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: $t.nav?.admin || "ADMIN",
|
||||
@@ -118,6 +125,13 @@
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
label: $t.nav?.reports || "REPORTS",
|
||||
icon: "M4 5h16M4 12h16M4 19h10",
|
||||
path: "/reports",
|
||||
subItems: [{ label: $t.nav?.reports || "Reports", path: "/reports" }],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: $t.nav?.admin || "ADMIN",
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
function goToTasksPage() {
|
||||
closeDrawer();
|
||||
window.location.href = "/tasks";
|
||||
}
|
||||
|
||||
// Handle overlay click
|
||||
function handleOverlayClick(event) {
|
||||
if (event.target === event.currentTarget) {
|
||||
@@ -239,23 +244,31 @@
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
|
||||
on:click={handleClose}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs font-semibold rounded-md border border-slate-700 text-slate-300 bg-slate-800/60 hover:bg-slate-800 transition-colors"
|
||||
on:click={goToTasksPage}
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{$t.nav?.tasks || "Tasks"}
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
|
||||
on:click={handleClose}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
@@ -318,4 +331,3 @@
|
||||
|
||||
<!-- [/DEF:TaskDrawer:Component] -->
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// [DEF:__tests__/test_breadcrumbs:Module]
|
||||
// @TIER: STANDARD
|
||||
// @PURPOSE: Contract-focused unit tests for Breadcrumbs.svelte logic and UX annotations
|
||||
// @LAYER: UI
|
||||
// @RELATION: VERIFIES -> frontend/src/lib/components/layout/Breadcrumbs.svelte
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const COMPONENT_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/components/layout/Breadcrumbs.svelte'
|
||||
);
|
||||
|
||||
function getBreadcrumbs(pathname, maxVisible = 3) {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const allItems = [{ label: 'Home', path: '/' }];
|
||||
|
||||
let currentPath = '';
|
||||
segments.forEach((segment, index) => {
|
||||
currentPath += `/${segment}`;
|
||||
const label = formatBreadcrumbLabel(segment);
|
||||
allItems.push({
|
||||
label,
|
||||
path: currentPath,
|
||||
isLast: index === segments.length - 1
|
||||
});
|
||||
});
|
||||
|
||||
if (allItems.length > maxVisible) {
|
||||
const firstItem = allItems[0];
|
||||
const itemsToShow = [];
|
||||
itemsToShow.push(firstItem);
|
||||
itemsToShow.push({ isEllipsis: true });
|
||||
|
||||
const startFromIndex = allItems.length - (maxVisible - 1);
|
||||
for (let i = startFromIndex; i < allItems.length; i++) {
|
||||
itemsToShow.push(allItems[i]);
|
||||
}
|
||||
return itemsToShow;
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
function formatBreadcrumbLabel(segment) {
|
||||
const specialCases = {
|
||||
dashboards: 'Dashboards',
|
||||
datasets: 'Datasets',
|
||||
storage: 'Storage',
|
||||
admin: 'Admin',
|
||||
settings: 'Settings',
|
||||
git: 'Git'
|
||||
};
|
||||
|
||||
if (specialCases[segment]) {
|
||||
return specialCases[segment];
|
||||
}
|
||||
|
||||
return segment
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
describe('Breadcrumbs Component Contract & Logic', () => {
|
||||
it('contains required UX tags and semantic header for STANDARD module', () => {
|
||||
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('@TIER: STANDARD');
|
||||
expect(source).toContain('@UX_STATE: Idle');
|
||||
expect(source).toContain('@UX_FEEDBACK');
|
||||
expect(source).toContain('@UX_RECOVERY');
|
||||
expect(source).toContain('@RELATION: DEPENDS_ON -> page store');
|
||||
});
|
||||
|
||||
it('returns Home for root path (Short-Path UX state)', () => {
|
||||
const result = getBreadcrumbs('/', 3);
|
||||
|
||||
expect(result).toEqual([{ label: 'Home', path: '/' }]);
|
||||
});
|
||||
|
||||
it('maps known segments to expected labels', () => {
|
||||
expect(formatBreadcrumbLabel('dashboards')).toBe('Dashboards');
|
||||
expect(formatBreadcrumbLabel('datasets')).toBe('Datasets');
|
||||
expect(formatBreadcrumbLabel('settings')).toBe('Settings');
|
||||
});
|
||||
|
||||
it('formats unknown kebab-case segment to title case', () => {
|
||||
expect(formatBreadcrumbLabel('data-quality-rules')).toBe('Data Quality Rules');
|
||||
});
|
||||
|
||||
it('truncates long paths with ellipsis and keeps tail segments', () => {
|
||||
const result = getBreadcrumbs('/dashboards/segment-a/segment-b/segment-c', 3);
|
||||
|
||||
expect(result[0]).toEqual({ label: 'Home', path: '/' });
|
||||
expect(result[1]).toEqual({ isEllipsis: true });
|
||||
const lastItem = result[result.length - 1];
|
||||
expect('label' in lastItem && lastItem.label).toBe('Segment C');
|
||||
expect(result.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:__tests__/test_breadcrumbs:Module]
|
||||
63
frontend/src/lib/components/reports/ReportCard.svelte
Normal file
63
frontend/src/lib/components/reports/ReportCard.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<!-- [DEF:ReportCard:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: reports, card, type-profile, accessibility, fallback
|
||||
* @PURPOSE: Render one report with explicit textual type label and profile-driven visual variant.
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/components/reports/reportTypeProfiles.js
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
* @INVARIANT: Unknown task type always uses fallback profile.
|
||||
*
|
||||
* @UX_STATE: Ready -> Card displays summary/status/type.
|
||||
* @UX_RECOVERY: Missing fields are rendered with explicit placeholder text.
|
||||
*/
|
||||
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getReportTypeProfile } from './reportTypeProfiles.js';
|
||||
|
||||
let { report, selected = false } = $props();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const profile = $derived(getReportTypeProfile(report?.task_type));
|
||||
const profileLabel = $derived(typeof profile?.label === 'function' ? profile.label() : profile?.label);
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (status === 'success') return 'bg-green-100 text-green-700';
|
||||
if (status === 'failed') return 'bg-red-100 text-red-700';
|
||||
if (status === 'in_progress') return 'bg-blue-100 text-blue-700';
|
||||
if (status === 'partial') return 'bg-amber-100 text-amber-700';
|
||||
return 'bg-slate-100 text-slate-700';
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return $t.reports?.not_provided || 'Not provided';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return $t.reports?.not_provided || 'Not provided';
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
dispatch('select', { report });
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="w-full rounded-lg border p-3 text-left transition hover:bg-slate-50 {selected ? 'border-blue-400 bg-blue-50' : 'border-slate-200 bg-white'}"
|
||||
on:click={onSelect}
|
||||
aria-label={`Report ${report?.report_id || ''} type ${profileLabel || ($t.reports?.unknown_type || 'Other / Unknown Type')}`}
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<span class="rounded px-2 py-0.5 text-xs font-semibold {profile?.variant || 'bg-slate-100 text-slate-700'}">
|
||||
{profileLabel || ($t.reports?.unknown_type || 'Other / Unknown Type')}
|
||||
</span>
|
||||
<span class="rounded px-2 py-0.5 text-xs font-semibold {getStatusClass(report?.status)}">
|
||||
{report?.status || ($t.reports?.not_provided || 'Not provided')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-slate-800">{report?.summary || ($t.reports?.not_provided || 'Not provided')}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">{formatDate(report?.updated_at)}</p>
|
||||
</button>
|
||||
|
||||
<!-- [/DEF:ReportCard:Component] -->
|
||||
66
frontend/src/lib/components/reports/ReportDetailPanel.svelte
Normal file
66
frontend/src/lib/components/reports/ReportDetailPanel.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<!-- [DEF:ReportDetailPanel:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: reports, detail, diagnostics, next-actions, placeholders
|
||||
* @PURPOSE: Display detailed report context with diagnostics and actionable recovery guidance.
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
* @INVARIANT: Failed/partial reports surface actionable hints when available.
|
||||
*
|
||||
* @UX_STATE: Ready -> Report detail content visible.
|
||||
* @UX_RECOVERY: Failed/partial report shows next actions and placeholder-safe diagnostics.
|
||||
*/
|
||||
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { detail = null } = $props();
|
||||
|
||||
function notProvided(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return $t.reports?.not_provided || 'Not provided';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return $t.reports?.not_provided || 'Not provided';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return $t.reports?.not_provided || 'Not provided';
|
||||
return date.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-slate-700">{$t.reports?.view_details || 'View details'}</h3>
|
||||
|
||||
{#if !detail || !detail.report}
|
||||
<p class="text-sm text-slate-500">{$t.reports?.not_provided || 'Not provided'}</p>
|
||||
{:else}
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><span class="text-slate-500">ID:</span> {notProvided(detail.report.report_id)}</p>
|
||||
<p><span class="text-slate-500">Type:</span> {notProvided(detail.report.task_type)}</p>
|
||||
<p><span class="text-slate-500">Status:</span> {notProvided(detail.report.status)}</p>
|
||||
<p><span class="text-slate-500">Summary:</span> {notProvided(detail.report.summary)}</p>
|
||||
<p><span class="text-slate-500">Updated:</span> {formatDate(detail.report.updated_at)}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">Diagnostics</p>
|
||||
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs text-slate-700">{JSON.stringify(detail.diagnostics || { note: $t.reports?.not_provided || 'Not provided' }, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
{#if (detail.next_actions && detail.next_actions.length > 0) || (detail.report.error_context && detail.report.error_context.next_actions && detail.report.error_context.next_actions.length > 0)}
|
||||
<div class="mt-4">
|
||||
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">Next actions</p>
|
||||
<ul class="list-disc space-y-1 pl-5 text-sm text-slate-700">
|
||||
{#each (detail.next_actions && detail.next_actions.length > 0 ? detail.next_actions : detail.report.error_context.next_actions) as action}
|
||||
<li>{action}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:ReportDetailPanel:Component] -->
|
||||
37
frontend/src/lib/components/reports/ReportsList.svelte
Normal file
37
frontend/src/lib/components/reports/ReportsList.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<!-- [DEF:ReportsList:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: reports, list, card, unified, mixed-types
|
||||
* @PURPOSE: Render unified list of normalized reports with canonical minimum fields.
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
* @INVARIANT: Every rendered row shows task_type label, status, summary, and updated_at.
|
||||
*
|
||||
* @UX_STATE: Ready -> Mixed-type list visible and scannable.
|
||||
* @UX_FEEDBACK: Click on report emits select event.
|
||||
* @UX_RECOVERY: Unknown/missing values rendered with explicit placeholders.
|
||||
*/
|
||||
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ReportCard from './ReportCard.svelte';
|
||||
|
||||
let { reports = [], selectedReportId = null } = $props();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleSelect(event) {
|
||||
dispatch('select', { report: event.detail.report });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each reports as report (report.report_id)}
|
||||
<ReportCard
|
||||
{report}
|
||||
selected={selectedReportId === report.report_id}
|
||||
on:select={handleSelect}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:ReportsList:Component] -->
|
||||
@@ -0,0 +1,90 @@
|
||||
// [DEF:reports.fixtures:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: reports, fixtures, test-data
|
||||
// @PURPOSE: Shared frontend fixtures for unified reports states.
|
||||
// @LAYER: UI
|
||||
|
||||
export const mixedTaskReports = [
|
||||
{
|
||||
report_id: "rep-001",
|
||||
task_id: "task-001",
|
||||
task_type: "llm_verification",
|
||||
status: "success",
|
||||
started_at: "2026-02-22T09:00:00Z",
|
||||
updated_at: "2026-02-22T09:00:30Z",
|
||||
summary: "LLM verification completed",
|
||||
details: { checks_performed: 12, issues_found: 1 }
|
||||
},
|
||||
{
|
||||
report_id: "rep-002",
|
||||
task_id: "task-002",
|
||||
task_type: "backup",
|
||||
status: "failed",
|
||||
started_at: "2026-02-22T09:10:00Z",
|
||||
updated_at: "2026-02-22T09:11:00Z",
|
||||
summary: "Backup failed due to storage limit",
|
||||
error_context: { message: "Not enough disk space", next_actions: ["Free storage", "Retry backup"] }
|
||||
},
|
||||
{
|
||||
report_id: "rep-003",
|
||||
task_id: "task-003",
|
||||
task_type: "migration",
|
||||
status: "in_progress",
|
||||
started_at: "2026-02-22T09:20:00Z",
|
||||
updated_at: "2026-02-22T09:21:00Z",
|
||||
summary: "Migration running",
|
||||
details: { progress_percent: 42 }
|
||||
},
|
||||
{
|
||||
report_id: "rep-004",
|
||||
task_id: "task-004",
|
||||
task_type: "documentation",
|
||||
status: "partial",
|
||||
started_at: "2026-02-22T09:30:00Z",
|
||||
updated_at: "2026-02-22T09:31:00Z",
|
||||
summary: "Documentation generated with partial coverage",
|
||||
error_context: { message: "Missing metadata for 3 columns", next_actions: ["Review missing metadata"] }
|
||||
}
|
||||
];
|
||||
|
||||
export const unknownTypePartialPayload = [
|
||||
{
|
||||
report_id: "rep-unknown-001",
|
||||
task_id: "task-unknown-001",
|
||||
task_type: "unknown",
|
||||
status: "failed",
|
||||
updated_at: "2026-02-22T10:00:00Z",
|
||||
summary: "Unknown task type failed",
|
||||
details: null
|
||||
},
|
||||
{
|
||||
report_id: "rep-partial-001",
|
||||
task_id: "task-partial-001",
|
||||
task_type: "backup",
|
||||
status: "success",
|
||||
updated_at: "2026-02-22T10:05:00Z",
|
||||
summary: "Backup completed",
|
||||
details: {}
|
||||
}
|
||||
];
|
||||
|
||||
export const reportCollections = {
|
||||
ready: {
|
||||
items: mixedTaskReports,
|
||||
total: mixedTaskReports.length,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
applied_filters: { page: 1, page_size: 20, sort_by: "updated_at", sort_order: "desc" }
|
||||
},
|
||||
empty: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
applied_filters: { page: 1, page_size: 20, sort_by: "updated_at", sort_order: "desc" }
|
||||
}
|
||||
};
|
||||
|
||||
// [/DEF:reports.fixtures:Module]
|
||||
@@ -0,0 +1,45 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.report_detail.integration:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: tests, reports, detail, recovery-guidance, integration
|
||||
// @PURPOSE: Validate detail-panel behavior for failed reports and recovery guidance visibility.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/lib/components/reports/ReportDetailPanel.svelte
|
||||
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
|
||||
// @INVARIANT: Failed report detail exposes actionable next actions when available.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mixedTaskReports } from './fixtures/reports.fixtures.js';
|
||||
|
||||
function buildFailedDetailFixture() {
|
||||
const failed = mixedTaskReports.find((item) => item.status === 'failed');
|
||||
return {
|
||||
report: failed,
|
||||
diagnostics: {
|
||||
error_context: failed?.error_context || { message: 'Not provided', next_actions: [] }
|
||||
},
|
||||
next_actions: failed?.error_context?.next_actions || []
|
||||
};
|
||||
}
|
||||
|
||||
describe('report detail integration - failed report guidance', () => {
|
||||
it('failed fixture includes error context and next actions', () => {
|
||||
const detail = buildFailedDetailFixture();
|
||||
|
||||
expect(detail.report).toBeTruthy();
|
||||
expect(detail.report.status).toBe('failed');
|
||||
expect(detail.diagnostics).toBeTruthy();
|
||||
expect(Array.isArray(detail.next_actions)).toBe(true);
|
||||
expect(detail.next_actions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('next actions are human-readable strings for operator recovery', () => {
|
||||
const detail = buildFailedDetailFixture();
|
||||
|
||||
for (const action of detail.next_actions) {
|
||||
expect(typeof action).toBe('string');
|
||||
expect(action.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.report_detail.integration:Module]
|
||||
@@ -0,0 +1,32 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.report_type_profiles:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: tests, reports, type-profiles, fallback
|
||||
// @PURPOSE: Validate report type profile mapping and unknown fallback behavior.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/lib/components/reports/reportTypeProfiles.js
|
||||
// @INVARIANT: Unknown task_type always resolves to the fallback profile.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getReportTypeProfile, REPORT_TYPE_PROFILES } from '../reportTypeProfiles.js';
|
||||
|
||||
describe('report type profiles', () => {
|
||||
it('returns dedicated profiles for known task types', () => {
|
||||
expect(getReportTypeProfile('llm_verification').key).toBe('llm_verification');
|
||||
expect(getReportTypeProfile('backup').key).toBe('backup');
|
||||
expect(getReportTypeProfile('migration').key).toBe('migration');
|
||||
expect(getReportTypeProfile('documentation').key).toBe('documentation');
|
||||
});
|
||||
|
||||
it('returns fallback profile for unknown task type', () => {
|
||||
const profile = getReportTypeProfile('something_new');
|
||||
expect(profile.key).toBe('unknown');
|
||||
expect(profile.fallback).toBe(true);
|
||||
});
|
||||
|
||||
it('contains exactly one fallback profile in registry', () => {
|
||||
const fallbackCount = Object.values(REPORT_TYPE_PROFILES).filter((p) => p.fallback === true).length;
|
||||
expect(fallbackCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.report_type_profiles:Module]
|
||||
@@ -0,0 +1,48 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.reports_filter_performance:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: tests, reports, performance, filtering
|
||||
// @PURPOSE: Guard test for report filter responsiveness on moderate in-memory dataset.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
function applyFilters(items, { taskType = 'all', status = 'all' } = {}) {
|
||||
return items.filter((item) => {
|
||||
const typeMatch = taskType === 'all' || item.task_type === taskType;
|
||||
const statusMatch = status === 'all' || item.status === status;
|
||||
return typeMatch && statusMatch;
|
||||
});
|
||||
}
|
||||
|
||||
function makeDataset(size = 2000) {
|
||||
const taskTypes = ['llm_verification', 'backup', 'migration', 'documentation'];
|
||||
const statuses = ['success', 'failed', 'in_progress', 'partial'];
|
||||
const out = [];
|
||||
for (let i = 0; i < size; i += 1) {
|
||||
out.push({
|
||||
report_id: `r-${i}`,
|
||||
task_id: `t-${i}`,
|
||||
task_type: taskTypes[i % taskTypes.length],
|
||||
status: statuses[i % statuses.length],
|
||||
summary: `Report ${i}`,
|
||||
updated_at: '2026-02-22T10:00:00Z'
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('reports filter performance guard', () => {
|
||||
it('applies task_type+status filter quickly on 2000 records', () => {
|
||||
const dataset = makeDataset(2000);
|
||||
|
||||
const start = Date.now();
|
||||
const result = applyFilters(dataset, { taskType: 'migration', status: 'failed' });
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.reports_filter_performance:Module]
|
||||
@@ -0,0 +1,40 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.reports_page.integration:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: tests, reports, integration, mixed-types, rendering
|
||||
// @PURPOSE: Integration-style checks for unified mixed-type reports rendering expectations.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
|
||||
// @RELATION: TESTS -> frontend/src/lib/components/reports/ReportsList.svelte
|
||||
// @INVARIANT: Mixed fixture includes all supported report types in one list.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mixedTaskReports } from './fixtures/reports.fixtures.js';
|
||||
|
||||
function collectVisibleTypeLabels(items) {
|
||||
return items.map((item) => item.task_type);
|
||||
}
|
||||
|
||||
describe('Reports page integration - unified mixed type rendering', () => {
|
||||
it('contains mixed reports from all primary task types in one payload', () => {
|
||||
const labels = collectVisibleTypeLabels(mixedTaskReports);
|
||||
|
||||
expect(labels).toContain('llm_verification');
|
||||
expect(labels).toContain('backup');
|
||||
expect(labels).toContain('migration');
|
||||
expect(labels).toContain('documentation');
|
||||
expect(mixedTaskReports.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('ensures canonical minimum fields are present for each report item', () => {
|
||||
for (const report of mixedTaskReports) {
|
||||
expect(typeof report.report_id).toBe('string');
|
||||
expect(typeof report.task_id).toBe('string');
|
||||
expect(typeof report.task_type).toBe('string');
|
||||
expect(typeof report.status).toBe('string');
|
||||
expect(typeof report.summary).toBe('string');
|
||||
expect(report.updated_at).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.reports_page.integration:Module]
|
||||
59
frontend/src/lib/components/reports/reportTypeProfiles.js
Normal file
59
frontend/src/lib/components/reports/reportTypeProfiles.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// [DEF:frontend.src.lib.components.reports.reportTypeProfiles:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: reports, ui, profiles, fallback, mapping
|
||||
// @PURPOSE: Deterministic mapping from report task_type to visual profile with one fallback.
|
||||
// @LAYER: UI
|
||||
// @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
// @INVARIANT: Unknown type always resolves to fallback profile.
|
||||
|
||||
import { _ } from '$lib/i18n';
|
||||
|
||||
export const REPORT_TYPE_PROFILES = {
|
||||
llm_verification: {
|
||||
key: 'llm_verification',
|
||||
label: 'LLM',
|
||||
variant: 'bg-violet-100 text-violet-700',
|
||||
icon: 'sparkles',
|
||||
fallback: false
|
||||
},
|
||||
backup: {
|
||||
key: 'backup',
|
||||
label: () => _('nav.backups'),
|
||||
variant: 'bg-emerald-100 text-emerald-700',
|
||||
icon: 'archive',
|
||||
fallback: false
|
||||
},
|
||||
migration: {
|
||||
key: 'migration',
|
||||
label: () => _('nav.migration'),
|
||||
variant: 'bg-amber-100 text-amber-700',
|
||||
icon: 'shuffle',
|
||||
fallback: false
|
||||
},
|
||||
documentation: {
|
||||
key: 'documentation',
|
||||
label: 'Documentation',
|
||||
variant: 'bg-sky-100 text-sky-700',
|
||||
icon: 'file-text',
|
||||
fallback: false
|
||||
},
|
||||
unknown: {
|
||||
key: 'unknown',
|
||||
label: () => _('reports.unknown_type'),
|
||||
variant: 'bg-slate-100 text-slate-700',
|
||||
icon: 'help-circle',
|
||||
fallback: true
|
||||
}
|
||||
};
|
||||
|
||||
// [DEF:getReportTypeProfile:Function]
|
||||
// @PURPOSE: Resolve visual profile by task type with guaranteed fallback.
|
||||
// @PRE: taskType may be known/unknown/empty.
|
||||
// @POST: Returns one profile object.
|
||||
export function getReportTypeProfile(taskType) {
|
||||
const key = typeof taskType === 'string' ? taskType : 'unknown';
|
||||
return REPORT_TYPE_PROFILES[key] || REPORT_TYPE_PROFILES.unknown;
|
||||
}
|
||||
// [/DEF:getReportTypeProfile:Function]
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.reportTypeProfiles:Module]
|
||||
@@ -25,6 +25,7 @@
|
||||
"migration": "Migration",
|
||||
"git": "Git",
|
||||
"tasks": "Tasks",
|
||||
"reports": "Reports",
|
||||
"settings": "Settings",
|
||||
"tools": "Tools",
|
||||
"tools_search": "Dataset Search",
|
||||
@@ -179,6 +180,14 @@
|
||||
"view_task": "View task",
|
||||
"empty": "No dashboards found"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Reports",
|
||||
"empty": "No reports available.",
|
||||
"filtered_empty": "No reports match your filters.",
|
||||
"unknown_type": "Other / Unknown Type",
|
||||
"not_provided": "Not provided",
|
||||
"view_details": "View details"
|
||||
},
|
||||
"datasets": {
|
||||
"empty": "No datasets found",
|
||||
"table_name": "Table Name",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"migration": "Миграция",
|
||||
"git": "Git",
|
||||
"tasks": "Задачи",
|
||||
"reports": "Отчеты",
|
||||
"settings": "Настройки",
|
||||
"tools": "Инструменты",
|
||||
"tools_search": "Поиск датасетов",
|
||||
@@ -178,6 +179,14 @@
|
||||
"view_task": "Просмотреть задачу",
|
||||
"empty": "Дашборды не найдены"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Отчеты",
|
||||
"empty": "Отчеты отсутствуют.",
|
||||
"filtered_empty": "Нет отчетов по выбранным фильтрам.",
|
||||
"unknown_type": "Прочее / Неизвестный тип",
|
||||
"not_provided": "Не указано",
|
||||
"view_details": "Подробнее"
|
||||
},
|
||||
"datasets": {
|
||||
"empty": "Датасеты не найдены",
|
||||
"table_name": "Имя таблицы",
|
||||
|
||||
@@ -14,6 +14,15 @@ vi.mock('$app/environment', () => ({
|
||||
}));
|
||||
|
||||
describe('SidebarStore', () => {
|
||||
beforeEach(() => {
|
||||
sidebarStore.set({
|
||||
isExpanded: true,
|
||||
activeCategory: 'dashboards',
|
||||
activeItem: '/dashboards',
|
||||
isMobileOpen: false
|
||||
});
|
||||
});
|
||||
|
||||
// [DEF:test_sidebar_initial_state:Function]
|
||||
// @TEST: Store initializes with default values
|
||||
// @PRE: No localStorage state
|
||||
|
||||
194
frontend/src/routes/reports/+page.svelte
Normal file
194
frontend/src/routes/reports/+page.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<!-- [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.
|
||||
*/
|
||||
|
||||
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 || 'All types' },
|
||||
{ value: 'llm_verification', label: 'LLM' },
|
||||
{ value: 'backup', label: $t.nav?.backups || 'Backups' },
|
||||
{ value: 'migration', label: $t.nav?.migration || 'Migration' },
|
||||
{ value: 'documentation', label: 'Documentation' }
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: $t.reports?.all_statuses || '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="container mx-auto max-w-6xl p-4">
|
||||
<PageHeader
|
||||
title={$t.reports?.title || 'Reports'}
|
||||
subtitle={() => null}
|
||||
actions={() => null}
|
||||
/>
|
||||
|
||||
<div class="mb-4 rounded-lg border border-slate-200 bg-white p-3">
|
||||
<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="rounded-md border border-slate-200 px-3 py-1.5 text-sm hover:bg-slate-50"
|
||||
on:click={() => loadReports()}
|
||||
>
|
||||
{$t.common?.refresh || 'Refresh'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded-md border border-slate-200 px-3 py-1.5 text-sm hover:bg-slate-50"
|
||||
on:click={clearFilters}
|
||||
>
|
||||
{$t.reports?.clear_filters || 'Clear filters'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-6 text-slate-500">
|
||||
{$t.common?.loading || 'Loading...'}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700">
|
||||
<p>{error}</p>
|
||||
<button class="mt-2 rounded border border-red-300 px-3 py-1 text-sm" on:click={() => loadReports()}>
|
||||
{$t.common?.retry || 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
{:else if !collection || collection.total === 0}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-6 text-slate-500">
|
||||
{$t.reports?.empty || 'No reports available.'}
|
||||
</div>
|
||||
{:else if collection.items.length === 0 && hasActiveFilters()}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-6 text-slate-500">
|
||||
<p>{$t.reports?.filtered_empty || 'No reports match your filters.'}</p>
|
||||
<button class="mt-2 rounded border border-slate-200 px-3 py-1 text-sm hover:bg-slate-50" on:click={clearFilters}>
|
||||
{$t.reports?.clear_filters || '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] -->
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getTasks } from '../../lib/api';
|
||||
import { getTasks, getTask } from '../../lib/api';
|
||||
import TaskList from '../../components/TaskList.svelte';
|
||||
import TaskLogViewer from '../../components/TaskLogViewer.svelte';
|
||||
import TaskResultPanel from '../../components/tasks/TaskResultPanel.svelte';
|
||||
@@ -17,10 +17,14 @@
|
||||
|
||||
let tasks = [];
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let selectedTaskId = null;
|
||||
let selectedTask = null;
|
||||
let pollInterval;
|
||||
let taskTypeFilter = 'all';
|
||||
let pageSize = 20;
|
||||
let currentPage = 1;
|
||||
let hasNextPage = false;
|
||||
|
||||
const TASK_TYPE_OPTIONS = [
|
||||
{ value: 'all', label: 'Все типы' },
|
||||
@@ -29,33 +33,49 @@
|
||||
{ value: 'migration', label: 'Миграции' }
|
||||
];
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50];
|
||||
|
||||
// [DEF:loadInitialData:Function]
|
||||
/**
|
||||
* @purpose Loads tasks and environments on page initialization.
|
||||
* @pre API must be reachable.
|
||||
* @post tasks and environments variables are populated.
|
||||
*/
|
||||
async function loadInitialData() {
|
||||
console.log("[loadInitialData][Action] Loading completed tasks");
|
||||
async function loadTasks({ silent = false } = {}) {
|
||||
try {
|
||||
loading = true;
|
||||
if (!silent) loading = true;
|
||||
error = '';
|
||||
const tasksData = await getTasks({
|
||||
limit: 100,
|
||||
limit: pageSize + 1,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
completed_only: true,
|
||||
task_type: taskTypeFilter === 'all' ? undefined : taskTypeFilter
|
||||
});
|
||||
tasks = tasksData;
|
||||
console.log(`[loadInitialData][Coherence:OK] Data loaded context={{'tasks': ${tasks.length}}}`);
|
||||
if (selectedTaskId && !tasks.some((task) => task.id === selectedTaskId)) {
|
||||
selectedTaskId = null;
|
||||
|
||||
if (Array.isArray(tasksData)) {
|
||||
hasNextPage = tasksData.length > pageSize;
|
||||
tasks = hasNextPage ? tasksData.slice(0, pageSize) : tasksData;
|
||||
} else {
|
||||
tasks = [];
|
||||
hasNextPage = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[loadInitialData][Coherence:Failed] Failed to load tasks data context={{'error': '${error.message}'}}`);
|
||||
|
||||
if (selectedTaskId) {
|
||||
const taskFromPage = tasks.find((task) => task.id === selectedTaskId);
|
||||
if (taskFromPage) {
|
||||
selectedTask = taskFromPage;
|
||||
} else {
|
||||
selectedTask = await getTask(selectedTaskId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[loadTasks][Coherence:Failed] Failed to load tasks data context={{'error': '${err.message}'}}`);
|
||||
error = err.message || 'Не удалось загрузить задачи';
|
||||
} finally {
|
||||
loading = false;
|
||||
if (!silent) loading = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:loadInitialData:Function]
|
||||
// [/DEF:loadTasks:Function]
|
||||
|
||||
// [DEF:refreshTasks:Function]
|
||||
/**
|
||||
@@ -64,19 +84,7 @@
|
||||
* @post tasks variable is updated if data is valid.
|
||||
*/
|
||||
async function refreshTasks() {
|
||||
try {
|
||||
const data = await getTasks({
|
||||
limit: 100,
|
||||
completed_only: true,
|
||||
task_type: taskTypeFilter === 'all' ? undefined : taskTypeFilter
|
||||
});
|
||||
// Ensure we don't try to parse HTML as JSON if the route returns 404
|
||||
if (Array.isArray(data)) {
|
||||
tasks = data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[refreshTasks][Coherence:Failed] Failed to refresh tasks context={{'error': '${error.message}'}}`);
|
||||
}
|
||||
await loadTasks({ silent: true });
|
||||
}
|
||||
// [/DEF:refreshTasks:Function]
|
||||
|
||||
@@ -88,12 +96,13 @@
|
||||
*/
|
||||
function handleSelectTask(event) {
|
||||
selectedTaskId = event.detail.id;
|
||||
selectedTask = event.detail.task || null;
|
||||
console.log(`[handleSelectTask][Action] Task selected context={{'taskId': '${selectedTaskId}'}}`);
|
||||
}
|
||||
// [/DEF:handleSelectTask:Function]
|
||||
|
||||
onMount(() => {
|
||||
loadInitialData();
|
||||
loadTasks();
|
||||
pollInterval = setInterval(refreshTasks, 3000);
|
||||
});
|
||||
|
||||
@@ -102,43 +111,107 @@
|
||||
});
|
||||
|
||||
function handleTaskTypeChange() {
|
||||
currentPage = 1;
|
||||
selectedTaskId = null;
|
||||
loadInitialData();
|
||||
selectedTask = null;
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
$: selectedTask = tasks.find((task) => task.id === selectedTaskId) || null;
|
||||
function handlePageSizeChange() {
|
||||
currentPage = 1;
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
function goToPrevPage() {
|
||||
if (currentPage === 1) return;
|
||||
currentPage -= 1;
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
function goToNextPage() {
|
||||
if (!hasNextPage) return;
|
||||
currentPage += 1;
|
||||
loadTasks();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-4 max-w-6xl">
|
||||
<PageHeader title={$t.tasks.management} />
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
||||
<div class="lg:col-span-1">
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<h2 class="text-lg font-semibold text-gray-700">Результаты задач</h2>
|
||||
<select
|
||||
bind:value={taskTypeFilter}
|
||||
on:change={handleTaskTypeChange}
|
||||
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-700 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{#each TASK_TYPE_OPTIONS as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-3 lg:sticky lg:top-20">
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<h2 class="text-lg font-semibold text-gray-700">Результаты задач</h2>
|
||||
<button
|
||||
on:click={() => loadTasks()}
|
||||
class="rounded-md border border-slate-200 px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
{$t.tasks?.refresh || 'Обновить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 grid grid-cols-2 gap-2">
|
||||
<select
|
||||
bind:value={taskTypeFilter}
|
||||
on:change={handleTaskTypeChange}
|
||||
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-700 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{#each TASK_TYPE_OPTIONS as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select
|
||||
bind:value={pageSize}
|
||||
on:change={handlePageSizeChange}
|
||||
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-700 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{#each PAGE_SIZE_OPTIONS as size}
|
||||
<option value={size}>{size} / стр</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="max-h-[58vh] overflow-y-auto rounded-md border border-slate-100">
|
||||
<TaskList tasks={tasks} {loading} {selectedTaskId} on:select={handleSelectTask} />
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-2 text-sm">
|
||||
<button
|
||||
on:click={goToPrevPage}
|
||||
disabled={currentPage === 1}
|
||||
class="rounded-md border border-slate-200 px-3 py-1.5 text-slate-700 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-slate-50"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span class="text-slate-500">Страница {currentPage}</span>
|
||||
<button
|
||||
on:click={goToNextPage}
|
||||
disabled={!hasNextPage}
|
||||
class="rounded-md border border-slate-200 px-3 py-1.5 text-slate-700 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-slate-50"
|
||||
>
|
||||
Вперед
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TaskList {tasks} {loading} on:select={handleSelectTask} />
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="text-lg font-semibold mb-3 text-gray-700">Результат и логи</h2>
|
||||
{#if selectedTaskId}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4 min-h-[60vh]">
|
||||
<TaskResultPanel task={selectedTask} />
|
||||
<div class="rounded-lg border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700">
|
||||
Логи задачи
|
||||
</div>
|
||||
<div class="h-[420px] flex flex-col overflow-hidden rounded-b-lg">
|
||||
<div class="h-[56vh] min-h-[360px] flex flex-col overflow-hidden rounded-b-lg">
|
||||
<TaskLogViewer
|
||||
taskId={selectedTaskId}
|
||||
taskStatus={selectedTask?.status}
|
||||
@@ -148,7 +221,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-gray-50 border-2 border-dashed border-gray-100 rounded-lg h-[600px] flex items-center justify-center text-gray-400">
|
||||
<div class="bg-gray-50 border-2 border-dashed border-gray-100 rounded-lg h-[60vh] min-h-[380px] flex items-center justify-center text-gray-400">
|
||||
<p>Выберите задачу из списка слева</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user