таски готовы

This commit is contained in:
2026-02-23 10:18:56 +03:00
parent f0c85e4c03
commit 008b6d72c9
48 changed files with 3559 additions and 72 deletions

View File

@@ -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] -->

View 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]

View File

@@ -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",

View File

@@ -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] -->

View File

@@ -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]

View 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] -->

View 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] -->

View 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] -->

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View 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]

View File

@@ -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",

View File

@@ -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": "Имя таблицы",

View File

@@ -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

View 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] -->

View File

@@ -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}