feat: 導入動態日誌管控機制架構
This commit is contained in:
parent
19d5a30305
commit
bc933e1e39
450
all_code.txt
450
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
|
|||
<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,32 +7563,26 @@ 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:
|
||||
return {"cms": []}
|
||||
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 = []
|
||||
# 嚴謹的 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())
|
||||
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)))}
|
||||
# 去重複並排序
|
||||
return {"cms": sorted(list(set(macs)))}
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}")
|
||||
|
|
@ -7432,22 +7591,21 @@ async def list_cms(host: str, username: str, password: str):
|
|||
@router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單")
|
||||
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:
|
||||
return {"rpds": []}
|
||||
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 = []
|
||||
# 嚴謹的 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():
|
||||
match = vcvs_pattern.search(line)
|
||||
if match:
|
||||
rpds.append(match.group(1))
|
||||
for line in raw_output.splitlines():
|
||||
match = vcvs_pattern.search(line)
|
||||
if match:
|
||||
rpds.append(match.group(1))
|
||||
|
||||
return {"rpds": sorted(list(set(rpds)))}
|
||||
return {"rpds": sorted(list(set(rpds)))}
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}")
|
||||
|
|
@ -7472,6 +7630,7 @@ from netmiko import ConnectHandler
|
|||
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
|
||||
from 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
19
index.html
19
index.html
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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="未知的模組名稱")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
218
routers/query.py
218
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}
|
||||
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,32 +246,26 @@ 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:
|
||||
return {"cms": []}
|
||||
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 = []
|
||||
# 嚴謹的 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())
|
||||
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)))}
|
||||
# 去重複並排序
|
||||
return {"cms": sorted(list(set(macs)))}
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}")
|
||||
|
|
@ -277,22 +274,21 @@ async def list_cms(host: str, username: str, password: str):
|
|||
@router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單")
|
||||
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:
|
||||
return {"rpds": []}
|
||||
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 = []
|
||||
# 嚴謹的 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():
|
||||
match = vcvs_pattern.search(line)
|
||||
if match:
|
||||
rpds.append(match.group(1))
|
||||
for line in raw_output.splitlines():
|
||||
match = vcvs_pattern.search(line)
|
||||
if match:
|
||||
rpds.append(match.group(1))
|
||||
|
||||
return {"rpds": sorted(list(set(rpds)))}
|
||||
return {"rpds": sorted(list(set(rpds)))}
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; // 🌟 新增這行,統一在這裡綁定!
|
||||
Loading…
Reference in New Issue