fix: 修复环路检测失败 + 多项UX增强

修复:
- 环路检测正则 \s+(Onu\S+)\s+ → \s+(Onu\S+) (splitlines移除换行后尾随\s无法匹配)
- 权限中间件 Header(...) → Header(None) 避免缺失Auth头返回422而非401
- 环路检测请求超时30s→120s (SSH连接30+台OLT实测需58秒)

重构 (ssh_service.py):
- 提取 _send_and_wait 为私有方法,消除3处重复内部函数
- 添加 __enter__/__exit__ 上下文管理器支持
- 加固 execute_command prompt检测 (按行匹配<DEVICE_NAME>)
- 移除未使用的settings import
- olt.py/devices.py 调用方改用 with 语法

新功能:
- 侧边栏退出登录上方显示当前用户名和角色
- 版本号从VERSION文件自动读取 (后端/health返回,前端动态显示)
- 基于广西南宁经纬度计算日落时间,自动切换深色/浅色主题
- /api/olt/loopback-detection 响应增加raw字段便于排查

基础设施:
- CLAUDE.md 加入 .gitignore
- 新增 .claude/rules/07-remote-operations.md (远程部署操作)
- 新增 .claude/rules/08-frp-notes.md (frp隧道注意事项)
- 新增 VERSION 文件 (版本号 0.10.0)
- 新增环路检测解析测试用例 (5个)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 11:07:29 +08:00
parent fcfa5af614
commit e5d6d843c3
14 changed files with 309 additions and 814 deletions
+43 -60
View File
@@ -4,8 +4,6 @@ import re
import time
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from app.core.config import settings
@dataclass
class ONUInfo:
@@ -51,7 +49,7 @@ class SSHService:
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if ">" in buf:
if re.search(r'<[^>]+>', buf):
break
else:
time.sleep(0.2)
@@ -77,14 +75,28 @@ class SSHService:
if "---- More ----" in chunk:
self.shell.send(" ")
time.sleep(0.3)
elif ">" in chunk:
# 命令提示符出现,说明输出完毕
elif re.search(r'^<[^>]+>\s*$', chunk, re.MULTILINE):
# 匹配整行为 <DEVICE_NAME> 的提示符行(不匹配回显中的 ">"
break
else:
time.sleep(0.2)
return output
def _send_and_wait(self, cmd: str, expect: str, timeout: int = 10) -> str:
"""发送命令并等待期望字符串出现,超时返回已收集的输出"""
self.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if expect in buf:
return buf
else:
time.sleep(0.2)
return buf
def clear_onu_port(self, port_id: str) -> bool:
"""清除指定端口的 ONU 配置(恢复默认)
流程: system-view -> interface Onu{port_id} -> default -> Y
@@ -92,26 +104,13 @@ class SSHService:
if not self.shell:
raise Exception("SSH 未连接")
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
self.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if expect in buf:
return buf
else:
time.sleep(0.2)
return buf
# 进入系统视图
out = send_and_wait("system-view", "]")
out = self._send_and_wait("system-view", "]")
if "]" not in out:
raise Exception("进入 system-view 失败")
# 进入端口
out = send_and_wait(f"interface Onu{port_id}", "]")
out = self._send_and_wait(f"interface Onu{port_id}", "]")
if "]" not in out:
raise Exception(f"进入端口 Onu{port_id} 失败")
@@ -138,8 +137,8 @@ class SSHService:
self.shell.recv(4096)
# 退出到用户视图
send_and_wait("quit", "]", timeout=5)
send_and_wait("quit", ">", timeout=5)
self._send_and_wait("quit", "]", timeout=5)
self._send_and_wait("quit", ">", timeout=5)
return True
@@ -150,7 +149,7 @@ class SSHService:
interfaces = []
if has_loop:
for line in output.splitlines():
m = re.match(r'\s+(Onu\S+)\s+', line)
m = re.match(r'\s+(Onu\S+)', line)
if m:
interfaces.append(m.group(1))
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
@@ -319,33 +318,20 @@ class SSHService:
if not self.shell:
raise Exception("SSH 未连接")
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
self.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if expect in buf:
return buf
else:
time.sleep(0.2)
return buf
out = send_and_wait("system-view", "]")
out = self._send_and_wait("system-view", "]")
if "]" not in out:
raise Exception("进入 system-view 失败")
out = send_and_wait(f"interface {port_name}", "]")
out = self._send_and_wait(f"interface {port_name}", "]")
if "]" not in out:
raise Exception(f"进入端口 {port_name} 失败")
out = send_and_wait(action, "]")
out = self._send_and_wait(action, "]")
if "]" not in out:
raise Exception(f"执行 {action} 失败")
send_and_wait("quit", "]", timeout=5)
send_and_wait("quit", ">", timeout=5)
self._send_and_wait("quit", "]", timeout=5)
self._send_and_wait("quit", ">", timeout=5)
return True
def sync_ntp(self, old_server: str, new_server: str) -> bool:
@@ -356,41 +342,28 @@ class SSHService:
if not self.shell:
raise Exception("SSH 未连接")
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
self.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if expect in buf:
return buf
else:
time.sleep(0.2)
return buf
out = send_and_wait("system-view", "]")
out = self._send_and_wait("system-view", "]")
if "]" not in out:
raise Exception("进入 system-view 失败")
# 删除旧 NTP 服务器(若不存在会报错,忽略即可)
send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
self._send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
# 添加新 NTP 服务器
out = send_and_wait(f"ntp-service unicast-server {new_server}", "]")
out = self._send_and_wait(f"ntp-service unicast-server {new_server}", "]")
if "]" not in out:
raise Exception(f"配置 NTP 服务器 {new_server} 失败")
# 设置时区为北京时间
out = send_and_wait("clock timezone Beijing add 08:00:00", "]")
out = self._send_and_wait("clock timezone Beijing add 08:00:00", "]")
if "]" not in out:
raise Exception("配置时区失败")
# 退出系统视图
send_and_wait("quit", ">")
self._send_and_wait("quit", ">")
# 强制保存配置
send_and_wait("save force", ">", timeout=30)
self._send_and_wait("save force", ">", timeout=30)
return True
@@ -427,6 +400,16 @@ class SSHService:
})
return events
def __enter__(self):
"""上下文管理器入口,自动连接"""
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""上下文管理器出口,自动关闭连接"""
self.close()
return False
def close(self):
"""关闭 SSH 连接"""
if self.client: