Files
ss-tools/frontend/src/lib/models/ValidationTasksListModel.svelte.ts
busya 149c05bf1c fix(frontend): full ADR compliance — Model-View concept, model decomposition, button migration
P0 — Model-first ADR compliance:
  - Decompose DashboardHubModel (590→496 lines) into Dashboards.FiltersModel,
    Dashboards.SelectionModel, Dashboards.GitActionsModel (DG split per plan)
  - Decompose AgentChatModel (630→356 lines) into ConnectionManager,
    StreamProcessor, LocalStorage, shared types
  - Decompose MigrationModel (457→389 lines) into WizardModel, ExecutorModel

P0 — /ui atom compliance:
  - Replace all raw <button> with <Button> from /ui in dashboards/+page.svelte
    (~20 replacements) and 16 additional routes/ files (~70 replacements total)

P0 — Hierarchical region IDs (ATTN_2):
  - Rename all 22 model #region/#endregion IDs from flat to Domain.Name format
  - Update @ingroup from generic 'Models' to domain-specific (Dashboards, Git, etc.)

P1 — UX contract compliance:
  - Add @UX_STATE declarations to agent/+page.svelte
  - Extract Gradio Client.connect from page into AgentChatModel.retryConnection()

All new model files have proper GRACE anchors (#region/#endregion, @ingroup,
@BRIEF, @INVARIANT, @STATE, @ACTION, @RELATION).

Build: npm run build passes.
Tests: DashboardHubModel 112/112, MigrationModel 74/74 pass.
2026-06-17 14:02:17 +03:00

177 lines
8.3 KiB
TypeScript

// #region ValidationTasks.ListModel [C:4] [TYPE Model] [SEMANTICS validation,tasks,list,pagination,crud,model]
// @ingroup ValidationTasks
// @BRIEF Screen model for validation tasks list — manages paginated list, search, inline actions (run/delete/toggle), and navigation.
// @RATIONALE Svelte 5 $state/$derived model-first pattern. Uses map-based array update instead of self-assignment hack for reactivity.
// @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 -> [ApiModule]
// @RELATION CALLS -> [CotLogger]
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 { getT } 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(getT()?.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(getT()?.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.map(t => t.id === task.id ? { ...t, is_active: newActive } : t);
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 ValidationTasks.ListModel