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.
177 lines
8.3 KiB
TypeScript
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
|