Unify ops detail dialogs and rename workbench monitoring section.

LLDP job detail, config-sync cycle tasks, and UME fold panels now use the shared modal layout; workbench section label is 系统对接.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-05 10:04:19 +08:00
parent 5a77e36839
commit b7a179d492
6 changed files with 1245 additions and 723 deletions

View file

@ -21,7 +21,7 @@ const en = {
backToWorkbench: "Back to Workbench", backToWorkbench: "Back to Workbench",
openInNewTab: "Open in new tab", openInNewTab: "Open in new tab",
openModule: "Open or switch to this module tab", openModule: "Open or switch to this module tab",
monitoring: "Monitoring", monitoring: "System integration",
operations: "Operations", operations: "Operations",
system: "System", system: "System",
welcomeEyebrow: "Workbench", welcomeEyebrow: "Workbench",
@ -350,6 +350,7 @@ const en = {
targetKeywordPh: "Name / IP / vendor", targetKeywordPh: "Name / IP / vendor",
jobsTitle: "Collect jobs", jobsTitle: "Collect jobs",
jobDetailTitle: "Job details", jobDetailTitle: "Job details",
expand: "Details",
edgesTitle: "Fabric links", edgesTitle: "Fabric links",
edgeKeywordPh: "Endpoint name or IP", edgeKeywordPh: "Endpoint name or IP",
edgeStatusAll: "All statuses", edgeStatusAll: "All statuses",
@ -417,7 +418,7 @@ const en = {
tasksTitle: "Cycle tasks", tasksTitle: "Cycle tasks",
taskKeywordPh: "Name / IP / message", taskKeywordPh: "Name / IP / message",
allStatus: "All statuses", allStatus: "All statuses",
expand: "Expand", expand: "Details",
collapse: "Collapse", collapse: "Collapse",
kpi: { kpi: {
snapshots: "Snapshots", snapshots: "Snapshots",
@ -910,8 +911,8 @@ const en = {
}, },
keyAlert: { keyAlert: {
title: "AI alarm monitor (OClaw WhatsApp)", title: "AI alarm monitor (OClaw WhatsApp)",
showPanel: "Expand", showPanel: "Details",
hidePanel: "Collapse", hidePanel: "Close",
help: "Exact match by notificationId, or substring match on alarm description (case-insensitive; nativeProbableCause, etc.). Optionally restrict to UME inventory ne_type values (multi-select; empty = all devices). Pushed via OClaw WebSocket. Shares NETX_OCLAW_ANALYZE_TOKEN.", help: "Exact match by notificationId, or substring match on alarm description (case-insensitive; nativeProbableCause, etc.). Optionally restrict to UME inventory ne_type values (multi-select; empty = all devices). Pushed via OClaw WebSocket. Shares NETX_OCLAW_ANALYZE_TOKEN.",
ws: "OClaw WSS", ws: "OClaw WSS",
wsConnected: "connected", wsConnected: "connected",
@ -1030,19 +1031,21 @@ const en = {
}, },
syncStatus: { syncStatus: {
title: "Sync history", title: "Sync history",
showPanel: "Expand", showPanel: "Details",
hidePanel: "Collapse", hidePanel: "Close",
empty: "No sync records", empty: "No sync records",
summary: "{{total}} record(s), {{running}} running",
}, },
ne: { ne: {
title: "Network elements", title: "Network elements",
showPanel: "Expand", showPanel: "Details",
hidePanel: "Collapse", hidePanel: "Close",
keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)", keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)",
clearTitle: "Clear keyword and reset to page 1", clearTitle: "Clear keyword and reset to page 1",
expand: "Expand details", expand: "Details",
collapse: "Collapse details", collapse: "Close",
cliConnect: "CLI connect", cliConnect: "CLI connect",
summary: "{{total}} inventory NE(s)",
}, },
tabs: { tabs: {
main: "UME sync", main: "UME sync",
@ -1050,8 +1053,8 @@ const en = {
}, },
cli: { cli: {
title: "UME CLI connection profile", title: "UME CLI connection profile",
showPanel: "Expand", showPanel: "Details",
hidePanel: "Collapse", hidePanel: "Close",
hint: "Unified SSH credentials and hop/bastion for UME inventory NEs. OClaw MCP can exec show commands via ume_ne_id without per-device managed_ne entries.", hint: "Unified SSH credentials and hop/bastion for UME inventory NEs. OClaw MCP can exec show commands via ume_ne_id without per-device managed_ne entries.",
statusReady: "CLI profile is ready for UME NE command execution.", statusReady: "CLI profile is ready for UME NE command execution.",
statusNotReady: "Set target username and hop/bastion settings (if enabled).", statusNotReady: "Set target username and hop/bastion settings (if enabled).",

View file

@ -21,7 +21,7 @@ const zh = {
backToWorkbench: "返回工作台", backToWorkbench: "返回工作台",
openInNewTab: "在新标签页中打开", openInNewTab: "在新标签页中打开",
openModule: "打开或切换到该模块页签", openModule: "打开或切换到该模块页签",
monitoring: "监控", monitoring: "系统对接",
operations: "运维", operations: "运维",
system: "系统管理", system: "系统管理",
welcomeEyebrow: "工作台", welcomeEyebrow: "工作台",
@ -347,6 +347,7 @@ const zh = {
targetKeywordPh: "名称 / IP / 厂商", targetKeywordPh: "名称 / IP / 厂商",
jobsTitle: "采集任务", jobsTitle: "采集任务",
jobDetailTitle: "任务明细", jobDetailTitle: "任务明细",
expand: "详情",
edgesTitle: "Fabric 链路", edgesTitle: "Fabric 链路",
edgeKeywordPh: "本端 / 对端名称或 IP", edgeKeywordPh: "本端 / 对端名称或 IP",
edgeStatusAll: "全部状态", edgeStatusAll: "全部状态",
@ -414,7 +415,7 @@ const zh = {
tasksTitle: "周期任务", tasksTitle: "周期任务",
taskKeywordPh: "名称 / IP / 消息", taskKeywordPh: "名称 / IP / 消息",
allStatus: "全部状态", allStatus: "全部状态",
expand: "展开", expand: "详情",
collapse: "收起", collapse: "收起",
kpi: { kpi: {
snapshots: "配置覆盖数", snapshots: "配置覆盖数",
@ -904,8 +905,8 @@ const zh = {
}, },
keyAlert: { keyAlert: {
title: "AI 告警监控(OClaw WhatsApp)", title: "AI 告警监控(OClaw WhatsApp)",
showPanel: "展开", showPanel: "详情",
hidePanel: "收起", hidePanel: "关闭",
help: "按 notificationId 精确匹配,或按告警描述关键字模糊匹配(不区分大小写,匹配 nativeProbableCause 等字段);可选限定 UME Inventory 中的网元类型 (ne_type),不选则匹配全部设备。经 OClaw WebSocket 推送到 WhatsApp 群。鉴权与 NETX_OCLAW_ANALYZE_TOKEN 共用。", help: "按 notificationId 精确匹配,或按告警描述关键字模糊匹配(不区分大小写,匹配 nativeProbableCause 等字段);可选限定 UME Inventory 中的网元类型 (ne_type),不选则匹配全部设备。经 OClaw WebSocket 推送到 WhatsApp 群。鉴权与 NETX_OCLAW_ANALYZE_TOKEN 共用。",
ws: "OClaw WSS", ws: "OClaw WSS",
wsConnected: "已连接", wsConnected: "已连接",
@ -1024,19 +1025,21 @@ const zh = {
}, },
syncStatus: { syncStatus: {
title: "同步状态", title: "同步状态",
showPanel: "展开", showPanel: "详情",
hidePanel: "收起", hidePanel: "关闭",
empty: "暂无同步记录", empty: "暂无同步记录",
summary: "共 {{total}} 条记录,进行中 {{running}}",
}, },
ne: { ne: {
title: "网元清单", title: "网元清单",
showPanel: "展开", showPanel: "详情",
hidePanel: "收起", hidePanel: "关闭",
keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)", keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)",
clearTitle: "清空 keyword,回到第 1 页", clearTitle: "清空 keyword,回到第 1 页",
expand: "展开详情", expand: "详情",
collapse: "收起详情", collapse: "关闭",
cliConnect: "CLI 连通", cliConnect: "CLI 连通",
summary: "Inventory 网元 {{total}} 台",
}, },
tabs: { tabs: {
main: "UME 同步", main: "UME 同步",
@ -1044,8 +1047,8 @@ const zh = {
}, },
cli: { cli: {
title: "UME CLI 连接配置", title: "UME CLI 连接配置",
showPanel: "展开", showPanel: "详情",
hidePanel: "收起", hidePanel: "关闭",
hint: "为 UME 清单网元配置统一 SSH 凭据与跳板/堡垒机;OClaw MCP 可通过 ume_ne_id 直接执行 show 命令,无需逐台录入 managed_ne。", hint: "为 UME 清单网元配置统一 SSH 凭据与跳板/堡垒机;OClaw MCP 可通过 ume_ne_id 直接执行 show 命令,无需逐台录入 managed_ne。",
statusReady: "CLI 配置已就绪,可对 UME 网元执行命令。", statusReady: "CLI 配置已就绪,可对 UME 网元执行命令。",
statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。", statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。",

View file

@ -8100,6 +8100,8 @@ pre {
color: #94a3b8; color: #94a3b8;
} }
/* Shared detail dialog for config view / LLDP jobs / config-sync cycles */
.ops-detail-modal,
.nm-config-modal { .nm-config-modal {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -8108,6 +8110,7 @@ pre {
overflow: hidden; overflow: hidden;
} }
.ops-detail-modal__head,
.nm-config-modal__head { .nm-config-modal__head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -8115,21 +8118,172 @@ pre {
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 12px; margin-bottom: 12px;
flex: 0 0 auto;
} }
.ops-detail-modal__title h3,
.nm-config-modal__title h3 { .nm-config-modal__title h3 {
margin: 0 0 4px; margin: 0 0 4px;
} }
.ops-detail-modal__title .muted,
.nm-config-modal__title .muted { .nm-config-modal__title .muted {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
} }
.ops-detail-modal__actions,
.nm-config-modal__actions { .nm-config-modal__actions {
flex: 0 0 auto; flex: 0 0 auto;
} }
.ops-detail-modal__toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 10px;
flex: 0 0 auto;
}
.ops-detail-modal__scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 8px;
background: rgba(2, 8, 23, 0.35);
}
.ops-detail-modal__scroll .pt-list-table-wrap,
.ops-detail-modal__scroll .data-table {
border: 0;
}
.ops-detail-modal__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 12px;
flex: 0 0 auto;
}
.ops-detail-modal__error {
margin: 0 0 10px;
color: #fca5a5;
font-size: 13px;
}
.ops-detail-modal--xl {
width: min(1100px, 100%);
}
.ops-detail-modal__scroll--pad {
padding: 12px 14px;
}
.ume-entry-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.ume-page > .ume-entry-grid > .panel {
padding: 16px 18px 18px;
border-color: var(--nm-panel-border);
box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06);
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 118px;
}
.ume-entry .panel__toolbar {
margin-bottom: 0;
padding-bottom: 10px;
}
.ume-entry__summary {
margin: 0;
font-size: 13px;
line-height: 1.45;
flex: 1 1 auto;
}
.ume-entry__pills {
margin: 0;
flex-wrap: wrap;
}
.ume-modal-form {
flex: 0 0 auto;
margin-bottom: 10px;
}
.ume-modal-form__grow {
flex: 1 1 160px;
min-width: 140px;
}
.ume-modal-form__label {
margin: 0 0 6px;
font-size: 13px;
}
.ume-modal-form__hint {
margin: 6px 0 0;
font-size: 12px;
}
.ume-modal-form__opts {
margin-top: 8px;
margin-bottom: 0;
}
.ume-ne-types {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
max-height: 120px;
overflow: auto;
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 8px;
background: rgba(2, 8, 23, 0.35);
}
.ume-ne-types--tall {
max-height: 240px;
}
.ume-ne-types__item {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
font-size: 13px;
}
.ume-ne-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 18px;
font-size: 13px;
}
.ume-ne-detail-grid > div > .muted {
display: block;
font-size: 11px;
margin-bottom: 2px;
}
.nowrap {
white-space: nowrap;
}
.nm-config-modal__tabs { .nm-config-modal__tabs {
margin-bottom: 10px; margin-bottom: 10px;
} }

View file

@ -436,12 +436,15 @@ export function ConfigSyncPage() {
<td> <td>
<button <button
type="button" type="button"
className="btn btn--sm btn--ghost"
onClick={() => { onClick={() => {
setExpandedCycleId((id) => (id === c.id ? "" : c.id)); setExpandedCycleId(c.id);
setTaskPage(1); setTaskPage(1);
setTaskKeyword("");
setTaskStatus("");
}} }}
> >
{expandedCycleId === c.id ? t("configSync.collapse") : t("configSync.expand")} {t("configSync.expand")}
</button> </button>
</td> </td>
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td> <td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
@ -524,74 +527,127 @@ export function ConfigSyncPage() {
</div> </div>
{expandedCycleId ? ( {expandedCycleId ? (
<div style={{ marginTop: 16 }}> <div
<h3>{t("configSync.tasksTitle")}</h3> className="modal-backdrop"
<div className="filter-inline"> role="presentation"
<input onClick={() => setExpandedCycleId("")}
value={taskKeyword} >
placeholder={t("configSync.taskKeywordPh")} <div
onChange={(e) => { className="modal modal--wide ops-detail-modal"
setTaskKeyword(e.target.value); role="dialog"
setTaskPage(1); aria-modal="true"
}} aria-label={t("configSync.tasksTitle")}
/> onClick={(e) => e.stopPropagation()}
<select >
value={taskStatus} <div className="ops-detail-modal__head">
onChange={(e) => { <div className="ops-detail-modal__title">
setTaskStatus(e.target.value); <h3>{t("configSync.tasksTitle")}</h3>
setTaskPage(1); <p className="muted">
}} {expandedCycleId.slice(0, 8)}
> {(() => {
<option value="">{t("configSync.allStatus")}</option> const c = cycles.find((x) => x.id === expandedCycleId);
<option value="pending">pending</option> return c
<option value="running">running</option> ? ` · ${c.trigger_mode} · ${c.status} · ${c.success_count}/${c.planned_count}`
<option value="success">success</option> : "";
<option value="fail">fail</option> })()}
<option value="skipped">skipped</option> </p>
</select> </div>
</div> <div className="btn-row ops-detail-modal__actions">
<table className="data-table"> <button type="button" onClick={() => setExpandedCycleId("")}>
<thead> {t("networkConfigs.close")}
<tr> </button>
<th>{t("configSync.col.name")}</th> </div>
<th>IP</th> </div>
<th>{t("configSync.col.vendor")}</th>
<th>{t("configSync.col.source")}</th> <div className="ops-detail-modal__toolbar filter-inline">
<th>{t("configSync.col.status")}</th> <input
<th>{t("configSync.col.message")}</th> value={taskKeyword}
</tr> placeholder={t("configSync.taskKeywordPh")}
</thead> onChange={(e) => {
<tbody> setTaskKeyword(e.target.value);
{(tasksQuery.data?.items ?? []).map((task) => ( setTaskPage(1);
<tr key={task.id}> }}
<td>{task.ne_name || task.target_id}</td> />
<td>{task.ne_ip}</td> <select
<td>{task.vendor || "-"}</td> value={taskStatus}
<td>{task.source}</td> onChange={(e) => {
<td>{task.status}</td> setTaskStatus(e.target.value);
<td title={task.message}>{task.message || "-"}</td> setTaskPage(1);
</tr> }}
))} >
</tbody> <option value="">{t("configSync.allStatus")}</option>
</table> <option value="pending">pending</option>
<div className="pager"> <option value="running">running</option>
<button type="button" disabled={taskPage <= 1} onClick={() => setTaskPage((p) => p - 1)}> <option value="success">success</option>
{t("common.prevPage")} <option value="fail">fail</option>
</button> <option value="skipped">skipped</option>
<span className="muted"> </select>
{t("common.pagerMeta", { </div>
total: String(tasksQuery.data?.total ?? 0),
page: String(taskPage), {tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
pages: String(pageCount(Number(tasksQuery.data?.total || 0), 20)), {tasksQuery.isError ? (
})} <p className="ops-detail-modal__error">
</span> {tasksQuery.error instanceof Error ? tasksQuery.error.message : String(tasksQuery.error)}
<button </p>
type="button" ) : null}
disabled={taskPage >= pageCount(Number(tasksQuery.data?.total || 0), 20)}
onClick={() => setTaskPage((p) => p + 1)} <div className="ops-detail-modal__scroll">
> <div className="pt-list-table-wrap">
{t("common.nextPage")} <table className="data-table pt-list-table">
</button> <thead>
<tr>
<th>{t("configSync.col.name")}</th>
<th>IP</th>
<th>{t("configSync.col.vendor")}</th>
<th>{t("configSync.col.source")}</th>
<th>{t("configSync.col.status")}</th>
<th>{t("configSync.col.message")}</th>
</tr>
</thead>
<tbody>
{(tasksQuery.data?.items ?? []).map((task) => (
<tr key={task.id}>
<td>{task.ne_name || task.target_id}</td>
<td>{task.ne_ip}</td>
<td>{task.vendor || "-"}</td>
<td>{task.source}</td>
<td>{task.status}</td>
<td title={task.message}>{task.message || "-"}</td>
</tr>
))}
{!tasksQuery.isLoading && !(tasksQuery.data?.items ?? []).length ? (
<tr>
<td colSpan={6} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(tasksQuery.data?.total ?? 0),
page: String(taskPage),
pages: String(pageCount(Number(tasksQuery.data?.total || 0), 20)),
})}
</span>
<div className="btn-row">
<button type="button" disabled={taskPage <= 1} onClick={() => setTaskPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button
type="button"
disabled={taskPage >= pageCount(Number(tasksQuery.data?.total || 0), 20)}
onClick={() => setTaskPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
</div>
</div> </div>
</div> </div>
) : null} ) : null}

File diff suppressed because it is too large Load diff

View file

@ -596,20 +596,19 @@ export function LldpLinksPage() {
</thead> </thead>
<tbody> <tbody>
{jobs.map((job) => { {jobs.map((job) => {
const open = expandedJobId === job.id;
return ( return (
<tr key={job.id} className={open ? "is-expanded" : undefined}> <tr key={job.id}>
<td> <td>
<button <button
type="button" type="button"
className="btn btn--sm btn--ghost" className="btn btn--sm btn--ghost"
onClick={() => { onClick={() => {
setItemDetail(null); setItemDetail(null);
setExpandedJobId(open ? "" : job.id); setExpandedJobId(job.id);
setItemPage(1); setItemPage(1);
}} }}
> >
{open ? "−" : "+"} {t("lldpLinks.expand")}
</button> </button>
</td> </td>
<td className="mono">{job.id.slice(0, 8)}</td> <td className="mono">{job.id.slice(0, 8)}</td>
@ -700,83 +699,128 @@ export function LldpLinksPage() {
</div> </div>
{expandedJobId ? ( {expandedJobId ? (
<div className="panel" style={{ marginTop: 16 }}> <div
<h3>{t("lldpLinks.jobDetailTitle")}</h3> className="modal-backdrop"
{jobDetailQuery.isLoading ? <p className="muted">…</p> : null} role="presentation"
{jobDetailQuery.data?.error ? ( onClick={() => {
<p className="muted" style={{ color: "var(--danger, #b00)" }}> setExpandedJobId("");
{jobDetailQuery.data.error} setItemDetail(null);
</p> }}
) : null} >
<table className="data-table"> <div
<thead> className="modal modal--wide ops-detail-modal"
<tr> role="dialog"
<th>{t("lldpLinks.col.name")}</th> aria-modal="true"
<th>IP</th> aria-label={t("lldpLinks.jobDetailTitle")}
<th>{t("lldpLinks.col.status")}</th> onClick={(e) => e.stopPropagation()}
<th>{t("lldpLinks.col.neighbors")}</th> >
<th>{t("lldpLinks.col.edges")}</th> <div className="ops-detail-modal__head">
<th>{t("lldpLinks.col.error")}</th> <div className="ops-detail-modal__title">
<th /> <h3>{t("lldpLinks.jobDetailTitle")}</h3>
</tr> <p className="muted">
</thead> {(() => {
<tbody> const job = jobs.find((j) => j.id === expandedJobId);
{detailItems.map((it) => { return job
const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0); ? `${job.id.slice(0, 8)} · ${job.status} · ${job.done}/${job.total}`
return ( : expandedJobId.slice(0, 8);
<tr key={it.id}> })()}
<td>{it.ne_name || it.ne_id || "—"}</td> </p>
<td>{it.ne_ip || "—"}</td> </div>
<td> <div className="btn-row ops-detail-modal__actions">
{!it.ok ? "fail" : it.parser_stub || unmatchedCount > 0 ? "warn" : "ok"} <button
</td> type="button"
<td>{it.neighbors}</td> onClick={() => {
<td> setExpandedJobId("");
+{it.edges_added} / ~{it.edges_updated} setItemDetail(null);
{unmatchedCount > 0 }}
? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}` >
: ""} {t("networkConfigs.close")}
</td> </button>
<td className="muted">{it.error || "—"}</td> </div>
<td> </div>
<button
type="button" {jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
className="btn btn--sm btn--ghost" {jobDetailQuery.data?.error ? (
onClick={() => setItemDetail(it)} <p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
> ) : null}
{t("topology.discoverViewDetail")}
</button> <div className="ops-detail-modal__scroll">
</td> <div className="pt-list-table-wrap">
</tr> <table className="data-table pt-list-table">
); <thead>
})} <tr>
{!detailItems.length && !jobDetailQuery.isLoading ? ( <th>{t("lldpLinks.col.name")}</th>
<tr> <th>IP</th>
<td colSpan={7} className="muted"> <th>{t("lldpLinks.col.status")}</th>
{t("common.empty")} <th>{t("lldpLinks.col.neighbors")}</th>
</td> <th>{t("lldpLinks.col.edges")}</th>
</tr> <th>{t("lldpLinks.col.error")}</th>
) : null} <th />
</tbody> </tr>
</table> </thead>
<div className="pager"> <tbody>
<button type="button" disabled={itemPage <= 1} onClick={() => setItemPage((p) => p - 1)}> {detailItems.map((it) => {
{t("common.prevPage")} const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
</button> return (
<span className="muted"> <tr key={it.id}>
{t("common.pagerMeta", { <td>{it.ne_name || it.ne_id || "—"}</td>
total: String(itemTotal), <td>{it.ne_ip || "—"}</td>
page: String(itemPage), <td>
pages: String(itemPages), {!it.ok ? "fail" : it.parser_stub || unmatchedCount > 0 ? "warn" : "ok"}
})} </td>
</span> <td>{it.neighbors}</td>
<button <td>
type="button" +{it.edges_added} / ~{it.edges_updated}
disabled={itemPage >= itemPages} {unmatchedCount > 0
onClick={() => setItemPage((p) => p + 1)} ? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
> : ""}
{t("common.nextPage")} </td>
</button> <td className="muted">{it.error || "—"}</td>
<td>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setItemDetail(it)}
>
{t("topology.discoverViewDetail")}
</button>
</td>
</tr>
);
})}
{!detailItems.length && !jobDetailQuery.isLoading ? (
<tr>
<td colSpan={7} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(itemTotal),
page: String(itemPage),
pages: String(itemPages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={itemPage <= 1} onClick={() => setItemPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button
type="button"
disabled={itemPage >= itemPages}
onClick={() => setItemPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
</div>
</div> </div>
</div> </div>
) : null} ) : null}