Improve port-traffic logs and add manual NE rebind.

Fix unreadable collect-log contrast, surface errors on the Logs button, and let operators re-link a monitor to a chosen inventory NE without wiping samples.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 12:52:26 +08:00
parent a6c7267b47
commit 3f0787da11
9 changed files with 537 additions and 60 deletions

View file

@ -51,8 +51,8 @@ const en = {
topologyMgmt: "Topology management",
devices: "Device list",
topology: "Topology",
lldpLinks: "LLDP links",
topoClassify: "Classify & slices",
lldpLinks: "LLDP",
topoClassify: "Classify / slices",
alarms: "Alarms",
configs: "Configurations",
webcrt: "WebCRT",
@ -197,6 +197,29 @@ const en = {
logMessage: "Message",
logClose: "Close",
logHasError: "Has error",
rebind: "Rebind NE",
rebindTitle: "Rebind network element",
rebindOk: "Rebound to selected NE; collect can resume",
rebindFailed: "Rebind failed",
rebindHint:
"Pick the correct NE and confirm. IPs may not be unique, and a matching IP does not mean the same device. History is kept.",
rebindCurrent: "Current binding",
rebindEmpty: "No NEs found — add the device back under inventory first.",
rebindSameIp: "Same IP",
rebindSameIpHint: "IP matches current monitor — still verify name and ID",
rebindSubmit: "Confirm rebind",
rebindConfirm: "Bind monitoring to {{ne}}? History samples are kept.",
err: {
managedNeNotFound:
"Managed NE not found (often deleted then re-created with a new id). Use Rebind NE and pick manually.",
targetErrors: "{{count}} interface(s) failed",
unsupportedVendor: "Unsupported vendor / device type",
invalidSource: "Invalid NE source",
rebindPickFirst: "Select an NE first",
rebindNeMissing: "Selected NE not found — refresh and try again",
rebindSourceMismatch: "NE source does not match this monitor device",
alreadyMonitored: "That NE is already monitored by another device entry",
},
kpiLatest: "latest",
kpiBw: "Bandwidth",
latestAt: "Latest sample",

View file

@ -51,8 +51,8 @@ const zh = {
topologyMgmt: "拓扑管理",
devices: "设备列表",
topology: "拓扑信息",
lldpLinks: "LLDP 链路",
topoClassify: "分类与切片",
lldpLinks: "LLDP",
topoClassify: "分类切片",
alarms: "告警信息",
configs: "配置信息",
webcrt: "WebCRT",
@ -197,6 +197,27 @@ const zh = {
logMessage: "内容",
logClose: "关闭",
logHasError: "有错误",
rebind: "重新绑定网元",
rebindTitle: "重新绑定网元",
rebindCurrent: "当前监控",
rebindOk: "已重新绑定网元,可继续采集",
rebindFailed: "重新绑定失败",
rebindHint: "请手动选择正确的网元并确认。同 IP 不一定是同一台设备,且 IP 可能不唯一;历史采样会保留。",
rebindEmpty: "没有可选网元,请先在设备管理中加回。",
rebindSameIp: "同 IP",
rebindSameIpHint: "与当前监控记录的 IP 相同,请仍核对手动选择",
rebindSubmit: "确认绑定",
rebindConfirm: "确认将监控绑定到 {{ne}}?绑定后将按该网元采集,历史采样保留。",
err: {
managedNeNotFound: "管理网元不存在(可能已删除后重建,ID 已变)。请点「重新绑定网元」手动选择。",
targetErrors: "{{count}} 个接口采集失败",
unsupportedVendor: "不支持的厂商/设备类型",
invalidSource: "无效的网元来源",
rebindPickFirst: "请先选择要绑定的网元",
rebindNeMissing: "所选网元不存在,请刷新列表后重选",
rebindSourceMismatch: "网元来源与监控不一致",
alreadyMonitored: "目标网元已有另一条监控,无法绑定",
},
kpiLatest: "最新",
kpiBw: "带宽",
latestAt: "最新样点",

View file

@ -5924,12 +5924,19 @@ pre {
.network-nav__list {
list-style: none;
margin: 0;
padding: 0 8px 6px 10px;
/* Indent under group label (toggle pad + chevron + gap), not under the chevron */
padding: 0 8px 6px 34px;
}
.network-nav__list > li {
width: 100%;
}
.network-nav__link {
display: block;
padding: 7px 12px 7px 18px;
box-sizing: border-box;
width: 100%;
padding: 7px 12px;
border-radius: 6px;
color: var(--nm-muted);
text-decoration: none;
@ -6270,22 +6277,19 @@ pre {
color: #b91c1c;
}
.pt-list-task-err-btn {
display: inline-block;
margin-top: 4px;
padding: 0;
border: 0;
background: transparent;
/* Beat .app-main button { color/background } so error state shows without hover. */
.app-main .pt-list-log-btn--error,
.pt-list-actions .pt-list-log-btn--error {
color: #b91c1c;
font-size: 12px;
border-color: #fecaca;
background: #fef2f2;
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.pt-list-task-err-btn:hover {
.app-main .pt-list-log-btn--error:hover,
.pt-list-actions .pt-list-log-btn--error:hover {
color: #991b1b;
background: #fee2e2;
}
.pt-log-modal__head {
@ -6362,14 +6366,50 @@ pre {
.pt-log-item__msg {
margin: 0;
/* Override global `pre` dark terminal chrome — logs sit on light cards. */
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 10px;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.45;
color: #1e293b;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.pt-rebind-modal__current {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.pt-rebind-modal__current strong {
margin-left: 8px;
font-weight: 600;
}
.pt-rebind-modal__table-wrap {
max-height: min(40vh, 360px);
overflow: auto;
}
.pt-rebind-row--same-ip {
background: #fffbeb;
}
.pt-rebind-same-ip {
margin-left: 8px;
font-size: 11px;
font-weight: 600;
color: #b45309;
}
.pt-list-status {
display: inline-flex;
align-items: center;

View file

@ -12,6 +12,7 @@ import {
fetchPortTrafficTargets,
pausePortTrafficDevice,
putPortTrafficInterfaces,
rebindPortTrafficDevice,
startPortTrafficDevice,
stopPortTrafficDevice,
updatePortTrafficDevice,
@ -40,6 +41,30 @@ function statusTone(status: string): "running" | "paused" | "stopped" | "other"
return "other";
}
function formatPortTrafficLogMessage(
raw: string,
t: (key: string, vars?: Record<string, string | number>) => string,
): string {
const s = String(raw || "").trim();
if (!s) return "";
if (s.includes("managed_ne_not_found") || s.includes("ume_ne_not_found")) {
return t("portTraffic.err.managedNeNotFound");
}
const m = s.match(/^(\d+)_target_errors$/);
if (m) return t("portTraffic.err.targetErrors", { count: m[1] });
if (s === "unsupported_vendor") return t("portTraffic.err.unsupportedVendor");
if (s === "invalid_source") return t("portTraffic.err.invalidSource");
return s;
}
function needsNeRebind(row: PortTrafficDevice): boolean {
const err = String(row.last_error || "");
if (err.includes("managed_ne_not_found") || err.includes("ume_ne_not_found")) return true;
// Older rounds only stored "N_target_errors"; still offer rebind when IP is known.
if (/^\d+_target_errors$/.test(err.trim()) && String(row.ne_ip || "").trim()) return true;
return false;
}
function formatBw(bps: number) {
if (!bps) return "—";
if (bps >= 1e9) return `${bps / 1e9}G`;
@ -84,6 +109,10 @@ export function PortTrafficPage() {
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
const [logDeviceId, setLogDeviceId] = useState("");
const [logDeviceLabel, setLogDeviceLabel] = useState("");
const [rebindDevice, setRebindDevice] = useState<PortTrafficDevice | null>(null);
const [rebindKeyword, setRebindKeyword] = useState("");
const [rebindPage, setRebindPage] = useState(1);
const [rebindSelectedNe, setRebindSelectedNe] = useState<CliTargetItem | null>(null);
const dashQuery = useQuery({
queryKey: queryKeys.portTrafficDashboard,
@ -107,6 +136,20 @@ export function PortTrafficPage() {
staleTime: 5000,
});
const rebindSource = rebindDevice?.source === "ume" ? "ume" : "managed";
const rebindNesQuery = useQuery({
queryKey: ["cliTargets", "rebind", rebindSource, rebindKeyword, rebindPage, TARGET_PAGE_SIZE],
queryFn: () =>
fetchCliTargets({
source: rebindSource,
keyword: rebindKeyword,
page: rebindPage,
pageSize: TARGET_PAGE_SIZE,
}),
enabled: Boolean(rebindDevice),
staleTime: 5000,
});
const editTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(editDeviceId),
queryFn: () => fetchPortTrafficTargets(editDeviceId),
@ -230,6 +273,61 @@ export function PortTrafficPage() {
onError: (e: Error) => showError(e.message),
});
const rebindMut = useMutation({
mutationFn: ({ id, neId }: { id: string; neId: string }) =>
rebindPortTrafficDevice(id, { ne_id: neId }),
onSuccess: () => {
showOk(t("portTraffic.rebindOk"));
setRebindDevice(null);
setRebindSelectedNe(null);
setRebindKeyword("");
setRebindPage(1);
invalidateAll();
},
onError: (e: Error) => {
const msg = String(e.message || "");
if (msg.includes("managed_ne_not_found") || msg.includes("ume_ne_not_found")) {
showError(t("portTraffic.err.rebindNeMissing"));
} else if (msg.includes("device_already_monitored")) {
showError(t("portTraffic.err.alreadyMonitored"));
} else if (msg.includes("ne_id_required")) {
showError(t("portTraffic.err.rebindPickFirst"));
} else {
showError(msg || t("portTraffic.rebindFailed"));
}
},
});
const openRebind = (row: PortTrafficDevice) => {
setRebindDevice(row);
setRebindSelectedNe(null);
setRebindKeyword(row.ne_ip || row.ne_name || "");
setRebindPage(1);
};
const closeRebind = () => {
if (rebindMut.isPending) return;
setRebindDevice(null);
setRebindSelectedNe(null);
setRebindKeyword("");
setRebindPage(1);
};
const confirmRebind = () => {
if (!rebindDevice || !rebindSelectedNe) {
showError(t("portTraffic.err.rebindPickFirst"));
return;
}
const src = rebindSelectedNe.source === "ume" ? "ume" : "managed";
if (src !== rebindSource) {
showError(t("portTraffic.err.rebindSourceMismatch"));
return;
}
const label = `${rebindSelectedNe.name} (${rebindSelectedNe.ip_address})`;
if (!window.confirm(t("portTraffic.rebindConfirm", { ne: label }))) return;
rebindMut.mutate({ id: rebindDevice.id, neId: rebindSelectedNe.id });
};
const resetWizard = () => {
setWizardStep(1);
setIntervalSec(60);
@ -488,18 +586,6 @@ export function PortTrafficPage() {
{row.vendor ? ` · ${row.vendor}` : ""}
{row.note ? ` · ${row.note}` : ""}
</div>
{row.last_error ? (
<button
type="button"
className="pt-list-task-err-btn"
onClick={() => {
setLogDeviceId(row.id);
setLogDeviceLabel(deviceLabel(row));
}}
>
{t("portTraffic.logHasError")}
</button>
) : null}
</td>
<td>
<span className={`pt-list-status pt-list-status--${tone}`}>
@ -530,6 +616,15 @@ export function PortTrafficPage() {
<button type="button" onClick={() => openEdit(row)}>
{t("portTraffic.edit")}
</button>
{needsNeRebind(row) ? (
<button
type="button"
title={t("portTraffic.rebindHint")}
onClick={() => openRebind(row)}
>
{t("portTraffic.rebind")}
</button>
) : null}
{row.status !== "running" ? (
<button
type="button"
@ -558,6 +653,7 @@ export function PortTrafficPage() {
) : null}
<button
type="button"
className={row.last_error ? "pt-list-log-btn--error" : undefined}
onClick={() => {
setLogDeviceId(row.id);
setLogDeviceLabel(deviceLabel(row));
@ -967,30 +1063,177 @@ export function PortTrafficPage() {
<p className="muted">{t("portTraffic.logEmpty")}</p>
) : (
<div className="pt-log-modal__list">
{(logQuery.data?.items || []).map((ev) => (
<article
key={ev.id}
className={`pt-log-item pt-log-item--${ev.level === "error"}`}
>
<div className="pt-log-item__meta">
<span className="pt-log-item__level">{ev.level || "error"}</span>
<span className="pt-log-item__time">
{formatSystemTime(ev.created_at) || "—"}
</span>
{ev.ifname ? (
<span className="pt-log-item__if" title={ev.ifname}>
{ev.ifname}
{(logQuery.data?.items || []).map((ev) => {
const level = String(ev.level || "error").toLowerCase();
const tone =
level === "warn" || level === "warning"
? "warn"
: level === "info"
? "info"
: "error";
return (
<article key={ev.id} className={`pt-log-item pt-log-item--${tone}`}>
<div className="pt-log-item__meta">
<span className="pt-log-item__level">{level}</span>
<span className="pt-log-item__time">
{formatSystemTime(ev.created_at) || "—"}
</span>
) : null}
</div>
<pre className="pt-log-item__msg">{ev.message}</pre>
</article>
))}
{ev.ifname ? (
<span className="pt-log-item__if" title={ev.ifname}>
{ev.ifname}
</span>
) : null}
</div>
<pre className="pt-log-item__msg">{formatPortTrafficLogMessage(ev.message, t)}</pre>
</article>
);
})}
</div>
)}
</div>
</div>
) : null}
{rebindDevice ? (
<div className="modal-backdrop" role="presentation" onClick={closeRebind}>
<div
className="modal modal--wide pt-rebind-modal"
role="dialog"
aria-modal="true"
aria-labelledby="pt-rebind-title"
onClick={(e) => e.stopPropagation()}
>
<div className="pt-log-modal__head">
<div>
<h3 id="pt-rebind-title">{t("portTraffic.rebindTitle")}</h3>
<p className="muted pt-log-modal__sub">
{t("portTraffic.rebindCurrent")}: {deviceLabel(rebindDevice)}
{rebindDevice.ne_ip ? ` · ${rebindDevice.ne_ip}` : ""}
{` · ${rebindSource}`}
{rebindDevice.ne_id ? ` · ID ${rebindDevice.ne_id.slice(0, 8)}…` : ""}
</p>
<p className="muted pt-log-modal__sub">{t("portTraffic.rebindHint")}</p>
</div>
<button type="button" onClick={closeRebind} disabled={rebindMut.isPending}>
{t("portTraffic.logClose")}
</button>
</div>
<div className="filter-inline" style={{ marginBottom: 10 }}>
<input
value={rebindKeyword}
onChange={(e) => {
setRebindKeyword(e.target.value);
setRebindPage(1);
}}
placeholder={t("portTraffic.neKeywordPh")}
/>
{rebindSelectedNe ? (
<span className="muted">
{t("portTraffic.selectedOneNe")}: {rebindSelectedNe.name} (
{rebindSelectedNe.ip_address})
</span>
) : null}
</div>
{rebindNesQuery.isLoading ? (
<p className="muted">…</p>
) : !(rebindNesQuery.data?.items || []).length ? (
<p className="muted">{t("portTraffic.rebindEmpty")}</p>
) : (
<div className="pt-rebind-modal__table-wrap">
<table className="data-table">
<thead>
<tr>
<th />
<th>{t("portTraffic.col.name")}</th>
<th>IP</th>
<th>{t("portTraffic.col.vendor")}</th>
<th>ID</th>
</tr>
</thead>
<tbody>
{(rebindNesQuery.data?.items || []).map((row) => {
const checked = rebindSelectedNe?.id === row.id;
const sameIp =
Boolean(rebindDevice.ne_ip) &&
String(row.ip_address || "").trim() ===
String(rebindDevice.ne_ip || "").trim();
return (
<tr
key={`${row.source}:${row.id}`}
className={sameIp ? "pt-rebind-row--same-ip" : undefined}
title={sameIp ? t("portTraffic.rebindSameIpHint") : undefined}
>
<td>
<input
type="radio"
name="pt-rebind-ne"
checked={checked}
onChange={() => setRebindSelectedNe(row)}
/>
</td>
<td>{row.name}</td>
<td>
{row.ip_address}
{sameIp ? (
<span className="pt-rebind-same-ip">{t("portTraffic.rebindSameIp")}</span>
) : null}
</td>
<td>{row.vendor || "—"}</td>
<td className="muted" style={{ fontSize: 12 }}>
{row.id.slice(0, 8)}…
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div className="pager pt-list-pager" style={{ marginTop: 8 }}>
<button
type="button"
className="pager__btn"
disabled={rebindPage <= 1}
onClick={() => setRebindPage((p) => p - 1)}
>
‹
</button>
<span className="muted">
{rebindPage}/
{Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))}
</span>
<button
type="button"
className="pager__btn"
disabled={
rebindPage >=
Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))
}
onClick={() => setRebindPage((p) => p + 1)}
>
›
</button>
</div>
<div className="modal__actions" style={{ marginTop: 12 }}>
<button type="button" onClick={closeRebind} disabled={rebindMut.isPending}>
{t("portTraffic.boardCancel")}
</button>
<button
type="button"
className="btn-primary"
disabled={!rebindSelectedNe || rebindMut.isPending}
onClick={confirmRebind}
>
{rebindMut.isPending ? "…" : t("portTraffic.rebindSubmit")}
</button>
</div>
</div>
</div>
) : null}
</section>
);
}

View file

@ -1305,6 +1305,12 @@ export const collectPortTrafficNow = (deviceId: string) =>
export const deletePortTrafficDevice = (deviceId: string) =>
apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`);
export const rebindPortTrafficDevice = (deviceId: string, body: { ne_id: string }) =>
apiPost<PortTrafficDevice>(
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/rebind`,
body,
);
export const fetchPortTrafficTargets = (deviceId: string) =>
apiGet<{ items: PortTrafficTarget[] }>(
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/targets`,