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)
+
+
+
+
+ 在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,無需重啟伺服器。
+ 💡 建議平時保持在 INFO 或 ERROR,僅在需要排查問題時開啟 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)
+
+
+
+
+ 在此動態調整各個後端模組的日誌輸出等級。設定會立即生效,無需重啟伺服器。
+ 💡 建議平時保持在 INFO 或 ERROR,僅在需要排查問題時開啟 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