Compare commits
9 Commits
e5d6d843c3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b07ec6df0 | |||
| 8a8ae4ed57 | |||
| a5ac25a01d | |||
| 32b7a2cc6a | |||
| 6623169e8a | |||
| 0bab72ea98 | |||
| 3ee8846011 | |||
| fe7649ed6e | |||
| 6e5f16ecf2 |
+39
-39
@@ -49,45 +49,6 @@ def get_audit_logs(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/logs/{log_id}")
|
||||
def get_audit_log_detail(
|
||||
log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('*')),
|
||||
):
|
||||
"""获取单条审计日志详情"""
|
||||
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
|
||||
if not log:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="日志不存在")
|
||||
return _fmt(log, detail=True)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_audit_stats(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('*')),
|
||||
):
|
||||
"""审计日志统计(最近N天)"""
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import func
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
rows = (
|
||||
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
|
||||
.filter(AuditLog.action_time >= since)
|
||||
.group_by(AuditLog.action_type, AuditLog.status)
|
||||
.all()
|
||||
)
|
||||
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
|
||||
by_type = {}
|
||||
for row in rows:
|
||||
if row.action_type not in by_type:
|
||||
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
|
||||
by_type[row.action_type][row.status] = row.cnt
|
||||
return {"total": total, "days": days, "by_type": by_type}
|
||||
|
||||
|
||||
@router.get("/logs/export/csv")
|
||||
def export_audit_logs(
|
||||
start_time: Optional[datetime] = Query(None),
|
||||
@@ -130,6 +91,45 @@ def export_audit_logs(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs/{log_id}")
|
||||
def get_audit_log_detail(
|
||||
log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('*')),
|
||||
):
|
||||
"""获取单条审计日志详情"""
|
||||
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
|
||||
if not log:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="日志不存在")
|
||||
return _fmt(log, detail=True)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_audit_stats(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('*')),
|
||||
):
|
||||
"""审计日志统计(最近N天)"""
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import func
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
rows = (
|
||||
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
|
||||
.filter(AuditLog.action_time >= since)
|
||||
.group_by(AuditLog.action_type, AuditLog.status)
|
||||
.all()
|
||||
)
|
||||
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
|
||||
by_type = {}
|
||||
for row in rows:
|
||||
if row.action_type not in by_type:
|
||||
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
|
||||
by_type[row.action_type][row.status] = row.cnt
|
||||
return {"total": total, "days": days, "by_type": by_type}
|
||||
|
||||
|
||||
def _fmt(r: AuditLog, detail: bool = False) -> dict:
|
||||
base = {
|
||||
"id": r.id,
|
||||
|
||||
@@ -84,12 +84,12 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/permissions")
|
||||
def get_my_permissions(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取当前用户的权限码列表"""
|
||||
from app.middleware.permission_middleware import get_role_permissions
|
||||
if not authorization.startswith("Bearer "):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
@@ -102,11 +102,11 @@ def get_my_permissions(
|
||||
|
||||
@router.get("/profile")
|
||||
def get_profile(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取当前用户信息"""
|
||||
if not authorization.startswith("Bearer "):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
|
||||
@@ -338,6 +338,55 @@ def export_replacements(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
def get_all_tags(db: Session = Depends(get_db)):
|
||||
"""获取所有不重复的设备标签"""
|
||||
from sqlalchemy import func as _func
|
||||
rows = db.query(ONUDevice.tags).filter(
|
||||
ONUDevice.tags.isnot(None), ONUDevice.tags != ''
|
||||
).all()
|
||||
tags = set()
|
||||
for (tag_str,) in rows:
|
||||
for t in tag_str.split(','):
|
||||
t = t.strip()
|
||||
if t:
|
||||
tags.add(t)
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
@router.get("/export/csv")
|
||||
def export_devices_csv(
|
||||
region: Optional[str] = Query(None),
|
||||
school_name: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""导出设备列表为 CSV"""
|
||||
from sqlalchemy import func as _func
|
||||
|
||||
query = db.query(ONUDevice)
|
||||
if region:
|
||||
query = query.filter(ONUDevice.region == region)
|
||||
if school_name:
|
||||
query = query.filter(ONUDevice.school_name == school_name)
|
||||
devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"])
|
||||
for d in devices:
|
||||
writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "",
|
||||
d.place_type or "", d.room_number or "", d.port_id or "", d.model or "",
|
||||
d.loid or "", d.distance_m or "", d.notes or ""])
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=onu_devices.csv"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{device_id}", response_model=ONUDeviceResponse)
|
||||
def get_device(
|
||||
device_id: int,
|
||||
@@ -701,52 +750,3 @@ def get_optical_power_history(
|
||||
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
def get_all_tags(db: Session = Depends(get_db)):
|
||||
"""获取所有不重复的设备标签"""
|
||||
from sqlalchemy import func as _func
|
||||
rows = db.query(ONUDevice.tags).filter(
|
||||
ONUDevice.tags.isnot(None), ONUDevice.tags != ''
|
||||
).all()
|
||||
tags = set()
|
||||
for (tag_str,) in rows:
|
||||
for t in tag_str.split(','):
|
||||
t = t.strip()
|
||||
if t:
|
||||
tags.add(t)
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
@router.get("/export/csv")
|
||||
def export_devices_csv(
|
||||
region: Optional[str] = Query(None),
|
||||
school_name: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""导出设备列表为 CSV"""
|
||||
from sqlalchemy import func as _func
|
||||
|
||||
query = db.query(ONUDevice)
|
||||
if region:
|
||||
query = query.filter(ONUDevice.region == region)
|
||||
if school_name:
|
||||
query = query.filter(ONUDevice.school_name == school_name)
|
||||
devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"])
|
||||
for d in devices:
|
||||
writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "",
|
||||
d.place_type or "", d.room_number or "", d.port_id or "", d.model or "",
|
||||
d.loid or "", d.distance_m or "", d.notes or ""])
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=onu_devices.csv"}
|
||||
)
|
||||
|
||||
@@ -112,3 +112,9 @@ def health_check():
|
||||
status["db"] = "error"
|
||||
status["status"] = "degraded"
|
||||
return status
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def api_health_check():
|
||||
"""API 路径下的健康检查(用于前端通过 /api/ 代理访问)"""
|
||||
return health_check()
|
||||
|
||||
@@ -103,8 +103,10 @@ class CheckService:
|
||||
"online": online_count,
|
||||
"offline": offline_count,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
# 连接异常时清除缓存,下次自动重连
|
||||
_conn_pool.pop(olt_id, None)
|
||||
raise
|
||||
|
||||
def check_single_device(self, device_id: int) -> Dict:
|
||||
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
|
||||
@@ -251,8 +253,9 @@ class CheckService:
|
||||
"offline": offline_count,
|
||||
"new_discovered": new_count,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
_conn_pool.pop(olt_id, None)
|
||||
raise
|
||||
|
||||
async def scan_olt(self, olt_id: int) -> Dict:
|
||||
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
|
||||
@@ -300,8 +303,9 @@ class CheckService:
|
||||
"devices": devices,
|
||||
"duplicates": duplicates,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
_conn_pool.pop(olt_id, None)
|
||||
raise
|
||||
|
||||
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
|
||||
for mac, records in duplicate_dict.items():
|
||||
|
||||
+3
-24
@@ -17,7 +17,7 @@ services:
|
||||
|
||||
celery-worker:
|
||||
build: ./backend
|
||||
command: celery -A celery_worker.celery_app worker --loglevel=info -Q h3c_onu_ms
|
||||
command: celery -A celery_worker.celery_app worker -B --loglevel=info -Q h3c_onu_ms --schedule=/tmp/celerybeat-schedule
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
volumes:
|
||||
@@ -27,26 +27,5 @@ services:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
celery-beat:
|
||||
build: ./backend
|
||||
command: celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "18002:80"
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
# frontend 仅在远程服务器部署,本地通过 docker-compose 不再启动
|
||||
# 部署命令见 .claude/rules/07-remote-operations.md
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
size="small"
|
||||
@change="fetchLogs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -938,7 +938,7 @@ const dismissNewDevice = async (id) => {
|
||||
const runQuickScan = async () => {
|
||||
quickScanning.value = true
|
||||
try {
|
||||
const { data } = await request.post('/olt/quick-scan')
|
||||
const { data } = await request.post('/olt/quick-scan', null, { timeout: 180000 })
|
||||
quickScanResult.value = data
|
||||
quickScanVisible.value = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
:total="repTotal"
|
||||
:page-sizes="[50, 100, 200]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
size="small"
|
||||
@size-change="repLoad"
|
||||
@current-change="repLoad"
|
||||
/>
|
||||
@@ -203,7 +203,7 @@
|
||||
:total="auditTotal"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
size="small"
|
||||
@change="fetchAudit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user