From 3f0787da117123443e9ff017e1dca21af58d5579 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 2 Aug 2026 12:52:26 +0800 Subject: [PATCH] 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 --- netx_api/port_traffic_router.py | 24 ++ netx_api/port_traffic_runner.py | 32 ++- netx_api/port_traffic_schemas.py | 6 + netx_api/port_traffic_service.py | 106 +++++++- web/src/i18n/en.ts | 27 +- web/src/i18n/zh.ts | 25 +- web/src/index.css | 68 ++++- web/src/pages/network/PortTrafficPage.tsx | 303 +++++++++++++++++++--- web/src/services/api.ts | 6 + 9 files changed, 537 insertions(+), 60 deletions(-) diff --git a/netx_api/port_traffic_router.py b/netx_api/port_traffic_router.py index 7ea640f..cfea399 100644 --- a/netx_api/port_traffic_router.py +++ b/netx_api/port_traffic_router.py @@ -16,6 +16,7 @@ from .port_traffic_schemas import ( PortTrafficBoardPanelsPut, PortTrafficBoardUpdate, PortTrafficDeviceCreate, + PortTrafficDeviceRebind, PortTrafficDeviceUpdate, PortTrafficInterfacesPut, PortTrafficReplacePortRequest, @@ -41,6 +42,7 @@ from .port_traffic_service import ( list_series, list_targets, put_interfaces, + rebind_device, replace_series_port, set_device_status, update_device, @@ -236,6 +238,28 @@ def api_delete_device(device_id: str, request: Request, db: Session = Depends(ge return out +@router.post("/devices/{device_id}/rebind") +def api_rebind_device( + device_id: str, + body: PortTrafficDeviceRebind, + request: Request, + db: Session = Depends(get_db), +): + out = rebind_device(db, device_id, body) + uid, uname = _actor(request) + write_audit( + db, + action="port_traffic.device.rebind", + actor_user_id=uid, + actor_username=uname, + method="POST", + path=f"/v1/port-traffic/devices/{device_id}/rebind", + status_code=200, + detail={"id": device_id, "ne_id": out.ne_id}, + ) + return out.model_dump() + + @router.post("/devices/{device_id}/start") def api_start_device( device_id: str, diff --git a/netx_api/port_traffic_runner.py b/netx_api/port_traffic_runner.py index ee0815d..fb1aac5 100644 --- a/netx_api/port_traffic_runner.py +++ b/netx_api/port_traffic_runner.py @@ -217,8 +217,12 @@ def _save_sample(target_row_id: str, parsed: Any, vendor_hint_bw: int = 0) -> No db.close() -def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int: - """One CLI login for the device; run show per interface. Returns error count.""" +def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> tuple[int, str]: + """One CLI login for the device; run show per interface. + + Returns (error_count, device_error). device_error is set when the whole + round fails for one shared reason (e.g. managed_ne_not_found). + """ per_cmd = int(settings.ne_collect_read_timeout_sec or 120) cap = int(settings.ne_collect_run_timeout_cap_sec or 600) errors = 0 @@ -227,7 +231,7 @@ def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int try: device = db.get(PortTrafficDevice, device_id) if not device: - return len(target_ids) + return len(target_ids), "device_not_found" source = str(device.source or "").strip().lower() ne_id = str(device.ne_id or "").strip() vendor_hint = str(device.vendor or "") @@ -239,17 +243,17 @@ def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int else: for tid in target_ids: _set_target_error(tid, "invalid_source") - return len(target_ids) + return len(target_ids), "invalid_source" except HTTPException as exc: msg = str(exc.detail or "resolve_failed")[:1020] for tid in target_ids: _set_target_error(tid, msg) - return len(target_ids) + return len(target_ids), msg except Exception as exc: msg = _format_error(exc) for tid in target_ids: _set_target_error(tid, msg) - return len(target_ids) + return len(target_ids), msg vendor = str(info.get("vendor") or vendor_hint or "") device_type = str(info.get("device_type") or "") @@ -257,7 +261,7 @@ def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int if cmds is None: for tid in target_ids: _set_target_error(tid, "unsupported_vendor") - return len(target_ids) + return len(target_ids), "unsupported_vendor" vendor_key = cmds.vendor_key targets = ( @@ -270,7 +274,7 @@ def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int db.close() if not ifaces: - return 0 + return 0, "" budget = min(cap, per_cmd * max(1, len(ifaces)) + 90) conn = None @@ -292,10 +296,11 @@ def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int _set_target_error(tid, msg) errors = len(ifaces) _log.exception("port_traffic session failed device=%s", device_id) + return errors, msg finally: if conn is not None: close_netmiko_connection(conn) - return errors + return errors, "" def dispatch_collect(device_id: str) -> int: @@ -304,13 +309,18 @@ def dispatch_collect(device_id: str) -> int: if not target_ids: return 0 + session_err = "" try: - errors = _sample_targets_shared_session(device_id, target_ids) + errors, session_err = _sample_targets_shared_session(device_id, target_ids) except Exception: errors = len(target_ids) + session_err = "collect_failed" _log.exception("port_traffic collect failed device=%s", device_id) finally: - err_msg = f"{errors}_target_errors" if errors else "" + if session_err and errors: + err_msg = session_err[:1020] + else: + err_msg = f"{errors}_target_errors" if errors else "" _finish_collect_round(device_id, error=err_msg) return len(target_ids) diff --git a/netx_api/port_traffic_schemas.py b/netx_api/port_traffic_schemas.py index 099685e..95fbd0f 100644 --- a/netx_api/port_traffic_schemas.py +++ b/netx_api/port_traffic_schemas.py @@ -51,6 +51,12 @@ class PortTrafficDeviceUpdate(BaseModel): vendor: str | None = None +class PortTrafficDeviceRebind(BaseModel): + """Re-link a monitor device to an explicitly chosen managed/UME NE; keeps samples.""" + + ne_id: str = Field(min_length=1, max_length=128) + + class PortTrafficDeviceOut(BaseModel): id: str source: str diff --git a/netx_api/port_traffic_service.py b/netx_api/port_traffic_service.py index 21b20aa..75a2c14 100644 --- a/netx_api/port_traffic_service.py +++ b/netx_api/port_traffic_service.py @@ -13,7 +13,15 @@ from sqlalchemy.orm import Session from .cli_resolve import resolve_cli_target from .config import settings -from .models import PortTrafficDevice, PortTrafficEvent, PortTrafficSample, PortTrafficSeries, PortTrafficTarget +from .models import ( + ManagedNE, + PortTrafficDevice, + PortTrafficEvent, + PortTrafficSample, + PortTrafficSeries, + PortTrafficTarget, + UmeInventoryNE, +) from .ne_session_factory import close_netmiko_connection, open_netmiko_connection from .ne_netmiko import send_show_command from .port_traffic_commands import commands_for_vendor @@ -28,6 +36,7 @@ from .port_traffic_schemas import ( PortTrafficDashboardOut, PortTrafficDeviceCreate, PortTrafficDeviceOut, + PortTrafficDeviceRebind, PortTrafficDeviceUpdate, PortTrafficEventOut, PortTrafficEventsOut, @@ -257,6 +266,101 @@ def update_device(db: Session, device_id: str, body: PortTrafficDeviceUpdate) -> return _device_out(db, device) +def rebind_device( + db: Session, + device_id: str, + body: PortTrafficDeviceRebind, +) -> PortTrafficDeviceOut: + """Point a monitor device at an explicitly chosen inventory NE; keeps samples.""" + device = db.get(PortTrafficDevice, device_id) + if not device: + raise HTTPException(status_code=404, detail="device_not_found") + if bool(device.collect_running): + raise HTTPException(status_code=409, detail="collect_running") + + source = str(device.source or "").strip().lower() or "managed" + want_id = str(body.ne_id or "").strip() + if not want_id: + raise HTTPException(status_code=400, detail="ne_id_required") + + new_id = "" + new_name = "" + new_ip = "" + new_vendor = "" + + if source == "managed": + row = db.get(ManagedNE, want_id) + if not row: + raise HTTPException(status_code=404, detail="managed_ne_not_found") + new_id = str(row.id) + new_name = str(row.name or "") + new_ip = str(row.ip_address or "") + new_vendor = str(row.vendor or "") + elif source == "ume": + inv = db.get(UmeInventoryNE, want_id) + if not inv: + raise HTTPException(status_code=404, detail="ume_ne_not_found") + new_id = str(inv.ne_id) + new_ip = str(inv.ip_address or "") + new_name = str(inv.user_label or inv.ne_name or inv.host_name or new_ip or "").strip() + new_vendor = str(inv.vendor or "") + else: + raise HTTPException(status_code=400, detail="invalid_source") + + if new_id == str(device.ne_id or ""): + # Already bound; clear stale errors so collect can resume. + device.last_error = "" + device.updated_at = _utcnow() + for tgt in db.query(PortTrafficTarget).filter(PortTrafficTarget.device_id == device_id).all(): + if "managed_ne_not_found" in str(tgt.last_error or "") or "ume_ne_not_found" in str( + tgt.last_error or "" + ): + tgt.last_error = "" + db.commit() + db.refresh(device) + return _device_out(db, device) + + clash = ( + db.query(PortTrafficDevice) + .filter( + PortTrafficDevice.source == source, + PortTrafficDevice.ne_id == new_id, + PortTrafficDevice.id != device_id, + ) + .first() + ) + if clash: + raise HTTPException(status_code=409, detail="device_already_monitored") + + device.ne_id = new_id + if new_name: + device.ne_name = new_name + if new_ip: + device.ne_ip = new_ip + if new_vendor: + device.vendor = new_vendor + device.last_error = "" + device.updated_at = _utcnow() + + for tgt in db.query(PortTrafficTarget).filter(PortTrafficTarget.device_id == device_id).all(): + tgt.target_id = new_id + if new_name: + tgt.ne_name = new_name + if new_ip: + tgt.ne_ip = new_ip + if new_vendor: + tgt.vendor = new_vendor + if "managed_ne_not_found" in str(tgt.last_error or "") or "ume_ne_not_found" in str( + tgt.last_error or "" + ): + tgt.last_error = "" + + db.commit() + db.refresh(device) + _log.info("port_traffic rebind device=%s -> ne_id=%s ip=%s", device_id, new_id, new_ip) + return _device_out(db, device) + + def delete_device(db: Session, device_id: str) -> dict[str, Any]: device = db.get(PortTrafficDevice, device_id) if not device: diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 6f14e95..41cbf22 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1330e27..7b004aa 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "最新样点", diff --git a/web/src/index.css b/web/src/index.css index 98e9524..7ef4e7a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; diff --git a/web/src/pages/network/PortTrafficPage.tsx b/web/src/pages/network/PortTrafficPage.tsx index 8da0e62..f5aac43 100644 --- a/web/src/pages/network/PortTrafficPage.tsx +++ b/web/src/pages/network/PortTrafficPage.tsx @@ -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 { + 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(null); + const [rebindKeyword, setRebindKeyword] = useState(""); + const [rebindPage, setRebindPage] = useState(1); + const [rebindSelectedNe, setRebindSelectedNe] = useState(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}` : ""} - {row.last_error ? ( - - ) : null} @@ -530,6 +616,15 @@ export function PortTrafficPage() { + {needsNeRebind(row) ? ( + + ) : null} {row.status !== "running" ? ( + + +
+ { + setRebindKeyword(e.target.value); + setRebindPage(1); + }} + placeholder={t("portTraffic.neKeywordPh")} + /> + {rebindSelectedNe ? ( + + {t("portTraffic.selectedOneNe")}: {rebindSelectedNe.name} ( + {rebindSelectedNe.ip_address}) + + ) : null} +
+ + {rebindNesQuery.isLoading ? ( +

…

+ ) : !(rebindNesQuery.data?.items || []).length ? ( +

{t("portTraffic.rebindEmpty")}

+ ) : ( +
+ + + + + + + + + + + {(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 ( + + + + + + + + ); + })} + +
+ {t("portTraffic.col.name")}IP{t("portTraffic.col.vendor")}ID
+ setRebindSelectedNe(row)} + /> + {row.name} + {row.ip_address} + {sameIp ? ( + {t("portTraffic.rebindSameIp")} + ) : null} + {row.vendor || "—"} + {row.id.slice(0, 8)}… +
+
+ )} + +
+ + + {rebindPage}/ + {Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))} + + +
+ +
+ + +
+ + + ) : null} ); } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 2470de9..4d3ed78 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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( + `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/rebind`, + body, + ); + export const fetchPortTrafficTargets = (deviceId: string) => apiGet<{ items: PortTrafficTarget[] }>( `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/targets`,