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
400 lines
17 KiB
Svelte
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 -->
|