mirror of
https://github.com/hansjone/netx.git
synced 2026-10-13 06:13:16 +08:00
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:
parent
a6c7267b47
commit
3f0787da11
9 changed files with 537 additions and 60 deletions
|
|
@ -16,6 +16,7 @@ from .port_traffic_schemas import (
|
||||||
PortTrafficBoardPanelsPut,
|
PortTrafficBoardPanelsPut,
|
||||||
PortTrafficBoardUpdate,
|
PortTrafficBoardUpdate,
|
||||||
PortTrafficDeviceCreate,
|
PortTrafficDeviceCreate,
|
||||||
|
PortTrafficDeviceRebind,
|
||||||
PortTrafficDeviceUpdate,
|
PortTrafficDeviceUpdate,
|
||||||
PortTrafficInterfacesPut,
|
PortTrafficInterfacesPut,
|
||||||
PortTrafficReplacePortRequest,
|
PortTrafficReplacePortRequest,
|
||||||
|
|
@ -41,6 +42,7 @@ from .port_traffic_service import (
|
||||||
list_series,
|
list_series,
|
||||||
list_targets,
|
list_targets,
|
||||||
put_interfaces,
|
put_interfaces,
|
||||||
|
rebind_device,
|
||||||
replace_series_port,
|
replace_series_port,
|
||||||
set_device_status,
|
set_device_status,
|
||||||
update_device,
|
update_device,
|
||||||
|
|
@ -236,6 +238,28 @@ def api_delete_device(device_id: str, request: Request, db: Session = Depends(ge
|
||||||
return out
|
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")
|
@router.post("/devices/{device_id}/start")
|
||||||
def api_start_device(
|
def api_start_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
|
|
||||||
|
|
@ -217,8 +217,12 @@ def _save_sample(target_row_id: str, parsed: Any, vendor_hint_bw: int = 0) -> No
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int:
|
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."""
|
"""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)
|
per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
|
||||||
cap = int(settings.ne_collect_run_timeout_cap_sec or 600)
|
cap = int(settings.ne_collect_run_timeout_cap_sec or 600)
|
||||||
errors = 0
|
errors = 0
|
||||||
|
|
@ -227,7 +231,7 @@ def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int
|
||||||
try:
|
try:
|
||||||
device = db.get(PortTrafficDevice, device_id)
|
device = db.get(PortTrafficDevice, device_id)
|
||||||
if not device:
|
if not device:
|
||||||
return len(target_ids)
|
return len(target_ids), "device_not_found"
|
||||||
source = str(device.source or "").strip().lower()
|
source = str(device.source or "").strip().lower()
|
||||||
ne_id = str(device.ne_id or "").strip()
|
ne_id = str(device.ne_id or "").strip()
|
||||||
vendor_hint = str(device.vendor or "")
|
vendor_hint = str(device.vendor or "")
|
||||||
|
|
@ -239,17 +243,17 @@ def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int
|
||||||
else:
|
else:
|
||||||
for tid in target_ids:
|
for tid in target_ids:
|
||||||
_set_target_error(tid, "invalid_source")
|
_set_target_error(tid, "invalid_source")
|
||||||
return len(target_ids)
|
return len(target_ids), "invalid_source"
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
msg = str(exc.detail or "resolve_failed")[:1020]
|
msg = str(exc.detail or "resolve_failed")[:1020]
|
||||||
for tid in target_ids:
|
for tid in target_ids:
|
||||||
_set_target_error(tid, msg)
|
_set_target_error(tid, msg)
|
||||||
return len(target_ids)
|
return len(target_ids), msg
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
msg = _format_error(exc)
|
msg = _format_error(exc)
|
||||||
for tid in target_ids:
|
for tid in target_ids:
|
||||||
_set_target_error(tid, msg)
|
_set_target_error(tid, msg)
|
||||||
return len(target_ids)
|
return len(target_ids), msg
|
||||||
|
|
||||||
vendor = str(info.get("vendor") or vendor_hint or "")
|
vendor = str(info.get("vendor") or vendor_hint or "")
|
||||||
device_type = str(info.get("device_type") 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:
|
if cmds is None:
|
||||||
for tid in target_ids:
|
for tid in target_ids:
|
||||||
_set_target_error(tid, "unsupported_vendor")
|
_set_target_error(tid, "unsupported_vendor")
|
||||||
return len(target_ids)
|
return len(target_ids), "unsupported_vendor"
|
||||||
vendor_key = cmds.vendor_key
|
vendor_key = cmds.vendor_key
|
||||||
|
|
||||||
targets = (
|
targets = (
|
||||||
|
|
@ -270,7 +274,7 @@ def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
if not ifaces:
|
if not ifaces:
|
||||||
return 0
|
return 0, ""
|
||||||
|
|
||||||
budget = min(cap, per_cmd * max(1, len(ifaces)) + 90)
|
budget = min(cap, per_cmd * max(1, len(ifaces)) + 90)
|
||||||
conn = None
|
conn = None
|
||||||
|
|
@ -292,10 +296,11 @@ def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int
|
||||||
_set_target_error(tid, msg)
|
_set_target_error(tid, msg)
|
||||||
errors = len(ifaces)
|
errors = len(ifaces)
|
||||||
_log.exception("port_traffic session failed device=%s", device_id)
|
_log.exception("port_traffic session failed device=%s", device_id)
|
||||||
|
return errors, msg
|
||||||
finally:
|
finally:
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
close_netmiko_connection(conn)
|
close_netmiko_connection(conn)
|
||||||
return errors
|
return errors, ""
|
||||||
|
|
||||||
|
|
||||||
def dispatch_collect(device_id: str) -> int:
|
def dispatch_collect(device_id: str) -> int:
|
||||||
|
|
@ -304,13 +309,18 @@ def dispatch_collect(device_id: str) -> int:
|
||||||
if not target_ids:
|
if not target_ids:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
session_err = ""
|
||||||
try:
|
try:
|
||||||
errors = _sample_targets_shared_session(device_id, target_ids)
|
errors, session_err = _sample_targets_shared_session(device_id, target_ids)
|
||||||
except Exception:
|
except Exception:
|
||||||
errors = len(target_ids)
|
errors = len(target_ids)
|
||||||
|
session_err = "collect_failed"
|
||||||
_log.exception("port_traffic collect failed device=%s", device_id)
|
_log.exception("port_traffic collect failed device=%s", device_id)
|
||||||
finally:
|
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)
|
_finish_collect_round(device_id, error=err_msg)
|
||||||
return len(target_ids)
|
return len(target_ids)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,12 @@ class PortTrafficDeviceUpdate(BaseModel):
|
||||||
vendor: str | None = None
|
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):
|
class PortTrafficDeviceOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
source: str
|
source: str
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,15 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .cli_resolve import resolve_cli_target
|
from .cli_resolve import resolve_cli_target
|
||||||
from .config import settings
|
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_session_factory import close_netmiko_connection, open_netmiko_connection
|
||||||
from .ne_netmiko import send_show_command
|
from .ne_netmiko import send_show_command
|
||||||
from .port_traffic_commands import commands_for_vendor
|
from .port_traffic_commands import commands_for_vendor
|
||||||
|
|
@ -28,6 +36,7 @@ from .port_traffic_schemas import (
|
||||||
PortTrafficDashboardOut,
|
PortTrafficDashboardOut,
|
||||||
PortTrafficDeviceCreate,
|
PortTrafficDeviceCreate,
|
||||||
PortTrafficDeviceOut,
|
PortTrafficDeviceOut,
|
||||||
|
PortTrafficDeviceRebind,
|
||||||
PortTrafficDeviceUpdate,
|
PortTrafficDeviceUpdate,
|
||||||
PortTrafficEventOut,
|
PortTrafficEventOut,
|
||||||
PortTrafficEventsOut,
|
PortTrafficEventsOut,
|
||||||
|
|
@ -257,6 +266,101 @@ def update_device(db: Session, device_id: str, body: PortTrafficDeviceUpdate) ->
|
||||||
return _device_out(db, device)
|
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]:
|
def delete_device(db: Session, device_id: str) -> dict[str, Any]:
|
||||||
device = db.get(PortTrafficDevice, device_id)
|
device = db.get(PortTrafficDevice, device_id)
|
||||||
if not device:
|
if not device:
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ const en = {
|
||||||
topologyMgmt: "Topology management",
|
topologyMgmt: "Topology management",
|
||||||
devices: "Device list",
|
devices: "Device list",
|
||||||
topology: "Topology",
|
topology: "Topology",
|
||||||
lldpLinks: "LLDP links",
|
lldpLinks: "LLDP",
|
||||||
topoClassify: "Classify & slices",
|
topoClassify: "Classify / slices",
|
||||||
alarms: "Alarms",
|
alarms: "Alarms",
|
||||||
configs: "Configurations",
|
configs: "Configurations",
|
||||||
webcrt: "WebCRT",
|
webcrt: "WebCRT",
|
||||||
|
|
@ -197,6 +197,29 @@ const en = {
|
||||||
logMessage: "Message",
|
logMessage: "Message",
|
||||||
logClose: "Close",
|
logClose: "Close",
|
||||||
logHasError: "Has error",
|
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",
|
kpiLatest: "latest",
|
||||||
kpiBw: "Bandwidth",
|
kpiBw: "Bandwidth",
|
||||||
latestAt: "Latest sample",
|
latestAt: "Latest sample",
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ const zh = {
|
||||||
topologyMgmt: "拓扑管理",
|
topologyMgmt: "拓扑管理",
|
||||||
devices: "设备列表",
|
devices: "设备列表",
|
||||||
topology: "拓扑信息",
|
topology: "拓扑信息",
|
||||||
lldpLinks: "LLDP 链路",
|
lldpLinks: "LLDP",
|
||||||
topoClassify: "分类与切片",
|
topoClassify: "分类切片",
|
||||||
alarms: "告警信息",
|
alarms: "告警信息",
|
||||||
configs: "配置信息",
|
configs: "配置信息",
|
||||||
webcrt: "WebCRT",
|
webcrt: "WebCRT",
|
||||||
|
|
@ -197,6 +197,27 @@ const zh = {
|
||||||
logMessage: "内容",
|
logMessage: "内容",
|
||||||
logClose: "关闭",
|
logClose: "关闭",
|
||||||
logHasError: "有错误",
|
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: "最新",
|
kpiLatest: "最新",
|
||||||
kpiBw: "带宽",
|
kpiBw: "带宽",
|
||||||
latestAt: "最新样点",
|
latestAt: "最新样点",
|
||||||
|
|
|
||||||
|
|
@ -5924,12 +5924,19 @@ pre {
|
||||||
.network-nav__list {
|
.network-nav__list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
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 {
|
.network-nav__link {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 7px 12px 7px 18px;
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--nm-muted);
|
color: var(--nm-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
@ -6270,22 +6277,19 @@ pre {
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pt-list-task-err-btn {
|
/* Beat .app-main button { color/background } so error state shows without hover. */
|
||||||
display: inline-block;
|
.app-main .pt-list-log-btn--error,
|
||||||
margin-top: 4px;
|
.pt-list-actions .pt-list-log-btn--error {
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
font-size: 12px;
|
border-color: #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
font-weight: 600;
|
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;
|
color: #991b1b;
|
||||||
|
background: #fee2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pt-log-modal__head {
|
.pt-log-modal__head {
|
||||||
|
|
@ -6362,14 +6366,50 @@ pre {
|
||||||
|
|
||||||
.pt-log-item__msg {
|
.pt-log-item__msg {
|
||||||
margin: 0;
|
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;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: #1e293b;
|
color: #0f172a;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
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 {
|
.pt-list-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
fetchPortTrafficTargets,
|
fetchPortTrafficTargets,
|
||||||
pausePortTrafficDevice,
|
pausePortTrafficDevice,
|
||||||
putPortTrafficInterfaces,
|
putPortTrafficInterfaces,
|
||||||
|
rebindPortTrafficDevice,
|
||||||
startPortTrafficDevice,
|
startPortTrafficDevice,
|
||||||
stopPortTrafficDevice,
|
stopPortTrafficDevice,
|
||||||
updatePortTrafficDevice,
|
updatePortTrafficDevice,
|
||||||
|
|
@ -40,6 +41,30 @@ function statusTone(status: string): "running" | "paused" | "stopped" | "other"
|
||||||
return "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) {
|
function formatBw(bps: number) {
|
||||||
if (!bps) return "—";
|
if (!bps) return "—";
|
||||||
if (bps >= 1e9) return `${bps / 1e9}G`;
|
if (bps >= 1e9) return `${bps / 1e9}G`;
|
||||||
|
|
@ -84,6 +109,10 @@ export function PortTrafficPage() {
|
||||||
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
|
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
|
||||||
const [logDeviceId, setLogDeviceId] = useState("");
|
const [logDeviceId, setLogDeviceId] = useState("");
|
||||||
const [logDeviceLabel, setLogDeviceLabel] = 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({
|
const dashQuery = useQuery({
|
||||||
queryKey: queryKeys.portTrafficDashboard,
|
queryKey: queryKeys.portTrafficDashboard,
|
||||||
|
|
@ -107,6 +136,20 @@ export function PortTrafficPage() {
|
||||||
staleTime: 5000,
|
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({
|
const editTargetsQuery = useQuery({
|
||||||
queryKey: queryKeys.portTrafficTargets(editDeviceId),
|
queryKey: queryKeys.portTrafficTargets(editDeviceId),
|
||||||
queryFn: () => fetchPortTrafficTargets(editDeviceId),
|
queryFn: () => fetchPortTrafficTargets(editDeviceId),
|
||||||
|
|
@ -230,6 +273,61 @@ export function PortTrafficPage() {
|
||||||
onError: (e: Error) => showError(e.message),
|
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 = () => {
|
const resetWizard = () => {
|
||||||
setWizardStep(1);
|
setWizardStep(1);
|
||||||
setIntervalSec(60);
|
setIntervalSec(60);
|
||||||
|
|
@ -488,18 +586,6 @@ export function PortTrafficPage() {
|
||||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||||
{row.note ? ` · ${row.note}` : ""}
|
{row.note ? ` · ${row.note}` : ""}
|
||||||
</div>
|
</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>
|
||||||
<td>
|
<td>
|
||||||
<span className={`pt-list-status pt-list-status--${tone}`}>
|
<span className={`pt-list-status pt-list-status--${tone}`}>
|
||||||
|
|
@ -530,6 +616,15 @@ export function PortTrafficPage() {
|
||||||
<button type="button" onClick={() => openEdit(row)}>
|
<button type="button" onClick={() => openEdit(row)}>
|
||||||
{t("portTraffic.edit")}
|
{t("portTraffic.edit")}
|
||||||
</button>
|
</button>
|
||||||
|
{needsNeRebind(row) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("portTraffic.rebindHint")}
|
||||||
|
onClick={() => openRebind(row)}
|
||||||
|
>
|
||||||
|
{t("portTraffic.rebind")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{row.status !== "running" ? (
|
{row.status !== "running" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -558,6 +653,7 @@ export function PortTrafficPage() {
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className={row.last_error ? "pt-list-log-btn--error" : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLogDeviceId(row.id);
|
setLogDeviceId(row.id);
|
||||||
setLogDeviceLabel(deviceLabel(row));
|
setLogDeviceLabel(deviceLabel(row));
|
||||||
|
|
@ -967,30 +1063,177 @@ export function PortTrafficPage() {
|
||||||
<p className="muted">{t("portTraffic.logEmpty")}</p>
|
<p className="muted">{t("portTraffic.logEmpty")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="pt-log-modal__list">
|
<div className="pt-log-modal__list">
|
||||||
{(logQuery.data?.items || []).map((ev) => (
|
{(logQuery.data?.items || []).map((ev) => {
|
||||||
<article
|
const level = String(ev.level || "error").toLowerCase();
|
||||||
key={ev.id}
|
const tone =
|
||||||
className={`pt-log-item pt-log-item--${ev.level === "error"}`}
|
level === "warn" || level === "warning"
|
||||||
>
|
? "warn"
|
||||||
<div className="pt-log-item__meta">
|
: level === "info"
|
||||||
<span className="pt-log-item__level">{ev.level || "error"}</span>
|
? "info"
|
||||||
<span className="pt-log-item__time">
|
: "error";
|
||||||
{formatSystemTime(ev.created_at) || "—"}
|
return (
|
||||||
</span>
|
<article key={ev.id} className={`pt-log-item pt-log-item--${tone}`}>
|
||||||
{ev.ifname ? (
|
<div className="pt-log-item__meta">
|
||||||
<span className="pt-log-item__if" title={ev.ifname}>
|
<span className="pt-log-item__level">{level}</span>
|
||||||
{ev.ifname}
|
<span className="pt-log-item__time">
|
||||||
|
{formatSystemTime(ev.created_at) || "—"}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
{ev.ifname ? (
|
||||||
</div>
|
<span className="pt-log-item__if" title={ev.ifname}>
|
||||||
<pre className="pt-log-item__msg">{ev.message}</pre>
|
{ev.ifname}
|
||||||
</article>
|
</span>
|
||||||
))}
|
) : null}
|
||||||
|
</div>
|
||||||
|
<pre className="pt-log-item__msg">{formatPortTrafficLogMessage(ev.message, t)}</pre>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1305,6 +1305,12 @@ export const collectPortTrafficNow = (deviceId: string) =>
|
||||||
export const deletePortTrafficDevice = (deviceId: string) =>
|
export const deletePortTrafficDevice = (deviceId: string) =>
|
||||||
apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`);
|
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) =>
|
export const fetchPortTrafficTargets = (deviceId: string) =>
|
||||||
apiGet<{ items: PortTrafficTarget[] }>(
|
apiGet<{ items: PortTrafficTarget[] }>(
|
||||||
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/targets`,
|
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/targets`,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue