refactor(frontend): extract ValidationTasksListModel for validation tasks list

Validation tasks list: 521→204 lines (-61%).
Model: paginated list with search, inline CRUD (run/delete/toggle),
debounced search, isMounted guard, initFromLoad() pattern.

7→6 oversized pages remaining.
This commit is contained in:
2026-06-02 18:19:22 +03:00
parent bb94d00dd2
commit 55eef20958
2 changed files with 356 additions and 498 deletions

View File

@@ -0,0 +1,174 @@
// #region ValidationTasksListModel [C:4] [TYPE Model] [SEMANTICS validation,tasks,list,pagination,crud,model]
// @BRIEF Screen model for validation tasks list — manages paginated list, search, inline actions (run/delete/toggle), and navigation.
// @INVARIANT Search resets pagination to page 1.
// @INVARIANT Delete removes task from list and decrements total atomically.
// @INVARIANT isMounted prevents state mutation after component unmount.
// @STATE idle — Initial state, no data loaded.
// @STATE loading — API call in progress.
// @STATE loaded — Data fetched, list populated.
// @STATE empty — Query returned zero results.
// @STATE error — API call failed.
// @ACTION loadTasks() — Fetches paginated task list.
// @ACTION handleSearchInput() — Debounced search with pagination reset.
// @ACTION handleRunNow(id) — Triggers validation run.
// @ACTION handleDelete(id) — Deletes task with atomic list removal.
// @ACTION handleToggleStatus(task) — Toggles is_active.
// @ACTION goToPage(p) — Navigates to page.
// @RELATION DEPENDS_ON -> [api]
// @RELATION CALLS -> [log]
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/routes';
import { api } from '$lib/api.js';
import { log } from '$lib/cot-logger';
import { addToast } from '$lib/toasts.svelte.js';
import { t } from '$lib/i18n/index.svelte.js';
// ── Types ───────────────────────────────────────────────────────
interface ValidationTask {
id: string;
name?: string;
plugin_id?: string;
is_active?: boolean;
schedule?: string;
[key: string]: unknown;
}
interface TaskListData {
tasks?: ValidationTask[];
total?: number;
page?: number;
page_size?: number;
error?: string | null;
}
export class ValidationTasksListModel {
// ── Data from load function ──────────────────────────────────
tasks: ValidationTask[] = $state([]);
total: number = $state(0);
currentPage: number = $state(1);
pageSize: number = $state(20);
error: string | null = $state(null);
isLoading: boolean = $state(false);
searchQuery: string = $state('');
// ── UI state ──────────────────────────────────────────────────
runningTasks: Record<string, boolean> = $state({});
deleteConfirmId: string | null = $state(null);
isMounted: boolean = $state(true);
// ── Internal ──────────────────────────────────────────────────
private _searchTimeout: ReturnType<typeof setTimeout> | undefined;
// ── Derived ───────────────────────────────────────────────────
totalPages = $derived(Math.max(1, Math.ceil(this.total / this.pageSize)));
// ── Init from load data ──────────────────────────────────────
initFromLoad(data: TaskListData): void {
this.tasks = data.tasks ?? [];
this.total = data.total ?? 0;
this.currentPage = data.page ?? 1;
this.pageSize = data.page_size ?? 20;
this.error = data.error ?? null;
}
// ── Actions ───────────────────────────────────────────────────
async loadTasks(): Promise<void> {
this.isLoading = true;
this.error = null;
log('ValidationTasksListModel', 'REASON', 'Loading validation tasks', { page: this.currentPage, pageSize: this.pageSize, search: this.searchQuery || undefined });
try {
const result = await api.getValidationTasks({ page: this.currentPage, page_size: this.pageSize, search: this.searchQuery || undefined });
if (!this.isMounted) return;
const items = (result as { tasks?: ValidationTask[]; results?: ValidationTask[] })?.tasks ?? (result as { tasks?: ValidationTask[]; results?: ValidationTask[] })?.results ?? [];
this.tasks = items;
this.total = (result as { total?: number })?.total ?? 0;
log('ValidationTasksListModel', 'REFLECT', 'Tasks loaded', { count: items.length, total: this.total });
} catch (e: unknown) {
if (!this.isMounted) return;
this.error = e instanceof Error ? e.message : 'Failed to load tasks';
log('ValidationTasksListModel', 'EXPLORE', 'Failed to load tasks', {}, String(this.error));
} finally {
if (this.isMounted) this.isLoading = false;
}
}
handleSearchInput(value: string): void {
this.searchQuery = value;
if (this._searchTimeout) clearTimeout(this._searchTimeout);
this._searchTimeout = setTimeout(() => {
this.currentPage = 1;
this.loadTasks();
}, 300);
}
goToPage(p: number): void {
if (p < 1 || p > this.totalPages) return;
this.currentPage = p;
this.loadTasks();
}
// ── Navigation ────────────────────────────────────────────────
navToNew(): void { goto(ROUTES.validationTasks.new(), { invalidateAll: false }); }
navToDetail(id: string): void { goto(ROUTES.validationTasks.detail(id), { invalidateAll: false }); }
navToEdit(id: string): void { goto(ROUTES.validationTasks.edit(id), { invalidateAll: false }); }
// ── CRUD Actions ─────────────────────────────────────────────
async handleRunNow(id: string): Promise<void> {
this.runningTasks = { ...this.runningTasks, [id]: true };
log('ValidationTasksListModel', 'REASON', 'Triggering run', { taskId: id });
try {
await api.triggerValidationRun(id);
if (!this.isMounted) return;
addToast($t.validation?.run_started || 'Validation task started', 'success');
log('ValidationTasksListModel', 'REFLECT', 'Run triggered', { taskId: id });
} catch (e: unknown) {
if (!this.isMounted) return;
addToast(e instanceof Error ? e.message : 'Failed to trigger run', 'error');
log('ValidationTasksListModel', 'EXPLORE', 'Failed to trigger run', { taskId: id }, String(e));
} finally {
if (this.isMounted) this.runningTasks = { ...this.runningTasks, [id]: false };
}
}
async handleDelete(id: string): Promise<void> {
log('ValidationTasksListModel', 'REASON', 'Deleting task', { taskId: id });
try {
await api.deleteValidationTask(id);
if (!this.isMounted) return;
addToast($t.validation?.delete_success || 'Task deleted', 'success');
this.deleteConfirmId = null;
this.tasks = this.tasks.filter(t => t.id !== id);
this.total = Math.max(0, this.total - 1);
log('ValidationTasksListModel', 'REFLECT', 'Task deleted', { taskId: id });
} catch (e: unknown) {
if (!this.isMounted) return;
addToast(e instanceof Error ? e.message : 'Failed to delete', 'error');
this.deleteConfirmId = null;
log('ValidationTasksListModel', 'EXPLORE', 'Failed to delete task', { taskId: id }, String(e));
}
}
async handleToggleStatus(task: ValidationTask): Promise<void> {
const newActive = !(task.is_active !== false);
log('ValidationTasksListModel', 'REASON', 'Toggling status', { taskId: task.id, to: newActive });
try {
await api.toggleValidationTaskStatus(task.id, newActive);
if (!this.isMounted) return;
task.is_active = newActive;
this.tasks = this.tasks; // trigger reactivity
log('ValidationTasksListModel', 'REFLECT', 'Status toggled', { taskId: task.id });
} catch (e: unknown) {
if (!this.isMounted) return;
addToast(e instanceof Error ? e.message : 'Failed to update status', 'error');
log('ValidationTasksListModel', 'EXPLORE', 'Failed to toggle status', { taskId: task.id }, String(e));
}
}
destroy(): void { this.isMounted = false; }
}
// #endregion ValidationTasksListModel

View File

@@ -1,520 +1,204 @@
<!-- #region ValidationTasksPage [C:3] [TYPE Page] [SEMANTICS validation,tasks,list,table] --> <!-- #region ValidationTasksPage [C:3] [TYPE Page] [SEMANTICS validation,tasks,list,table] -->
<!-- @BRIEF Validation tasks list page — table layout with status filter, pagination, and inline actions. --> <!-- @BRIEF Validation tasks list page — renders ValidationTasksListModel state. -->
<!-- @RELATION CALLS -> [ValidationApi] --> <!-- @LAYER Page -->
<!-- @UX_STATE Loading -> Skeleton table rows matching column layout --> <!-- @RELATION BINDS_TO -> [ValidationTasksListModel] -->
<!-- @UX_STATE Loaded -> Data rows with all columns --> <!-- @UX_STATE loading -> Skeleton table rows matching column layout -->
<!-- @UX_STATE Empty -> Centered empty card with CTA to create first task --> <!-- @UX_STATE loaded -> Data rows with all columns -->
<!-- @UX_STATE Error -> Red banner with error details and retry button --> <!-- @UX_STATE empty -> Centered empty card with CTA -->
<!-- @UX_FEEDBACK Toast on run trigger, delete success, toggle error --> <!-- @UX_STATE error -> Red banner with error and retry -->
<!-- @UX_RECOVERY Retry button on load error; inline confirm on delete -->
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { onMount } from 'svelte';
import { ROUTES } from '$lib/routes'; import { t } from '$lib/i18n/index.svelte.js';
import { t } from '$lib/i18n/index.svelte.js'; import { ValidationTasksListModel } from '$lib/models/ValidationTasksListModel.svelte';
import { addToast } from '$lib/toasts.svelte.js';
import { api } from '$lib/api.js';
import { onMount } from 'svelte';
import { log } from '$lib/cot-logger';
/** @type {{ data: import('./$types').PageData }} */ let { data } = $props();
let { data } = $props();
// ── Derived initial state from load function ─────────────── const m = new ValidationTasksListModel();
/** @type {Array<any>} */ let tasks = $state(data.tasks ?? []); m.initFromLoad(data);
/** @type {number} */ let total = $state(data.total ?? 0);
/** @type {number} */ let currentPage = $state(data.page ?? 1);
/** @type {number} */ let pageSize = $state(data.page_size ?? 20);
/** @type {string | null} */ let error = $state(data.error ?? null);
let isLoading = $state(false);
let searchQuery = $state('');
/** @type {Record<string, boolean>} */ onMount(() => {
let runningTasks = $state({}); return () => { m.destroy(); };
let deleteConfirmId = $state(/** @type {string | null} */(null)); });
let isMounted = $state(true);
onMount(() => { function formatSchedule(input: string | null | undefined): string {
return () => { isMounted = false; }; if (!input) return $t.validation?.manual || 'Manual';
}); return input;
}
// ── Derived ──────────────────────────────────────────────── function formatTimestamp(ts: unknown): string {
let totalPages = $derived(Math.max(1, Math.ceil(total / pageSize))); if (!ts) return '\u2014';
try { return new Date(ts as string).toLocaleString(); }
catch { return String(ts); }
}
// ── Data fetching ────────────────────────────────────────── function formatLastRunSummary(summary: { pass?: number; warn?: number; fail?: number } | null | undefined): string {
async function loadTasks() { if (!summary) return '\u2014';
isLoading = true; const parts: string[] = [];
error = null; if (summary.pass) parts.push(`${summary.pass} PASS`);
log('ValidationTasksPage', 'REASON', 'Loading validation tasks', { if (summary.warn) parts.push(`${summary.warn} WARN`);
page: currentPage, pageSize, search: searchQuery || undefined if (summary.fail) parts.push(`${summary.fail} FAIL`);
}); return parts.join('\u2022') || '\u2014';
try { }
const result = await api.getValidationTasks({
page: currentPage,
page_size: pageSize,
search: searchQuery || undefined
});
if (!isMounted) return;
const items = result?.tasks ?? result?.results ?? [];
tasks = items;
total = result?.total ?? 0;
log('ValidationTasksPage', 'REFLECT', 'Tasks loaded', { count: items.length, total });
} catch (e) {
if (!isMounted) return;
const msg = e instanceof Error ? e.message : 'Failed to load tasks';
error = msg;
log('ValidationTasksPage', 'EXPLORE', 'Failed to load tasks', {}, msg);
} finally {
if (isMounted) isLoading = false;
}
}
// ── Search with debounce ────────────────────────────────── function getSummaryBadgeClass(summary: { fail?: number; warn?: number; pass?: number } | null | undefined): string {
let searchTimeout = $state(/** @type {ReturnType<typeof setTimeout> | undefined} */(undefined)); if (!summary) return 'bg-surface-muted text-text-muted';
if (summary.fail && summary.fail > 0) return 'bg-destructive-light text-destructive';
if (summary.warn && summary.warn > 0) return 'bg-warning-light text-warning';
if (summary.pass && summary.pass > 0) return 'bg-success-light text-success';
return 'bg-surface-muted text-text-muted';
}
function handleSearchInput(/** @type {string} */ value) { function getEnvLabel(task: Record<string, unknown>): string {
searchQuery = value; return String(task.environment_name || task.environment_id || '\u2014');
if (searchTimeout) clearTimeout(searchTimeout); }
searchTimeout = setTimeout(() => {
currentPage = 1;
loadTasks();
}, 300);
}
// ── Navigation ───────────────────────────────────────────── function pageBtnClass(p: number): string {
function navToNew() { const base = 'px-3 py-1.5 text-sm rounded-lg border transition-colors';
goto(ROUTES.validationTasks.new(), { invalidateAll: false }); if (p === m.currentPage) return `${base} bg-primary-light border-primary-ring text-primary`;
} return `${base} border-border text-text-muted hover:bg-surface-muted`;
}
function navToDetail(/** @type {string} */ id) {
goto(ROUTES.validationTasks.detail(id), { invalidateAll: false });
}
function navToEdit(/** @type {string} */ id) {
goto(ROUTES.validationTasks.edit(id), { invalidateAll: false });
}
// ── Actions ─────────────────────────────────────────────────
async function handleRunNow(/** @type {string} */ id) {
runningTasks = { ...runningTasks, [id]: true };
log('ValidationTasksPage', 'REASON', 'Triggering run', { taskId: id });
try {
await api.triggerValidationRun(id);
if (!isMounted) return;
addToast($t.validation?.run_started || 'Validation task started', 'success');
log('ValidationTasksPage', 'REFLECT', 'Run triggered', { taskId: id });
} catch (e) {
if (!isMounted) return;
const msg = e instanceof Error ? e.message : 'Failed to trigger run';
addToast(msg, 'error');
log('ValidationTasksPage', 'EXPLORE', 'Failed to trigger run', { taskId: id }, msg);
} finally {
if (isMounted) runningTasks = { ...runningTasks, [id]: false };
}
}
async function handleDelete(/** @type {string} */ id) {
log('ValidationTasksPage', 'REASON', 'Deleting task', { taskId: id });
try {
await api.deleteValidationTask(id);
if (!isMounted) return;
addToast($t.validation?.delete_success || 'Task deleted', 'success');
deleteConfirmId = null;
// Refresh list
tasks = tasks.filter(t => t.id !== id);
total = Math.max(0, total - 1);
log('ValidationTasksPage', 'REFLECT', 'Task deleted', { taskId: id });
} catch (e) {
if (!isMounted) return;
const msg = e instanceof Error ? e.message : 'Failed to delete';
addToast(msg, 'error');
deleteConfirmId = null;
log('ValidationTasksPage', 'EXPLORE', 'Failed to delete task', { taskId: id }, msg);
}
}
async function handleToggleStatus(/** @type {any} */ task) {
const newActive = !(task.is_active !== false);
log('ValidationTasksPage', 'REASON', 'Toggling status', {
taskId: task.id, from: task.is_active, to: newActive
});
try {
await api.toggleValidationTaskStatus(task.id, newActive);
if (!isMounted) return;
task.is_active = newActive;
tasks = tasks; // trigger reactivity
log('ValidationTasksPage', 'REFLECT', 'Status toggled', { taskId: task.id, isActive: newActive });
} catch (e) {
if (!isMounted) return;
const msg = e instanceof Error ? e.message : 'Failed to update status';
addToast(msg, 'error');
log('ValidationTasksPage', 'EXPLORE', 'Failed to toggle status', { taskId: task.id }, msg);
}
}
// ── Pagination ─────────────────────────────────────────────
function goToPage(/** @type {number} */ p) {
if (p < 1 || p > totalPages) return;
currentPage = p;
loadTasks();
}
// ── Helpers ─────────────────────────────────────────────────
/**
* @param {string | null | undefined} scheduleInput
* @returns {string}
*/
function formatSchedule(scheduleInput) {
if (!scheduleInput) return $t.validation?.manual || 'Manual';
return scheduleInput;
}
/**
* @param {string | number | Date | null | undefined} ts
* @returns {string}
*/
function formatTimestamp(ts) {
if (!ts) return '—';
try {
return new Date(ts).toLocaleString();
} catch {
return String(ts);
}
}
/**
* @param {{ pass?: number, warn?: number, fail?: number } | null | undefined} summary
* @returns {string}
*/
function formatLastRunSummary(summary) {
if (!summary) return '—';
const parts = [];
if (summary.pass) parts.push(`${summary.pass} PASS`);
if (summary.warn) parts.push(`${summary.warn} WARN`);
if (summary.fail) parts.push(`${summary.fail} FAIL`);
return parts.join(' • ') || '—';
}
/**
* @param {{ pass?: number, warn?: number, fail?: number } | null | undefined} summary
* @returns {string}
*/
function getSummaryBadgeClass(summary) {
if (!summary) return 'bg-surface-muted text-text-muted';
if (summary.fail && summary.fail > 0) return 'bg-destructive-light text-destructive';
if (summary.warn && summary.warn > 0) return 'bg-warning-light text-warning';
if (summary.pass && summary.pass > 0) return 'bg-success-light text-success';
return 'bg-surface-muted text-text-muted';
}
/**
* @param {any} task
* @returns {string}
*/
function getEnvLabel(task) {
return task.environment_name || task.environment_id || '—';
}
/**
* @param {number} p
* @returns {string}
*/
function pageBtnClass(p) {
const base = 'px-3 py-1.5 text-sm rounded-lg border transition-colors';
if (p === currentPage) return `${base} bg-primary-light border-primary-ring text-primary`;
return `${base} border-border text-text-muted hover:bg-surface-muted`;
}
</script> </script>
<div class="max-w-7xl mx-auto px-4 py-6"> <div class="max-w-7xl mx-auto px-4 py-6">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-text"> <h1 class="text-2xl font-bold text-text">{$t.validation?.title || 'Validation Tasks'}</h1>
{$t.validation?.title || 'Validation Tasks'} <button onclick={() => m.navToNew()} class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors font-medium text-sm">
</h1> <svg class="w-5 h-5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
<button {$t.validation?.new_task || '+ New Task'}
onclick={navToNew} </button>
class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors font-medium text-sm" </div>
>
<svg class="w-5 h-5 mr-1.5" 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>
<!-- Search bar --> <!-- Search -->
<div class="mb-4"> <div class="mb-4">
<div class="relative max-w-sm"> <div class="relative max-w-sm">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-subtle" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-subtle" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <input type="text" placeholder={$t.common?.search || 'Search tasks...'} value={m.searchQuery} oninput={(e) => m.handleSearchInput((e.target as HTMLInputElement).value)} class="w-full pl-10 pr-4 py-2 border border-border-strong rounded-lg text-sm focus:ring-2 focus:ring-primary-ring focus:border-primary-ring outline-none" aria-label={$t.common?.search} />
</svg> </div>
<input </div>
type="text"
placeholder={$t.common?.search || 'Search tasks...'}
value={searchQuery}
oninput={(e) => handleSearchInput(/** @type {HTMLInputElement} */(e.target).value)}
class="w-full pl-10 pr-4 py-2 border border-border-strong rounded-lg text-sm focus:ring-2 focus:ring-primary-ring focus:border-primary-ring outline-none"
aria-label={$t.common?.search || 'Search tasks'}
/>
</div>
</div>
<!-- Error state --> <!-- Error -->
{#if error && !isLoading} {#if m.error && !m.isLoading}
<div class="bg-destructive-light border border-destructive-ring rounded-lg p-4 mb-6 flex items-center justify-between" role="alert"> <div class="bg-destructive-light border border-destructive-ring rounded-lg p-4 mb-6 flex items-center justify-between" role="alert">
<div class="flex items-center gap-2 text-destructive"> <div class="flex items-center gap-2 text-destructive">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <span class="text-sm">{m.error}</span>
</svg> </div>
<span class="text-sm">{error}</span> <button onclick={() => m.loadTasks()} class="px-3 py-1.5 text-sm bg-destructive text-white rounded-lg hover:bg-destructive transition-colors">{$t.validation?.retry}</button>
</div> </div>
<button {/if}
onclick={loadTasks}
class="px-3 py-1.5 text-sm bg-destructive text-white rounded-lg hover:bg-destructive transition-colors"
>
{$t.validation?.retry || 'Retry'}
</button>
</div>
{/if}
<!-- Loading skeleton --> <!-- Loading -->
{#if isLoading} {#if m.isLoading}
<div class="bg-surface-card border border-border rounded-lg overflow-hidden"> <div class="bg-surface-card border border-border rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-border"> <table class="min-w-full divide-y divide-border">
<thead class="bg-surface-muted"> <thead class="bg-surface-muted"><tr>
<tr> <th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.task_name}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.task_name || 'Task Name'}</th> <th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.environment}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.environment || 'Environment'}</th> <th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.schedule}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.schedule || 'Schedule'}</th> <th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.last_run}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.last_run || 'Last Run'}</th> <th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.status}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.status || 'Status'}</th> <th class="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.actions}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.actions || 'Actions'}</th> </tr></thead>
</tr> <tbody class="bg-surface-card divide-y divide-border">
</thead> {#each Array(5) as _, i (i)}<tr class="animate-pulse"><td class="px-6 py-4"><div class="h-4 w-40 bg-surface-muted rounded"></div></td><td class="px-6 py-4"><div class="h-4 w-24 bg-surface-muted rounded"></div></td><td class="px-6 py-4"><div class="h-4 w-20 bg-surface-muted rounded"></div></td><td class="px-6 py-4"><div class="h-4 w-32 bg-surface-muted rounded"></div></td><td class="px-6 py-4"><div class="h-5 w-14 bg-surface-muted rounded-full"></div></td><td class="px-6 py-4"><div class="h-4 w-24 bg-surface-muted rounded ml-auto"></div></td></tr>{/each}
<tbody class="bg-surface-card divide-y divide-border"> </tbody>
{#each Array(5) as _, i (i)} </table>
<tr class="animate-pulse"> </div>
<td class="px-6 py-4"><div class="h-4 w-40 bg-surface-muted rounded"></div></td>
<td class="px-6 py-4"><div class="h-4 w-24 bg-surface-muted rounded"></div></td>
<td class="px-6 py-4"><div class="h-4 w-20 bg-surface-muted rounded"></div></td>
<td class="px-6 py-4"><div class="h-4 w-32 bg-surface-muted rounded"></div></td>
<td class="px-6 py-4"><div class="h-5 w-14 bg-surface-muted rounded-full"></div></td>
<td class="px-6 py-4"><div class="h-4 w-24 bg-surface-muted rounded ml-auto"></div></td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Empty state --> <!-- Empty -->
{:else if !error && tasks.length === 0} {:else if !m.error && m.tasks.length === 0}
<div class="bg-surface-muted border border-dashed border-border-strong rounded-lg p-12 text-center"> <div class="bg-surface-muted border border-dashed border-border-strong rounded-lg p-12 text-center">
<svg class="w-16 h-16 mx-auto text-text-subtle mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-16 h-16 mx-auto text-text-subtle 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>
<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" /> <p class="text-text-muted text-sm mb-1">{$t.validation?.no_tasks || 'No validation tasks yet'}</p>
</svg> <p class="text-text-subtle text-sm mb-4">{$t.validation?.create_first || 'Create your first task'}</p>
<p class="text-text-muted text-sm mb-1">{$t.validation?.no_tasks || 'No validation tasks yet'}</p> <button onclick={() => m.navToNew()} class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm font-medium">{$t.validation?.new_task}</button>
<p class="text-text-subtle text-sm mb-4">{$t.validation?.create_first || 'Create your first task'}</p> </div>
<button
onclick={navToNew}
class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm font-medium"
>
{$t.validation?.new_task || '+ New Task'}
</button>
</div>
<!-- Loaded state: table --> <!-- Loaded: table -->
{:else} {:else}
<div class="bg-surface-card border border-border rounded-lg overflow-hidden shadow-sm"> <div class="bg-surface-card border border-border rounded-lg overflow-hidden shadow-sm">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-border"> <table class="min-w-full divide-y divide-border">
<thead class="bg-surface-muted"> <thead class="bg-surface-muted"><tr>
<tr> <th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.task_name}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.task_name || 'Task Name'}</th> <th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.environment}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.environment || 'Environment'}</th> <th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.schedule}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.schedule || 'Schedule'}</th> <th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.last_run}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.last_run || 'Last Run'}</th> <th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.status}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.status || 'Status'}</th> <th class="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.actions}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">{$t.validation?.actions || 'Actions'}</th> </tr></thead>
</tr> <tbody class="bg-surface-card divide-y divide-border">
</thead> {#each m.tasks as task (task.id)}
<tbody class="bg-surface-card divide-y divide-border"> <tr class="hover:bg-surface-muted transition-colors">
{#each tasks as task (task.id)} <td class="px-6 py-4 whitespace-nowrap">
<tr class="hover:bg-surface-muted transition-colors"> <button onclick={() => m.navToDetail(task.id)} class="text-sm font-medium text-primary hover:text-primary hover:underline text-left">{task.name || '\u2014'}</button>
<!-- Task Name --> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap"><span class="text-sm text-text">{getEnvLabel(task)}</span></td>
<button <td class="px-6 py-4 whitespace-nowrap">
onclick={() => navToDetail(task.id)} {#if task.schedule}
class="text-sm font-medium text-primary hover:text-primary hover:underline text-left" <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-light text-primary">{formatSchedule((task as Record<string,unknown>).schedule_description as string || task.schedule)}</span>
> {:else}
{task.name || '—'} <span class="text-sm text-text-subtle">{$t.validation?.manual || 'Manual'}</span>
</button> {/if}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap">
{#if task.last_run_at || task.last_run_summary}
<div class="flex flex-col gap-0.5">
{#if task.last_run_at}<span class="text-xs text-text-muted">{formatTimestamp(task.last_run_at)}</span>{/if}
{#if task.last_run_summary}<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {getSummaryBadgeClass(task.last_run_summary as { pass?: number; warn?: number; fail?: number } | null)}">{formatLastRunSummary(task.last_run_summary as { pass?: number; warn?: number; fail?: number } | null)}</span>{/if}
</div>
{:else}<span class="text-sm text-text-subtle">\u2014</span>{/if}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<button onclick={() => m.handleToggleStatus(task)} class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-ring focus:ring-offset-2" class:bg-primary={task.is_active !== false} class:bg-surface-muted={task.is_active === false} role="switch" aria-checked={task.is_active !== false}>
<span class="inline-block h-4 w-4 transform rounded-full bg-surface-card transition-transform" class:translate-x-6={task.is_active !== false} class:translate-x-1={task.is_active === false}></span>
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-1">
<button onclick={() => m.handleRunNow(task.id)} disabled={m.runningTasks[task.id]} class="p-2 text-text-subtle hover:text-primary rounded-lg hover:bg-primary-light transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title={$t.validation?.run_now}>
{#if m.runningTasks[task.id]}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else}
<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>
{/if}
</button>
<button onclick={() => m.navToEdit(task.id)} class="p-2 text-text-subtle hover:text-primary rounded-lg hover:bg-primary-light transition-colors" title={$t.validation?.edit}>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
{#if m.deleteConfirmId === task.id}
<div class="flex items-center gap-1">
<button onclick={() => m.handleDelete(task.id)} class="px-2 py-1 text-xs bg-destructive text-white rounded hover:bg-destructive transition-colors">{$t.validation?.confirm_delete}</button>
<button onclick={() => { m.deleteConfirmId = null; }} class="px-2 py-1 text-xs bg-surface-muted text-text rounded hover:bg-surface-muted transition-colors">{$t.common?.cancel}</button>
</div>
{:else}
<button onclick={() => { m.deleteConfirmId = task.id; }} class="p-2 text-text-subtle hover:text-destructive rounded-lg hover:bg-destructive-light transition-colors" title={$t.validation?.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>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Environment --> <!-- Pagination -->
<td class="px-6 py-4 whitespace-nowrap"> {#if m.totalPages > 1}
<span class="text-sm text-text">{getEnvLabel(task)}</span> <div class="flex items-center justify-between mt-4">
</td> <p class="text-sm text-text-muted">{($t.validation?.showing || 'Showing {count} of {total}').replace('{count}', String(m.tasks.length)).replace('{total}', String(m.total))}</p>
<nav class="flex items-center gap-1" aria-label="Pagination">
<!-- Schedule --> <button onclick={() => m.goToPage(m.currentPage - 1)} disabled={m.currentPage <= 1} class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-surface-muted text-text-muted transition-colors">{$t.validation?.previous}</button>
<td class="px-6 py-4 whitespace-nowrap"> {#each Array.from({ length: Math.min(m.totalPages, 7) }, (_, i) => { if (m.totalPages <= 7) return i + 1; const half = 3; const start = Math.max(1, m.currentPage - half); const end = Math.min(m.totalPages, start + 6); const adj = Math.max(1, end - 6); return adj + i; }) as p (p)}
{#if task.schedule} <button onclick={() => m.goToPage(p)} class={pageBtnClass(p)}>{p}</button>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-light text-primary"> {/each}
{formatSchedule(task.schedule_description || task.schedule)} <button onclick={() => m.goToPage(m.currentPage + 1)} disabled={m.currentPage >= m.totalPages} class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-surface-muted text-text-muted transition-colors">{$t.validation?.next}</button>
</span> </nav>
{:else} </div>
<span class="text-sm text-text-subtle">{$t.validation?.manual || 'Manual'}</span> {/if}
{/if} {/if}
</td>
<!-- Last Run -->
<td class="px-6 py-4 whitespace-nowrap">
{#if task.last_run_at || task.last_run_summary}
<div class="flex flex-col gap-0.5">
{#if task.last_run_at}
<span class="text-xs text-text-muted">{formatTimestamp(task.last_run_at)}</span>
{/if}
{#if task.last_run_summary}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {getSummaryBadgeClass(task.last_run_summary)}">
{formatLastRunSummary(task.last_run_summary)}
</span>
{/if}
</div>
{:else}
<span class="text-sm text-text-subtle"></span>
{/if}
</td>
<!-- Status Toggle -->
<td class="px-6 py-4 whitespace-nowrap">
<button
onclick={() => handleToggleStatus(task)}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-ring focus:ring-offset-2"
class:bg-primary={task.is_active !== false}
class:bg-surface-muted={task.is_active === false}
role="switch"
aria-checked={task.is_active !== false}
aria-label={$t.validation?.active || 'Active'}
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-surface-card transition-transform"
class:translate-x-6={task.is_active !== false}
class:translate-x-1={task.is_active === false}
></span>
</button>
</td>
<!-- Actions -->
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-1">
<!-- Run Now -->
<button
onclick={() => handleRunNow(task.id)}
disabled={runningTasks[task.id]}
class="p-2 text-text-subtle hover:text-primary rounded-lg hover:bg-primary-light transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title={$t.validation?.run_now || 'Run Now'}
>
{#if runningTasks[task.id]}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{:else}
<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>
{/if}
</button>
<!-- Edit -->
<button
onclick={() => navToEdit(task.id)}
class="p-2 text-text-subtle hover:text-primary rounded-lg hover:bg-primary-light transition-colors"
title={$t.validation?.edit || 'Edit'}
>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<!-- Delete -->
{#if deleteConfirmId === task.id}
<div class="flex items-center gap-1">
<button
onclick={() => handleDelete(task.id)}
class="px-2 py-1 text-xs bg-destructive text-white rounded hover:bg-destructive transition-colors"
>
{$t.validation?.confirm_delete || 'Delete'}
</button>
<button
onclick={() => { deleteConfirmId = null; }}
class="px-2 py-1 text-xs bg-surface-muted text-text rounded hover:bg-surface-muted transition-colors"
>
{$t.common?.cancel || 'Cancel'}
</button>
</div>
{:else}
<button
onclick={() => { deleteConfirmId = task.id; }}
class="p-2 text-text-subtle hover:text-destructive rounded-lg hover:bg-destructive-light transition-colors"
title={$t.validation?.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>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-4">
<p class="text-sm text-text-muted">
{($t.validation?.showing || 'Showing {count} of {total}')
.replace('{count}', String(tasks.length))
.replace('{total}', String(total))}
</p>
<nav class="flex items-center gap-1" aria-label="Pagination">
<button
onclick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-surface-muted text-text-muted transition-colors"
>
{$t.validation?.previous || 'Previous'}
</button>
{#each Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
// Show window around current page
if (totalPages <= 7) return i + 1;
const half = 3;
const start = Math.max(1, currentPage - half);
const end = Math.min(totalPages, start + 6);
const adjustedStart = Math.max(1, end - 6);
return adjustedStart + i;
}) as p (p)}
<button
onclick={() => goToPage(p)}
class={pageBtnClass(p)}
>
{p}
</button>
{/each}
<button
onclick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
class="px-3 py-1.5 text-sm border border-border rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-surface-muted text-text-muted transition-colors"
>
{$t.validation?.next || 'Next'}
</button>
</nav>
</div>
{/if}
{/if}
</div> </div>
<!-- #endregion ValidationTasksPage --> <!-- #endregion ValidationTasksPage -->