feat(web): schedule control, next-run, broken badge, run-log modal

This commit is contained in:
2026-07-03 13:19:14 +07:00
parent d2c69c6a5e
commit e8acab6920
5 changed files with 147 additions and 2 deletions
+20
View File
@@ -18,6 +18,9 @@ export interface Task {
dst_endpoint_id: number dst_endpoint_id: number
status: string status: string
folder_mapping?: Record<string, string> folder_mapping?: Record<string, string>
schedule_interval_seconds?: number
broken?: boolean
next_run_at?: string | null
} }
export type TestStatus = 'pending' | 'ok' | 'fail' | string 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 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) => { export const importCSV = (id: number, file: File) => {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
+33
View 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; } .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 ---------- */ /* ---------- tables ---------- */
.tbl-wrap { .tbl-wrap {
+55
View File
@@ -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>
)
}
+38 -2
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react' 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 { connectTaskWS, type TaskEvent } from '../ws'
import { StatusBadge } from '../components/StatusBadge' import { StatusBadge } from '../components/StatusBadge'
import { useConfirm } from '../components/ConfirmProvider' import { useConfirm } from '../components/ConfirmProvider'
import { FolderMappingModal } from '../components/FolderMappingModal' import { FolderMappingModal } from '../components/FolderMappingModal'
import { RunLogModal } from '../components/RunLogModal'
const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' } const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' }
@@ -85,6 +86,7 @@ export function TaskDetail({ id }: { id: number }) {
const confirm = useConfirm() const confirm = useConfirm()
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [live, setLive] = useState<Record<number, LiveProgress>>({}) const [live, setLive] = useState<Record<number, LiveProgress>>({})
const [showRuns, setShowRuns] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
function reload() { function reload() {
@@ -164,7 +166,7 @@ export function TaskDetail({ id }: { id: number }) {
} }
// Structural events refresh the persisted view; `progress` is covered by live state. // 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() 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) { if (notFound) {
return ( return (
<div className="panel"> <div className="panel">
@@ -374,6 +386,7 @@ export function TaskDetail({ id }: { id: number }) {
</h1> </h1>
</div> </div>
<StatusBadge status={task.status} /> <StatusBadge status={task.status} />
{task.broken && <span className="badge badge-fail" style={{ marginLeft: 8 }}><span className="dot" />broken</span>}
</div> </div>
<div className="panel"> <div className="panel">
@@ -406,6 +419,28 @@ export function TaskDetail({ id }: { id: number }) {
</button> </button>
{!allTested && accounts.length > 0 && <span className="hint">run unlocks once every account tests OK on both sides</span>} {!allTested && accounts.length > 0 && <span className="hint">run unlocks once every account tests OK on both sides</span>}
</div> </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>
<div className="panel-grid"> <div className="panel-grid">
@@ -635,6 +670,7 @@ export function TaskDetail({ id }: { id: number }) {
onConfirm={saveEditMapping} onConfirm={saveEditMapping}
/> />
)} )}
<RunLogModal taskId={id} open={showRuns} onClose={() => setShowRuns(false)} />
</> </>
) )
} }
+1
View File
@@ -163,6 +163,7 @@ export function Tasks() {
</td> </td>
<td> <td>
<StatusBadge status={t.status} /> <StatusBadge status={t.status} />
{t.broken && <span className="badge badge-fail" style={{ marginLeft: 8 }}><span className="dot" />broken</span>}
</td> </td>
<td className="num-cell"> <td className="num-cell">
<button <button