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 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
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
</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>
@ -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 += `
<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)
@ -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}
except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
# 確保指令包含取消分頁的參數
if "| nomore" not in command.lower():
command += " | nomore"
@router.get("/configuration")
async def get_configuration():
net_connect = None
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}
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 Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
raise Exception(f"SSH Unexpected Error: {str(e)}")
async def execute_interactive_command(host, username, password, commands: list, timeout=15.0):
"""
執行互動式指令 (適用於需要進入 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")
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,26 +7563,20 @@ 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)
raw_output = await execute_single_command(host, username, password, "show cable modem", timeout=15.0)
if result.exit_status != 0 or not result.stdout:
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 result.stdout.splitlines():
for line in raw_output.splitlines():
match = mac_pattern.search(line)
if match:
macs.append(match.group(1).lower())
@ -7432,17 +7591,16 @@ 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)
raw_output = await execute_single_command(host, username, password, "show cable rpd", timeout=15.0)
if result.exit_status != 0 or not result.stdout:
if not raw_output:
return {"rpds": []}
# 嚴謹的 Regex匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6
vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])")
rpds = []
for line in result.stdout.splitlines():
for line in raw_output.splitlines():
match = vcvs_pattern.search(line)
if match:
rpds.append(match.group(1))
@ -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 collections import defaultdict
from fastapi.responses import StreamingResponse
from logger import get_all_log_levels, set_log_level
router = APIRouter()
@ -7901,6 +8060,25 @@ async def update_tree_filters(req: SettingsRequest):
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="未知的模組名稱")
================================================================================
FILE: routers/leaf_options.py

View File

@ -7,6 +7,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 混合欄位"""
@ -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)

View File

@ -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 = {

View File

@ -431,6 +431,25 @@
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
</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>

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 ---
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()

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 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="未知的模組名稱")

View File

@ -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:

View File

@ -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}
except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Connection Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
# 確保指令包含取消分頁的參數
if "| nomore" not in command.lower():
command += " | nomore"
@router.get("/configuration")
async def get_configuration():
net_connect = None
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}
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 Error: {str(e)}")
finally:
if net_connect:
net_connect.disconnect()
raise Exception(f"SSH Unexpected Error: {str(e)}")
async def execute_interactive_command(host, username, password, commands: list, timeout=15.0):
"""
執行互動式指令 (適用於需要進入 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")
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,26 +246,20 @@ 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)
raw_output = await execute_single_command(host, username, password, "show cable modem", timeout=15.0)
if result.exit_status != 0 or not result.stdout:
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 result.stdout.splitlines():
for line in raw_output.splitlines():
match = mac_pattern.search(line)
if match:
macs.append(match.group(1).lower())
@ -277,17 +274,16 @@ 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)
raw_output = await execute_single_command(host, username, password, "show cable rpd", timeout=15.0)
if result.exit_status != 0 or not result.stdout:
if not raw_output:
return {"rpds": []}
# 嚴謹的 Regex匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6
vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])")
rpds = []
for line in result.stdout.splitlines():
for line in raw_output.splitlines():
match = vcvs_pattern.search(line)
if match:
rpds.append(match.group(1))

View File

@ -1,8 +1,9 @@
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")
@ -28,7 +29,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')
@ -36,8 +37,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:
@ -57,8 +57,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())
@ -72,7 +71,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():
@ -83,8 +82,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:

View File

@ -211,3 +211,17 @@ 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();
}

View File

@ -14,7 +14,8 @@ import {
apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics,
apiListCms, apiListRpds
apiListCms, apiListRpds,
apiGetLogLevels, apiSetLogLevel
} from './api.js';
// 3. 共用工具模組 (Utils)
@ -1162,6 +1163,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 += `
<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)
@ -1990,3 +2046,4 @@ window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup;
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!