feat(web): per-account folders button + edit-mapping modal
This commit is contained in:
@@ -659,6 +659,13 @@ table.tbl a.rowlink:hover {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-row td {
|
.empty-row td {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--fg-faint);
|
color: var(--fg-faint);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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, probeFolders, runTask, setFolderMapping, testAccounts, type TaskDetail as TaskDetailData } from '../api'
|
import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, 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'
|
||||||
@@ -75,6 +75,13 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
const [form, setForm] = useState(emptyAccount)
|
const [form, setForm] = useState(emptyAccount)
|
||||||
const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | 'delete' | 'probe' | null>(null)
|
const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | 'delete' | 'probe' | null>(null)
|
||||||
const [mapState, setMapState] = useState<{ src: string[]; dst: string[]; creds: typeof emptyAccount } | null>(null)
|
const [mapState, setMapState] = useState<{ src: string[]; dst: string[]; creds: typeof emptyAccount } | null>(null)
|
||||||
|
const [editMap, setEditMap] = useState<{
|
||||||
|
accId: number
|
||||||
|
src: string[]
|
||||||
|
dst: string[]
|
||||||
|
mapping: Record<string, string>
|
||||||
|
excluded: string[]
|
||||||
|
} | null>(null)
|
||||||
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>>({})
|
||||||
@@ -186,13 +193,13 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmMapping(mapping: Record<string, string>) {
|
async function confirmMapping(mapping: Record<string, string>, excluded: string[]) {
|
||||||
if (!mapState) return
|
if (!mapState) return
|
||||||
setBusy('add')
|
setBusy('add')
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await createAccount(id, mapState.creds)
|
const { id: accId } = await createAccount(id, mapState.creds)
|
||||||
await setFolderMapping(id, mapping)
|
await setAccountFolderMapping(id, accId, mapping, excluded)
|
||||||
setForm(emptyAccount)
|
setForm(emptyAccount)
|
||||||
setMapState(null)
|
setMapState(null)
|
||||||
reload()
|
reload()
|
||||||
@@ -203,6 +210,47 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onEditFolders(a: { id: number; src_login: string; folder_mapping?: Record<string, string>; excluded_folders?: string[] }) {
|
||||||
|
setBusy('probe')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await probeAccountFolders(id, a.id)
|
||||||
|
if (!res.src.ok || !res.dst.ok) {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (!res.src.ok) parts.push(`source: ${res.src.error ?? 'login failed'}`)
|
||||||
|
if (!res.dst.ok) parts.push(`destination: ${res.dst.error ?? 'login failed'}`)
|
||||||
|
setError(`Folder probe failed — ${parts.join('; ')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEditMap({
|
||||||
|
accId: a.id,
|
||||||
|
src: res.src.folders ?? [],
|
||||||
|
dst: res.dst.folders ?? [],
|
||||||
|
mapping: a.folder_mapping ?? {},
|
||||||
|
excluded: a.excluded_folders ?? [],
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to probe folders')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditMapping(mapping: Record<string, string>, excluded: string[]) {
|
||||||
|
if (!editMap) return
|
||||||
|
setBusy('add')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await setAccountFolderMapping(id, editMap.accId, mapping, excluded)
|
||||||
|
setEditMap(null)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save mapping')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function downloadExampleCSV() {
|
function downloadExampleCSV() {
|
||||||
const sample = [
|
const sample = [
|
||||||
'alice@source.example,SrcPass1,alice@dest.example,DstPass1',
|
'alice@source.example,SrcPass1,alice@dest.example,DstPass1',
|
||||||
@@ -528,20 +576,32 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
<td className="num-cell">{live[a.id]?.skipped ?? a.skipped}</td>
|
<td className="num-cell">{live[a.id]?.skipped ?? a.skipped}</td>
|
||||||
<td className="num-cell">{a.errors}</td>
|
<td className="num-cell">{a.errors}</td>
|
||||||
<td className="num-cell">
|
<td className="num-cell">
|
||||||
{a.status === 'running' ? (
|
<div className="row-actions">
|
||||||
<button type="button" className="link-btn danger" onClick={() => onCancelAccount(a.id)}>
|
{a.status !== 'running' && (
|
||||||
cancel
|
<button
|
||||||
</button>
|
type="button"
|
||||||
) : (
|
className="link-btn"
|
||||||
<button
|
onClick={() => onEditFolders(a)}
|
||||||
type="button"
|
disabled={busy !== null}
|
||||||
className="link-btn danger"
|
>
|
||||||
onClick={() => onDeleteAccount(a.id, a.src_login)}
|
folders
|
||||||
disabled={busy !== null || data?.task.status === 'running'}
|
</button>
|
||||||
>
|
)}
|
||||||
remove
|
{a.status === 'running' ? (
|
||||||
</button>
|
<button type="button" className="link-btn danger" onClick={() => onCancelAccount(a.id)}>
|
||||||
)}
|
cancel
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-btn danger"
|
||||||
|
onClick={() => onDeleteAccount(a.id, a.src_login)}
|
||||||
|
disabled={busy !== null || data?.task.status === 'running'}
|
||||||
|
>
|
||||||
|
remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@@ -558,10 +618,23 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
srcFolders={mapState.src}
|
srcFolders={mapState.src}
|
||||||
dstFolders={mapState.dst}
|
dstFolders={mapState.dst}
|
||||||
initialMapping={task.folder_mapping ?? {}}
|
initialMapping={task.folder_mapping ?? {}}
|
||||||
|
initialExcluded={[]}
|
||||||
onCancel={() => setMapState(null)}
|
onCancel={() => setMapState(null)}
|
||||||
onConfirm={confirmMapping}
|
onConfirm={confirmMapping}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{editMap && (
|
||||||
|
<FolderMappingModal
|
||||||
|
key={`edit-${editMap.accId}`}
|
||||||
|
open
|
||||||
|
srcFolders={editMap.src}
|
||||||
|
dstFolders={editMap.dst}
|
||||||
|
initialMapping={editMap.mapping}
|
||||||
|
initialExcluded={editMap.excluded}
|
||||||
|
onCancel={() => setEditMap(null)}
|
||||||
|
onConfirm={saveEditMapping}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user