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,7 +618,6 @@ 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>
@ -582,8 +627,8 @@ function JobRow({
<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={onToggle}> <button type="button" className="link-btn" onClick={onOpenDetail}>
{expanded ? t("collect.jobs.collapse") : t("collect.jobs.expand")} {t("collect.jobs.expand")}
</button> </button>
{canPause ? ( {canPause ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onPause}> <button type="button" className="link-btn" disabled={actionPending} onClick={onPause}>
@ -615,21 +660,6 @@ function JobRow({
) : null} ) : null}
</td> </td>
</tr> </tr>
{expanded ? (
<tr>
<td colSpan={6}>
<JobRunsPanel
jobId={job.id}
jobStatus={job.status}
failCount={job.fail_count}
commands={detail?.job.commands ?? ""}
onRetryFailed={onRetryFailed}
retryPending={actionPending}
/>
</td>
</tr>
) : null}
</>
); );
} }
@ -681,14 +711,12 @@ 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">
{t("collect.runs.filterDevice")}
<input <input
type="search" type="search"
value={runKeyword} value={runKeyword}
@ -698,9 +726,6 @@ function JobRunsPanel({
setRunPage(1); setRunPage(1);
}} }}
/> />
</label>
<label className="collect-runs-filter">
{t("collect.runs.filterStatus")}
<select <select
value={runStatus} value={runStatus}
onChange={(e) => { onChange={(e) => {
@ -715,22 +740,21 @@ function JobRunsPanel({
</option> </option>
))} ))}
</select> </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>
@ -770,22 +794,34 @@ function JobRunsPanel({
</td> </td>
</tr> </tr>
))} ))}
{!runsQuery.isLoading && runs.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody> </tbody>
</table> </table>
) : null} </div>
{runTotal > 0 ? ( </div>
<div className="pager"> <div className="ops-detail-modal__foot">
<div className="pager__meta">{t("common.pagerMeta", { total: runTotal, page: runPage, pages: runPages })}</div> <span className="muted">
<div className="pager__controls"> {t("common.pagerMeta", {
<button className="pager__btn" disabled={runPage <= 1} onClick={() => setRunPage(runPage - 1)}> 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")} {t("common.prevPage")}
</button> </button>
<button className="pager__btn" disabled={runPage >= runPages} onClick={() => setRunPage(runPage + 1)}> <button type="button" disabled={runPage >= runPages} onClick={() => setRunPage(runPage + 1)}>
{t("common.nextPage")} {t("common.nextPage")}
</button> </button>
</div> </div>
</div> </div>
) : null} </>
</div>
); );
} }