feat: implement WebSocket Log Streamer with UI toggle switch

This commit is contained in:
swpa 2026-06-15 18:04:20 +08:00
parent d4721b163f
commit 69447981bc
8 changed files with 514 additions and 28 deletions

View File

@ -34,15 +34,13 @@
### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management) ### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management)
- [x] **智能視覺診斷 (Visual Diagnostics)** - [x] **智能視覺診斷 (Visual Diagnostics)**
- CM 一鍵診斷中心整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。 - CM 一鍵診斷中心整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。
- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)** - [x] **備份保留策略 (Retention Policy)**
- 重新 Review `generate_diff_commands` 演算法。 - **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時,採用 FIFO (先進先出) 機制自動清理舊資料,防止資料庫無限膨脹。
- 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。 - [ ] **系統日誌動態儀表板 (Log Viewer UI)** :
- [ ] **自動備份攔截防護 (Auto-Backup Interceptor)** - 基於 FastAPI 實作一個 WebSocket Log Streamer。
- 在執行任何 `generate_cli` (寫入設備變更) 的 API 之前,實作強制背景備份 `running-config` 的防呆機制。 1. **Custom Log Handler**: 在現有的 Python `logging` 模組中,撰寫一個自訂的 Handler能夠攔截系統的 Log 訊息(包含 ANSI 色碼)。
- 將此類備份標記為 `is_auto = true`,確保每次人為變更前都有絕對的還原點。 2. **WebSocket Endpoint**: 建立一個 FastAPI WebSocket 路由 `/ws/logs`
- [ ] **雙軌制備份保留策略 (Dual-Track Retention Policy)** 3. **Broadcaster**: 實作一個簡單的機制,當 Custom Log Handler 收到新日誌時,能非同步地將訊息推播給所有連線中的 WebSocket 客戶端。
- **自動快照滾動 (Rolling Window)**:限制每台設備最多保留 30 份自動快照,採用 FIFO (先進先出) 機制自動清理舊資料。
- **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時於前端 UI 提示使用者進行清理,防止資料庫無限膨脹。
### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard) ### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard)
- [ ] **RPD 樣板克隆引擎 (Template Cloning)** - [ ] **RPD 樣板克隆引擎 (Template Cloning)**
@ -51,6 +49,9 @@
- 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。 - 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。
- [ ] **安全寫入與校驗 (Safe Provisioning)** - [ ] **安全寫入與校驗 (Safe Provisioning)**
- 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。 - 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。
- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)**
- 重新 Review `generate_diff_commands` 演算法。
- 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。
--- ---

View File

@ -1226,6 +1226,21 @@ FILE: index.html
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span> <span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
</div> </div>
</div> </div>
<!-- 🌟 新增:🎙️ 系統實時日誌面板 (System Live Logs) -->
<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; display: flex; justify-content: space-between; align-items: center;">
<span>🎙️ 系統實時日誌 (System Live Logs)</span>
<div style="display: flex; align-items: center; gap: 15px;">
<span id="log-ws-status" style="font-size: 13px; color: #7f8c8d; font-weight: normal;">狀態:已暫停 ⚪</span>
<button id="btnToggleLog" onclick="toggleLogStream()" class="btn-modern btn-disconnect" style="padding: 4px 12px; font-size: 12px;">🔌 啟動監聽</button>
</div>
</h3>
<!-- 日誌終端機容器 -->
<div id="log-terminal-container" style="width: 100%; height: 300px; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); overflow: hidden; position: relative; padding: 10px; box-sizing: border-box;"></div>
</div>
</div> </div>
</div> </div>
@ -1886,14 +1901,17 @@ from fastapi.responses import HTMLResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from starlette.middleware.base import BaseHTTPMiddleware # 🌟 新增引入 from starlette.middleware.base import BaseHTTPMiddleware # 🌟 新增引入
import database import database
import asyncio
# 引入我們剛剛拆分出來的路由模組 # 引入我們剛剛拆分出來的路由模組
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth, logs
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # Startup
await database.init_db_pool() await database.init_db_pool()
# 🌟 新增:初始化 Log Broadcaster 的 Event Loop 參考,確保跨執行緒排程安全
logs.log_broadcaster.loop = asyncio.get_running_loop()
yield yield
# Shutdown # Shutdown
await database.close_db_pool() await database.close_db_pool()
@ -1934,6 +1952,7 @@ app.include_router(auth.router, prefix="/api/v1")
# WebSocket 通常獨立於 API 版本之外,所以不加前綴 # WebSocket 通常獨立於 API 版本之外,所以不加前綴
app.include_router(terminal.router) app.include_router(terminal.router)
app.include_router(logs.router) # 🌟 新增:掛載日誌 WebSocket 路由
# 根目錄路由:回傳前端 UI # 根目錄路由:回傳前端 UI
@app.get("/", response_class=HTMLResponse, tags=["UI"]) @app.get("/", response_class=HTMLResponse, tags=["UI"])
@ -2012,6 +2031,8 @@ MANAGED_LOGGERS = [
"app.diagnostics", # CM 診斷 "app.diagnostics", # CM 診斷
"app.backup", # 備份與還原 "app.backup", # 備份與還原
"app.ssh" # SSH 連線引擎 "app.ssh" # SSH 連線引擎
"app.ssh" # SSH 連線引擎
"app.auth" # 🌟 新增:上帝模式與安全授權
] ]
def setup_logger(name: str, level=logging.INFO): def setup_logger(name: str, level=logging.INFO):
@ -2047,6 +2068,12 @@ def set_log_level(name: str, level_str: str):
return True return True
return False return False
def register_websocket_handler(handler: logging.Handler):
"""🌟 新增:註冊自訂 Handler 到所有受管控的 Logger並防止重複添加"""
for name in MANAGED_LOGGERS:
logger = logging.getLogger(name)
if handler not in logger.handlers:
logger.addHandler(handler)
================================================================================ ================================================================================
FILE: PROJECT_STATE.md FILE: PROJECT_STATE.md
@ -2087,15 +2114,13 @@ FILE: PROJECT_STATE.md
### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management) ### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management)
- [x] **智能視覺診斷 (Visual Diagnostics)** - [x] **智能視覺診斷 (Visual Diagnostics)**
- CM 一鍵診斷中心整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。 - CM 一鍵診斷中心整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。
- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)** - [x] **備份保留策略 (Retention Policy)**
- 重新 Review `generate_diff_commands` 演算法。 - **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時,採用 FIFO (先進先出) 機制自動清理舊資料,防止資料庫無限膨脹。
- 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。 - [ ] **系統日誌動態儀表板 (Log Viewer UI)** :
- [ ] **自動備份攔截防護 (Auto-Backup Interceptor)** - 基於 FastAPI 實作一個 WebSocket Log Streamer。
- 在執行任何 `generate_cli` (寫入設備變更) 的 API 之前,實作強制背景備份 `running-config` 的防呆機制。 1. **Custom Log Handler**: 在現有的 Python `logging` 模組中,撰寫一個自訂的 Handler能夠攔截系統的 Log 訊息(包含 ANSI 色碼)。
- 將此類備份標記為 `is_auto = true`,確保每次人為變更前都有絕對的還原點。 2. **WebSocket Endpoint**: 建立一個 FastAPI WebSocket 路由 `/ws/logs`。
- [ ] **雙軌制備份保留策略 (Dual-Track Retention Policy)** 3. **Broadcaster**: 實作一個簡單的機制,當 Custom Log Handler 收到新日誌時,能非同步地將訊息推播給所有連線中的 WebSocket 客戶端。
- **自動快照滾動 (Rolling Window)**:限制每台設備最多保留 30 份自動快照,採用 FIFO (先進先出) 機制自動清理舊資料。
- **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時於前端 UI 提示使用者進行清理,防止資料庫無限膨脹。
### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard) ### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard)
- [ ] **RPD 樣板克隆引擎 (Template Cloning)** - [ ] **RPD 樣板克隆引擎 (Template Cloning)**
@ -2104,6 +2129,9 @@ FILE: PROJECT_STATE.md
- 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。 - 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。
- [ ] **安全寫入與校驗 (Safe Provisioning)** - [ ] **安全寫入與校驗 (Safe Provisioning)**
- 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。 - 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。
- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)**
- 重新 Review `generate_diff_commands` 演算法。
- 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。
--- ---
@ -2417,6 +2445,10 @@ let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒) const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
let globalSSE = null; let globalSSE = null;
let logTerm = null;
let logWs = null;
let isLogEnabled = false; // 預設關閉,不主動消耗資源
// 初始化全域 SSE 監聽器 (設備級別) // 初始化全域 SSE 監聽器 (設備級別)
function initGlobalSSE() { function initGlobalSSE() {
// 🌟 新增防線:如果尚未連線,強制關閉舊的 SSE 並退出 // 🌟 新增防線:如果尚未連線,強制關閉舊的 SSE 並退出
@ -3600,9 +3632,102 @@ async function switchFilterMode() {
await openSystemSettings(); await openSystemSettings();
} }
// 開啟系統設定並讀取目前的過濾器清單 function initLogTerminal() {
const container = document.getElementById('log-terminal-container');
if (!container || logTerm) return;
logTerm = new Terminal({
cursorBlink: false,
disableStdin: true,
theme: { background: '#1e1e1e', foreground: '#e0e0e0' },
fontFamily: 'Courier New, monospace',
fontSize: 13,
scrollback: 1000,
cols: 250 // 確保不折行
});
logTerm.open(container);
logTerm.writeln('\x1b[33m[System] Live Log Terminal Ready. Click "啟動監聽" to start streaming.\x1b[0m\r\n');
}
function toggleLogStream() {
isLogEnabled = !isLogEnabled;
const btn = document.getElementById('btnToggleLog');
if (isLogEnabled) {
initLogTerminal();
connectLogWebSocket();
if (btn) {
btn.textContent = '⏸️ 暫停監聽';
btn.className = 'btn-modern btn-clear';
}
} else {
disconnectLogWebSocket();
if (btn) {
btn.textContent = '🔌 啟動監聽';
btn.className = 'btn-modern btn-connect';
}
}
}
function connectLogWebSocket() {
if (!isLogEnabled) return;
if (logWs && logWs.readyState === WebSocket.OPEN) return;
const statusEl = document.getElementById('log-ws-status');
if (statusEl) {
statusEl.textContent = '狀態:正在連線... 🟡';
statusEl.style.color = '#f39c12';
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
logWs = new WebSocket(wsUrl);
logWs.onopen = () => {
if (statusEl) {
statusEl.textContent = '狀態:實時監聽中 🟢';
statusEl.style.color = '#27ae60';
}
};
logWs.onmessage = (event) => {
if (logTerm && isLogEnabled) {
const formattedLog = event.data.replace(/\n/g, '\r\n') + '\r\n';
logTerm.write(formattedLog);
}
};
logWs.onclose = () => {
const statusEl = document.getElementById('log-ws-status');
if (isLogEnabled) {
if (statusEl) {
statusEl.textContent = '狀態:連線中斷,嘗試重連... 🔴';
statusEl.style.color = '#c0392b';
}
setTimeout(connectLogWebSocket, 5000);
} else {
if (statusEl) {
statusEl.textContent = '狀態:已暫停 ⚪';
statusEl.style.color = '#7f8c8d';
}
}
};
}
function disconnectLogWebSocket() {
if (logWs) {
logWs.close();
logWs = null;
}
}
// --- 3. 確保 openSystemSettings 正常初始化 ---
async function openSystemSettings() { async function openSystemSettings() {
// 🌟 動態抓取過濾器模式 setTimeout(() => {
initLogTerminal();
}, 50);
const modeSelect = document.getElementById('filter-mode-select'); const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running'; const mode = modeSelect ? modeSelect.value : 'running';
@ -3613,7 +3738,6 @@ async function openSystemSettings() {
console.error("讀取設定失敗:", error); console.error("讀取設定失敗:", error);
} }
// 🌟 新增:同時載入日誌等級
loadLogLevels(); loadLogLevels();
} }
@ -3630,7 +3754,8 @@ async function loadLogLevels() {
'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)', 'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)',
'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)', 'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)',
'app.backup': '💾 備份與還原模組 (Backup)', 'app.backup': '💾 備份與還原模組 (Backup)',
'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)' 'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)',
'app.auth': '🔐 安全授權模組 (Auth)' // 🌟 新增這行
}; };
for (const [module, level] of Object.entries(res.data)) { for (const [module, level] of Object.entries(res.data)) {
@ -4604,6 +4729,7 @@ window.scanMissingOptions = scanMissingOptions;
window.clearOptionsCache = clearOptionsCache; window.clearOptionsCache = clearOptionsCache;
window.previewNodeCLI = previewNodeCLI; window.previewNodeCLI = previewNodeCLI;
window.switchFilterMode = switchFilterMode; window.switchFilterMode = switchFilterMode;
window.toggleLogStream = toggleLogStream;
// --- 樹狀圖展開與編輯操作 --- // --- 樹狀圖展開與編輯操作 ---
window.expandAll = expandAll; window.expandAll = expandAll;
@ -5141,6 +5267,41 @@ button:hover { background-color: #3498db; }
contain-intrinsic-size: 0 24px; contain-intrinsic-size: 0 24px;
} }
/* ============================================================================
🎙️ 系統實時日誌終端機 - 滾動條隔離優化 (Scrollbar Isolation - 修正版)
============================================================================ */
#log-terminal-container .xterm {
width: 100% !important;
}
#log-terminal-container .xterm-screen {
/* 🌟 關鍵:限制文字區最大寬度,扣除右側垂直滾動條的寬度 */
max-width: calc(100% - 18px) !important;
/* 🌟 關鍵:只允許水平滾動,強制關閉垂直滾動,消除多餘的藍灰色軌道 */
overflow-x: auto !important;
overflow-y: hidden !important;
}
/* 讓水平滾動條呈現精美的扁平化現代風格 */
#log-terminal-container .xterm-screen::-webkit-scrollbar {
height: 8px;
}
#log-terminal-container .xterm-screen::-webkit-scrollbar-track {
background: #1e1e1e;
border-radius: 4px;
}
#log-terminal-container .xterm-screen::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
#log-terminal-container .xterm-screen::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
.unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); } .unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); }
================================================================================ ================================================================================
@ -7867,6 +8028,90 @@ async def get_cm_diagnostics(
logger.error("CM Diagnostics Error: " + str(e)) logger.error("CM Diagnostics Error: " + str(e))
raise HTTPException(status_code=500, detail="設備連線或查詢失敗: " + str(e)) raise HTTPException(status_code=500, detail="設備連線或查詢失敗: " + str(e))
================================================================================
FILE: routers/logs.py
================================================================================
# --- routers/logs.py ---
import asyncio
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from logger import ColoredFormatter, register_websocket_handler
router = APIRouter()
class LogBroadcaster:
"""管理所有活躍 WebSocket 連線的廣播器 (Connection Manager)"""
def __init__(self):
self.active_connections: set[WebSocket] = set()
self.loop: asyncio.AbstractEventLoop = None
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.add(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.discard(websocket)
async def broadcast(self, message: str):
"""非同步推播日誌給所有連線中的客戶端"""
if not self.active_connections:
return
# 併發發送,並透過 return_exceptions=True 確保單一連線異常不影響其他客戶端
tasks = [self._safe_send(conn, message) for conn in self.active_connections]
await asyncio.gather(*tasks, return_exceptions=True)
async def _safe_send(self, websocket: WebSocket, message: str):
try:
await websocket.send_text(message)
except Exception:
self.disconnect(websocket)
# 建立全域廣播器實例
log_broadcaster = LogBroadcaster()
class WebSocketLogHandler(logging.Handler):
"""自訂 Log Handler攔截系統日誌並安全地派發至非同步廣播器"""
def __init__(self, broadcaster: LogBroadcaster):
super().__init__()
self.broadcaster = broadcaster
# 沿用系統 ColoredFormatter完美保留 ANSI 色碼
self.setFormatter(ColoredFormatter())
def emit(self, record):
try:
msg = self.format(record)
loop = self.broadcaster.loop
# 確保在 Event Loop 處於執行狀態時,安全地跨執行緒派發任務
if loop and loop.is_running():
loop.call_soon_threadsafe(
lambda: asyncio.create_task(self.broadcaster.broadcast(msg))
)
except Exception:
self.handleError(record)
# 建立 Handler 實例並註冊至所有受管控的 Logger
websocket_log_handler = WebSocketLogHandler(log_broadcaster)
register_websocket_handler(websocket_log_handler)
@router.websocket("/ws/logs")
async def websocket_logs(websocket: WebSocket):
"""WebSocket 實時日誌串流端點"""
# 若尚未綁定 Event Loop則於首次連線時動態綁定
if not log_broadcaster.loop:
log_broadcaster.loop = asyncio.get_running_loop()
await log_broadcaster.connect(websocket)
try:
# 保持連線,監聽客戶端斷線狀態
while True:
await websocket.receive_text()
except WebSocketDisconnect:
log_broadcaster.disconnect(websocket)
except Exception:
log_broadcaster.disconnect(websocket)
================================================================================ ================================================================================
FILE: routers/query.py FILE: routers/query.py
================================================================================ ================================================================================

View File

@ -449,6 +449,21 @@
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span> <span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
</div> </div>
</div> </div>
<!-- 🌟 新增:🎙️ 系統實時日誌面板 (System Live Logs) -->
<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; display: flex; justify-content: space-between; align-items: center;">
<span>🎙️ 系統實時日誌 (System Live Logs)</span>
<div style="display: flex; align-items: center; gap: 15px;">
<span id="log-ws-status" style="font-size: 13px; color: #7f8c8d; font-weight: normal;">狀態:已暫停 ⚪</span>
<button id="btnToggleLog" onclick="toggleLogStream()" class="btn-modern btn-disconnect" style="padding: 4px 12px; font-size: 12px;">🔌 啟動監聽</button>
</div>
</h3>
<!-- 日誌終端機容器 -->
<div id="log-terminal-container" style="width: 100%; height: 300px; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); overflow: hidden; position: relative; padding: 10px; box-sizing: border-box;"></div>
</div>
</div> </div>
</div> </div>

View File

@ -27,6 +27,8 @@ MANAGED_LOGGERS = [
"app.diagnostics", # CM 診斷 "app.diagnostics", # CM 診斷
"app.backup", # 備份與還原 "app.backup", # 備份與還原
"app.ssh" # SSH 連線引擎 "app.ssh" # SSH 連線引擎
"app.ssh" # SSH 連線引擎
"app.auth" # 🌟 新增:上帝模式與安全授權
] ]
def setup_logger(name: str, level=logging.INFO): def setup_logger(name: str, level=logging.INFO):
@ -61,3 +63,10 @@ def set_log_level(name: str, level_str: str):
logging.getLogger(name).setLevel(level) logging.getLogger(name).setLevel(level)
return True return True
return False return False
def register_websocket_handler(handler: logging.Handler):
"""🌟 新增:註冊自訂 Handler 到所有受管控的 Logger並防止重複添加"""
for name in MANAGED_LOGGERS:
logger = logging.getLogger(name)
if handler not in logger.handlers:
logger.addHandler(handler)

View File

@ -6,14 +6,17 @@ from fastapi.responses import HTMLResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from starlette.middleware.base import BaseHTTPMiddleware # 🌟 新增引入 from starlette.middleware.base import BaseHTTPMiddleware # 🌟 新增引入
import database import database
import asyncio
# 引入我們剛剛拆分出來的路由模組 # 引入我們剛剛拆分出來的路由模組
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth, logs
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # Startup
await database.init_db_pool() await database.init_db_pool()
# 🌟 新增:初始化 Log Broadcaster 的 Event Loop 參考,確保跨執行緒排程安全
logs.log_broadcaster.loop = asyncio.get_running_loop()
yield yield
# Shutdown # Shutdown
await database.close_db_pool() await database.close_db_pool()
@ -54,6 +57,7 @@ app.include_router(auth.router, prefix="/api/v1")
# WebSocket 通常獨立於 API 版本之外,所以不加前綴 # WebSocket 通常獨立於 API 版本之外,所以不加前綴
app.include_router(terminal.router) app.include_router(terminal.router)
app.include_router(logs.router) # 🌟 新增:掛載日誌 WebSocket 路由
# 根目錄路由:回傳前端 UI # 根目錄路由:回傳前端 UI
@app.get("/", response_class=HTMLResponse, tags=["UI"]) @app.get("/", response_class=HTMLResponse, tags=["UI"])

79
routers/logs.py Normal file
View File

@ -0,0 +1,79 @@
# --- routers/logs.py ---
import asyncio
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from logger import ColoredFormatter, register_websocket_handler
router = APIRouter()
class LogBroadcaster:
"""管理所有活躍 WebSocket 連線的廣播器 (Connection Manager)"""
def __init__(self):
self.active_connections: set[WebSocket] = set()
self.loop: asyncio.AbstractEventLoop = None
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.add(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.discard(websocket)
async def broadcast(self, message: str):
"""非同步推播日誌給所有連線中的客戶端"""
if not self.active_connections:
return
# 併發發送,並透過 return_exceptions=True 確保單一連線異常不影響其他客戶端
tasks = [self._safe_send(conn, message) for conn in self.active_connections]
await asyncio.gather(*tasks, return_exceptions=True)
async def _safe_send(self, websocket: WebSocket, message: str):
try:
await websocket.send_text(message)
except Exception:
self.disconnect(websocket)
# 建立全域廣播器實例
log_broadcaster = LogBroadcaster()
class WebSocketLogHandler(logging.Handler):
"""自訂 Log Handler攔截系統日誌並安全地派發至非同步廣播器"""
def __init__(self, broadcaster: LogBroadcaster):
super().__init__()
self.broadcaster = broadcaster
# 沿用系統 ColoredFormatter完美保留 ANSI 色碼
self.setFormatter(ColoredFormatter())
def emit(self, record):
try:
msg = self.format(record)
loop = self.broadcaster.loop
# 確保在 Event Loop 處於執行狀態時,安全地跨執行緒派發任務
if loop and loop.is_running():
loop.call_soon_threadsafe(
lambda: asyncio.create_task(self.broadcaster.broadcast(msg))
)
except Exception:
self.handleError(record)
# 建立 Handler 實例並註冊至所有受管控的 Logger
websocket_log_handler = WebSocketLogHandler(log_broadcaster)
register_websocket_handler(websocket_log_handler)
@router.websocket("/ws/logs")
async def websocket_logs(websocket: WebSocket):
"""WebSocket 實時日誌串流端點"""
# 若尚未綁定 Event Loop則於首次連線時動態綁定
if not log_broadcaster.loop:
log_broadcaster.loop = asyncio.get_running_loop()
await log_broadcaster.connect(websocket)
try:
# 保持連線,監聽客戶端斷線狀態
while True:
await websocket.receive_text()
except WebSocketDisconnect:
log_broadcaster.disconnect(websocket)
except Exception:
log_broadcaster.disconnect(websocket)

View File

@ -56,6 +56,10 @@ let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒) const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
let globalSSE = null; let globalSSE = null;
let logTerm = null;
let logWs = null;
let isLogEnabled = false; // 預設關閉,不主動消耗資源
// 初始化全域 SSE 監聽器 (設備級別) // 初始化全域 SSE 監聽器 (設備級別)
function initGlobalSSE() { function initGlobalSSE() {
// 🌟 新增防線:如果尚未連線,強制關閉舊的 SSE 並退出 // 🌟 新增防線:如果尚未連線,強制關閉舊的 SSE 並退出
@ -1239,9 +1243,102 @@ async function switchFilterMode() {
await openSystemSettings(); await openSystemSettings();
} }
// 開啟系統設定並讀取目前的過濾器清單 function initLogTerminal() {
const container = document.getElementById('log-terminal-container');
if (!container || logTerm) return;
logTerm = new Terminal({
cursorBlink: false,
disableStdin: true,
theme: { background: '#1e1e1e', foreground: '#e0e0e0' },
fontFamily: 'Courier New, monospace',
fontSize: 13,
scrollback: 1000,
cols: 250 // 確保不折行
});
logTerm.open(container);
logTerm.writeln('\x1b[33m[System] Live Log Terminal Ready. Click "啟動監聽" to start streaming.\x1b[0m\r\n');
}
function toggleLogStream() {
isLogEnabled = !isLogEnabled;
const btn = document.getElementById('btnToggleLog');
if (isLogEnabled) {
initLogTerminal();
connectLogWebSocket();
if (btn) {
btn.textContent = '⏸️ 暫停監聽';
btn.className = 'btn-modern btn-clear';
}
} else {
disconnectLogWebSocket();
if (btn) {
btn.textContent = '🔌 啟動監聽';
btn.className = 'btn-modern btn-connect';
}
}
}
function connectLogWebSocket() {
if (!isLogEnabled) return;
if (logWs && logWs.readyState === WebSocket.OPEN) return;
const statusEl = document.getElementById('log-ws-status');
if (statusEl) {
statusEl.textContent = '狀態:正在連線... 🟡';
statusEl.style.color = '#f39c12';
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
logWs = new WebSocket(wsUrl);
logWs.onopen = () => {
if (statusEl) {
statusEl.textContent = '狀態:實時監聽中 🟢';
statusEl.style.color = '#27ae60';
}
};
logWs.onmessage = (event) => {
if (logTerm && isLogEnabled) {
const formattedLog = event.data.replace(/\n/g, '\r\n') + '\r\n';
logTerm.write(formattedLog);
}
};
logWs.onclose = () => {
const statusEl = document.getElementById('log-ws-status');
if (isLogEnabled) {
if (statusEl) {
statusEl.textContent = '狀態:連線中斷,嘗試重連... 🔴';
statusEl.style.color = '#c0392b';
}
setTimeout(connectLogWebSocket, 5000);
} else {
if (statusEl) {
statusEl.textContent = '狀態:已暫停 ⚪';
statusEl.style.color = '#7f8c8d';
}
}
};
}
function disconnectLogWebSocket() {
if (logWs) {
logWs.close();
logWs = null;
}
}
// --- 3. 確保 openSystemSettings 正常初始化 ---
async function openSystemSettings() { async function openSystemSettings() {
// 🌟 動態抓取過濾器模式 setTimeout(() => {
initLogTerminal();
}, 50);
const modeSelect = document.getElementById('filter-mode-select'); const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running'; const mode = modeSelect ? modeSelect.value : 'running';
@ -1252,7 +1349,6 @@ async function openSystemSettings() {
console.error("讀取設定失敗:", error); console.error("讀取設定失敗:", error);
} }
// 🌟 新增:同時載入日誌等級
loadLogLevels(); loadLogLevels();
} }
@ -1269,7 +1365,8 @@ async function loadLogLevels() {
'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)', 'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)',
'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)', 'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)',
'app.backup': '💾 備份與還原模組 (Backup)', 'app.backup': '💾 備份與還原模組 (Backup)',
'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)' 'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)',
'app.auth': '🔐 安全授權模組 (Auth)' // 🌟 新增這行
}; };
for (const [module, level] of Object.entries(res.data)) { for (const [module, level] of Object.entries(res.data)) {
@ -2243,6 +2340,7 @@ window.scanMissingOptions = scanMissingOptions;
window.clearOptionsCache = clearOptionsCache; window.clearOptionsCache = clearOptionsCache;
window.previewNodeCLI = previewNodeCLI; window.previewNodeCLI = previewNodeCLI;
window.switchFilterMode = switchFilterMode; window.switchFilterMode = switchFilterMode;
window.toggleLogStream = toggleLogStream;
// --- 樹狀圖展開與編輯操作 --- // --- 樹狀圖展開與編輯操作 ---
window.expandAll = expandAll; window.expandAll = expandAll;

View File

@ -304,4 +304,39 @@ button:hover { background-color: #3498db; }
contain-intrinsic-size: 0 24px; contain-intrinsic-size: 0 24px;
} }
/* ============================================================================
🎙 系統實時日誌終端機 - 滾動條隔離優化 (Scrollbar Isolation - 修正版)
============================================================================ */
#log-terminal-container .xterm {
width: 100% !important;
}
#log-terminal-container .xterm-screen {
/* 🌟 關鍵:限制文字區最大寬度,扣除右側垂直滾動條的寬度 */
max-width: calc(100% - 18px) !important;
/* 🌟 關鍵:只允許水平滾動,強制關閉垂直滾動,消除多餘的藍灰色軌道 */
overflow-x: auto !important;
overflow-y: hidden !important;
}
/* 讓水平滾動條呈現精美的扁平化現代風格 */
#log-terminal-container .xterm-screen::-webkit-scrollbar {
height: 8px;
}
#log-terminal-container .xterm-screen::-webkit-scrollbar-track {
background: #1e1e1e;
border-radius: 4px;
}
#log-terminal-container .xterm-screen::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
#log-terminal-container .xterm-screen::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
.unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); } .unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); }