feat: 導入動態日誌管控機制架構

This commit is contained in:
swpa 2026-06-05 18:29:04 +08:00
parent 19d5a30305
commit bc933e1e39
12 changed files with 641 additions and 289 deletions

View File

@ -8,15 +8,15 @@ FILE: database.py
================================================================================ ================================================================================
import asyncpg import asyncpg
import json import json
import logging
import uuid import uuid
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from logger import get_logger
# ========================================== # ==========================================
# 💡 PostgreSQL 連線與操作 (高可用性版) # 💡 PostgreSQL 連線與操作 (高可用性版)
# ========================================== # ==========================================
logger = logging.getLogger(__name__) logger = get_logger("app.database")
# DB 設定 (從您的 init_db.py 中提取) # DB 設定 (從您的 init_db.py 中提取)
DB_CONFIG = { DB_CONFIG = {
@ -1103,6 +1103,25 @@ FILE: index.html
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span> <span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
</div> </div>
</div> </div>
<!-- 🌟 新增:伺服器日誌管控面板 -->
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block; margin-top: 20px;">
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
🎛️ 伺服器日誌管控 (Server Log Management)
</h3>
<div style="background: #f8f9fa; border-left: 4px solid #8e44ad; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,<b>無需重啟伺服器</b>。<br>
<span style="color: #7f8c8d; font-size: 14px;">💡 建議平時保持在 <b>INFO</b> 或 <b>ERROR</b>,僅在需要排查問題時開啟 <b>DEBUG</b>。</span>
</p>
</div>
<!-- 矩陣式下拉選單容器 -->
<div id="log-settings-container" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 15px;">
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
</div>
</div>
</div> </div>
</div> </div>
@ -1345,6 +1364,9 @@ import os
import time import time
import database import database
from shared import USE_DB from shared import USE_DB
from logger import get_logger
logger = get_logger("app.scraper")
def parse_question_mark_output(output: str) -> dict: def parse_question_mark_output(output: str) -> dict:
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位""" """解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
@ -1494,7 +1516,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本 cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本
for batch_idx, batch in enumerate(batches): for batch_idx, batch in enumerate(batches):
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...") logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
try: try:
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放 # 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放
@ -1574,7 +1596,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
process.stdin.write("\x03") process.stdin.write("\x03")
await process.stdin.drain() await process.stdin.drain()
else: else:
print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}") logger.debug(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n") process.stdin.write("\n")
await process.stdin.drain() await process.stdin.drain()
@ -1625,11 +1647,11 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
if db_write_count > 0: if db_write_count > 0:
db_success = True db_success = True
print(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!") logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!")
else: else:
print("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...") logger.warning("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...")
except Exception as e: except Exception as e:
print(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...") logger.warning(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...")
# 2. 如果 DB 寫入失敗或 USE_DB=False則 Fallback 寫入 JSON # 2. 如果 DB 寫入失敗或 USE_DB=False則 Fallback 寫入 JSON
if not db_success: if not db_success:
@ -1664,10 +1686,10 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
json.dump(data, f, indent=4, ensure_ascii=False) json.dump(data, f, indent=4, ensure_ascii=False)
await asyncio.to_thread(write_json_to_file, cache_file, cache_data) await asyncio.to_thread(write_json_to_file, cache_file, cache_data)
print(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})") logger.info(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})")
except Exception as e: except Exception as e:
print(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}") logger.error(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}")
if batch_idx < len(batches) - 1: if batch_idx < len(batches) - 1:
await asyncio.sleep(2) await asyncio.sleep(2)
@ -1855,6 +1877,74 @@ if __name__ == "__main__":
print(json.dumps(result, indent=4, ensure_ascii=False)) print(json.dumps(result, indent=4, ensure_ascii=False))
================================================================================
FILE: logger.py
================================================================================
# --- logger.py ---
import logging
import sys
# 定義終端機輸出的 ANSI 顏色
COLORS = {
"DEBUG": "\033[36m", # 青色 (Cyan)
"INFO": "\033[32m", # 綠色 (Green)
"WARNING": "\033[33m", # 黃色 (Yellow)
"ERROR": "\033[31m", # 紅色 (Red)
"CRITICAL": "\033[1;31m",# 粗體紅色 (Bold Red)
"RESET": "\033[0m" # 重置顏色
}
class ColoredFormatter(logging.Formatter):
def format(self, record):
log_color = COLORS.get(record.levelname, COLORS["RESET"])
# 格式:時間 | 等級 | 模組名稱 | 訊息
format_str = f"{log_color}%(asctime)s | %(levelname)-7s | %(name)-15s | %(message)s{COLORS['RESET']}"
formatter = logging.Formatter(format_str, datefmt="%Y-%m-%d %H:%M:%S")
return formatter.format(record)
# 定義系統中受管控的模組清單
MANAGED_LOGGERS = [
"app.database", # 資料庫操作
"app.scraper", # 爬蟲與快取
"app.diagnostics", # CM 診斷
"app.backup", # 備份與還原
"app.ssh" # SSH 連線引擎
]
def setup_logger(name: str, level=logging.INFO):
logger = logging.getLogger(name)
logger.setLevel(level)
# 避免重複添加 Handler 導致日誌印兩次
if not logger.handlers:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(ColoredFormatter())
logger.addHandler(handler)
logger.propagate = False # 防止日誌往上傳遞給 root logger (避免被 FastAPI 預設格式覆蓋)
return logger
# 系統啟動時,初始化所有模組,預設等級為 INFO
for name in MANAGED_LOGGERS:
setup_logger(name, logging.INFO)
def get_logger(name: str):
"""供各個檔案引入 logger 使用"""
if name not in MANAGED_LOGGERS:
return setup_logger(name)
return logging.getLogger(name)
def get_all_log_levels():
"""取得目前所有模組的日誌等級 (供前端 UI 顯示)"""
return {name: logging.getLevelName(logging.getLogger(name).level) for name in MANAGED_LOGGERS}
def set_log_level(name: str, level_str: str):
"""動態設定特定模組的日誌等級"""
if name in MANAGED_LOGGERS:
level = getattr(logging, level_str.upper(), logging.INFO)
logging.getLogger(name).setLevel(level)
return True
return False
================================================================================ ================================================================================
FILE: PROJECT_STATE.md FILE: PROJECT_STATE.md
================================================================================ ================================================================================
@ -2119,6 +2209,20 @@ export async function apiGetCmDiagnostics(host, username, password, mac) {
return response.json(); return response.json();
} }
export async function apiGetLogLevels() {
const response = await fetch('/api/v1/settings/logs');
return response.json();
}
export async function apiSetLogLevel(module, level) {
const response = await fetch('/api/v1/settings/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module, level })
});
return response.json();
}
================================================================================ ================================================================================
FILE: static/app.js FILE: static/app.js
@ -2139,7 +2243,8 @@ import {
apiSyncLeafOptionsStream, apiGetCmtsVersion, apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus, apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics, apiGetCmDiagnostics,
apiListCms, apiListRpds apiListCms, apiListRpds,
apiGetLogLevels, apiSetLogLevel
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 3. 共用工具模組 (Utils)
@ -3287,6 +3392,61 @@ async function openSystemSettings() {
} catch (error) { } catch (error) {
console.error("讀取設定失敗:", error); console.error("讀取設定失敗:", error);
} }
// 🌟 新增:同時載入日誌等級
loadLogLevels();
}
async function loadLogLevels() {
try {
const res = await apiGetLogLevels();
if (res.status === 'success') {
const container = document.getElementById('log-settings-container');
container.innerHTML = '';
// 模組名稱對照表
const moduleNames = {
'app.database': '🗄️ 資料庫模組 (Database)',
'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)',
'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)',
'app.backup': '💾 備份與還原模組 (Backup)',
'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)'
};
for (const [module, level] of Object.entries(res.data)) {
const displayName = moduleNames[module] || module;
// 根據等級改變下拉選單顏色
const bgColor = level === 'DEBUG' ? '#fcf3f2' : (level === 'ERROR' ? '#fdedec' : '#f8f9fa');
const textColor = level === 'DEBUG' ? '#c0392b' : (level === 'ERROR' ? '#e74c3c' : '#2c3e50');
container.innerHTML += `
<div style="display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 12px 15px; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
<span style="font-weight: bold; color: #34495e; font-size: 14px;">${displayName}</span>
<select onchange="changeLogLevel('${module}', this.value)" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 13px; cursor: pointer; background-color: ${bgColor}; color: ${textColor}; transition: all 0.2s;">
<option value="DEBUG" ${level === 'DEBUG' ? 'selected' : ''}>🐞 DEBUG (詳細)</option>
<option value="INFO" ${level === 'INFO' ? 'selected' : ''}>🟢 INFO (一般)</option>
<option value="WARNING" ${level === 'WARNING' ? 'selected' : ''}>🟠 WARNING (警告)</option>
<option value="ERROR" ${level === 'ERROR' ? 'selected' : ''}>🔴 ERROR (錯誤)</option>
</select>
</div>
`;
}
}
} catch (e) {
console.error("載入日誌等級失敗", e);
}
}
// 💡 改成一般的 function 定義
async function changeLogLevel(module, level) {
try {
const res = await apiSetLogLevel(module, level);
if (res.status === 'success') {
loadLogLevels(); // 重新渲染以更新顏色
}
} catch (e) {
alert("設定日誌等級失敗!");
}
} }
// 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API) // 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API)
@ -4115,6 +4275,7 @@ window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup; window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup; window.restoreBackup = restoreBackup;
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域 window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
================================================================================ ================================================================================
FILE: static/terminal.js FILE: static/terminal.js
@ -6439,7 +6600,6 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
FILE: routers/backup.py FILE: routers/backup.py
================================================================================ ================================================================================
# --- routers/backup.py --- # --- routers/backup.py ---
import logging
import asyncssh import asyncssh
import asyncio import asyncio
import re import re
@ -6448,6 +6608,7 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from logger import get_logger
# 引入 DB 函數 # 引入 DB 函數
from database import ( from database import (
@ -6461,7 +6622,7 @@ from cmts_scraper import fetch_raw_config
# 🌟 [Priority 1 修復] 引入 cmts_config_locks # 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import parse_cli_to_tree, cmts_config_locks from shared import parse_cli_to_tree, cmts_config_locks
logger = logging.getLogger(__name__) logger = get_logger("app.backup")
router = APIRouter( router = APIRouter(
prefix="/backups", prefix="/backups",
@ -6764,6 +6925,9 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
await process.stdin.drain() await process.stdin.drain()
commit_out = await read_until_quiet(timeout=6.0, prompt_pattern=r"\(config\)#") commit_out = await read_until_quiet(timeout=6.0, prompt_pattern=r"\(config\)#")
# 清理 Commit 回傳的雜訊
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
# 5. 退出並關閉連線 # 5. 退出並關閉連線
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()
@ -6787,9 +6951,10 @@ FILE: routers/terminal.py
================================================================================ ================================================================================
import asyncio import asyncio
import asyncssh import asyncssh
import traceback
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from logger import get_logger
logger = get_logger("app.ssh")
router = APIRouter() router = APIRouter()
@router.websocket("/ws/terminal") @router.websocket("/ws/terminal")
@ -6815,7 +6980,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
while True: while True:
data_bytes = await process.stdout.read(8192) data_bytes = await process.stdout.read(8192)
if not data_bytes: if not data_bytes:
print("[DEBUG] 設備端主動關閉了 stdout 通道") logger.debug("設備端主動關閉了 stdout 通道")
break break
safe_text = data_bytes.decode('utf-8', errors='replace') safe_text = data_bytes.decode('utf-8', errors='replace')
@ -6823,8 +6988,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:") logger.error("❌ [WS Forward Error] 讀取設備畫面時發生錯誤", exc_info=True)
traceback.print_exc()
async def forward_to_ssh(): async def forward_to_ssh():
try: try:
@ -6844,8 +7008,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:") logger.error("❌ [SSH Forward Error] 寫入指令到設備時發生錯誤", exc_info=True)
traceback.print_exc()
ws_task = asyncio.create_task(forward_to_ws()) ws_task = asyncio.create_task(forward_to_ws())
ssh_task = asyncio.create_task(forward_to_ssh()) ssh_task = asyncio.create_task(forward_to_ssh())
@ -6859,7 +7022,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
task.cancel() task.cancel()
except WebSocketDisconnect: except WebSocketDisconnect:
print("[DEBUG] WebSocket 正常斷線,正在終止背景任務...") logger.debug("WebSocket 正常斷線,正在終止背景任務...")
if ws_task and not ws_task.done(): if ws_task and not ws_task.done():
ws_task.cancel() ws_task.cancel()
if ssh_task and not ssh_task.done(): if ssh_task and not ssh_task.done():
@ -6870,8 +7033,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
await websocket.send_text(error_msg) await websocket.send_text(error_msg)
except: except:
pass pass
print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:") logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True)
traceback.print_exc()
finally: finally:
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak # 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
if process: if process:
@ -7002,11 +7164,11 @@ FILE: routers/diagnostics.py
# --- routers/diagnostics.py --- # --- routers/diagnostics.py ---
import re import re
import asyncssh import asyncssh
import logging from logger import get_logger
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import Dict, Any from typing import Dict, Any
logger = logging.getLogger(__name__) logger = get_logger("app.diagnostics")
router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"]) router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"])
@router.get("/") @router.get("/")
@ -7036,7 +7198,7 @@ async def get_cm_diagnostics(
try: try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
print("\n🚀 [Diagnostics] 開始診斷 MAC: " + mac) logger.debug(f"🚀 [Diagnostics] 開始診斷 MAC: {mac}")
# ========================================== # ==========================================
# 1. 查詢基本狀態 # 1. 查詢基本狀態
@ -7143,7 +7305,7 @@ async def get_cm_diagnostics(
"min_mer": min_mer if min_mer != 999 else "N/A" "min_mer": min_mer if min_mer != 999 else "N/A"
} }
print("✅ [Diagnostics] 解析結果: " + str(result_data)) logger.debug(f"✅ [Diagnostics] 解析結果: {result_data}")
return {"status": "success", "data": result_data} return {"status": "success", "data": result_data}
except Exception as e: except Exception as e:
@ -7158,61 +7320,92 @@ import re
import asyncssh import asyncssh
import asyncio import asyncio
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from netmiko import ConnectHandler
from shared import CMTS_DEVICE from shared import CMTS_DEVICE
router = APIRouter() router = APIRouter()
def parse_cm_output(raw_text: str) -> list: # ==========================================
parsed_data = [] # 🛠️ AsyncSSH 共用執行引擎 (取代 Netmiko)
lines = raw_text.splitlines() # ==========================================
data_started = False
for line in lines:
if not line.strip(): continue
if line.strip().startswith('---'):
data_started = True
continue
if line.strip().startswith('==='): break
if data_started:
columns = re.split(r'\s{2,}', line.strip())
if len(columns) >= 8:
cm_info = {
"downstream": columns[0], "upstream": columns[1],
"bond_cap": columns[2], "ofdm_cap": columns[3],
"mac_address": columns[4], "ip_address": columns[5],
"num_cpe": int(columns[6]), "state": columns[7]
}
parsed_data.append(cm_info)
return parsed_data
@router.get("/cable-modems") async def execute_single_command(host, username, password, command, timeout=15.0):
async def get_cable_modems(): """
net_connect = None 執行單一查詢指令 (適用於 90% 的標準 show 指令)
自動防呆:確保指令帶有取消分頁的後綴,防止 Event Loop 卡死
"""
try: try:
net_connect = ConnectHandler(**CMTS_DEVICE) async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
raw_output = str(net_connect.send_command("show cable modem")) # 確保指令包含取消分頁的參數
structured_data = parse_cm_output(raw_output) if "| nomore" not in command.lower():
return {"status": "success", "total_count": len(structured_data), "data": structured_data} command += " | nomore"
except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/configuration") result = await conn.run(command, check=False, timeout=timeout)
async def get_configuration(): return result.stdout or ""
net_connect = None
try: except asyncssh.Error as e:
net_connect = ConnectHandler(**CMTS_DEVICE) raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
net_connect.send_command_timing("config") except asyncio.TimeoutError:
raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120) raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
net_connect.send_command_timing("exit")
return {"status": "success", "data": raw_output}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}") raise Exception(f"SSH Unexpected Error: {str(e)}")
finally:
if net_connect: async def execute_interactive_command(host, username, password, commands: list, timeout=15.0):
net_connect.disconnect() """
執行互動式指令 (適用於需要進入 config 模式,或使用 '?' 觸發補全的指令)
動態處理終端機的 --More-- 提示
"""
try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24)) as process:
async def read_until_quiet(wait_time):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=wait_time)
if not chunk: break
output += chunk
# 動態處理分頁符號
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
except asyncio.TimeoutError:
break
return output
# 1. 清除登入 MOTD
await read_until_quiet(1.5)
# 2. 執行取消分頁指令 (雙重保險)
process.stdin.write("terminal length 0\n")
await process.stdin.drain()
await read_until_quiet(0.5)
# 3. 執行目標指令
final_output = ""
for cmd in commands:
process.stdin.write(cmd)
await process.stdin.drain()
final_output += await read_until_quiet(timeout)
# 4. 退出 session
process.stdin.write("exit\n")
await process.stdin.drain()
# 清理 ANSI 控制碼與退格鍵雜訊
clean_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', final_output)
return clean_output
except asyncssh.Error as e:
raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
except asyncio.TimeoutError:
raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
except Exception as e:
raise Exception(f"SSH Unexpected Error: {str(e)}")
# ==========================================
# 🚀 API 路由
# ==========================================
@router.get("/cmts-query") @router.get("/cmts-query")
async def get_cmts_query( async def get_cmts_query(
@ -7222,10 +7415,7 @@ async def get_cmts_query(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
target_str = f" {target.strip()}" if target.strip() else "" target_str = f" {target.strip()}" if target.strip() else ""
commands = { commands = {
@ -7269,26 +7459,17 @@ async def get_cmts_query(
raise ValueError(f"未知的查詢類型: {query_type}") raise ValueError(f"未知的查詢類型: {query_type}")
cli_command = commands[query_type] + " | nomore" cli_command = commands[query_type] + " | nomore"
net_connect = ConnectHandler(**device) raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)
raw_output = net_connect.send_command(cli_command, read_timeout=15)
return {"status": "success", "command": cli_command, "data": raw_output} return {"status": "success", "command": cli_command, "data": raw_output}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Query Error: {str(e)}") raise HTTPException(status_code=500, detail=f"CMTS Query Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-mac-domain-config") @router.get("/cmts-mac-domain-config")
async def get_mac_domain_config(target: str, host: str, username: str, password: str): async def get_mac_domain_config(target: str, host: str, username: str, password: str):
net_connect = None
try: try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
cli_command = f"show running-config cable mac-domain {target.strip()} | nomore" cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
net_connect = ConnectHandler(**device) raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)
raw_output = str(net_connect.send_command(cli_command, read_timeout=15))
# 💡 正名工程:更新字典的 Key 為一致性的命名 # 💡 正名工程:更新字典的 Key 為一致性的命名
config = { config = {
@ -7344,9 +7525,6 @@ async def get_mac_domain_config(target: str, host: str, username: str, password:
return {"status": "success", "data": config} return {"status": "success", "data": config}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Parse Error: {str(e)}") raise HTTPException(status_code=500, detail=f"Parse Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-mac-domain-list") @router.get("/cmts-mac-domain-list")
async def get_cmts_mac_domain_list( async def get_cmts_mac_domain_list(
@ -7354,24 +7532,17 @@ async def get_cmts_mac_domain_list(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() # 這裡使用 '?' 觸發補全,必須使用互動式引擎
device.update({'host': host, 'username': username, 'password': password}) commands = ["show running-config cable mac-domain ?"]
net_connect = ConnectHandler(**device) raw_output = await execute_interactive_command(host, username, password, commands, timeout=5.0)
raw_output = str(net_connect.send_command_timing("show running-config cable mac-domain ?"))
import re
matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output) matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output)
mac_domains = list(dict.fromkeys(matches)) mac_domains = list(dict.fromkeys(matches))
return {"status": "success", "data": mac_domains} return {"status": "success", "data": mac_domains}
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-version") @router.get("/cmts-version")
async def get_cmts_version( async def get_cmts_version(
@ -7379,15 +7550,9 @@ async def get_cmts_version(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() raw_output = await execute_single_command(host, username, password, "show version", timeout=15.0)
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command("show version", read_timeout=15))
import re
# 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號 # 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output) match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output)
@ -7398,32 +7563,26 @@ async def get_cmts_version(
except Exception as e: except Exception as e:
return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"} return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/list-cms", summary="獲取設備上所有的 CM MAC 清單") @router.get("/list-cms", summary="獲取設備上所有的 CM MAC 清單")
async def list_cms(host: str, username: str, password: str): async def list_cms(host: str, username: str, password: str):
try: try:
# 使用 asyncssh 建立非同步連線 raw_output = await execute_single_command(host, username, password, "show cable modem", timeout=15.0)
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
# 加上 | nomore 徹底避開分頁問題,並設定 timeout 防止卡死
result = await conn.run("show cable modem | nomore", check=False, timeout=15.0)
if result.exit_status != 0 or not result.stdout: if not raw_output:
return {"cms": []} return {"cms": []}
# 嚴謹的 Regex匹配 xxxx.xxxx.xxxx 或 xx:xx:xx:xx:xx:xx # 嚴謹的 Regex匹配 xxxx.xxxx.xxxx 或 xx:xx:xx:xx:xx:xx
mac_pattern = re.compile(r"([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})") mac_pattern = re.compile(r"([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
macs = [] macs = []
for line in result.stdout.splitlines(): for line in raw_output.splitlines():
match = mac_pattern.search(line) match = mac_pattern.search(line)
if match: if match:
macs.append(match.group(1).lower()) macs.append(match.group(1).lower())
# 去重複並排序 # 去重複並排序
return {"cms": sorted(list(set(macs)))} return {"cms": sorted(list(set(macs)))}
except Exception as e: except Exception as e:
print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}") print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}")
@ -7432,22 +7591,21 @@ async def list_cms(host: str, username: str, password: str):
@router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單") @router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單")
async def list_rpds(host: str, username: str, password: str): async def list_rpds(host: str, username: str, password: str):
try: try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: raw_output = await execute_single_command(host, username, password, "show cable rpd", timeout=15.0)
result = await conn.run("show cable rpd | nomore", check=False, timeout=15.0)
if result.exit_status != 0 or not result.stdout: if not raw_output:
return {"rpds": []} return {"rpds": []}
# 嚴謹的 Regex匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6 # 嚴謹的 Regex匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6
vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])") vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])")
rpds = [] rpds = []
for line in result.stdout.splitlines(): for line in raw_output.splitlines():
match = vcvs_pattern.search(line) match = vcvs_pattern.search(line)
if match: if match:
rpds.append(match.group(1)) rpds.append(match.group(1))
return {"rpds": sorted(list(set(rpds)))} return {"rpds": sorted(list(set(rpds)))}
except Exception as e: except Exception as e:
print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}") print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}")
@ -7472,6 +7630,7 @@ from netmiko import ConnectHandler
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
from collections import defaultdict from collections import defaultdict
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from logger import get_all_log_levels, set_log_level
router = APIRouter() router = APIRouter()
@ -7901,6 +8060,25 @@ async def update_tree_filters(req: SettingsRequest):
await save_tree_filters(req.config_type, req.hidden_keys) await save_tree_filters(req.config_type, req.hidden_keys)
return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"} return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}
# ==========================================
# 🌟 伺服器日誌管控 API (Server Log Management)
# ==========================================
class LogLevelRequest(BaseModel):
module: str
level: str
@router.get("/settings/logs")
async def api_get_log_levels():
"""獲取目前所有模組的日誌等級"""
return {"status": "success", "data": get_all_log_levels()}
@router.post("/settings/logs")
async def api_set_log_level(req: LogLevelRequest):
"""動態修改特定模組的日誌等級"""
success = set_log_level(req.module, req.level)
if success:
return {"status": "success", "message": f"已將 {req.module} 的日誌等級設為 {req.level}"}
raise HTTPException(status_code=400, detail="未知的模組名稱")
================================================================================ ================================================================================
FILE: routers/leaf_options.py FILE: routers/leaf_options.py

View File

@ -7,6 +7,9 @@ import os
import time import time
import database import database
from shared import USE_DB from shared import USE_DB
from logger import get_logger
logger = get_logger("app.scraper")
def parse_question_mark_output(output: str) -> dict: def parse_question_mark_output(output: str) -> dict:
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位""" """解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
@ -156,7 +159,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本 cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本
for batch_idx, batch in enumerate(batches): for batch_idx, batch in enumerate(batches):
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...") logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
try: try:
# 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放 # 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放
@ -236,7 +239,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
process.stdin.write("\x03") process.stdin.write("\x03")
await process.stdin.drain() await process.stdin.drain()
else: else:
print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}") logger.debug(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n") process.stdin.write("\n")
await process.stdin.drain() await process.stdin.drain()
@ -287,11 +290,11 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
if db_write_count > 0: if db_write_count > 0:
db_success = True db_success = True
print(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!") logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!")
else: else:
print("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...") logger.warning("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...")
except Exception as e: except Exception as e:
print(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...") logger.warning(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...")
# 2. 如果 DB 寫入失敗或 USE_DB=False則 Fallback 寫入 JSON # 2. 如果 DB 寫入失敗或 USE_DB=False則 Fallback 寫入 JSON
if not db_success: if not db_success:
@ -326,10 +329,10 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
json.dump(data, f, indent=4, ensure_ascii=False) json.dump(data, f, indent=4, ensure_ascii=False)
await asyncio.to_thread(write_json_to_file, cache_file, cache_data) await asyncio.to_thread(write_json_to_file, cache_file, cache_data)
print(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})") logger.info(f"💾 [JSON] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})")
except Exception as e: except Exception as e:
print(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}") logger.error(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}")
if batch_idx < len(batches) - 1: if batch_idx < len(batches) - 1:
await asyncio.sleep(2) await asyncio.sleep(2)

View File

@ -1,14 +1,14 @@
import asyncpg import asyncpg
import json import json
import logging
import uuid import uuid
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from logger import get_logger
# ========================================== # ==========================================
# 💡 PostgreSQL 連線與操作 (高可用性版) # 💡 PostgreSQL 連線與操作 (高可用性版)
# ========================================== # ==========================================
logger = logging.getLogger(__name__) logger = get_logger("app.database")
# DB 設定 (從您的 init_db.py 中提取) # DB 設定 (從您的 init_db.py 中提取)
DB_CONFIG = { DB_CONFIG = {

View File

@ -431,6 +431,25 @@
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span> <span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
</div> </div>
</div> </div>
<!-- 🌟 新增:伺服器日誌管控面板 -->
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block; margin-top: 20px;">
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
🎛️ 伺服器日誌管控 (Server Log Management)
</h3>
<div style="background: #f8f9fa; border-left: 4px solid #8e44ad; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,<b>無需重啟伺服器</b><br>
<span style="color: #7f8c8d; font-size: 14px;">💡 建議平時保持在 <b>INFO</b><b>ERROR</b>,僅在需要排查問題時開啟 <b>DEBUG</b></span>
</p>
</div>
<!-- 矩陣式下拉選單容器 -->
<div id="log-settings-container" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 15px;">
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
</div>
</div>
</div> </div>
</div> </div>

63
logger.py Normal file
View File

@ -0,0 +1,63 @@
# --- logger.py ---
import logging
import sys
# 定義終端機輸出的 ANSI 顏色
COLORS = {
"DEBUG": "\033[36m", # 青色 (Cyan)
"INFO": "\033[32m", # 綠色 (Green)
"WARNING": "\033[33m", # 黃色 (Yellow)
"ERROR": "\033[31m", # 紅色 (Red)
"CRITICAL": "\033[1;31m",# 粗體紅色 (Bold Red)
"RESET": "\033[0m" # 重置顏色
}
class ColoredFormatter(logging.Formatter):
def format(self, record):
log_color = COLORS.get(record.levelname, COLORS["RESET"])
# 格式:時間 | 等級 | 模組名稱 | 訊息
format_str = f"{log_color}%(asctime)s | %(levelname)-7s | %(name)-15s | %(message)s{COLORS['RESET']}"
formatter = logging.Formatter(format_str, datefmt="%Y-%m-%d %H:%M:%S")
return formatter.format(record)
# 定義系統中受管控的模組清單
MANAGED_LOGGERS = [
"app.database", # 資料庫操作
"app.scraper", # 爬蟲與快取
"app.diagnostics", # CM 診斷
"app.backup", # 備份與還原
"app.ssh" # SSH 連線引擎
]
def setup_logger(name: str, level=logging.INFO):
logger = logging.getLogger(name)
logger.setLevel(level)
# 避免重複添加 Handler 導致日誌印兩次
if not logger.handlers:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(ColoredFormatter())
logger.addHandler(handler)
logger.propagate = False # 防止日誌往上傳遞給 root logger (避免被 FastAPI 預設格式覆蓋)
return logger
# 系統啟動時,初始化所有模組,預設等級為 INFO
for name in MANAGED_LOGGERS:
setup_logger(name, logging.INFO)
def get_logger(name: str):
"""供各個檔案引入 logger 使用"""
if name not in MANAGED_LOGGERS:
return setup_logger(name)
return logging.getLogger(name)
def get_all_log_levels():
"""取得目前所有模組的日誌等級 (供前端 UI 顯示)"""
return {name: logging.getLevelName(logging.getLogger(name).level) for name in MANAGED_LOGGERS}
def set_log_level(name: str, level_str: str):
"""動態設定特定模組的日誌等級"""
if name in MANAGED_LOGGERS:
level = getattr(logging, level_str.upper(), logging.INFO)
logging.getLogger(name).setLevel(level)
return True
return False

View File

@ -1,5 +1,4 @@
# --- routers/backup.py --- # --- routers/backup.py ---
import logging
import asyncssh import asyncssh
import asyncio import asyncio
import re import re
@ -8,6 +7,7 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from logger import get_logger
# 引入 DB 函數 # 引入 DB 函數
from database import ( from database import (
@ -21,7 +21,7 @@ from cmts_scraper import fetch_raw_config
# 🌟 [Priority 1 修復] 引入 cmts_config_locks # 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import parse_cli_to_tree, cmts_config_locks from shared import parse_cli_to_tree, cmts_config_locks
logger = logging.getLogger(__name__) logger = get_logger("app.backup")
router = APIRouter( router = APIRouter(
prefix="/backups", prefix="/backups",
@ -324,6 +324,9 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
await process.stdin.drain() await process.stdin.drain()
commit_out = await read_until_quiet(timeout=6.0, prompt_pattern=r"\(config\)#") commit_out = await read_until_quiet(timeout=6.0, prompt_pattern=r"\(config\)#")
# 清理 Commit 回傳的雜訊
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
# 5. 退出並關閉連線 # 5. 退出並關閉連線
process.stdin.write("exit\n") process.stdin.write("exit\n")
await process.stdin.drain() await process.stdin.drain()

View File

@ -13,6 +13,7 @@ from netmiko import ConnectHandler
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
from collections import defaultdict from collections import defaultdict
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from logger import get_all_log_levels, set_log_level
router = APIRouter() router = APIRouter()
@ -441,3 +442,23 @@ async def update_tree_filters(req: SettingsRequest):
# 🌟 根據 config_type 寫入對應的 JSON # 🌟 根據 config_type 寫入對應的 JSON
await save_tree_filters(req.config_type, req.hidden_keys) await save_tree_filters(req.config_type, req.hidden_keys)
return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"} return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}
# ==========================================
# 🌟 伺服器日誌管控 API (Server Log Management)
# ==========================================
class LogLevelRequest(BaseModel):
module: str
level: str
@router.get("/settings/logs")
async def api_get_log_levels():
"""獲取目前所有模組的日誌等級"""
return {"status": "success", "data": get_all_log_levels()}
@router.post("/settings/logs")
async def api_set_log_level(req: LogLevelRequest):
"""動態修改特定模組的日誌等級"""
success = set_log_level(req.module, req.level)
if success:
return {"status": "success", "message": f"已將 {req.module} 的日誌等級設為 {req.level}"}
raise HTTPException(status_code=400, detail="未知的模組名稱")

View File

@ -1,11 +1,11 @@
# --- routers/diagnostics.py --- # --- routers/diagnostics.py ---
import re import re
import asyncssh import asyncssh
import logging from logger import get_logger
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import Dict, Any from typing import Dict, Any
logger = logging.getLogger(__name__) logger = get_logger("app.diagnostics")
router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"]) router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"])
@router.get("/") @router.get("/")
@ -35,7 +35,7 @@ async def get_cm_diagnostics(
try: try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
print("\n🚀 [Diagnostics] 開始診斷 MAC: " + mac) logger.debug(f"🚀 [Diagnostics] 開始診斷 MAC: {mac}")
# ========================================== # ==========================================
# 1. 查詢基本狀態 # 1. 查詢基本狀態
@ -142,7 +142,7 @@ async def get_cm_diagnostics(
"min_mer": min_mer if min_mer != 999 else "N/A" "min_mer": min_mer if min_mer != 999 else "N/A"
} }
print("✅ [Diagnostics] 解析結果: " + str(result_data)) logger.debug(f"✅ [Diagnostics] 解析結果: {result_data}")
return {"status": "success", "data": result_data} return {"status": "success", "data": result_data}
except Exception as e: except Exception as e:

View File

@ -3,61 +3,92 @@ import re
import asyncssh import asyncssh
import asyncio import asyncio
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from netmiko import ConnectHandler
from shared import CMTS_DEVICE from shared import CMTS_DEVICE
router = APIRouter() router = APIRouter()
def parse_cm_output(raw_text: str) -> list: # ==========================================
parsed_data = [] # 🛠️ AsyncSSH 共用執行引擎 (取代 Netmiko)
lines = raw_text.splitlines() # ==========================================
data_started = False
for line in lines:
if not line.strip(): continue
if line.strip().startswith('---'):
data_started = True
continue
if line.strip().startswith('==='): break
if data_started:
columns = re.split(r'\s{2,}', line.strip())
if len(columns) >= 8:
cm_info = {
"downstream": columns[0], "upstream": columns[1],
"bond_cap": columns[2], "ofdm_cap": columns[3],
"mac_address": columns[4], "ip_address": columns[5],
"num_cpe": int(columns[6]), "state": columns[7]
}
parsed_data.append(cm_info)
return parsed_data
@router.get("/cable-modems") async def execute_single_command(host, username, password, command, timeout=15.0):
async def get_cable_modems(): """
net_connect = None 執行單一查詢指令 (適用於 90% 的標準 show 指令)
自動防呆確保指令帶有取消分頁的後綴防止 Event Loop 卡死
"""
try: try:
net_connect = ConnectHandler(**CMTS_DEVICE) async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
raw_output = str(net_connect.send_command("show cable modem")) # 確保指令包含取消分頁的參數
structured_data = parse_cm_output(raw_output) if "| nomore" not in command.lower():
return {"status": "success", "total_count": len(structured_data), "data": structured_data} command += " | nomore"
except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/configuration") result = await conn.run(command, check=False, timeout=timeout)
async def get_configuration(): return result.stdout or ""
net_connect = None
try: except asyncssh.Error as e:
net_connect = ConnectHandler(**CMTS_DEVICE) raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
net_connect.send_command_timing("config") except asyncio.TimeoutError:
raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120) raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
net_connect.send_command_timing("exit")
return {"status": "success", "data": raw_output}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}") raise Exception(f"SSH Unexpected Error: {str(e)}")
finally:
if net_connect: async def execute_interactive_command(host, username, password, commands: list, timeout=15.0):
net_connect.disconnect() """
執行互動式指令 (適用於需要進入 config 模式或使用 '?' 觸發補全的指令)
動態處理終端機的 --More-- 提示
"""
try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24)) as process:
async def read_until_quiet(wait_time):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=wait_time)
if not chunk: break
output += chunk
# 動態處理分頁符號
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
except asyncio.TimeoutError:
break
return output
# 1. 清除登入 MOTD
await read_until_quiet(1.5)
# 2. 執行取消分頁指令 (雙重保險)
process.stdin.write("terminal length 0\n")
await process.stdin.drain()
await read_until_quiet(0.5)
# 3. 執行目標指令
final_output = ""
for cmd in commands:
process.stdin.write(cmd)
await process.stdin.drain()
final_output += await read_until_quiet(timeout)
# 4. 退出 session
process.stdin.write("exit\n")
await process.stdin.drain()
# 清理 ANSI 控制碼與退格鍵雜訊
clean_output = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|\x08', '', final_output)
return clean_output
except asyncssh.Error as e:
raise Exception(f"SSH Authentication/Connection Error: {str(e)}")
except asyncio.TimeoutError:
raise Exception("SSH Timeout Error: 設備無回應 (Timeout)")
except Exception as e:
raise Exception(f"SSH Unexpected Error: {str(e)}")
# ==========================================
# 🚀 API 路由
# ==========================================
@router.get("/cmts-query") @router.get("/cmts-query")
async def get_cmts_query( async def get_cmts_query(
@ -67,10 +98,7 @@ async def get_cmts_query(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
target_str = f" {target.strip()}" if target.strip() else "" target_str = f" {target.strip()}" if target.strip() else ""
commands = { commands = {
@ -114,26 +142,17 @@ async def get_cmts_query(
raise ValueError(f"未知的查詢類型: {query_type}") raise ValueError(f"未知的查詢類型: {query_type}")
cli_command = commands[query_type] + " | nomore" cli_command = commands[query_type] + " | nomore"
net_connect = ConnectHandler(**device) raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)
raw_output = net_connect.send_command(cli_command, read_timeout=15)
return {"status": "success", "command": cli_command, "data": raw_output} return {"status": "success", "command": cli_command, "data": raw_output}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Query Error: {str(e)}") raise HTTPException(status_code=500, detail=f"CMTS Query Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-mac-domain-config") @router.get("/cmts-mac-domain-config")
async def get_mac_domain_config(target: str, host: str, username: str, password: str): async def get_mac_domain_config(target: str, host: str, username: str, password: str):
net_connect = None
try: try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
cli_command = f"show running-config cable mac-domain {target.strip()} | nomore" cli_command = f"show running-config cable mac-domain {target.strip()} | nomore"
net_connect = ConnectHandler(**device) raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0)
raw_output = str(net_connect.send_command(cli_command, read_timeout=15))
# 💡 正名工程:更新字典的 Key 為一致性的命名 # 💡 正名工程:更新字典的 Key 為一致性的命名
config = { config = {
@ -189,9 +208,6 @@ async def get_mac_domain_config(target: str, host: str, username: str, password:
return {"status": "success", "data": config} return {"status": "success", "data": config}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Parse Error: {str(e)}") raise HTTPException(status_code=500, detail=f"Parse Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-mac-domain-list") @router.get("/cmts-mac-domain-list")
async def get_cmts_mac_domain_list( async def get_cmts_mac_domain_list(
@ -199,24 +215,17 @@ async def get_cmts_mac_domain_list(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() # 這裡使用 '?' 觸發補全,必須使用互動式引擎
device.update({'host': host, 'username': username, 'password': password}) commands = ["show running-config cable mac-domain ?"]
net_connect = ConnectHandler(**device) raw_output = await execute_interactive_command(host, username, password, commands, timeout=5.0)
raw_output = str(net_connect.send_command_timing("show running-config cable mac-domain ?"))
import re
matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output) matches = re.findall(r'\b\d+:\d+/\d+\.\d+\b', raw_output)
mac_domains = list(dict.fromkeys(matches)) mac_domains = list(dict.fromkeys(matches))
return {"status": "success", "data": mac_domains} return {"status": "success", "data": mac_domains}
except Exception as e: except Exception as e:
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-version") @router.get("/cmts-version")
async def get_cmts_version( async def get_cmts_version(
@ -224,15 +233,9 @@ async def get_cmts_version(
username: str = Query(...), username: str = Query(...),
password: str = Query(...) password: str = Query(...)
): ):
net_connect = None
try: try:
device = CMTS_DEVICE.copy() raw_output = await execute_single_command(host, username, password, "show version", timeout=15.0)
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
raw_output = str(net_connect.send_command("show version", read_timeout=15))
import re
# 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號 # 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output) match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output)
@ -243,32 +246,26 @@ async def get_cmts_version(
except Exception as e: except Exception as e:
return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"} return {"status": "error", "message": f"CMTS Connection Error: {str(e)}"}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/list-cms", summary="獲取設備上所有的 CM MAC 清單") @router.get("/list-cms", summary="獲取設備上所有的 CM MAC 清單")
async def list_cms(host: str, username: str, password: str): async def list_cms(host: str, username: str, password: str):
try: try:
# 使用 asyncssh 建立非同步連線 raw_output = await execute_single_command(host, username, password, "show cable modem", timeout=15.0)
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
# 加上 | nomore 徹底避開分頁問題,並設定 timeout 防止卡死
result = await conn.run("show cable modem | nomore", check=False, timeout=15.0)
if result.exit_status != 0 or not result.stdout: if not raw_output:
return {"cms": []} return {"cms": []}
# 嚴謹的 Regex匹配 xxxx.xxxx.xxxx 或 xx:xx:xx:xx:xx:xx # 嚴謹的 Regex匹配 xxxx.xxxx.xxxx 或 xx:xx:xx:xx:xx:xx
mac_pattern = re.compile(r"([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})") mac_pattern = re.compile(r"([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
macs = [] macs = []
for line in result.stdout.splitlines(): for line in raw_output.splitlines():
match = mac_pattern.search(line) match = mac_pattern.search(line)
if match: if match:
macs.append(match.group(1).lower()) macs.append(match.group(1).lower())
# 去重複並排序 # 去重複並排序
return {"cms": sorted(list(set(macs)))} return {"cms": sorted(list(set(macs)))}
except Exception as e: except Exception as e:
print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}") print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}")
@ -277,22 +274,21 @@ async def list_cms(host: str, username: str, password: str):
@router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單") @router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單")
async def list_rpds(host: str, username: str, password: str): async def list_rpds(host: str, username: str, password: str):
try: try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: raw_output = await execute_single_command(host, username, password, "show cable rpd", timeout=15.0)
result = await conn.run("show cable rpd | nomore", check=False, timeout=15.0)
if result.exit_status != 0 or not result.stdout: if not raw_output:
return {"rpds": []} return {"rpds": []}
# 嚴謹的 Regex匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6 # 嚴謹的 Regex匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6
vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])") vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])")
rpds = [] rpds = []
for line in result.stdout.splitlines(): for line in raw_output.splitlines():
match = vcvs_pattern.search(line) match = vcvs_pattern.search(line)
if match: if match:
rpds.append(match.group(1)) rpds.append(match.group(1))
return {"rpds": sorted(list(set(rpds)))} return {"rpds": sorted(list(set(rpds)))}
except Exception as e: except Exception as e:
print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}") print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}")

View File

@ -1,8 +1,9 @@
import asyncio import asyncio
import asyncssh import asyncssh
import traceback
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from logger import get_logger
logger = get_logger("app.ssh")
router = APIRouter() router = APIRouter()
@router.websocket("/ws/terminal") @router.websocket("/ws/terminal")
@ -28,7 +29,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
while True: while True:
data_bytes = await process.stdout.read(8192) data_bytes = await process.stdout.read(8192)
if not data_bytes: if not data_bytes:
print("[DEBUG] 設備端主動關閉了 stdout 通道") logger.debug("設備端主動關閉了 stdout 通道")
break break
safe_text = data_bytes.decode('utf-8', errors='replace') safe_text = data_bytes.decode('utf-8', errors='replace')
@ -36,8 +37,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:") logger.error("❌ [WS Forward Error] 讀取設備畫面時發生錯誤", exc_info=True)
traceback.print_exc()
async def forward_to_ssh(): async def forward_to_ssh():
try: try:
@ -57,8 +57,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception as e: except Exception as e:
print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:") logger.error("❌ [SSH Forward Error] 寫入指令到設備時發生錯誤", exc_info=True)
traceback.print_exc()
ws_task = asyncio.create_task(forward_to_ws()) ws_task = asyncio.create_task(forward_to_ws())
ssh_task = asyncio.create_task(forward_to_ssh()) ssh_task = asyncio.create_task(forward_to_ssh())
@ -72,7 +71,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
task.cancel() task.cancel()
except WebSocketDisconnect: except WebSocketDisconnect:
print("[DEBUG] WebSocket 正常斷線,正在終止背景任務...") logger.debug("WebSocket 正常斷線,正在終止背景任務...")
if ws_task and not ws_task.done(): if ws_task and not ws_task.done():
ws_task.cancel() ws_task.cancel()
if ssh_task and not ssh_task.done(): if ssh_task and not ssh_task.done():
@ -83,8 +82,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
await websocket.send_text(error_msg) await websocket.send_text(error_msg)
except: except:
pass pass
print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:") logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True)
traceback.print_exc()
finally: finally:
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak # 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
if process: if process:

View File

@ -211,3 +211,17 @@ export async function apiGetCmDiagnostics(host, username, password, mac) {
} }
return response.json(); return response.json();
} }
export async function apiGetLogLevels() {
const response = await fetch('/api/v1/settings/logs');
return response.json();
}
export async function apiSetLogLevel(module, level) {
const response = await fetch('/api/v1/settings/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module, level })
});
return response.json();
}

View File

@ -14,7 +14,8 @@ import {
apiSyncLeafOptionsStream, apiGetCmtsVersion, apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus, apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics, apiGetCmDiagnostics,
apiListCms, apiListRpds apiListCms, apiListRpds,
apiGetLogLevels, apiSetLogLevel
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 3. 共用工具模組 (Utils)
@ -1162,6 +1163,61 @@ async function openSystemSettings() {
} catch (error) { } catch (error) {
console.error("讀取設定失敗:", error); console.error("讀取設定失敗:", error);
} }
// 🌟 新增:同時載入日誌等級
loadLogLevels();
}
async function loadLogLevels() {
try {
const res = await apiGetLogLevels();
if (res.status === 'success') {
const container = document.getElementById('log-settings-container');
container.innerHTML = '';
// 模組名稱對照表
const moduleNames = {
'app.database': '🗄️ 資料庫模組 (Database)',
'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)',
'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)',
'app.backup': '💾 備份與還原模組 (Backup)',
'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)'
};
for (const [module, level] of Object.entries(res.data)) {
const displayName = moduleNames[module] || module;
// 根據等級改變下拉選單顏色
const bgColor = level === 'DEBUG' ? '#fcf3f2' : (level === 'ERROR' ? '#fdedec' : '#f8f9fa');
const textColor = level === 'DEBUG' ? '#c0392b' : (level === 'ERROR' ? '#e74c3c' : '#2c3e50');
container.innerHTML += `
<div style="display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 12px 15px; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.02);">
<span style="font-weight: bold; color: #34495e; font-size: 14px;">${displayName}</span>
<select onchange="changeLogLevel('${module}', this.value)" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 13px; cursor: pointer; background-color: ${bgColor}; color: ${textColor}; transition: all 0.2s;">
<option value="DEBUG" ${level === 'DEBUG' ? 'selected' : ''}>🐞 DEBUG (詳細)</option>
<option value="INFO" ${level === 'INFO' ? 'selected' : ''}>🟢 INFO (一般)</option>
<option value="WARNING" ${level === 'WARNING' ? 'selected' : ''}>🟠 WARNING (警告)</option>
<option value="ERROR" ${level === 'ERROR' ? 'selected' : ''}>🔴 ERROR (錯誤)</option>
</select>
</div>
`;
}
}
} catch (e) {
console.error("載入日誌等級失敗", e);
}
}
// 💡 改成一般的 function 定義
async function changeLogLevel(module, level) {
try {
const res = await apiSetLogLevel(module, level);
if (res.status === 'success') {
loadLogLevels(); // 重新渲染以更新顏色
}
} catch (e) {
alert("設定日誌等級失敗!");
}
} }
// 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API) // 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API)
@ -1990,3 +2046,4 @@ window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup; window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup; window.restoreBackup = restoreBackup;
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域 window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!