Add account selection checkboxes and selective run button
This commit is contained in:
@@ -1056,3 +1056,16 @@ table.tbl a.rowlink:hover {
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tbl .chk-col {
|
||||||
|
width: 32px;
|
||||||
|
text-align: center;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
.tbl .chk-col input[type='checkbox'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tbl .chk-col input[type='checkbox']:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
const [live, setLive] = useState<Record<number, LiveProgress>>({})
|
const [live, setLive] = useState<Record<number, LiveProgress>>({})
|
||||||
const [showRuns, setShowRuns] = useState(false)
|
const [showRuns, setShowRuns] = useState(false)
|
||||||
const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null)
|
const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null)
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
@@ -332,7 +333,8 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
setBusy('run')
|
setBusy('run')
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await runTask(id)
|
const ids = accounts.filter((a) => selected.has(a.id)).map((a) => a.id)
|
||||||
|
await runTask(id, ids.length ? ids : undefined)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to start run')
|
setError(err instanceof Error ? err.message : 'Failed to start run')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -366,7 +368,33 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { task, accounts } = data
|
const { task, accounts } = data
|
||||||
const allTested = accounts.length > 0 && accounts.every((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
|
const isRunning = task.status === 'running'
|
||||||
|
// A row is selectable only when both connection tests pass and no run is live.
|
||||||
|
const selectableIds = accounts
|
||||||
|
.filter((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
|
||||||
|
.map((a) => a.id)
|
||||||
|
const selectableSet = new Set(selectableIds)
|
||||||
|
// Effective set: the checked accounts, or all accounts when nothing is checked.
|
||||||
|
const effectiveSelected = accounts.filter((a) => selected.has(a.id))
|
||||||
|
const runSet = effectiveSelected.length > 0 ? effectiveSelected : accounts
|
||||||
|
const runReady =
|
||||||
|
runSet.length > 0 && runSet.every((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
|
||||||
|
const allSelectableChecked =
|
||||||
|
selectableIds.length > 0 && selectableIds.every((id) => selected.has(id))
|
||||||
|
const someSelectableChecked = selectableIds.some((id) => selected.has(id))
|
||||||
|
|
||||||
|
function toggleOne(accId: number, checked: boolean) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (checked) next.add(accId)
|
||||||
|
else next.delete(accId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(checked: boolean) {
|
||||||
|
setSelected(checked ? new Set(selectableIds) : new Set())
|
||||||
|
}
|
||||||
// Prefer live (WS) copied/skipped over the DB values, which only advance per
|
// Prefer live (WS) copied/skipped over the DB values, which only advance per
|
||||||
// folder — so the summary moves in real time during a large folder.
|
// folder — so the summary moves in real time during a large folder.
|
||||||
const totals = accounts.reduce(
|
const totals = accounts.reduce(
|
||||||
@@ -418,10 +446,20 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
<button className="btn" onClick={onTest} disabled={busy !== null || accounts.length === 0}>
|
<button className="btn" onClick={onTest} disabled={busy !== null || accounts.length === 0}>
|
||||||
{busy === 'test' ? 'Testing…' : 'Test connections'}
|
{busy === 'test' ? 'Testing…' : 'Test connections'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary" onClick={onRun} disabled={busy !== null || !allTested || task.status === 'running'}>
|
<button className="btn btn-primary" onClick={onRun} disabled={busy !== null || !runReady || isRunning}>
|
||||||
{busy === 'run' ? 'Starting…' : 'Run migration'}
|
{busy === 'run'
|
||||||
|
? 'Starting…'
|
||||||
|
: effectiveSelected.length > 0
|
||||||
|
? `Run selected (${effectiveSelected.length})`
|
||||||
|
: 'Run migration'}
|
||||||
</button>
|
</button>
|
||||||
{!allTested && accounts.length > 0 && <span className="hint">run unlocks once every account tests OK on both sides</span>}
|
{!runReady && accounts.length > 0 && (
|
||||||
|
<span className="hint">
|
||||||
|
{effectiveSelected.length > 0
|
||||||
|
? 'selected accounts must pass both connection tests'
|
||||||
|
: 'run unlocks once every account tests OK on both sides'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="sched-row">
|
<div className="sched-row">
|
||||||
<label htmlFor="sched">Schedule</label>
|
<label htmlFor="sched">Schedule</label>
|
||||||
@@ -540,6 +578,18 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
<table className="tbl">
|
<table className="tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th className="chk-col">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="Select all accounts"
|
||||||
|
checked={allSelectableChecked}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) el.indeterminate = !allSelectableChecked && someSelectableChecked
|
||||||
|
}}
|
||||||
|
disabled={isRunning || selectableIds.length === 0}
|
||||||
|
onChange={(e) => toggleAll(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th>Account</th>
|
<th>Account</th>
|
||||||
<th>Src test</th>
|
<th>Src test</th>
|
||||||
<th>Dst test</th>
|
<th>Dst test</th>
|
||||||
@@ -554,11 +604,20 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{accounts.length === 0 ? (
|
{accounts.length === 0 ? (
|
||||||
<tr className="empty-row">
|
<tr className="empty-row">
|
||||||
<td colSpan={9}>no accounts yet — add one or import a CSV above</td>
|
<td colSpan={10}>no accounts yet — add one or import a CSV above</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
accounts.map((a) => (
|
accounts.map((a) => (
|
||||||
<tr key={a.id}>
|
<tr key={a.id}>
|
||||||
|
<td className="chk-col">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Select ${a.src_login}`}
|
||||||
|
checked={selected.has(a.id)}
|
||||||
|
disabled={isRunning || !selectableSet.has(a.id)}
|
||||||
|
onChange={(e) => toggleOne(a.id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="acct-ident">
|
<div className="acct-ident">
|
||||||
<span>{a.src_login}</span>
|
<span>{a.src_login}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user