feat(web): schedule control, next-run, broken badge, run-log modal
This commit is contained in:
@@ -18,6 +18,9 @@ export interface Task {
|
||||
dst_endpoint_id: number
|
||||
status: string
|
||||
folder_mapping?: Record<string, string>
|
||||
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<Run[]>(`/api/tasks/${taskId}/runs`)
|
||||
|
||||
export const importCSV = (id: number, file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Run[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setRuns(null)
|
||||
listRuns(taskId).then((r) => setRuns(r ?? [])).catch(() => setRuns([]))
|
||||
}, [open, taskId])
|
||||
|
||||
return (
|
||||
<Modal open={open} title="Run log" onClose={onClose} size="lg">
|
||||
<div className="tbl-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Started</th>
|
||||
<th>Finished</th>
|
||||
<th>Trigger</th>
|
||||
<th>Status</th>
|
||||
<th>Copied</th>
|
||||
<th>Skipped</th>
|
||||
<th>Errors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs === null ? (
|
||||
<tr className="empty-row"><td colSpan={7}>loading…</td></tr>
|
||||
) : runs.length === 0 ? (
|
||||
<tr className="empty-row"><td colSpan={7}>no runs yet</td></tr>
|
||||
) : (
|
||||
runs.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{fmt(r.started_at)}</td>
|
||||
<td>{fmt(r.finished_at)}</td>
|
||||
<td>{r.trigger}</td>
|
||||
<td><StatusBadge status={r.status} /></td>
|
||||
<td className="num-cell">{r.total_copied}</td>
|
||||
<td className="num-cell">{r.total_skipped}</td>
|
||||
<td className="num-cell">{r.total_errors}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const [live, setLive] = useState<Record<number, LiveProgress>>({})
|
||||
const [showRuns, setShowRuns] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="panel">
|
||||
@@ -374,6 +386,7 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
</h1>
|
||||
</div>
|
||||
<StatusBadge status={task.status} />
|
||||
{task.broken && <span className="badge badge-fail" style={{ marginLeft: 8 }}><span className="dot" />broken</span>}
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
@@ -406,6 +419,28 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
</button>
|
||||
{!allTested && accounts.length > 0 && <span className="hint">run unlocks once every account tests OK on both sides</span>}
|
||||
</div>
|
||||
<div className="sched-row">
|
||||
<label htmlFor="sched">Schedule</label>
|
||||
<select
|
||||
id="sched"
|
||||
value={task.schedule_interval_seconds ?? 0}
|
||||
onChange={(e) => onSchedule(Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>Off</option>
|
||||
<option value={3600}>Every 1h</option>
|
||||
<option value={10800}>Every 3h</option>
|
||||
<option value={21600}>Every 6h</option>
|
||||
<option value={43200}>Every 12h</option>
|
||||
<option value={86400}>Every 24h</option>
|
||||
</select>
|
||||
{task.next_run_at && (
|
||||
<span className="sched-next">Next run: {new Date(task.next_run_at).toLocaleString()}</span>
|
||||
)}
|
||||
{task.broken && <span className="badge badge-fail"><span className="dot" />broken</span>}
|
||||
<button type="button" className="link-btn" onClick={() => setShowRuns(true)}>
|
||||
runs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-grid">
|
||||
@@ -635,6 +670,7 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
onConfirm={saveEditMapping}
|
||||
/>
|
||||
)}
|
||||
<RunLogModal taskId={id} open={showRuns} onClose={() => setShowRuns(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ export function Tasks() {
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={t.status} />
|
||||
{t.broken && <span className="badge badge-fail" style={{ marginLeft: 8 }}><span className="dot" />broken</span>}
|
||||
</td>
|
||||
<td className="num-cell">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user