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
|
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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 { 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)} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user