diff --git a/all_code.txt b/all_code.txt index 78b44b7..37b62e6 100644 --- a/all_code.txt +++ b/all_code.txt @@ -8,15 +8,15 @@ FILE: database.py ================================================================================ import asyncpg import json -import logging import uuid from typing import Dict, List, Optional, Any +from logger import get_logger # ========================================== # 💡 PostgreSQL 連線與操作 (高可用性版) # ========================================== -logger = logging.getLogger(__name__) +logger = get_logger("app.database") # DB 設定 (從您的 init_db.py 中提取) DB_CONFIG = { @@ -1103,6 +1103,25 @@ FILE: index.html + + +
+

+ 🎛️ 伺服器日誌管控 (Server Log Management) +

+ +
+

+ 在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,無需重啟伺服器
+ 💡 建議平時保持在 INFOERROR,僅在需要排查問題時開啟 DEBUG +

+
+ + +
+ ⏳ 正在載入日誌設定... +
+
@@ -1345,6 +1364,9 @@ import os import time import database from shared import USE_DB +from logger import get_logger + +logger = get_logger("app.scraper") def parse_question_mark_output(output: str) -> dict: """解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位""" @@ -1494,7 +1516,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本 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: # 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放 @@ -1574,7 +1596,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con process.stdin.write("\x03") await process.stdin.drain() else: - print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}") + logger.debug(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}") process.stdin.write("\n") 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: db_success = True - print(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!") + logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!") else: - print("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...") + logger.warning("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...") except Exception as e: - print(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...") + logger.warning(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...") # 2. 如果 DB 寫入失敗或 USE_DB=False,則 Fallback 寫入 JSON 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) 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: - print(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}") + logger.error(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}") if batch_idx < len(batches) - 1: await asyncio.sleep(2) @@ -1855,6 +1877,74 @@ if __name__ == "__main__": 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 ================================================================================ @@ -2119,6 +2209,20 @@ export async function apiGetCmDiagnostics(host, username, password, mac) { 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 @@ -2139,7 +2243,8 @@ import { apiSyncLeafOptionsStream, apiGetCmtsVersion, apiGetScanStatus, apiGetLockStatus, apiGetCmDiagnostics, - apiListCms, apiListRpds + apiListCms, apiListRpds, + apiGetLogLevels, apiSetLogLevel } from './api.js'; // 3. 共用工具模組 (Utils) @@ -3287,6 +3392,61 @@ async function openSystemSettings() { } catch (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 += ` +
+ ${displayName} + +
+ `; + } + } + } 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) @@ -4115,6 +4275,7 @@ window.viewBackup = viewBackup; window.deleteBackup = deleteBackup; window.restoreBackup = restoreBackup; window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域 +window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定! ================================================================================ FILE: static/terminal.js @@ -6439,7 +6600,6 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa FILE: routers/backup.py ================================================================================ # --- routers/backup.py --- -import logging import asyncssh import asyncio import re @@ -6448,6 +6608,7 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel from typing import Optional, List +from logger import get_logger # 引入 DB 函數 from database import ( @@ -6461,7 +6622,7 @@ from cmts_scraper import fetch_raw_config # 🌟 [Priority 1 修復] 引入 cmts_config_locks from shared import parse_cli_to_tree, cmts_config_locks -logger = logging.getLogger(__name__) +logger = get_logger("app.backup") router = APIRouter( prefix="/backups", @@ -6764,6 +6925,9 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest): await process.stdin.drain() 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. 退出並關閉連線 process.stdin.write("exit\n") await process.stdin.drain() @@ -6787,9 +6951,10 @@ FILE: routers/terminal.py ================================================================================ import asyncio import asyncssh -import traceback from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from logger import get_logger +logger = get_logger("app.ssh") router = APIRouter() @router.websocket("/ws/terminal") @@ -6815,7 +6980,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas while True: data_bytes = await process.stdout.read(8192) if not data_bytes: - print("[DEBUG] 設備端主動關閉了 stdout 通道") + logger.debug("設備端主動關閉了 stdout 通道") break 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: pass except Exception as e: - print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:") - traceback.print_exc() + logger.error("❌ [WS Forward Error] 讀取設備畫面時發生錯誤", exc_info=True) async def forward_to_ssh(): try: @@ -6844,8 +7008,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas except asyncio.CancelledError: pass except Exception as e: - print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:") - traceback.print_exc() + logger.error("❌ [SSH Forward Error] 寫入指令到設備時發生錯誤", exc_info=True) ws_task = asyncio.create_task(forward_to_ws()) 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() except WebSocketDisconnect: - print("[DEBUG] WebSocket 正常斷線,正在終止背景任務...") + logger.debug("WebSocket 正常斷線,正在終止背景任務...") if ws_task and not ws_task.done(): ws_task.cancel() 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) except: pass - print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:") - traceback.print_exc() + logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True) finally: # 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak if process: @@ -7002,11 +7164,11 @@ FILE: routers/diagnostics.py # --- routers/diagnostics.py --- import re import asyncssh -import logging +from logger import get_logger from fastapi import APIRouter, HTTPException, Query from typing import Dict, Any -logger = logging.getLogger(__name__) +logger = get_logger("app.diagnostics") router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"]) @router.get("/") @@ -7036,7 +7198,7 @@ async def get_cm_diagnostics( try: 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. 查詢基本狀態 @@ -7143,7 +7305,7 @@ async def get_cm_diagnostics( "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} except Exception as e: @@ -7158,61 +7320,92 @@ import re import asyncssh import asyncio from fastapi import APIRouter, HTTPException, Query -from netmiko import ConnectHandler from shared import CMTS_DEVICE router = APIRouter() -def parse_cm_output(raw_text: str) -> list: - parsed_data = [] - 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 +# ========================================== +# 🛠️ AsyncSSH 共用執行引擎 (取代 Netmiko) +# ========================================== -@router.get("/cable-modems") -async def get_cable_modems(): - net_connect = None +async def execute_single_command(host, username, password, command, timeout=15.0): + """ + 執行單一查詢指令 (適用於 90% 的標準 show 指令) + 自動防呆:確保指令帶有取消分頁的後綴,防止 Event Loop 卡死 + """ try: - net_connect = ConnectHandler(**CMTS_DEVICE) - raw_output = str(net_connect.send_command("show cable modem")) - structured_data = parse_cm_output(raw_output) - return {"status": "success", "total_count": len(structured_data), "data": structured_data} + async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: + # 確保指令包含取消分頁的參數 + if "| nomore" not in command.lower(): + command += " | nomore" + + result = await conn.run(command, check=False, timeout=timeout) + return result.stdout or "" + + 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 HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}") - finally: - if net_connect: - net_connect.disconnect() + raise Exception(f"SSH Unexpected Error: {str(e)}") -@router.get("/configuration") -async def get_configuration(): - net_connect = None +async def execute_interactive_command(host, username, password, commands: list, timeout=15.0): + """ + 執行互動式指令 (適用於需要進入 config 模式,或使用 '?' 觸發補全的指令) + 動態處理終端機的 --More-- 提示 + """ try: - net_connect = ConnectHandler(**CMTS_DEVICE) - net_connect.send_command_timing("config") - raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120) - net_connect.send_command_timing("exit") - return {"status": "success", "data": raw_output} + 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 HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}") - finally: - if net_connect: - net_connect.disconnect() + raise Exception(f"SSH Unexpected Error: {str(e)}") + +# ========================================== +# 🚀 API 路由 +# ========================================== @router.get("/cmts-query") async def get_cmts_query( @@ -7222,10 +7415,7 @@ async def get_cmts_query( username: str = Query(...), password: str = Query(...) ): - net_connect = None try: - device = CMTS_DEVICE.copy() - device.update({'host': host, 'username': username, 'password': password}) target_str = f" {target.strip()}" if target.strip() else "" commands = { @@ -7269,26 +7459,17 @@ async def get_cmts_query( raise ValueError(f"未知的查詢類型: {query_type}") cli_command = commands[query_type] + " | nomore" - net_connect = ConnectHandler(**device) - raw_output = net_connect.send_command(cli_command, read_timeout=15) + raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0) return {"status": "success", "command": cli_command, "data": raw_output} except Exception as 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") async def get_mac_domain_config(target: str, host: str, username: str, password: str): - net_connect = None 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" - net_connect = ConnectHandler(**device) - raw_output = str(net_connect.send_command(cli_command, read_timeout=15)) + raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0) # 💡 正名工程:更新字典的 Key 為一致性的命名 config = { @@ -7344,9 +7525,6 @@ async def get_mac_domain_config(target: str, host: str, username: str, password: return {"status": "success", "data": config} except Exception as 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") async def get_cmts_mac_domain_list( @@ -7354,24 +7532,17 @@ async def get_cmts_mac_domain_list( username: str = Query(...), password: str = Query(...) ): - net_connect = None try: - device = CMTS_DEVICE.copy() - device.update({'host': host, 'username': username, 'password': password}) - net_connect = ConnectHandler(**device) + # 這裡使用 '?' 觸發補全,必須使用互動式引擎 + commands = ["show running-config cable mac-domain ?"] + 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) mac_domains = list(dict.fromkeys(matches)) return {"status": "success", "data": mac_domains} except Exception as e: return {"status": "error", "message": str(e)} - finally: - if net_connect: - net_connect.disconnect() @router.get("/cmts-version") async def get_cmts_version( @@ -7379,15 +7550,9 @@ async def get_cmts_version( username: str = Query(...), password: str = Query(...) ): - net_connect = None try: - device = CMTS_DEVICE.copy() - device.update({'host': host, 'username': username, 'password': password}) - net_connect = ConnectHandler(**device) + raw_output = await execute_single_command(host, username, password, "show version", timeout=15.0) - raw_output = str(net_connect.send_command("show version", read_timeout=15)) - - import re # 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號 match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output) @@ -7398,33 +7563,27 @@ async def get_cmts_version( except Exception as 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 清單") async def list_cms(host: str, username: str, password: str): try: - # 使用 asyncssh 建立非同步連線 - 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: - return {"cms": []} - - # 嚴謹的 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})") - macs = [] - - for line in result.stdout.splitlines(): - match = mac_pattern.search(line) - if match: - macs.append(match.group(1).lower()) - - # 去重複並排序 - return {"cms": sorted(list(set(macs)))} - + raw_output = await execute_single_command(host, username, password, "show cable modem", timeout=15.0) + + if not raw_output: + return {"cms": []} + + # 嚴謹的 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})") + macs = [] + + for line in raw_output.splitlines(): + match = mac_pattern.search(line) + if match: + macs.append(match.group(1).lower()) + + # 去重複並排序 + return {"cms": sorted(list(set(macs)))} + except Exception as e: print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}") return {"cms": []} # 發生錯誤 (如 Timeout, 空行) 安全回傳空陣列 @@ -7432,23 +7591,22 @@ async def list_cms(host: str, username: str, password: str): @router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單") async def list_rpds(host: str, username: str, password: str): try: - async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: - result = await conn.run("show cable rpd | nomore", check=False, timeout=15.0) - - if result.exit_status != 0 or not result.stdout: - return {"rpds": []} - - # 嚴謹的 Regex:匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6 - vcvs_pattern = re.compile(r"(? dict: """解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位""" @@ -156,7 +159,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con cmts_version = "unknown" # 🌟 新增:用來記錄當前爬蟲抓到的版本 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: # 🧹 [穩定性修復] 全面改用 async with 管理生命週期,確保連線與 process 絕對釋放 @@ -236,7 +239,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con process.stdin.write("\x03") await process.stdin.drain() else: - print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}") + logger.debug(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}") process.stdin.write("\n") 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: db_success = True - print(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!") + logger.info(f"💾 [DB] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入資料庫!") else: - print("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...") + logger.warning("⚠️ [Fallback] 資料庫寫入 0 筆,退回寫入 JSON 快取...") except Exception as e: - print(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...") + logger.warning(f"⚠️ [Fallback] 資料庫寫入發生例外: {e},自動切換至 JSON 快取寫入...") # 2. 如果 DB 寫入失敗或 USE_DB=False,則 Fallback 寫入 JSON 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) 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: - print(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}") + logger.error(f"⚠️ 寫入快取失敗 (DB 與 JSON 皆失敗): {e}") if batch_idx < len(batches) - 1: await asyncio.sleep(2) diff --git a/database.py b/database.py index 59ae7b8..edec941 100644 --- a/database.py +++ b/database.py @@ -1,14 +1,14 @@ import asyncpg import json -import logging import uuid from typing import Dict, List, Optional, Any +from logger import get_logger # ========================================== # 💡 PostgreSQL 連線與操作 (高可用性版) # ========================================== -logger = logging.getLogger(__name__) +logger = get_logger("app.database") # DB 設定 (從您的 init_db.py 中提取) DB_CONFIG = { diff --git a/index.html b/index.html index e1c8442..a251580 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,25 @@ + + +
+

+ 🎛️ 伺服器日誌管控 (Server Log Management) +

+ +
+

+ 在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,無需重啟伺服器
+ 💡 建議平時保持在 INFOERROR,僅在需要排查問題時開啟 DEBUG +

+
+ + +
+ ⏳ 正在載入日誌設定... +
+
diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..6c28593 --- /dev/null +++ b/logger.py @@ -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 diff --git a/routers/backup.py b/routers/backup.py index 5028794..05ff9a7 100644 --- a/routers/backup.py +++ b/routers/backup.py @@ -1,5 +1,4 @@ # --- routers/backup.py --- -import logging import asyncssh import asyncio import re @@ -8,6 +7,7 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel from typing import Optional, List +from logger import get_logger # 引入 DB 函數 from database import ( @@ -21,7 +21,7 @@ from cmts_scraper import fetch_raw_config # 🌟 [Priority 1 修復] 引入 cmts_config_locks from shared import parse_cli_to_tree, cmts_config_locks -logger = logging.getLogger(__name__) +logger = get_logger("app.backup") router = APIRouter( prefix="/backups", @@ -324,6 +324,9 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest): await process.stdin.drain() 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. 退出並關閉連線 process.stdin.write("exit\n") await process.stdin.drain() diff --git a/routers/config.py b/routers/config.py index e3f7acc..25c0ffe 100644 --- a/routers/config.py +++ b/routers/config.py @@ -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 collections import defaultdict from fastapi.responses import StreamingResponse +from logger import get_all_log_levels, set_log_level router = APIRouter() @@ -441,3 +442,23 @@ async def update_tree_filters(req: SettingsRequest): # 🌟 根據 config_type 寫入對應的 JSON await save_tree_filters(req.config_type, req.hidden_keys) 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="未知的模組名稱") \ No newline at end of file diff --git a/routers/diagnostics.py b/routers/diagnostics.py index 2bbd277..b877582 100644 --- a/routers/diagnostics.py +++ b/routers/diagnostics.py @@ -1,11 +1,11 @@ # --- routers/diagnostics.py --- import re import asyncssh -import logging +from logger import get_logger from fastapi import APIRouter, HTTPException, Query from typing import Dict, Any -logger = logging.getLogger(__name__) +logger = get_logger("app.diagnostics") router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"]) @router.get("/") @@ -35,7 +35,7 @@ async def get_cm_diagnostics( try: 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. 查詢基本狀態 @@ -142,7 +142,7 @@ async def get_cm_diagnostics( "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} except Exception as e: diff --git a/routers/query.py b/routers/query.py index 410a269..1703f04 100644 --- a/routers/query.py +++ b/routers/query.py @@ -3,61 +3,92 @@ import re import asyncssh import asyncio from fastapi import APIRouter, HTTPException, Query -from netmiko import ConnectHandler from shared import CMTS_DEVICE router = APIRouter() -def parse_cm_output(raw_text: str) -> list: - parsed_data = [] - 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 +# ========================================== +# 🛠️ AsyncSSH 共用執行引擎 (取代 Netmiko) +# ========================================== -@router.get("/cable-modems") -async def get_cable_modems(): - net_connect = None +async def execute_single_command(host, username, password, command, timeout=15.0): + """ + 執行單一查詢指令 (適用於 90% 的標準 show 指令) + 自動防呆:確保指令帶有取消分頁的後綴,防止 Event Loop 卡死 + """ try: - net_connect = ConnectHandler(**CMTS_DEVICE) - raw_output = str(net_connect.send_command("show cable modem")) - structured_data = parse_cm_output(raw_output) - return {"status": "success", "total_count": len(structured_data), "data": structured_data} + async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: + # 確保指令包含取消分頁的參數 + if "| nomore" not in command.lower(): + command += " | nomore" + + result = await conn.run(command, check=False, timeout=timeout) + return result.stdout or "" + + 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 HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}") - finally: - if net_connect: - net_connect.disconnect() + raise Exception(f"SSH Unexpected Error: {str(e)}") -@router.get("/configuration") -async def get_configuration(): - net_connect = None +async def execute_interactive_command(host, username, password, commands: list, timeout=15.0): + """ + 執行互動式指令 (適用於需要進入 config 模式,或使用 '?' 觸發補全的指令) + 動態處理終端機的 --More-- 提示 + """ try: - net_connect = ConnectHandler(**CMTS_DEVICE) - net_connect.send_command_timing("config") - raw_output = net_connect.send_command("show full-configuration | nomore", expect_string=r"#", read_timeout=120) - net_connect.send_command_timing("exit") - return {"status": "success", "data": raw_output} + 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 HTTPException(status_code=500, detail=f"CMTS Error: {str(e)}") - finally: - if net_connect: - net_connect.disconnect() + raise Exception(f"SSH Unexpected Error: {str(e)}") + +# ========================================== +# 🚀 API 路由 +# ========================================== @router.get("/cmts-query") async def get_cmts_query( @@ -67,10 +98,7 @@ async def get_cmts_query( username: str = Query(...), password: str = Query(...) ): - net_connect = None try: - device = CMTS_DEVICE.copy() - device.update({'host': host, 'username': username, 'password': password}) target_str = f" {target.strip()}" if target.strip() else "" commands = { @@ -114,26 +142,17 @@ async def get_cmts_query( raise ValueError(f"未知的查詢類型: {query_type}") cli_command = commands[query_type] + " | nomore" - net_connect = ConnectHandler(**device) - raw_output = net_connect.send_command(cli_command, read_timeout=15) + raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0) return {"status": "success", "command": cli_command, "data": raw_output} except Exception as 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") async def get_mac_domain_config(target: str, host: str, username: str, password: str): - net_connect = None 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" - net_connect = ConnectHandler(**device) - raw_output = str(net_connect.send_command(cli_command, read_timeout=15)) + raw_output = await execute_single_command(host, username, password, cli_command, timeout=15.0) # 💡 正名工程:更新字典的 Key 為一致性的命名 config = { @@ -189,9 +208,6 @@ async def get_mac_domain_config(target: str, host: str, username: str, password: return {"status": "success", "data": config} except Exception as 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") async def get_cmts_mac_domain_list( @@ -199,24 +215,17 @@ async def get_cmts_mac_domain_list( username: str = Query(...), password: str = Query(...) ): - net_connect = None try: - device = CMTS_DEVICE.copy() - device.update({'host': host, 'username': username, 'password': password}) - net_connect = ConnectHandler(**device) + # 這裡使用 '?' 觸發補全,必須使用互動式引擎 + commands = ["show running-config cable mac-domain ?"] + 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) mac_domains = list(dict.fromkeys(matches)) return {"status": "success", "data": mac_domains} except Exception as e: return {"status": "error", "message": str(e)} - finally: - if net_connect: - net_connect.disconnect() @router.get("/cmts-version") async def get_cmts_version( @@ -224,15 +233,9 @@ async def get_cmts_version( username: str = Query(...), password: str = Query(...) ): - net_connect = None try: - device = CMTS_DEVICE.copy() - device.update({'host': host, 'username': username, 'password': password}) - net_connect = ConnectHandler(**device) + raw_output = await execute_single_command(host, username, password, "show version", timeout=15.0) - raw_output = str(net_connect.send_command("show version", read_timeout=15)) - - import re # 🌟 使用 Regex 尋找 infra 或 vcmts-cd-0 後面的版本號 match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", raw_output) @@ -243,33 +246,27 @@ async def get_cmts_version( except Exception as 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 清單") async def list_cms(host: str, username: str, password: str): try: - # 使用 asyncssh 建立非同步連線 - 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: - return {"cms": []} - - # 嚴謹的 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})") - macs = [] - - for line in result.stdout.splitlines(): - match = mac_pattern.search(line) - if match: - macs.append(match.group(1).lower()) - - # 去重複並排序 - return {"cms": sorted(list(set(macs)))} - + raw_output = await execute_single_command(host, username, password, "show cable modem", timeout=15.0) + + if not raw_output: + return {"cms": []} + + # 嚴謹的 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})") + macs = [] + + for line in raw_output.splitlines(): + match = mac_pattern.search(line) + if match: + macs.append(match.group(1).lower()) + + # 去重複並排序 + return {"cms": sorted(list(set(macs)))} + except Exception as e: print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}") return {"cms": []} # 發生錯誤 (如 Timeout, 空行) 安全回傳空陣列 @@ -277,23 +274,22 @@ async def list_cms(host: str, username: str, password: str): @router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單") async def list_rpds(host: str, username: str, password: str): try: - async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: - result = await conn.run("show cable rpd | nomore", check=False, timeout=15.0) - - if result.exit_status != 0 or not result.stdout: - return {"rpds": []} - - # 嚴謹的 Regex:匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6 - vcvs_pattern = re.compile(r"(? + ${displayName} + + + `; + } + } + } 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) @@ -1989,4 +2045,5 @@ window.loadBackupHistory = loadBackupHistory; window.viewBackup = viewBackup; window.deleteBackup = deleteBackup; window.restoreBackup = restoreBackup; -window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域 \ No newline at end of file +window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域 +window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定! \ No newline at end of file