Files
ss-tools/frontend/src/routes/validation/+page.svelte
busya 48cc02ea0b feat: LLM Validation section — 9 API endpoints + 3 Svelte pages + Playwright optimization
Backend:
- /api/validation/ CRUD for validation tasks (policies)
- Run history with 6 filters + pagination
- Alembic migrations (provider_id, policy_id)
- 28 pytest tests

Frontend:
- /validation — task list page with status filters + CRUD
- /validation/[id] — task config (Config + History tabs)
- /validation/history — run history with metrics
- Sidebar nav entry under Operations
- i18n en/ru

Playwright (ScreenshotService):
- Removed 25s hardcoded sleeps → conditional waits
- CDP fallback to full_page screenshot
- Total timeout enforcement (120s)
- Debug screenshots → temp dir with cleanup

QA fixes:
- Debug screenshot accumulation in production (tempdir + rmtree)
- Frontend API payload field name mismatch
- History issues field reference fix
- delete_runs scoped by policy_id

Belief protocol:
- @RATIONALE/@REJECTED on 27 C4+ contracts

Closes: VALIDATION-REWORK-2026
2026-05-21 20:29:36 +03:00

400 lines
17 KiB
Svelte

<!-- #region ValidationTaskList [C:3] [TYPE Page] [SEMANTICS sveltekit, validation, task, list] -->
<!-- @BRIEF Validation task list page with status filter, task cards, and create/duplicate/delete actions. -->
<!-- @RATIONALE Card layout chosen over table because tasks have rich metadata (schedule, provider, last run status, environment) that does not fit into table columns without excessive horizontal scrolling. -->
<!-- @REJECTED Table view for task list rejected — dashboard_id + environment_id + provider_id + schedule + last_run_status creates too many columns for a table; cards show all metadata vertically. -->
<!-- @UX_STATE Loading -> 3 skeleton cards with animate-pulse -->
<!-- @UX_STATE Error -> Alert with error message + Retry button -->
<!-- @UX_STATE Empty -> Centered icon + descriptive message + Create Task button -->
<!-- @UX_STATE FilteredEmpty -> "No tasks match filters" message + Clear button -->
<!-- @UX_STATE Populated -> Task cards grid -->
<script>
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { t } from '$lib/i18n';
import { addToast } from '$lib/toasts.js';
import { fetchTasks, deleteTask, duplicateTask, triggerRun } from '$lib/api/validation.js';
let isMounted = $state(true);
onDestroy(() => {
isMounted = false;
});
/** @type {'idle'|'loading'|'empty'|'populated'|'error'|'filtered_empty'} */
let uxState = $state('idle');
let tasks = $state([]);
let total = $state(0);
let error = $state(null);
let isLoading = $state(true);
let showDeleteConfirm = $state(null);
let deleteWithRuns = $state(false);
let statusFilter = $state('');
let currentPage = $state(1);
let pageSize = $state(20);
let runningTasks = $state({});
onMount(() => {
loadTasks();
});
async function loadTasks() {
isLoading = true;
uxState = 'loading';
error = null;
try {
const result = await fetchTasks({
page: currentPage,
page_size: pageSize,
is_active: statusFilter === 'active' ? true : statusFilter === 'inactive' ? false : undefined,
});
if (!isMounted) return;
const items = Array.isArray(result) ? result : (result?.results || result?.items || []);
tasks = items;
total = result?.total || result?.count || items.length;
uxState = items.length === 0 ? (statusFilter ? 'filtered_empty' : 'empty') : 'populated';
} catch (err) {
if (!isMounted) return;
error = err?.message || $t.validation?.load_failed || 'Failed to load tasks.';
uxState = 'error';
} finally {
if (isMounted) isLoading = false;
}
}
/** @param {string} status */
function filterByStatus(status) {
statusFilter = status;
currentPage = 1;
loadTasks();
}
async function handleNavToConfig(taskId) {
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(`/validation/${taskId}`);
}
async function handleDuplicate(taskId) {
try {
await duplicateTask(taskId);
if (!isMounted) return;
addToast($t.validation?.task_duplicated || 'Task duplicated.', 'success');
loadTasks();
} catch (err) {
if (!isMounted) return;
addToast(err?.message || 'Failed to duplicate.', 'error');
}
}
async function handleDelete(taskId) {
try {
await deleteTask(taskId, deleteWithRuns);
if (!isMounted) return;
addToast($t.validation?.delete_success || 'Deleted successfully.', 'success');
showDeleteConfirm = null;
deleteWithRuns = false;
loadTasks();
} catch (err) {
if (!isMounted) return;
addToast(err?.message || 'Failed to delete.', 'error');
showDeleteConfirm = null;
}
}
async function handleRunNow(taskId) {
runningTasks = { ...runningTasks, [taskId]: true };
try {
await triggerRun(taskId);
if (!isMounted) return;
addToast($t.validation?.run_triggered || 'Validation run triggered.', 'success');
} catch (err) {
if (!isMounted) return;
addToast(err?.message || 'Failed to trigger run.', 'error');
} finally {
if (isMounted) runningTasks = { ...runningTasks, [taskId]: false };
}
}
function getStatusBadgeClass(status) {
const map = {
PASS: 'bg-green-100 text-green-700',
WARN: 'bg-yellow-100 text-yellow-700',
FAIL: 'bg-red-100 text-red-700',
UNKNOWN: 'bg-gray-100 text-gray-500',
ACTIVE: 'bg-green-100 text-green-700',
INACTIVE: 'bg-gray-100 text-gray-500',
};
return map[status] || 'bg-gray-100 text-gray-700';
}
function getRunStatusBadgeClass(status) {
const map = {
PASS: 'bg-green-100 text-green-700',
WARN: 'bg-yellow-100 text-yellow-700',
FAIL: 'bg-red-100 text-red-700',
UNKNOWN: 'bg-gray-100 text-gray-500',
};
return map[status] || 'bg-gray-100 text-gray-500';
}
let activeCount = $derived(tasks.filter(t => t.is_active !== false).length);
let inactiveCount = $derived(tasks.filter(t => t.is_active === false).length);
let statusPills = $derived([
{ label: 'All', value: '', count: tasks.length },
{ label: $t.validation?.active || 'Active', value: 'active', count: activeCount },
{ label: $t.validation?.inactive || 'Inactive', value: 'inactive', count: inactiveCount },
]);
async function handleNavToNew() {
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto('/validation/new');
}
</script>
<div class="container mx-auto px-4 py-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">{$t.validation?.tasks_title || 'Validation Tasks'}</h1>
<p class="text-sm text-gray-500 mt-1">{$t.validation?.last_run_status || 'Monitor and validate dashboard quality'}</p>
</div>
<button
onclick={handleNavToNew}
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{$t.validation?.new_task || 'New Task'}
</button>
</div>
<!-- Filter Pills -->
<div class="flex flex-wrap gap-2 mb-4">
{#each statusPills as pill (pill.value)}
<button
onclick={() => filterByStatus(pill.value)}
class="px-3 py-1.5 text-sm rounded-full border transition-colors
{statusFilter === pill.value
? 'bg-blue-50 border-blue-300 text-blue-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}"
>
{pill.label}
<span class="ml-1 text-xs opacity-70">({pill.count})</span>
</button>
{/each}
</div>
<!-- Loading State -->
{#if isLoading}
<div class="space-y-3">
{#each Array(3) as _, i (i)}
<div class="bg-white border border-gray-200 rounded-xl p-4 animate-pulse">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0 space-y-2">
<div class="flex items-center gap-3">
<div class="h-5 w-40 bg-gray-200 rounded"></div>
<div class="h-5 w-16 bg-gray-200 rounded-full"></div>
</div>
<div class="h-3 w-64 bg-gray-100 rounded"></div>
<div class="flex gap-4">
<div class="h-3 w-24 bg-gray-100 rounded"></div>
<div class="h-3 w-20 bg-gray-100 rounded"></div>
</div>
</div>
<div class="flex gap-2">
<div class="h-8 w-8 bg-gray-100 rounded"></div>
<div class="h-8 w-8 bg-gray-100 rounded"></div>
</div>
</div>
</div>
{/each}
</div>
<!-- Error State -->
{:else if uxState === 'error'}
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p class="text-red-700 mb-3">{error}</p>
<button
onclick={loadTasks}
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
{$t.validation?.retry || 'Retry'}
</button>
</div>
<!-- Empty State -->
{:else if uxState === 'empty'}
<div class="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-12 text-center">
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-gray-500 mb-4">{$t.validation?.no_tasks || 'No validation tasks yet. Create your first task to start monitoring dashboards.'}</p>
<button
onclick={handleNavToNew}
class="inline-flex px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{$t.validation?.create_task_cta || 'Create Task'}
</button>
</div>
<!-- Filtered Empty State -->
{:else if uxState === 'filtered_empty'}
<div class="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-12 text-center">
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-gray-500 mb-2">{$t.validation?.no_tasks_filtered || 'No tasks match your filters.'}</p>
<button
onclick={() => filterByStatus('')}
class="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
{$t.validation?.clear_filters || 'Clear filters'}
</button>
</div>
<!-- Populated State -->
{:else if uxState === 'populated'}
<div class="space-y-3">
{#each tasks as task (task.id)}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
onclick={() => handleNavToConfig(task.id)}
onkeydown={(e) => { if (e.key === 'Enter') handleNavToConfig(task.id); }}
role="button"
tabindex="0"
class="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-md hover:border-gray-300 transition-all cursor-pointer"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<h3 class="text-lg font-semibold text-gray-900 truncate">{task.name}</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getStatusBadgeClass(task.is_active !== false ? 'ACTIVE' : 'INACTIVE')}">
{task.is_active !== false ? ($t.validation?.active || 'Active') : ($t.validation?.inactive || 'Inactive')}
</span>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-500">
{#if task.dashboard_id}
<span class="truncate max-w-[200px]" title={String(task.dashboard_id)}>
{$t.validation?.dashboard || 'Dashboard'}: {task.dashboard_id?.substring(0, 16)}...
</span>
{/if}
{#if task.environment_id}
<span>{$t.validation?.environment || 'Env'}: {task.environment_id?.substring(0, 12)}...</span>
{/if}
{#if task.last_run_status}
<span class="flex items-center gap-1">
{$t.validation?.last_run || 'Last run'}:
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {getRunStatusBadgeClass(task.last_run_status)}">
{task.last_run_status}
</span>
</span>
{/if}
{#if task.last_run_at}
<span class="text-xs text-gray-400">{new Date(task.last_run_at).toLocaleString()}</span>
{/if}
{#if task.provider_id}
<span class="text-xs text-gray-400">{$t.validation?.provider || 'LLM'}: {task.provider_id?.substring(0, 12)}...</span>
{/if}
{#if task.schedule_days?.length}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700">
{$t.validation?.scheduled || 'Scheduled'}
</span>
{:else}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500">
{$t.validation?.manual_only || 'Manual only'}
</span>
{/if}
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex items-center gap-2 ml-4" onclick={(e) => e.stopPropagation()}>
<button
onclick={() => handleRunNow(task.id)}
disabled={runningTasks[task.id]}
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors disabled:opacity-50"
title={$t.validation?.run_now || 'Run Now'}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
onclick={() => handleDuplicate(task.id)}
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 transition-colors"
title={$t.validation?.duplicate_task || 'Duplicate'}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
{#if showDeleteConfirm === task.id}
<div class="flex items-center gap-1">
<button
onclick={() => handleDelete(task.id)}
class="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700"
>
{$t.validation?.confirm_delete || 'Delete'}
</button>
<button
onclick={() => { showDeleteConfirm = null; deleteWithRuns = false; }}
class="px-2 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
>
{$t.validation?.cancel_delete || 'Cancel'}
</button>
</div>
{:else}
<button
onclick={() => { showDeleteConfirm = task.id; deleteWithRuns = false; }}
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
title={$t.common?.delete || 'Delete'}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{/if}
</div>
</div>
{#if showDeleteConfirm === task.id}
<div class="mt-2 pt-2 border-t border-gray-100">
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
<input type="checkbox" bind:checked={deleteWithRuns} class="rounded border-gray-300" />
{$t.validation?.delete_task_with_runs || 'Also delete all run history'}
</label>
</div>
{/if}
</div>
{/each}
</div>
<!-- Pagination -->
{#if total > pageSize}
<div class="flex items-center justify-between mt-4">
<p class="text-sm text-gray-500">
{($t.validation?.showing || 'Showing {count} of {total}')
.replace('{count}', String(tasks.length))
.replace('{total}', String(total))}
</p>
<div class="flex gap-2">
<button
onclick={() => { currentPage--; loadTasks(); }}
disabled={currentPage <= 1}
class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg disabled:opacity-50 hover:bg-gray-50"
>
{$t.validation?.previous || 'Previous'}
</button>
<button
onclick={() => { currentPage++; loadTasks(); }}
disabled={currentPage * pageSize >= total}
class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg disabled:opacity-50 hover:bg-gray-50"
>
{$t.validation?.next || 'Next'}
</button>
</div>
</div>
{/if}
{/if}
</div>
<!-- #endregion ValidationTaskList -->