diff --git a/frontend/src/lib/models/ValidationTasksListModel.svelte.ts b/frontend/src/lib/models/ValidationTasksListModel.svelte.ts new file mode 100644 index 00000000..8c63e0f4 --- /dev/null +++ b/frontend/src/lib/models/ValidationTasksListModel.svelte.ts @@ -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 = $state({}); + deleteConfirmId: string | null = $state(null); + isMounted: boolean = $state(true); + + // ── Internal ────────────────────────────────────────────────── + private _searchTimeout: ReturnType | 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 { + 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 { + 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 { + 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 { + 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 diff --git a/frontend/src/routes/validation-tasks/+page.svelte b/frontend/src/routes/validation-tasks/+page.svelte index effbb754..ecf467fc 100644 --- a/frontend/src/routes/validation-tasks/+page.svelte +++ b/frontend/src/routes/validation-tasks/+page.svelte @@ -1,520 +1,204 @@ - - - - - - - - + + + + + + +
- -
-

- {$t.validation?.title || 'Validation Tasks'} -

- -
+ +
+

{$t.validation?.title || 'Validation Tasks'}

+ +
- -
-
- - - - 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'} - /> -
-
+ +
+
+ + 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} /> +
+
- - {#if error && !isLoading} - - {/if} + + {#if m.error && !m.isLoading} + + {/if} - - {#if isLoading} -
- - - - - - - - - - - - - {#each Array(5) as _, i (i)} - - - - - - - - - {/each} - -
{$t.validation?.task_name || 'Task Name'}{$t.validation?.environment || 'Environment'}{$t.validation?.schedule || 'Schedule'}{$t.validation?.last_run || 'Last Run'}{$t.validation?.status || 'Status'}{$t.validation?.actions || 'Actions'}
-
+ + {#if m.isLoading} +
+ + + + + + + + + + + {#each Array(5) as _, i (i)}{/each} + +
{$t.validation?.task_name}{$t.validation?.environment}{$t.validation?.schedule}{$t.validation?.last_run}{$t.validation?.status}{$t.validation?.actions}
+
- - {:else if !error && tasks.length === 0} -
- - - -

{$t.validation?.no_tasks || 'No validation tasks yet'}

-

{$t.validation?.create_first || 'Create your first task'}

- -
+ + {:else if !m.error && m.tasks.length === 0} +
+ +

{$t.validation?.no_tasks || 'No validation tasks yet'}

+

{$t.validation?.create_first || 'Create your first task'}

+ +
- - {:else} -
-
- - - - - - - - - - - - - {#each tasks as task (task.id)} - - - + + {:else} +
+
+
{$t.validation?.task_name || 'Task Name'}{$t.validation?.environment || 'Environment'}{$t.validation?.schedule || 'Schedule'}{$t.validation?.last_run || 'Last Run'}{$t.validation?.status || 'Status'}{$t.validation?.actions || 'Actions'}
- -
+ + + + + + + + + + {#each m.tasks as task (task.id)} + + + + + + + + + {/each} + +
{$t.validation?.task_name}{$t.validation?.environment}{$t.validation?.schedule}{$t.validation?.last_run}{$t.validation?.status}{$t.validation?.actions}
+ + {getEnvLabel(task)} + {#if task.schedule} + {formatSchedule((task as Record).schedule_description as string || task.schedule)} + {:else} + {$t.validation?.manual || 'Manual'} + {/if} + + {#if task.last_run_at || task.last_run_summary} +
+ {#if task.last_run_at}{formatTimestamp(task.last_run_at)}{/if} + {#if task.last_run_summary}{formatLastRunSummary(task.last_run_summary as { pass?: number; warn?: number; fail?: number } | null)}{/if} +
+ {:else}\u2014{/if} +
+ + +
+ + + {#if m.deleteConfirmId === task.id} +
+ + +
+ {:else} + + {/if} +
+
+
+
- - - {getEnvLabel(task)} - - - - - {#if task.schedule} - - {formatSchedule(task.schedule_description || task.schedule)} - - {:else} - {$t.validation?.manual || 'Manual'} - {/if} - - - - - {#if task.last_run_at || task.last_run_summary} -
- {#if task.last_run_at} - {formatTimestamp(task.last_run_at)} - {/if} - {#if task.last_run_summary} - - {formatLastRunSummary(task.last_run_summary)} - - {/if} -
- {:else} - - {/if} - - - - - - - - - -
- - - - - - - - {#if deleteConfirmId === task.id} -
- - -
- {:else} - - {/if} -
- - - {/each} - - -
- - - - {#if totalPages > 1} -
-

- {($t.validation?.showing || 'Showing {count} of {total}') - .replace('{count}', String(tasks.length)) - .replace('{total}', String(total))} -

- -
- {/if} - {/if} + + {#if m.totalPages > 1} +
+

{($t.validation?.showing || 'Showing {count} of {total}').replace('{count}', String(m.tasks.length)).replace('{total}', String(m.total))}

+ +
+ {/if} + {/if}