Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec1d9860ff | |||
| 4488e0ef42 | |||
| e87e3ebe4b | |||
| 8dc66d7f88 | |||
| a9551d87f5 | |||
| 0d3fa8dd51 |
+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")
|
@router.get("/logs/export/csv")
|
||||||
def export_audit_logs(
|
def export_audit_logs(
|
||||||
start_time: Optional[datetime] = Query(None),
|
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:
|
def _fmt(r: AuditLog, detail: bool = False) -> dict:
|
||||||
base = {
|
base = {
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
|
|||||||
@@ -84,12 +84,12 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/permissions")
|
@router.get("/permissions")
|
||||||
def get_my_permissions(
|
def get_my_permissions(
|
||||||
authorization: str = Header(..., alias="Authorization"),
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取当前用户的权限码列表"""
|
"""获取当前用户的权限码列表"""
|
||||||
from app.middleware.permission_middleware import get_role_permissions
|
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="未授权")
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
token = authorization[7:]
|
token = authorization[7:]
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
@@ -102,11 +102,11 @@ def get_my_permissions(
|
|||||||
|
|
||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
def get_profile(
|
def get_profile(
|
||||||
authorization: str = Header(..., alias="Authorization"),
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取当前用户信息"""
|
"""获取当前用户信息"""
|
||||||
if not authorization.startswith("Bearer "):
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
raise HTTPException(status_code=401, detail="未授权")
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
token = authorization[7:]
|
token = authorization[7:]
|
||||||
payload = verify_token(token)
|
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)
|
@router.get("/{device_id}", response_model=ONUDeviceResponse)
|
||||||
def get_device(
|
def get_device(
|
||||||
device_id: int,
|
device_id: int,
|
||||||
@@ -701,52 +750,3 @@ def get_optical_power_history(
|
|||||||
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None}
|
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None}
|
||||||
for r in rows
|
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["db"] = "error"
|
||||||
status["status"] = "degraded"
|
status["status"] = "degraded"
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def api_health_check():
|
||||||
|
"""API 路径下的健康检查(用于前端通过 /api/ 代理访问)"""
|
||||||
|
return health_check()
|
||||||
|
|||||||
@@ -103,8 +103,10 @@ class CheckService:
|
|||||||
"online": online_count,
|
"online": online_count,
|
||||||
"offline": offline_count,
|
"offline": offline_count,
|
||||||
}
|
}
|
||||||
finally:
|
except Exception:
|
||||||
ssh.close()
|
# 连接异常时清除缓存,下次自动重连
|
||||||
|
_conn_pool.pop(olt_id, None)
|
||||||
|
raise
|
||||||
|
|
||||||
def check_single_device(self, device_id: int) -> Dict:
|
def check_single_device(self, device_id: int) -> Dict:
|
||||||
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
|
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
|
||||||
@@ -251,8 +253,9 @@ class CheckService:
|
|||||||
"offline": offline_count,
|
"offline": offline_count,
|
||||||
"new_discovered": new_count,
|
"new_discovered": new_count,
|
||||||
}
|
}
|
||||||
finally:
|
except Exception:
|
||||||
ssh.close()
|
_conn_pool.pop(olt_id, None)
|
||||||
|
raise
|
||||||
|
|
||||||
async def scan_olt(self, olt_id: int) -> Dict:
|
async def scan_olt(self, olt_id: int) -> Dict:
|
||||||
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
|
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
|
||||||
@@ -300,8 +303,9 @@ class CheckService:
|
|||||||
"devices": devices,
|
"devices": devices,
|
||||||
"duplicates": duplicates,
|
"duplicates": duplicates,
|
||||||
}
|
}
|
||||||
finally:
|
except Exception:
|
||||||
ssh.close()
|
_conn_pool.pop(olt_id, None)
|
||||||
|
raise
|
||||||
|
|
||||||
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
|
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
|
||||||
for mac, records in duplicate_dict.items():
|
for mac, records in duplicate_dict.items():
|
||||||
|
|||||||
+3
-24
@@ -17,7 +17,7 @@ services:
|
|||||||
|
|
||||||
celery-worker:
|
celery-worker:
|
||||||
build: ./backend
|
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:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
volumes:
|
volumes:
|
||||||
@@ -27,26 +27,5 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
celery-beat:
|
# frontend 仅在远程服务器部署,本地通过 docker-compose 不再启动
|
||||||
build: ./backend
|
# 部署命令见 .claude/rules/07-remote-operations.md
|
||||||
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
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
:total="total"
|
:total="total"
|
||||||
:page-sizes="[20, 50, 100]"
|
:page-sizes="[20, 50, 100]"
|
||||||
layout="total, sizes, prev, pager, next"
|
layout="total, sizes, prev, pager, next"
|
||||||
small
|
size="small"
|
||||||
@change="fetchLogs"
|
@change="fetchLogs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -938,7 +938,7 @@ const dismissNewDevice = async (id) => {
|
|||||||
const runQuickScan = async () => {
|
const runQuickScan = async () => {
|
||||||
quickScanning.value = true
|
quickScanning.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await request.post('/olt/quick-scan')
|
const { data } = await request.post('/olt/quick-scan', null, { timeout: 180000 })
|
||||||
quickScanResult.value = data
|
quickScanResult.value = data
|
||||||
quickScanVisible.value = true
|
quickScanVisible.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
:total="repTotal"
|
:total="repTotal"
|
||||||
:page-sizes="[50, 100, 200]"
|
:page-sizes="[50, 100, 200]"
|
||||||
layout="total, sizes, prev, pager, next"
|
layout="total, sizes, prev, pager, next"
|
||||||
small
|
size="small"
|
||||||
@size-change="repLoad"
|
@size-change="repLoad"
|
||||||
@current-change="repLoad"
|
@current-change="repLoad"
|
||||||
/>
|
/>
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
:total="auditTotal"
|
:total="auditTotal"
|
||||||
:page-sizes="[20, 50, 100]"
|
:page-sizes="[20, 50, 100]"
|
||||||
layout="total, sizes, prev, pager, next"
|
layout="total, sizes, prev, pager, next"
|
||||||
small
|
size="small"
|
||||||
@change="fetchAudit"
|
@change="fetchAudit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user