feat: implement WebSocket Log Streamer with UI toggle switch
This commit is contained in:
parent
d4721b163f
commit
69447981bc
|
|
@ -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 拒絕執行或引發非預期刪除。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
273
all_code.txt
273
all_code.txt
|
|
@ -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
|
||||||
================================================================================
|
================================================================================
|
||||||
|
|
|
||||||
15
index.html
15
index.html
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
6
main.py
6
main.py
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
106
static/app.js
106
static/app.js
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
Loading…
Reference in New Issue