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:
174
frontend/src/lib/models/ValidationTasksListModel.svelte.ts
Normal file
174
frontend/src/lib/models/ValidationTasksListModel.svelte.ts
Normal 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
|
||||||
@@ -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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user