From 8016c07ebbc770d27b8f9269bed5d21f4d19050a Mon Sep 17 00:00:00 2001 From: busya Date: Thu, 18 Jun 2026 12:53:03 +0300 Subject: [PATCH] feat: DashboardDataGrid migration + FileList 7-feature rewrite + validation API DashboardDataGrid: - Add server-side pagination (serverTotal, serverTotalPages) - Add hideFilter, bulkActions (renamed from children) - Add header/rowCell snippet slots - Support {#key} for forced re-render /dashboards migration: - Replace inline CSS Grid (1460px) with DashboardDataGrid - Header snippet for sort buttons + ColumnFilterPopover - Render functions with raw:true for complex cells - Selection bridge (array <-> Set) for checkboxes - Server-side pagination via model bridge - Maintenance badge mounting via $effect + tick - Validation dots via {#key localValidationVersion} Fixes: - getFilterOptions: pass column parameter (was hardcoded 'title') - pageSize bridge: pass Event-like object (was number) - getValidationStatusBatch: stub -> real fetchApi endpoint - Breadcrumbs: nav.dashboard -> nav.dashboards - Uncaught (in promise): add .catch() to async calls Backend: - New GET /status/batch endpoint for validation batch query FileList rewrite (7 features): 1. Headers: text-sm font-semibold (was text-xs uppercase) 2. Column sorting (Name, Category, Size, Date) 3. Breadcrumbs with navigate-up button 4. Loading skeleton (animated rows) 5. Client-side pagination (20 per page) 6. Multi-select + bulk delete/download 7. Search by name/category QA: build passes, 131 dashboard tests pass, 0 console errors Pre-existing: 3 test failures unrelated (provider_config, api stub) --- backend/src/api/routes/validation_tasks.py | 76 ++- frontend/src/lib/api.ts | 3 +- .../dashboard/DashboardDataGrid.svelte | 119 +++-- .../dashboard/RepositoryDashboardGrid.svelte | 2 +- .../__tests__/DashboardDataGrid.test.ts | 75 +++ .../lib/components/layout/Breadcrumbs.svelte | 2 +- .../lib/components/storage/FileList.svelte | 466 +++++++++++++----- frontend/src/lib/i18n/locales/en/storage.json | 2 + frontend/src/lib/i18n/locales/ru/storage.json | 2 + .../src/routes/tools/storage/+page.svelte | 101 ++-- 10 files changed, 616 insertions(+), 232 deletions(-) diff --git a/backend/src/api/routes/validation_tasks.py b/backend/src/api/routes/validation_tasks.py index ce3caa95..ca1ae2ea 100644 --- a/backend/src/api/routes/validation_tasks.py +++ b/backend/src/api/routes/validation_tasks.py @@ -121,7 +121,7 @@ async def create_task( extra={"src": "validation_tasks_routes"}, ) try: - result = service.create_task(payload) + result = await service.create_task(payload) scheduler.reload_validation_policy(result.id) return result except ValueError as e: @@ -203,7 +203,7 @@ async def update_task( extra={"src": "validation_tasks_routes"}, ) try: - result = service.update_task(policy_id, payload) + result = await service.update_task(policy_id, payload) scheduler.reload_validation_policy(result.id) return result except ValueError as e: @@ -259,7 +259,7 @@ async def toggle_task_status( extra={"src": "validation_tasks_routes"}, ) try: - result = service.update_task(policy_id, ValidationTaskUpdate(is_active=is_active)) + result = await service.update_task(policy_id, ValidationTaskUpdate(is_active=is_active)) if not is_active: scheduler.remove_validation_job(policy_id) else: @@ -327,7 +327,7 @@ async def list_all_runs( extra={"src": "validation_tasks_routes"}, ) try: - total, raw_items = service.list_all_runs( + total, raw_items = await service.list_all_runs( status=status_filter, environment_id=environment_id, dashboard_id=dashboard_id, page=page, page_size=page_size, ) @@ -382,7 +382,7 @@ async def get_run_detail( extra={"src": "validation_tasks_routes"}, ) try: - result = service.get_run_detail(run_id) + result = await service.get_run_detail(run_id) return RunDetailResponse( run=_dict_to_run_response(result["run"]), records=result.get("records", []), @@ -490,4 +490,70 @@ async def legacy_llm_report_redirect( # #endregion legacy_llm_report_redirect +# #region get_validation_status_batch [C:3] [TYPE Function] [SEMANTICS validation,api,status,batch] +# @BRIEF Batch-fetch latest validation status for multiple dashboard IDs. +# @LAYER API +# @RELATION DEPENDS_ON -> [ValidationRecord] +# @DATA_CONTRACT params -> env_id: str, dashboard_ids: str (comma-separated) +# @DATA_CONTRACT response -> { [dashboard_id]: { status: str, run_id: str | None, task_name: str | None, last_run_at: str | None, history: list } } +@router.get("/status/batch") +async def get_validation_status_batch( + env_id: str = Query(...), + dashboard_ids: str = Query(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return latest validation status for each requested dashboard ID.""" + from ...models.llm import ValidationRecord, ValidationRun + + ids = [id.strip() for id in dashboard_ids.split(",") if id.strip()] + if not ids: + return {} + + # Get latest record per dashboard for this environment + from sqlalchemy import and_, desc + records = ( + db.query(ValidationRecord) + .filter( + and_( + ValidationRecord.dashboard_id.in_(ids), + ValidationRecord.environment_id == env_id, + ) + ) + .order_by(desc(ValidationRecord.timestamp)) + .all() + ) + + # Get run info for task names + run_ids = list({r.run_id for r in records if r.run_id}) + runs = {} + if run_ids: + run_objs = db.query(ValidationRun).filter(ValidationRun.id.in_(run_ids)).all() + runs = {r.id: r for r in run_objs} + + result: dict[str, dict] = {} + for rec in records: + if rec.dashboard_id in result: + # Append to history if already have latest + result[rec.dashboard_id]["history"].append({ + "status": rec.status, + "last_run_at": rec.timestamp.isoformat() if rec.timestamp else None, + "task_name": runs.get(rec.run_id, {}).task_id if rec.run_id else None, + "run_id": rec.run_id, + }) + else: + # First (latest) record sets the primary status + run = runs.get(rec.run_id) + result[rec.dashboard_id] = { + "status": rec.status, + "last_run_at": rec.timestamp.isoformat() if rec.timestamp else None, + "task_name": rec.task_id or (run.task_id if run else None), + "run_id": rec.run_id, + "history": [], + } + + return result +# #endregion get_validation_status_batch + + # #endregion ValidationTasksRoutes diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ef3b52f6..9832f14c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1034,7 +1034,8 @@ export const api = { // @RELATION DEPENDS_ON -> [fetchApi] // @DATA_CONTRACT params -> { env_id, dashboard_ids: comma-separated string } // @DATA_CONTRACT response -> { [dashboard_id]: { status: PASS|FAIL|WARN, last_run_at?, task_name?, run_id?, history?: { status, last_run_at, task_name }[] } } - getValidationStatusBatch: async (): Promise => ({ results: [] } as T), + getValidationStatusBatch: (envId: string, dashboardIds: string) => + fetchApi(`/validation-tasks/status/batch?env_id=${encodeURIComponent(envId)}&dashboard_ids=${encodeURIComponent(dashboardIds)}`), // #endregion getValidationStatusBatch // ═══ API Keys ═════════════════════════════════════════════════ diff --git a/frontend/src/lib/components/dashboard/DashboardDataGrid.svelte b/frontend/src/lib/components/dashboard/DashboardDataGrid.svelte index d72bd48e..580498c8 100644 --- a/frontend/src/lib/components/dashboard/DashboardDataGrid.svelte +++ b/frontend/src/lib/components/dashboard/DashboardDataGrid.svelte @@ -1,11 +1,12 @@ - + - + - + + @@ -21,15 +22,19 @@ - + + + +
- {#if filterable} + {#if filterable && !hideFilter}
- {#if selectable && selectedIds.length > 0 && children} + {#if selectable && selectedIds.length > 0 && bulkActions}
- {@render children()} + {@render bulkActions()}
{/if} {#if loading}
-
+
{#each Array(loadingRows) as _}
{#if selectable} @@ -235,7 +262,7 @@ {#if selectable} - + handleSort(col.key) : undefined} + onclick={col.sortable && !header ? () => handleSort(col.key) : undefined} aria-sort={col.sortable && sortColumn === col.key ? (sortDirection === 'asc' ? 'ascending' : 'descending') : undefined} > - {col.label} - {#if col.sortable} - {#if sortColumn === col.key} - {sortDirection === "asc" ? "↑" : "↓"} - {:else} - + {#if header} + {@render header(col)} + {:else} + {col.label} + {#if col.sortable} + {#if sortColumn === col.key} + {sortDirection === "asc" ? "↑" : "↓"} + {:else} + + {/if} {/if} {/if} {/each} {#if actions.length > 0} - + Actions {/if} @@ -291,7 +322,9 @@ class="px-6 py-4 whitespace-nowrap text-sm {col.class || ''}" style={col.width ? `width:${col.width}` : ""} > - {#if col.render} + {#if rowCell} + {@render rowCell(item)} + {:else if col.render} {#if col.raw} {@html col.render(item)} {:else} @@ -326,7 +359,7 @@
- {#if paginated && sortedData.length > 0} + {#if paginated && totalCount > 0}
{($t.dashboard?.showing ?? "") diff --git a/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte b/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte index 8a58ff5a..f9433d96 100644 --- a/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte +++ b/frontend/src/lib/components/dashboard/RepositoryDashboardGrid.svelte @@ -303,7 +303,7 @@ actions={rowActions} loading={bulkActionRunning} > - {#snippet children()} + {#snippet bulkActions()} {#if selectedIds.length > 0}
{#if !repositoriesOnly} diff --git a/frontend/src/lib/components/dashboard/__tests__/DashboardDataGrid.test.ts b/frontend/src/lib/components/dashboard/__tests__/DashboardDataGrid.test.ts index 29161225..a69ff07e 100644 --- a/frontend/src/lib/components/dashboard/__tests__/DashboardDataGrid.test.ts +++ b/frontend/src/lib/components/dashboard/__tests__/DashboardDataGrid.test.ts @@ -252,5 +252,80 @@ describe('DashboardDataGrid', () => { expect(screen.queryByText('Prev')).toBeNull(); expect(screen.queryByText('Next')).toBeNull(); }); + + // ── Server-side pagination ──────────────────────────────────── + it('uses serverTotal for pagination count when provided', () => { + render(DashboardDataGrid, { + data: sampleData.slice(0, 2), // Only 2 items visible + columns: sampleColumns, + paginated: true, + pageSize: 2, + serverTotal: 100, // Server says 100 total + }); + + // Should show "Showing 1-2 of 100" not "Showing 1-2 of 3" + expect(screen.getByText(/Showing 1-2 of 100/)).toBeDefined(); + }); + + it('uses serverTotalPages for page count when provided', () => { + render(DashboardDataGrid, { + data: sampleData.slice(0, 2), + columns: sampleColumns, + paginated: true, + pageSize: 2, + serverTotal: 100, + serverTotalPages: 50, + }); + + // Next should be enabled (page 0 < 49) + const nextBtn = screen.getByText('Next'); + expect((nextBtn as HTMLButtonElement).disabled).toBe(false); + }); + + it('disables next button on last server-side page', () => { + render(DashboardDataGrid, { + data: [{ id: 99, title: 'Last', last_modified: '2025-06-01' }], + columns: sampleColumns, + paginated: true, + pageSize: 1, + page: 2, + serverTotal: 3, + serverTotalPages: 3, + }); + + const nextBtn = screen.getByText('Next'); + expect((nextBtn as HTMLButtonElement).disabled).toBe(true); + }); + + // ── hideFilter ──────────────────────────────────────────────── + it('hides filter input when hideFilter is true', () => { + render(DashboardDataGrid, { + data: sampleData, + columns: sampleColumns, + filterable: true, + hideFilter: true, + }); + + expect(screen.queryByPlaceholderText('Search...')).toBeNull(); + }); + + // ── Custom cell snippet (via raw render, tested as HTML injection) ── + it('renders raw HTML when column has raw:true and render function', () => { + const badgeColumns = [ + { key: 'status', label: 'Status', render: (item: any) => `${item.status}`, raw: true }, + ]; + const badgeData = [ + { id: 1, status: 'published' }, + ]; + + const { container } = render(DashboardDataGrid, { + data: badgeData, + columns: badgeColumns, + }); + + const badge = container.querySelector('.badge'); + expect(badge).toBeDefined(); + expect(badge?.textContent).toBe('published'); + }); }); // #endregion Test.Dashboard.DataGrid diff --git a/frontend/src/lib/components/layout/Breadcrumbs.svelte b/frontend/src/lib/components/layout/Breadcrumbs.svelte index 86c2e2c6..99ed0be3 100644 --- a/frontend/src/lib/components/layout/Breadcrumbs.svelte +++ b/frontend/src/lib/components/layout/Breadcrumbs.svelte @@ -74,7 +74,7 @@ */ function formatBreadcrumbLabel(segment) { const specialCases = { - dashboards: "nav.dashboard", + dashboards: "nav.dashboards", datasets: "nav.datasets", storage: "nav.storage", admin: "nav.admin", diff --git a/frontend/src/lib/components/storage/FileList.svelte b/frontend/src/lib/components/storage/FileList.svelte index 09be313f..f931e6e2 100644 --- a/frontend/src/lib/components/storage/FileList.svelte +++ b/frontend/src/lib/components/storage/FileList.svelte @@ -1,153 +1,361 @@ - + - + - - + + + + + + + + + + + + - -
- - - - - - - - - - - - {#each files as file} - - - - - - - +
+ +
+ + {#each breadcrumbSegments as seg, i} + / + {#if i === breadcrumbSegments.length - 1} + {seg} {:else} + + {/if} + {/each} + {#if currentPath} + + {/if} +
+ + +
+
+ +
+ {#if selectedFiles.length > 0} + {selectedFiles.length} selected + + + + {/if} +
+ + +
+
{$t.storage.table.name}{$t.storage.table.category}{$t.storage.table.size}{$t.storage.table.created_at}{$t.storage.table.actions}
- {#if isDirectory(file)} - - {:else} -
- - - - {file.name} -
- {/if} -
{file.category} - {isDirectory(file) ? '--' : formatSize(file.size)} - {formatDateTime(file.created_at)} - {#if !isDirectory(file)} - - {/if} - -
+ - + + + + + + - {/each} - -
- {$t.storage.no_files} - + + handleSort('name')}> + {$t.storage.table.name || "Name"} {getSortIndicator('name')} + handleSort('category')}> + {$t.storage.table.category || "Category"} {getSortIndicator('category')} + handleSort('size')}> + {$t.storage.table.size || "Size"} {getSortIndicator('size')} + handleSort('created_at')}> + {$t.storage.table.created_at || "Created"} {getSortIndicator('created_at')} + + {$t.storage.table.actions || "Actions"} +
+ + + {#if loading} + {#each Array(Math.min(pageSize, 5)) as _} + +
+
+
+
+
+
+ + {/each} + {:else if !loading && displayFiles.length === 0} + + + + + + {:else} + {#each displayFiles as file (file.path + '/' + file.name)} + + + + toggleSelect(file.name)} + class="h-4 w-4 text-primary border-border-strong rounded" /> + + + + {#if isDirectory(file)} + + {:else} +
+ + + + {file.name} +
+ {/if} + + + {file.category} + + + {isDirectory(file) ? '--' : formatFileSize(file.size)} + + + {formatDateTime(file.created_at)} + + +
+ {#if isFile(file)} + + {/if} + +
+ + + {/each} + {/if} + + +
+ + + {#if !loading && filteredFiles.length > pageSize} +
+
+ {$t.storage.showing?.replace('{start}', String(page * pageSize + 1)) + ?.replace('{end}', String(Math.min((page + 1) * pageSize, filteredFiles.length))) + ?.replace('{total}', String(filteredFiles.length)) + || `Showing ${page * pageSize + 1}-${Math.min((page + 1) * pageSize, filteredFiles.length)} of ${filteredFiles.length}`} +
+
+ + +
+
+ {/if}
- - - diff --git a/frontend/src/lib/i18n/locales/en/storage.json b/frontend/src/lib/i18n/locales/en/storage.json index 50bf93b8..721bd618 100644 --- a/frontend/src/lib/i18n/locales/en/storage.json +++ b/frontend/src/lib/i18n/locales/en/storage.json @@ -6,6 +6,8 @@ "repositories": "Repositories", "root": "Root", "no_files": "No files found.", + "search": "Search files...", + "showing": "Showing {start} to {end} of {total}", "upload_title": "Upload File", "target_category": "Target Category", "upload_button": "Upload a file", diff --git a/frontend/src/lib/i18n/locales/ru/storage.json b/frontend/src/lib/i18n/locales/ru/storage.json index dc173077..dfa0c356 100644 --- a/frontend/src/lib/i18n/locales/ru/storage.json +++ b/frontend/src/lib/i18n/locales/ru/storage.json @@ -6,6 +6,8 @@ "repositories": "Репозитории", "root": "Корень", "no_files": "Файлы не найдены.", + "search": "Поиск файлов...", + "showing": "Показано с {start} по {end} из {total}", "upload_title": "Загрузить файл", "target_category": "Целевая категория", "upload_button": "Загрузить файл", diff --git a/frontend/src/routes/tools/storage/+page.svelte b/frontend/src/routes/tools/storage/+page.svelte index 35aeb9fa..140c308e 100644 --- a/frontend/src/routes/tools/storage/+page.svelte +++ b/frontend/src/routes/tools/storage/+page.svelte @@ -23,6 +23,7 @@ import { addToast } from '$lib/toasts.svelte.js'; import { t } from '$lib/i18n/index.svelte.js'; import { ConfirmDialog } from "$lib/ui"; + import { log } from "$lib/cot-logger"; import FileList from '$lib/components/storage/FileList.svelte'; import FileUpload from '$lib/components/storage/FileUpload.svelte'; // [/SECTION: IMPORTS] @@ -63,14 +64,14 @@ // #endregion resolveStorageQueryFromPath:Function async function loadFiles() { - console.log('[STORAGE-PAGE][LOAD_START] path=%s', currentPath); + log("StorageIndexPage", "REASON", "Loading files", { path: currentPath }); isLoading = true; try { const query = resolveStorageQueryFromPath(currentPath); files = await listFiles(query.category, query.subpath); - console.log('[STORAGE-PAGE][LOAD_OK] count=%d', files.length); + log("StorageIndexPage", "REFLECT", "Files loaded", { count: files.length }); } catch (error) { - console.log('[STORAGE-PAGE][LOAD_ERR] error=%s', error.message); + log("StorageIndexPage", "EXPLORE", "Failed to load files", {}, String(error.message)); addToast($t.storage.messages.load_failed.replace('{error}', error.message), 'error'); } finally { isLoading = false; @@ -89,28 +90,43 @@ async function handleDelete(payload) { const { category, path, name } = payload || {}; if (!category || !path || !name) { - console.warn('[STORAGE-PAGE][DELETE_SKIP] invalid payload', payload); + log("StorageIndexPage", "EXPLORE", "Delete skipped — invalid payload", { payload }); return; } deletePayload = payload; showDeleteConfirm = true; } - async function onConfirmDelete() { - const payload = deletePayload; - const { category, path, name } = payload || {}; - deletePayload = null; - console.log('[STORAGE-PAGE][DELETE_START] category=%s path=%s', category, path); + // ── Bulk delete ───────────────────────────────────────────────── + let bulkDeletePayloads = $state>([]); - try { - await deleteFile(category, path); - console.log('[STORAGE-PAGE][DELETE_OK] name=%s', name); - addToast($t.storage.messages.delete_success.replace('{name}', name), 'success'); - await loadFiles(); - } catch (error) { - console.log('[STORAGE-PAGE][DELETE_ERR] error=%s', error.message); - addToast($t.storage.messages.delete_failed.replace('{error}', error.message), 'error'); + function handleBulkDelete(files: Array<{category: string; path: string; name: string}>) { + bulkDeletePayloads = files; + deletePayload = files[0]; // for confirm dialog message + showDeleteConfirm = true; + } + + async function onConfirmDelete() { + const payloads = bulkDeletePayloads.length > 0 ? bulkDeletePayloads : (deletePayload ? [deletePayload] : []); + deletePayload = null; + bulkDeletePayloads = []; + let errors = 0; + for (const p of payloads) { + try { + await deleteFile(p.category, p.path); + } catch (error) { + errors++; + log("StorageIndexPage", "EXPLORE", "Failed to delete file", {}, String(error.message)); + } } + if (errors === 0) { + addToast(payloads.length > 1 + ? $t.storage.messages.bulk_delete_success?.replace('{count}', String(payloads.length)) || `${payloads.length} files deleted` + : $t.storage.messages.delete_success.replace('{name}', payloads[0].name), 'success'); + } else { + addToast($t.storage.messages.delete_failed.replace('{error}', `${errors} failed`), 'error'); + } + await loadFiles(); } // #endregion handleDelete:Function @@ -124,10 +140,10 @@ */ function handleNavigate(path) { if (!path || typeof path !== 'string') { - console.warn('[STORAGE-PAGE][NAVIGATE_SKIP] invalid path', path); + log("StorageIndexPage", "EXPLORE", "Navigate skipped — invalid path", { path }); return; } - console.log('[STORAGE-PAGE][NAVIGATE] path=%s', path); + log("StorageIndexPage", "REASON", "Navigating to path", { path }); currentPath = path; loadFiles(); } @@ -178,25 +194,10 @@ }); - +

{$t.storage.management}

-
- - {#each currentPath.split('/').filter(Boolean) as part, i} - / - - {/each} -
@@ -212,21 +213,15 @@
-
- {#if currentPath} - - {/if} -
- - +
@@ -243,12 +238,14 @@ 1 + ? `Delete ${bulkDeletePayloads.length} files?` + : (deletePayload ? $t.storage.messages.delete_confirm.replace('{name}', deletePayload.name) : "")} variant="destructive" confirmLabel="Delete" cancelLabel="Cancel" onConfirm={onConfirmDelete} - onCancel={() => {}} + onCancel={() => { deletePayload = null; bulkDeletePayloads = []; }} />