mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
feat(collect): draft jobs, merged create UI, and unified start
Create collection jobs without auto-running; add /start for first run and re-run. Merge create form with filterable NE picker and selected list. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
fed7f56ad0
commit
f1ed4fe753
9 changed files with 343 additions and 147 deletions
|
|
@ -67,8 +67,8 @@ def reconcile_stale_collection_job(db: Session, job_id: str) -> bool:
|
|||
if st in _TERMINAL:
|
||||
continue
|
||||
if st == "pending":
|
||||
# Pending while job is running means queued in the worker pool, not stuck.
|
||||
if str(job.status or "") == "running":
|
||||
# Pending while job is running/queued, or draft job not started yet.
|
||||
if str(job.status or "") in ("running", "pending"):
|
||||
continue
|
||||
anchor = job.started_at or job.created_at
|
||||
limit = pending_stale_sec
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from .collection_service import (
|
||||
build_collection_job_zip,
|
||||
create_and_start_collection,
|
||||
create_collection,
|
||||
delete_collection_job,
|
||||
get_collection_job,
|
||||
list_collection_jobs,
|
||||
|
|
@ -15,6 +15,7 @@ from .collection_service import (
|
|||
pause_collection_job,
|
||||
resolve_run_output_path,
|
||||
restart_collection_job,
|
||||
start_collection_job,
|
||||
retry_failed_collection_job,
|
||||
)
|
||||
from .collection_schemas import CollectionJobCreate
|
||||
|
|
@ -29,25 +30,15 @@ router = APIRouter(prefix="/v1/ne-collections", tags=["ne-collections"])
|
|||
def api_eligible_ne(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=200, ge=1, le=500),
|
||||
keyword: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_eligible_ne(db, page=page, page_size=page_size)
|
||||
return list_eligible_ne(db, page=page, page_size=page_size, keyword=keyword)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def api_create_collection(
|
||||
body: CollectionJobCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
out, payload = create_and_start_collection(db, body)
|
||||
background_tasks.add_task(
|
||||
dispatch_collection_runs,
|
||||
payload["job_id"],
|
||||
payload["run_ids"],
|
||||
payload["commands"],
|
||||
)
|
||||
return out.model_dump()
|
||||
def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_db)):
|
||||
return create_collection(db, body).model_dump()
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
@ -103,12 +94,29 @@ def api_pause_collection(job_id: str, db: Session = Depends(get_db)):
|
|||
return pause_collection_job(db, job_id).model_dump()
|
||||
|
||||
|
||||
@router.post("/{job_id}/start")
|
||||
def api_start_collection(
|
||||
job_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
out, payload = start_collection_job(db, job_id)
|
||||
background_tasks.add_task(
|
||||
dispatch_collection_runs,
|
||||
payload["job_id"],
|
||||
payload["run_ids"],
|
||||
payload["commands"],
|
||||
)
|
||||
return out.model_dump()
|
||||
|
||||
|
||||
@router.post("/{job_id}/restart")
|
||||
def api_restart_collection(
|
||||
job_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Alias of /start for backward compatibility."""
|
||||
out, payload = restart_collection_job(db, job_id)
|
||||
background_tasks.add_task(
|
||||
dispatch_collection_runs,
|
||||
|
|
|
|||
|
|
@ -111,8 +111,25 @@ def run_to_out(row: NeCollectionRun) -> CollectionRunOut:
|
|||
)
|
||||
|
||||
|
||||
def list_eligible_ne(db: Session, *, page: int = 1, page_size: int = 200) -> dict[str, Any]:
|
||||
def list_eligible_ne(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 200,
|
||||
keyword: str = "",
|
||||
) -> dict[str, Any]:
|
||||
stmt = db.query(ManagedNE).filter(ManagedNE.connect_status == "pass")
|
||||
kw = str(keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
stmt = stmt.filter(
|
||||
or_(
|
||||
ManagedNE.name.ilike(like),
|
||||
ManagedNE.ip_address.ilike(like),
|
||||
ManagedNE.vendor.ilike(like),
|
||||
ManagedNE.device_type.ilike(like),
|
||||
)
|
||||
)
|
||||
total = int(stmt.count())
|
||||
rows = (
|
||||
stmt.order_by(ManagedNE.name.asc())
|
||||
|
|
@ -135,9 +152,7 @@ def list_eligible_ne(db: Session, *, page: int = 1, page_size: int = 200) -> dic
|
|||
return {"total": total, "page": page, "page_size": page_size, "items": items}
|
||||
|
||||
|
||||
def create_and_start_collection(
|
||||
db: Session, body: CollectionJobCreate
|
||||
) -> tuple[CollectionJobOut, CollectionSchedulePayload]:
|
||||
def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOut:
|
||||
commands = _parse_commands(body.commands)
|
||||
if not commands:
|
||||
raise HTTPException(status_code=400, detail="commands_empty")
|
||||
|
|
@ -168,16 +183,15 @@ def create_and_start_collection(
|
|||
job = NeCollectionJob(
|
||||
title=str(body.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}",
|
||||
commands="\n".join(commands),
|
||||
status="running",
|
||||
status="pending",
|
||||
ne_count=len(ne_rows),
|
||||
created_at=now,
|
||||
started_at=now,
|
||||
last_run_at=now,
|
||||
started_at=None,
|
||||
last_run_at=None,
|
||||
)
|
||||
db.add(job)
|
||||
db.flush()
|
||||
|
||||
run_ids: list[str] = []
|
||||
for ne in ne_rows:
|
||||
run = NeCollectionRun(
|
||||
job_id=str(job.id),
|
||||
|
|
@ -187,16 +201,9 @@ def create_and_start_collection(
|
|||
status="pending",
|
||||
)
|
||||
db.add(run)
|
||||
run_ids.append(str(run.id))
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
|
||||
payload: CollectionSchedulePayload = {
|
||||
"job_id": str(job.id),
|
||||
"run_ids": run_ids,
|
||||
"commands": commands,
|
||||
}
|
||||
return job_to_out(job, output_count=0), payload
|
||||
return job_to_out(job, output_count=0)
|
||||
|
||||
|
||||
def list_collection_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
|
|
@ -355,7 +362,8 @@ def _start_job_retry(
|
|||
return job_to_out(job, output_count=_output_count_for_job(db, job_id)), payload
|
||||
|
||||
|
||||
def restart_collection_job(db: Session, job_id: str) -> tuple[CollectionJobOut, CollectionSchedulePayload]:
|
||||
def start_collection_job(db: Session, job_id: str) -> tuple[CollectionJobOut, CollectionSchedulePayload]:
|
||||
"""Start a draft job or re-run all NEs after pause/completion."""
|
||||
job = db.get(NeCollectionJob, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="collection_job_not_found")
|
||||
|
|
@ -367,6 +375,13 @@ def restart_collection_job(db: Session, job_id: str) -> tuple[CollectionJobOut,
|
|||
commands = _parse_commands(str(job.commands or ""))
|
||||
if not commands:
|
||||
raise HTTPException(status_code=400, detail="commands_empty")
|
||||
|
||||
if str(job.status or "") == "pending":
|
||||
run_ids = [str(r.id) for r in runs if str(r.status or "") == "pending"]
|
||||
if not run_ids:
|
||||
raise HTTPException(status_code=400, detail="collection_nothing_to_start")
|
||||
return _start_job_retry(db, job, job_id, run_ids, commands, reset_all_counts=False)
|
||||
|
||||
retry_ids = _reset_runs_for_retry(
|
||||
db,
|
||||
job_id,
|
||||
|
|
@ -376,6 +391,10 @@ def restart_collection_job(db: Session, job_id: str) -> tuple[CollectionJobOut,
|
|||
return _start_job_retry(db, job, job_id, retry_ids, commands, reset_all_counts=True)
|
||||
|
||||
|
||||
def restart_collection_job(db: Session, job_id: str) -> tuple[CollectionJobOut, CollectionSchedulePayload]:
|
||||
return start_collection_job(db, job_id)
|
||||
|
||||
|
||||
def retry_failed_collection_job(
|
||||
db: Session, job_id: str
|
||||
) -> tuple[CollectionJobOut, CollectionSchedulePayload]:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const queryKeys = {
|
|||
managedNe: (keyword: string, vendor: string, connectStatus: string, page: number, pageSize: number) =>
|
||||
["managedNe", keyword, vendor, connectStatus, page, pageSize] as const,
|
||||
collectionEligibleNeAll: ["collectionEligibleNe"] as const,
|
||||
collectionEligibleNe: (page: number) => ["collectionEligibleNe", page] as const,
|
||||
collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const,
|
||||
neCollectionsAll: ["neCollections"] as const,
|
||||
neCollections: (page: number) => ["neCollections", page] as const,
|
||||
neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,25 @@ const en = {
|
|||
langEn: "English",
|
||||
},
|
||||
collect: {
|
||||
create: {
|
||||
title: "New collection job",
|
||||
hint: "Configure commands and NEs, then create the job. Click Start in the job list to run collection.",
|
||||
selectedTitle: "Selected NEs",
|
||||
selectedEmpty: "Filter below and add NEs one by one.",
|
||||
selectedCount: "{{count}} selected",
|
||||
clearSelected: "Clear selection",
|
||||
remove: "Remove",
|
||||
pickTitle: "Eligible NEs (connectivity passed)",
|
||||
pickHint: "Only NEs with connect_status=pass. Run connectivity test in NE Management first.",
|
||||
pickEmpty: "No matching NEs.",
|
||||
filterKeyword: "Filter",
|
||||
filterKeywordPh: "Name / IP / vendor / device type",
|
||||
add: "Add",
|
||||
added: "Added",
|
||||
create: "Create job",
|
||||
creating: "Creating…",
|
||||
meta: "{{ne}} NE(s) selected · {{cmd}} command(s)",
|
||||
},
|
||||
eligible: {
|
||||
title: "Eligible NEs (connectivity passed)",
|
||||
hint: "Only NEs with connect_status=pass. Run connectivity test in NE Management first.",
|
||||
|
|
@ -58,7 +77,8 @@ const en = {
|
|||
start: "Start collection",
|
||||
starting: "Submitting…",
|
||||
},
|
||||
started: "Collection job started: {{id}}",
|
||||
created: "Collection job created: {{id}}",
|
||||
started: "Collection started: {{id}}",
|
||||
paused: "Job paused",
|
||||
restarted: "Collection restarted",
|
||||
retryFailedDone: "Retrying failed devices",
|
||||
|
|
@ -80,6 +100,8 @@ const en = {
|
|||
expand: "Details",
|
||||
collapse: "Hide",
|
||||
pause: "Pause",
|
||||
start: "Start",
|
||||
starting: "Starting…",
|
||||
restart: "Restart",
|
||||
retryFailed: "Retry failed",
|
||||
downloadResults: "Download results",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,25 @@ const zh = {
|
|||
langEn: "English",
|
||||
},
|
||||
collect: {
|
||||
create: {
|
||||
title: "新建采集任务",
|
||||
hint: "配置命令与网元后创建任务;创建后需在任务列表中点击「开始」执行采集。",
|
||||
selectedTitle: "已选择网元",
|
||||
selectedEmpty: "请从下方筛选并逐个添加网元。",
|
||||
selectedCount: "共 {{count}} 台",
|
||||
clearSelected: "清空已选",
|
||||
remove: "移除",
|
||||
pickTitle: "可选网元(连通性已通过)",
|
||||
pickHint: "仅展示 connect_status=pass 的网元。请先在「网元管理」完成连通性测试。",
|
||||
pickEmpty: "没有匹配的网元。",
|
||||
filterKeyword: "筛选",
|
||||
filterKeywordPh: "名称 / IP / 厂商 / 设备类型",
|
||||
add: "添加",
|
||||
added: "已添加",
|
||||
create: "创建任务",
|
||||
creating: "创建中…",
|
||||
meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令",
|
||||
},
|
||||
eligible: {
|
||||
title: "可选网元(连通性已通过)",
|
||||
hint: "仅展示 connect_status=pass 的网元。请先在「网元管理」完成连通性测试。",
|
||||
|
|
@ -58,7 +77,8 @@ const zh = {
|
|||
start: "开始采集",
|
||||
starting: "提交中…",
|
||||
},
|
||||
started: "采集任务已启动:{{id}}",
|
||||
created: "采集任务已创建:{{id}}",
|
||||
started: "采集已开始:{{id}}",
|
||||
paused: "任务已暂停",
|
||||
restarted: "已重新开始采集",
|
||||
retryFailedDone: "已开始重采失败网元",
|
||||
|
|
@ -80,6 +100,8 @@ const zh = {
|
|||
expand: "详情",
|
||||
collapse: "收起",
|
||||
pause: "暂停",
|
||||
start: "开始",
|
||||
starting: "启动中…",
|
||||
restart: "重新开始",
|
||||
retryFailed: "重采失败网元",
|
||||
downloadResults: "下载结果",
|
||||
|
|
|
|||
|
|
@ -909,6 +909,64 @@ pre {
|
|||
margin-right: auto;
|
||||
}
|
||||
|
||||
.collect-create-panel h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.collect-selected-block,
|
||||
.collect-pick-block {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.collect-selected-block__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.collect-selected-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.collect-selected-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.collect-selected-chip__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.collect-selected-chip__meta {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.collect-pick-filters {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.collect-cmd-preview {
|
||||
font-size: 12px;
|
||||
background: #f8fafc;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
fetchEligibleNe,
|
||||
fetchNeCollections,
|
||||
pauseCollectionJob,
|
||||
restartCollectionJob,
|
||||
startCollectionJob,
|
||||
retryFailedCollectionJob,
|
||||
collectionJobDownloadUrl,
|
||||
collectionRunDownloadUrl,
|
||||
|
|
@ -20,6 +20,9 @@ import type { CollectionJobDetail, CollectionJobItem, EligibleNeItem } from "../
|
|||
import { pageCount } from "../utils/display";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
const POLL_MS = 2000;
|
||||
const ELIGIBLE_PAGE_SIZE = 20;
|
||||
|
||||
export function CollectPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
|
|
@ -27,17 +30,19 @@ export function CollectPage() {
|
|||
|
||||
const [commands, setCommands] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, EligibleNeItem>>({});
|
||||
const [neKeyword, setNeKeyword] = useState("");
|
||||
const [nePage, setNePage] = useState(1);
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [expandedJobId, setExpandedJobId] = useState("");
|
||||
|
||||
const POLL_MS = 2000;
|
||||
const ELIGIBLE_PAGE_SIZE = 20;
|
||||
const selectedIds = useMemo(() => Object.keys(selectedMap), [selectedMap]);
|
||||
const selectedList = useMemo(() => Object.values(selectedMap), [selectedMap]);
|
||||
|
||||
const eligibleQuery = useQuery({
|
||||
queryKey: queryKeys.collectionEligibleNe(nePage),
|
||||
queryFn: () => fetchEligibleNe({ page: nePage, pageSize: ELIGIBLE_PAGE_SIZE }),
|
||||
queryKey: queryKeys.collectionEligibleNe(nePage, neKeyword),
|
||||
queryFn: () =>
|
||||
fetchEligibleNe({ page: nePage, pageSize: ELIGIBLE_PAGE_SIZE, keyword: neKeyword }),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
|
|
@ -95,10 +100,10 @@ export function CollectPage() {
|
|||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: restartCollectionJob,
|
||||
const startJobMutation = useMutation({
|
||||
mutationFn: startCollectionJob,
|
||||
onSuccess: async (job) => {
|
||||
showOk(t("collect.restarted"));
|
||||
showOk(t("collect.started", { id: job.id }));
|
||||
setExpandedJobId(job.id);
|
||||
await invalidateJobs(job.id);
|
||||
},
|
||||
|
|
@ -125,15 +130,15 @@ export function CollectPage() {
|
|||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createNeCollection({
|
||||
title: title.trim(),
|
||||
commands,
|
||||
ne_ids: selected,
|
||||
ne_ids: selectedIds,
|
||||
}),
|
||||
onSuccess: async (job) => {
|
||||
showOk(t("collect.started", { id: job.id }));
|
||||
showOk(t("collect.created", { id: job.id }));
|
||||
setExpandedJobId(job.id);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(job.id) });
|
||||
|
|
@ -141,18 +146,21 @@ export function CollectPage() {
|
|||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const items = eligibleQuery.data?.items ?? [];
|
||||
const allSelected = items.length > 0 && items.every((x) => selected.includes(x.id));
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
const ids = new Set(items.map((x) => x.id));
|
||||
setSelected((prev) => prev.filter((id) => !ids.has(id)));
|
||||
} else {
|
||||
setSelected((prev) => [...new Set([...prev, ...items.map((x) => x.id)])]);
|
||||
}
|
||||
const addNe = (row: EligibleNeItem) => {
|
||||
setSelectedMap((prev) => (prev[row.id] ? prev : { ...prev, [row.id]: row }));
|
||||
};
|
||||
|
||||
const removeNe = (id: string) => {
|
||||
setSelectedMap((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearSelected = () => setSelectedMap({});
|
||||
|
||||
const eligibleItems = eligibleQuery.data?.items ?? [];
|
||||
const neTotal = eligibleQuery.data?.total ?? 0;
|
||||
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
|
||||
|
||||
|
|
@ -168,77 +176,20 @@ export function CollectPage() {
|
|||
[commands],
|
||||
);
|
||||
|
||||
const actionPending =
|
||||
pauseMutation.isPending ||
|
||||
startJobMutation.isPending ||
|
||||
retryFailedMutation.isPending ||
|
||||
deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<div>
|
||||
<h2>{t("collect.eligible.title")}</h2>
|
||||
<p className="panel__hint">{t("collect.eligible.hint")}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => eligibleQuery.refetch()} disabled={eligibleQuery.isFetching}>
|
||||
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
<section className="panel collect-create-panel">
|
||||
<div>
|
||||
<h2>{t("collect.create.title")}</h2>
|
||||
<p className="panel__hint">{t("collect.create.hint")}</p>
|
||||
</div>
|
||||
{eligibleQuery.isLoading ? <p>{t("common.refreshing")}</p> : null}
|
||||
{!eligibleQuery.isLoading && items.length === 0 ? (
|
||||
<p>{t("collect.eligible.empty")}</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="select all" />
|
||||
</th>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.vendor")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
<th>{t("managedNe.col.connect")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row: EligibleNeItem) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={() =>
|
||||
setSelected((prev) =>
|
||||
prev.includes(row.id) ? prev.filter((x) => x !== row.id) : [...prev, row.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>{row.name || row.ip_address}</td>
|
||||
<td>{row.vendor}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>
|
||||
<span className="conn-pill conn-pill--up">{row.connect_status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{neTotal > 0 ? (
|
||||
<div className="pager">
|
||||
<div className="pager__meta">{t("common.pagerMeta", { total: neTotal, page: nePage, pages: nePages })}</div>
|
||||
<div className="pager__controls">
|
||||
<button className="pager__btn" disabled={nePage <= 1} onClick={() => setNePage(nePage - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button className="pager__btn" disabled={nePage >= nePages} onClick={() => setNePage(nePage + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h2>{t("collect.form.title")}</h2>
|
||||
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
|
||||
<div className="form-grid form-grid--single">
|
||||
<label className="form-grid__full">
|
||||
{t("collect.form.jobTitle")}
|
||||
|
|
@ -248,23 +199,137 @@ export function CollectPage() {
|
|||
{t("collect.form.commands")}
|
||||
<textarea
|
||||
className="collect-commands"
|
||||
rows={10}
|
||||
rows={8}
|
||||
value={commands}
|
||||
onChange={(e) => setCommands(e.target.value)}
|
||||
placeholder={t("collect.form.commandsPh")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
|
||||
|
||||
<div className="collect-selected-block">
|
||||
<div className="collect-selected-block__head">
|
||||
<h3>{t("collect.create.selectedTitle")}</h3>
|
||||
<span className="collect-meta">{t("collect.create.selectedCount", { count: selectedList.length })}</span>
|
||||
{selectedList.length > 0 ? (
|
||||
<button type="button" className="link-btn" onClick={clearSelected}>
|
||||
{t("collect.create.clearSelected")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedList.length === 0 ? (
|
||||
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p>
|
||||
) : (
|
||||
<div className="collect-selected-list">
|
||||
{selectedList.map((row) => (
|
||||
<div key={row.id} className="collect-selected-chip">
|
||||
<span className="collect-selected-chip__main">
|
||||
<strong>{row.name || row.ip_address}</strong>
|
||||
<span className="collect-selected-chip__meta">
|
||||
{row.ip_address}
|
||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="link-btn" onClick={() => removeNe(row.id)}>
|
||||
{t("collect.create.remove")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="collect-pick-block">
|
||||
<div className="panel__toolbar">
|
||||
<div>
|
||||
<h3>{t("collect.create.pickTitle")}</h3>
|
||||
<p className="panel__hint">{t("collect.create.pickHint")}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => eligibleQuery.refetch()} disabled={eligibleQuery.isFetching}>
|
||||
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="collect-pick-filters">
|
||||
<label className="collect-runs-filter">
|
||||
{t("collect.create.filterKeyword")}
|
||||
<input
|
||||
type="search"
|
||||
value={neKeyword}
|
||||
placeholder={t("collect.create.filterKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setNeKeyword(e.target.value);
|
||||
setNePage(1);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{eligibleQuery.isLoading ? <p>{t("common.refreshing")}</p> : null}
|
||||
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
|
||||
<p>{t("collect.create.pickEmpty")}</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.vendor")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
<th>{t("managedNe.col.connect")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eligibleItems.map((row) => {
|
||||
const picked = Boolean(selectedMap[row.id]);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td>{row.name || row.ip_address}</td>
|
||||
<td>{row.vendor}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>
|
||||
<span className="conn-pill conn-pill--up">{row.connect_status}</span>
|
||||
</td>
|
||||
<td className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
disabled={picked}
|
||||
onClick={() => addNe(row)}
|
||||
>
|
||||
{picked ? t("collect.create.added") : t("collect.create.add")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{neTotal > 0 ? (
|
||||
<div className="pager">
|
||||
<div className="pager__meta">
|
||||
{t("common.pagerMeta", { total: neTotal, page: nePage, pages: nePages })}
|
||||
</div>
|
||||
<div className="pager__controls">
|
||||
<button className="pager__btn" disabled={nePage <= 1} onClick={() => setNePage(nePage - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button className="pager__btn" disabled={nePage >= nePages} onClick={() => setNePage(nePage + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="panel__actions">
|
||||
<span className="collect-meta">
|
||||
{t("collect.form.meta", { ne: selected.length, cmd: commandLines })}
|
||||
</span>
|
||||
<span className="collect-meta">{t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.length === 0 || commandLines === 0 || startMutation.isPending}
|
||||
onClick={() => startMutation.mutate()}
|
||||
disabled={selectedIds.length === 0 || commandLines === 0 || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{startMutation.isPending ? t("collect.form.starting") : t("collect.form.start")}
|
||||
{createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -304,17 +369,13 @@ export function CollectPage() {
|
|||
detail={expandedJobId === job.id ? detailQuery.data : undefined}
|
||||
onToggle={() => setExpandedJobId(expandedJobId === job.id ? "" : job.id)}
|
||||
onPause={() => pauseMutation.mutate(job.id)}
|
||||
onRestart={() => restartMutation.mutate(job.id)}
|
||||
onStart={() => startJobMutation.mutate(job.id)}
|
||||
onRetryFailed={() => retryFailedMutation.mutate(job.id)}
|
||||
onDelete={() => {
|
||||
if (window.confirm(t("collect.confirmDelete"))) deleteMutation.mutate(job.id);
|
||||
}}
|
||||
actionPending={
|
||||
pauseMutation.isPending ||
|
||||
restartMutation.isPending ||
|
||||
retryFailedMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
actionPending={actionPending}
|
||||
startPending={startJobMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -341,24 +402,26 @@ function JobRow({
|
|||
detail,
|
||||
onToggle,
|
||||
onPause,
|
||||
onRestart,
|
||||
onStart,
|
||||
onRetryFailed,
|
||||
onDelete,
|
||||
actionPending,
|
||||
startPending,
|
||||
}: {
|
||||
job: CollectionJobItem;
|
||||
expanded: boolean;
|
||||
detail?: CollectionJobDetail;
|
||||
onToggle: () => void;
|
||||
onPause: () => void;
|
||||
onRestart: () => void;
|
||||
onStart: () => void;
|
||||
onRetryFailed: () => void;
|
||||
onDelete: () => void;
|
||||
actionPending: boolean;
|
||||
startPending: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const canPause = job.status === "running";
|
||||
const canRestart = job.status !== "running";
|
||||
const canStart = job.status !== "running";
|
||||
const canRetryFailed = job.status !== "running" && job.fail_count > 0;
|
||||
const canDelete = job.status !== "running";
|
||||
const hasResults = (job.output_count ?? 0) > 0;
|
||||
|
|
@ -385,9 +448,9 @@ function JobRow({
|
|||
{t("collect.jobs.pause")}
|
||||
</button>
|
||||
) : null}
|
||||
{canRestart ? (
|
||||
<button type="button" className="link-btn" disabled={actionPending} onClick={onRestart}>
|
||||
{t("collect.jobs.restart")}
|
||||
{canStart ? (
|
||||
<button type="button" className="link-btn" disabled={actionPending} onClick={onStart}>
|
||||
{startPending ? t("collect.jobs.starting") : t("collect.jobs.start")}
|
||||
</button>
|
||||
) : null}
|
||||
{canRetryFailed ? (
|
||||
|
|
|
|||
|
|
@ -142,10 +142,11 @@ export const importManagedNe = async (file: File): Promise<ManagedNeImportResult
|
|||
return data as ManagedNeImportResult;
|
||||
};
|
||||
|
||||
export const fetchEligibleNe = (params: { page: number; pageSize: number }) => {
|
||||
export const fetchEligibleNe = (params: { page: number; pageSize: number; keyword?: string }) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, params.page)));
|
||||
p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize))));
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: EligibleNeItem[] }>(
|
||||
`/v1/ne-collections/eligible-ne?${p.toString()}`,
|
||||
);
|
||||
|
|
@ -183,6 +184,9 @@ export const fetchCollectionRuns = (params: {
|
|||
export const pauseCollectionJob = (jobId: string) =>
|
||||
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/pause`, {});
|
||||
|
||||
export const startCollectionJob = (jobId: string) =>
|
||||
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/start`, {});
|
||||
|
||||
export const restartCollectionJob = (jobId: string) =>
|
||||
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/restart`, {});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue