diff --git a/web/src/api.ts b/web/src/api.ts index 55049ca..1e5d0df 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -18,6 +18,9 @@ export interface Task { dst_endpoint_id: number status: string folder_mapping?: Record + schedule_interval_seconds?: number + broken?: boolean + next_run_at?: string | null } export type TestStatus = 'pending' | 'ok' | 'fail' | string @@ -137,6 +140,23 @@ export const testAccounts = (id: number) => api(`/api/tasks/${id}/test`, { metho export const runTask = (id: number) => api(`/api/tasks/${id}/run`, { method: 'POST' }) +export interface Run { + id: number + task_id: number + status: string + started_at: string + finished_at: string | null + total_copied: number + total_skipped: number + total_errors: number + trigger: string +} + +export const setTaskSchedule = (taskId: number, intervalSeconds: number) => + api(`/api/tasks/${taskId}/schedule`, { ...jsonBody({ interval_seconds: intervalSeconds }), method: 'PUT' }) + +export const listRuns = (taskId: number) => api(`/api/tasks/${taskId}/runs`) + export const importCSV = (id: number, file: File) => { const fd = new FormData() fd.append('file', file) diff --git a/web/src/app.css b/web/src/app.css index a03fdc1..ce58e13 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -605,6 +605,39 @@ table.tbl a.rowlink:focus-visible { } .badge-info .dot { background: var(--info); animation: pulse 1.4s ease-in-out infinite; } +/* ---------- schedule row ---------- */ + +.sched-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 14px; + flex-wrap: wrap; +} + +.sched-row label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fg-dim); +} + +.sched-row select { + background: var(--bg-inset); + border: 1px solid var(--border); + color: var(--fg); + padding: 7px 10px; + font-size: 13px; + border-radius: 2px; +} + +.sched-next { + font-size: 12px; + color: var(--fg-dim); + font-variant-numeric: tabular-nums; +} + /* ---------- tables ---------- */ .tbl-wrap { diff --git a/web/src/components/RunLogModal.tsx b/web/src/components/RunLogModal.tsx new file mode 100644 index 0000000..823d1b2 --- /dev/null +++ b/web/src/components/RunLogModal.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react' +import { Modal } from './Modal' +import { StatusBadge } from './StatusBadge' +import { listRuns, type Run } from '../api' + +const fmt = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : '—') + +export function RunLogModal({ taskId, open, onClose }: { taskId: number; open: boolean; onClose: () => void }) { + const [runs, setRuns] = useState(null) + + useEffect(() => { + if (!open) return + setRuns(null) + listRuns(taskId).then((r) => setRuns(r ?? [])).catch(() => setRuns([])) + }, [open, taskId]) + + return ( + +
+ + + + + + + + + + + + + + {runs === null ? ( + + ) : runs.length === 0 ? ( + + ) : ( + runs.map((r) => ( + + + + + + + + + + )) + )} + +
StartedFinishedTriggerStatusCopiedSkippedErrors
loading…
no runs yet
{fmt(r.started_at)}{fmt(r.finished_at)}{r.trigger}{r.total_copied}{r.total_skipped}{r.total_errors}
+
+
+ ) +} diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx index 204f023..dd6f85d 100644 --- a/web/src/pages/TaskDetail.tsx +++ b/web/src/pages/TaskDetail.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react' -import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, testAccounts, type TaskDetail as TaskDetailData } from '../api' +import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, type TaskDetail as TaskDetailData } from '../api' import { connectTaskWS, type TaskEvent } from '../ws' import { StatusBadge } from '../components/StatusBadge' import { useConfirm } from '../components/ConfirmProvider' import { FolderMappingModal } from '../components/FolderMappingModal' +import { RunLogModal } from '../components/RunLogModal' const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' } @@ -85,6 +86,7 @@ export function TaskDetail({ id }: { id: number }) { const confirm = useConfirm() const [error, setError] = useState(null) const [live, setLive] = useState>({}) + const [showRuns, setShowRuns] = useState(false) const fileInputRef = useRef(null) function reload() { @@ -164,7 +166,7 @@ export function TaskDetail({ id }: { id: number }) { } // Structural events refresh the persisted view; `progress` is covered by live state. - if (['account_started', 'account_test', 'account_done', 'run_started', 'run_done', 'error', 'folder', 'cancelled', 'plan'].includes(ev.type)) { + if (['account_started', 'account_test', 'account_done', 'run_started', 'run_done', 'error', 'folder', 'cancelled', 'plan', 'task_broken'].includes(ev.type)) { reload() } }), @@ -334,6 +336,16 @@ export function TaskDetail({ id }: { id: number }) { } } + async function onSchedule(intervalSeconds: number) { + setError(null) + try { + await setTaskSchedule(id, intervalSeconds) + reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to set schedule') + } + } + if (notFound) { return (
@@ -374,6 +386,7 @@ export function TaskDetail({ id }: { id: number }) {
+ {task.broken && broken}
@@ -406,6 +419,28 @@ export function TaskDetail({ id }: { id: number }) { {!allTested && accounts.length > 0 && run unlocks once every account tests OK on both sides}
+
+ + + {task.next_run_at && ( + Next run: {new Date(task.next_run_at).toLocaleString()} + )} + {task.broken && broken} + +
@@ -635,6 +670,7 @@ export function TaskDetail({ id }: { id: number }) { onConfirm={saveEditMapping} /> )} + setShowRuns(false)} /> ) } diff --git a/web/src/pages/Tasks.tsx b/web/src/pages/Tasks.tsx index 8fc127c..382e3c7 100644 --- a/web/src/pages/Tasks.tsx +++ b/web/src/pages/Tasks.tsx @@ -163,6 +163,7 @@ export function Tasks() { + {t.broken && broken}