Show collect job run details in the shared ops modal.

Replace the inline expand row with the same detail dialog used by LLDP and config sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-05 11:34:22 +08:00
parent 19e06e4a2d
commit 0100fb8430
4 changed files with 202 additions and 158 deletions

View file

@ -648,6 +648,7 @@ const en = {
fail: "fail", fail: "fail",
expand: "Details", expand: "Details",
collapse: "Hide", collapse: "Hide",
detailTitle: "Job details",
pause: "Pause", pause: "Pause",
start: "Start", start: "Start",
starting: "Starting…", starting: "Starting…",

View file

@ -644,6 +644,7 @@ const zh = {
fail: "失败", fail: "失败",
expand: "详情", expand: "详情",
collapse: "收起", collapse: "收起",
detailTitle: "任务明细",
pause: "暂停", pause: "暂停",
start: "开始", start: "开始",
starting: "启动中…", starting: "启动中…",

View file

@ -2519,6 +2519,12 @@ pre {
font-family: ui-monospace, Consolas, monospace; font-family: ui-monospace, Consolas, monospace;
} }
.collect-cmd-preview--modal {
flex: 0 0 auto;
margin: 0 0 10px;
max-height: 88px;
}
.collect-runs-panel { .collect-runs-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -16,7 +16,7 @@ import {
import { queryKeys } from "../constants/queryKeys"; import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
import type { CollectionJobDetail, CollectionJobItem, EligibleNeItem } from "../types"; import type { CollectionJobItem, EligibleNeItem } from "../types";
import { pageCount } from "../utils/display"; import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time"; import { formatSystemTime } from "../utils/time";
@ -498,9 +498,7 @@ export function CollectPage() {
<JobRow <JobRow
key={job.id} key={job.id}
job={job} job={job}
expanded={expandedJobId === job.id} onOpenDetail={() => setExpandedJobId(job.id)}
detail={expandedJobId === job.id ? detailQuery.data : undefined}
onToggle={() => setExpandedJobId(expandedJobId === job.id ? "" : job.id)}
onPause={() => pauseMutation.mutate(job.id)} onPause={() => pauseMutation.mutate(job.id)}
onStart={() => startJobMutation.mutate(job.id)} onStart={() => startJobMutation.mutate(job.id)}
onRetryFailed={() => retryFailedMutation.mutate(job.id)} onRetryFailed={() => retryFailedMutation.mutate(job.id)}
@ -534,15 +532,65 @@ export function CollectPage() {
</div> </div>
</div> </div>
</section> </section>
{expandedJobId ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => setExpandedJobId("")}
>
<div
className="modal modal--wide ops-detail-modal ops-detail-modal--xl"
role="dialog"
aria-modal="true"
aria-label={t("collect.jobs.detailTitle")}
onClick={(e) => e.stopPropagation()}
>
<div className="ops-detail-modal__head">
<div className="ops-detail-modal__title">
<h3>{t("collect.jobs.detailTitle")}</h3>
<p className="muted">
{(() => {
const job = jobs.find((j) => j.id === expandedJobId) || detailQuery.data?.job;
return job
? `${job.title} · ${job.status} · ${job.success_count}/${job.ne_count}`
: expandedJobId.slice(0, 8);
})()}
</p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button type="button" onClick={() => setExpandedJobId("")}>
{t("networkConfigs.close")}
</button>
</div>
</div>
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
<JobRunsPanel
jobId={expandedJobId}
jobStatus={
jobs.find((j) => j.id === expandedJobId)?.status ||
detailQuery.data?.job.status ||
""
}
failCount={
jobs.find((j) => j.id === expandedJobId)?.fail_count ??
detailQuery.data?.job.fail_count ??
0
}
commands={detailQuery.data?.job.commands ?? ""}
onRetryFailed={() => retryFailedMutation.mutate(expandedJobId)}
retryPending={actionPending}
/>
</div>
</div>
) : null}
</div> </div>
); );
} }
function JobRow({ function JobRow({
job, job,
expanded, onOpenDetail,
detail,
onToggle,
onPause, onPause,
onStart, onStart,
onRetryFailed, onRetryFailed,
@ -551,9 +599,7 @@ function JobRow({
startPending, startPending,
}: { }: {
job: CollectionJobItem; job: CollectionJobItem;
expanded: boolean; onOpenDetail: () => void;
detail?: CollectionJobDetail;
onToggle: () => void;
onPause: () => void; onPause: () => void;
onStart: () => void; onStart: () => void;
onRetryFailed: () => void; onRetryFailed: () => void;
@ -572,64 +618,48 @@ function JobRow({
window.location.assign(collectionJobDownloadUrl(job.id)); window.location.assign(collectionJobDownloadUrl(job.id));
}; };
return ( return (
<> <tr>
<tr> <td>{job.title}</td>
<td>{job.title}</td> <td>{job.status}</td>
<td>{job.status}</td> <td>
<td> {job.success_count}/{job.ne_count} {t("collect.jobs.ok")}, {job.fail_count} {t("collect.jobs.fail")}
{job.success_count}/{job.ne_count} {t("collect.jobs.ok")}, {job.fail_count} {t("collect.jobs.fail")} </td>
</td> <td>{formatSystemTime(job.created_at)}</td>
<td>{formatSystemTime(job.created_at)}</td> <td>{job.last_run_at ? formatSystemTime(job.last_run_at) : t("common.empty")}</td>
<td>{job.last_run_at ? formatSystemTime(job.last_run_at) : t("common.empty")}</td> <td className="table-actions">
<td className="table-actions"> <button type="button" className="link-btn" onClick={onOpenDetail}>
<button type="button" className="link-btn" onClick={onToggle}> {t("collect.jobs.expand")}
{expanded ? t("collect.jobs.collapse") : t("collect.jobs.expand")} </button>
{canPause ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onPause}>
{t("collect.jobs.pause")}
</button> </button>
{canPause ? ( ) : null}
<button type="button" className="link-btn" disabled={actionPending} onClick={onPause}> {canStart ? (
{t("collect.jobs.pause")} <button type="button" className="link-btn" disabled={actionPending} onClick={onStart}>
</button> {startPending ? t("collect.jobs.starting") : t("collect.jobs.start")}
) : null}
{canStart ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onStart}>
{startPending ? t("collect.jobs.starting") : t("collect.jobs.start")}
</button>
) : null}
{canRetryFailed ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onRetryFailed}>
{t("collect.jobs.retryFailed")}
</button>
) : null}
<button
type="button"
className="link-btn"
disabled={actionPending || !hasResults}
onClick={downloadResults}
>
{t("collect.jobs.downloadResults")}
</button> </button>
{canDelete ? ( ) : null}
<button type="button" className="link-btn link-btn--danger" disabled={actionPending} onClick={onDelete}> {canRetryFailed ? (
{t("collect.jobs.delete")} <button type="button" className="link-btn" disabled={actionPending} onClick={onRetryFailed}>
</button> {t("collect.jobs.retryFailed")}
) : null} </button>
</td> ) : null}
</tr> <button
{expanded ? ( type="button"
<tr> className="link-btn"
<td colSpan={6}> disabled={actionPending || !hasResults}
<JobRunsPanel onClick={downloadResults}
jobId={job.id} >
jobStatus={job.status} {t("collect.jobs.downloadResults")}
failCount={job.fail_count} </button>
commands={detail?.job.commands ?? ""} {canDelete ? (
onRetryFailed={onRetryFailed} <button type="button" className="link-btn link-btn--danger" disabled={actionPending} onClick={onDelete}>
retryPending={actionPending} {t("collect.jobs.delete")}
/> </button>
</td> ) : null}
</tr> </td>
) : null} </tr>
</>
); );
} }
@ -681,111 +711,117 @@ function JobRunsPanel({
const runs = runsQuery.data?.items ?? []; const runs = runsQuery.data?.items ?? [];
return ( return (
<div className="collect-runs-panel"> <>
{jobStatus === "running" ? ( {jobStatus === "running" ? (
<p className="panel__hint panel__hint--live">{t("collect.jobs.runsInProgress")}</p> <p className="panel__hint panel__hint--live">{t("collect.jobs.runsInProgress")}</p>
) : null} ) : null}
{commands ? <pre className="collect-cmd-preview">{commands}</pre> : null} {commands ? <pre className="collect-cmd-preview collect-cmd-preview--modal">{commands}</pre> : null}
<div className="collect-runs-toolbar"> <div className="ops-detail-modal__toolbar filter-inline collect-runs-toolbar">
<label className="collect-runs-filter"> <input
{t("collect.runs.filterDevice")} type="search"
<input value={runKeyword}
type="search" placeholder={t("collect.runs.deviceKeywordPh")}
value={runKeyword} onChange={(e) => {
placeholder={t("collect.runs.deviceKeywordPh")} setRunKeyword(e.target.value);
onChange={(e) => { setRunPage(1);
setRunKeyword(e.target.value); }}
setRunPage(1); />
}} <select
/> value={runStatus}
</label> onChange={(e) => {
<label className="collect-runs-filter"> setRunStatus(e.target.value);
{t("collect.runs.filterStatus")} setRunPage(1);
<select }}
value={runStatus} >
onChange={(e) => { <option value="">{t("collect.runs.allStatus")}</option>
setRunStatus(e.target.value); {RUN_STATUS_OPTIONS.map((st) => (
setRunPage(1); <option key={st} value={st}>
}} {st}
> </option>
<option value="">{t("collect.runs.allStatus")}</option> ))}
{RUN_STATUS_OPTIONS.map((st) => ( </select>
<option key={st} value={st}>
{st}
</option>
))}
</select>
</label>
{runStatus || runKeyword ? ( {runStatus || runKeyword ? (
<button type="button" className="link-btn" onClick={clearRunFilters}> <button type="button" onClick={clearRunFilters}>
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </button>
) : null} ) : null}
{jobStatus !== "running" && failCount > 0 ? ( {jobStatus !== "running" && failCount > 0 ? (
<button type="button" className="link-btn" disabled={retryPending} onClick={onRetryFailed}> <button type="button" disabled={retryPending} onClick={onRetryFailed}>
{t("collect.jobs.retryFailed")} {t("collect.jobs.retryFailed")}
</button> </button>
) : null} ) : null}
</div> </div>
{runsQuery.isLoading ? <p>{t("common.refreshing")}</p> : null} {runsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!runsQuery.isLoading && runs.length === 0 ? <p>{t("common.empty")}</p> : null} <div className="ops-detail-modal__scroll">
{runs.length > 0 ? ( <div className="pt-list-table-wrap">
<table> <table className="data-table pt-list-table">
<thead> <thead>
<tr> <tr>
<th>{t("managedNe.col.source")}</th> <th>{t("managedNe.col.source")}</th>
<th>{t("managedNe.col.name")}</th> <th>{t("managedNe.col.name")}</th>
<th>{t("managedNe.col.ip")}</th> <th>{t("managedNe.col.ip")}</th>
<th>{t("collect.runs.status")}</th> <th>{t("collect.runs.status")}</th>
<th>{t("collect.runs.message")}</th> <th>{t("collect.runs.message")}</th>
<th>{t("collect.runs.download")}</th> <th>{t("collect.runs.download")}</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id}>
<td>
<span className="table-tag">{run.ne_source || "managed"}</span>
</td>
<td>{run.ne_name}</td>
<td>{run.ne_ip}</td>
<td>{run.status}</td>
<td>
{run.message ? (
<div className="collect-run-message" title={run.message}>
{run.message}
</div>
) : (
t("common.empty")
)}
</td>
<td>
{run.has_output ? (
<a className="link-btn" href={collectionRunDownloadUrl(run.id)} target="_blank" rel="noreferrer">
{t("collect.runs.downloadFile")}
</a>
) : (
t("common.empty")
)}
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {runs.map((run) => (
) : null} <tr key={run.id}>
{runTotal > 0 ? ( <td>
<div className="pager"> <span className="table-tag">{run.ne_source || "managed"}</span>
<div className="pager__meta">{t("common.pagerMeta", { total: runTotal, page: runPage, pages: runPages })}</div> </td>
<div className="pager__controls"> <td>{run.ne_name}</td>
<button className="pager__btn" disabled={runPage <= 1} onClick={() => setRunPage(runPage - 1)}> <td>{run.ne_ip}</td>
{t("common.prevPage")} <td>{run.status}</td>
</button> <td>
<button className="pager__btn" disabled={runPage >= runPages} onClick={() => setRunPage(runPage + 1)}> {run.message ? (
{t("common.nextPage")} <div className="collect-run-message" title={run.message}>
</button> {run.message}
</div> </div>
) : (
t("common.empty")
)}
</td>
<td>
{run.has_output ? (
<a className="link-btn" href={collectionRunDownloadUrl(run.id)} target="_blank" rel="noreferrer">
{t("collect.runs.downloadFile")}
</a>
) : (
t("common.empty")
)}
</td>
</tr>
))}
{!runsQuery.isLoading && runs.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div> </div>
) : null} </div>
</div> <div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(runTotal),
page: String(runPage),
pages: String(runPages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={runPage <= 1} onClick={() => setRunPage(runPage - 1)}>
{t("common.prevPage")}
</button>
<button type="button" disabled={runPage >= runPages} onClick={() => setRunPage(runPage + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
</>
); );
} }