From 8bc68b8bf12f25420dae814c0b8e9017880302f5 Mon Sep 17 00:00:00 2001 From: swpa Date: Thu, 4 Jun 2026 18:09:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=99=BA=E8=83=BD=E8=A6=96=E8=A6=BA?= =?UTF-8?q?=E8=A8=BA=E6=96=B7=20(Visual=20Diagnostics)=20:=20-=20CM=20?= =?UTF-8?q?=E4=B8=80=E9=8D=B5=E8=A8=BA=E6=96=B7=E4=B8=AD=E5=BF=83=EF=BC=9A?= =?UTF-8?q?=E6=95=B4=E5=90=88=E5=9F=BA=E7=A4=8E=E7=8B=80=E6=85=8B=E3=80=81?= =?UTF-8?q?PHY=20=E5=B0=84=E9=A0=BB=E6=8C=87=E6=A8=99=E8=88=87=20OFDM=20ME?= =?UTF-8?q?R=20=E9=A0=BB=E8=AD=9C=E3=80=82=E5=BC=95=E5=85=A5=20Chart.js=20?= =?UTF-8?q?=E5=B0=87=20ASCII=20=E5=A0=B1=E8=A1=A8=E8=BD=89=E5=8C=96?= =?UTF-8?q?=E7=82=BA=E7=B4=85=E9=BB=83=E7=B6=A0=E7=8B=80=E6=85=8B=E5=9C=96?= =?UTF-8?q?=E8=88=87=E9=95=B7=E6=A2=9D=E5=9C=96=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- all_code.txt | 441 ++++++- index.html | 72 +- main.py | 3 +- merge_code.py | 112 +- routers/diagnostics.py | 150 +++ static/api.js | 10 + static/app.js | 200 ++- target_code.txt | 2806 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 3753 insertions(+), 41 deletions(-) create mode 100644 routers/diagnostics.py create mode 100644 target_code.txt diff --git a/all_code.txt b/all_code.txt index 8bd3b76..70b8f27 100644 --- a/all_code.txt +++ b/all_code.txt @@ -1,5 +1,5 @@ ================================================================================ -PROJECT SOURCE CODE EXPORT +PROJECT SOURCE CODE EXPORT (FULL) ================================================================================ @@ -680,6 +680,7 @@ FILE: index.html + @@ -740,6 +741,7 @@ FILE: index.html @@ -826,8 +828,76 @@ FILE: index.html + + +
+

CM 深度診斷與 MER 分析

+ + +
+ + + + +
+ + + +
- +
@@ -1712,7 +1782,7 @@ from contextlib import asynccontextmanager import database # 引入我們剛剛拆分出來的路由模組 -from routers import query, config, terminal, lock, leaf_options, backup +from routers import query, config, terminal, lock, leaf_options, backup, diagnostics @asynccontextmanager async def lifespan(app: FastAPI): @@ -1733,6 +1803,7 @@ app.include_router(config.router, prefix="/api/v1") app.include_router(leaf_options.router, prefix="/api/v1") app.include_router(lock.router, prefix="/api/v1") app.include_router(backup.router, prefix="/api/v1") +app.include_router(diagnostics.router, prefix="/api/v1") # WebSocket 通常獨立於 API 版本之外,所以不加前綴 app.include_router(terminal.router) @@ -2023,6 +2094,16 @@ export async function apiSyncLeafOptionsStream(host, paths, onProgress, configTy } } +export async function apiGetCmDiagnostics(host, username, password, mac) { + const url = `/api/v1/cm-diagnostics/?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&mac=${encodeURIComponent(mac)}`; + const response = await fetch(url); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || "伺服器發生錯誤"); + } + return response.json(); +} + ================================================================================ FILE: static/app.js @@ -2041,7 +2122,8 @@ import { apiGetFullConfig, apiClearCache, apiExecuteQuery, apiGetTreeFilters, apiSaveTreeFilters, apiSyncLeafOptionsStream, apiGetCmtsVersion, - apiGetScanStatus, apiGetLockStatus + apiGetScanStatus, apiGetLockStatus, + apiGetCmDiagnostics } from './api.js'; // 3. 共用工具模組 (Utils) @@ -2641,6 +2723,203 @@ async function fetchFullConfig(configType = 'running') { } } +// ============================================================================ +// 🌟 CM 一鍵診斷中心邏輯 (多頻道切換與紅綠燈實作) +// ============================================================================ +let merChartInstance = null; +let globalMerData = {}; // 暫存所有頻道的 MER 資料供切換使用 + +window.runCmDiagnostics = async function() { + const connInfo = getGlobalConnectionInfo(); + if (!connInfo) return; + + const macInput = document.getElementById('diagMacInput').value.trim(); + if (!macInput) return alert("請輸入目標 CM 的 MAC Address!"); + + const btn = document.getElementById('btnRunDiag'); + const msg = document.getElementById('diagStatusMsg'); + const resultArea = document.getElementById('diagResultArea'); + + btn.disabled = true; + btn.style.opacity = '0.7'; + msg.style.display = 'inline-block'; + resultArea.style.display = 'none'; + + try { + const result = await apiGetCmDiagnostics(connInfo.host, connInfo.user, connInfo.pass, macInput); + + if (result.status === 'success') { + const data = result.data; + + // 1. 填入基本資訊 + document.getElementById('diagResMac').textContent = data.mac; + document.getElementById('diagResIp').textContent = data.ip || 'N/A'; + document.getElementById('diagResCpe').textContent = data.cpe_count; + + const stateEl = document.getElementById('diagResState'); + stateEl.textContent = data.state || 'N/A'; + stateEl.style.color = (data.state && data.state.toLowerCase().includes('online')) ? '#27ae60' : '#e74c3c'; + + document.getElementById('diagResTx').textContent = data.phy.tx_power !== null ? data.phy.tx_power : 'N/A'; + document.getElementById('diagResRx').textContent = data.phy.rx_power !== null ? data.phy.rx_power : 'N/A'; + + // 2. 渲染上行 SNR (Upstreams) 標籤群 + const usContainer = document.getElementById('upstreamSnrContainer'); + usContainer.innerHTML = ''; + if (data.upstreams && data.upstreams.length > 0) { + data.upstreams.forEach(us => { + let color = '#e74c3c'; // 預設紅 (差: < 30) + if (us.snr >= 35) color = '#27ae60'; // 綠 (優: >= 35) + else if (us.snr >= 30) color = '#f39c12'; // 橘 (尚可: 30~34.9) + + usContainer.innerHTML += ` +
+ ${us.channel} : ${us.snr} +
+ `; + }); + } else { + usContainer.innerHTML = '無資料'; + } + + // 3. 處理 OFDM MER 頻道按鈕與圖表 + globalMerData = data.mer_channels; + const tabsContainer = document.getElementById('ofdmChannelTabs'); + tabsContainer.innerHTML = ''; + + const channels = Object.keys(globalMerData); + if (channels.length > 0) { + channels.forEach((ch, index) => { + const chData = globalMerData[ch]; + // 根據後端判斷的健康度上色 (>=41綠, 38~40橘, <=37紅) + let bgCol = '#bdc3c7'; // 預設灰色 (無資料) + if (chData.health === 'good') bgCol = '#27ae60'; + else if (chData.health === 'warning') bgCol = '#f39c12'; + else if (chData.health === 'critical') bgCol = '#e74c3c'; + + const btn = document.createElement('button'); + // 💡 拔除方正的 class,改用純手工膠囊樣式 + btn.style.boxSizing = 'border-box'; + btn.style.cursor = 'pointer'; + btn.style.backgroundColor = bgCol; + btn.style.color = '#ffffff'; + btn.style.padding = '6px 14px'; + btn.style.borderRadius = '20px'; // 🌟 完美的膠囊圓角 + btn.style.fontSize = '12px'; + btn.style.fontWeight = 'bold'; + btn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; + btn.style.transition = 'all 0.2s ease'; + + // 點擊與未點擊的狀態區分 (透明度與外框) + btn.style.opacity = index === 0 ? '1' : '0.5'; + btn.style.border = index === 0 ? '2px solid #2c3e50' : '2px solid transparent'; + + // 標籤文字加入最低 MER 數值提示 + btn.textContent = `${ch} (Min: ${chData.min_mer} dB)`; + + btn.onclick = () => { + // 重置所有按鈕外觀 + Array.from(tabsContainer.children).forEach(b => { + b.style.opacity = '0.5'; + b.style.border = '2px solid transparent'; + }); + // 突顯當前點擊的按鈕 + btn.style.opacity = '1'; + btn.style.border = '2px solid #2c3e50'; + // 重新繪圖 + renderMerChart(ch, chData.histogram); + }; + tabsContainer.appendChild(btn); + }); + + // 預設渲染第一個頻道的圖表 + renderMerChart(channels[0], globalMerData[channels[0]].histogram); + } else { + tabsContainer.innerHTML = '⚠️ 設備無回傳任何 OFDM 頻道 MER 數據'; + renderMerChart('無資料', {}); + } + + resultArea.style.display = 'flex'; + } + } catch (error) { + alert(`❌ 診斷失敗: ${error.message}`); + } finally { + btn.disabled = false; + btn.style.opacity = '1'; + msg.style.display = 'none'; + } +}; + +function renderMerChart(channelName, histogramData) { + if (merChartInstance) { + merChartInstance.destroy(); + } + const ctx = document.getElementById('merChart').getContext('2d'); + + if (!histogramData || Object.keys(histogramData).length === 0) { + merChartInstance = new Chart(ctx, { + type: 'bar', + data: { labels: ['無資料'], datasets: [{ label: 'Subcarriers', data: [0] }] }, + options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: '無法取得 MER 數據' } } } + }); + return; + } + + const labels = Object.keys(histogramData).sort((a, b) => parseInt(a) - parseInt(b)); + const dataPoints = labels.map(label => histogramData[label]); + + merChartInstance = new Chart(ctx, { + type: 'bar', + data: { + labels: labels.map(l => `${l} dB`), + datasets: [{ + label: 'Subcarriers 數量', + data: dataPoints, + backgroundColor: 'rgba(155, 89, 182, 0.6)', + borderColor: 'rgba(142, 68, 173, 1)', + borderWidth: 1, + borderRadius: 4, + hoverBackgroundColor: 'rgba(155, 89, 182, 0.9)' + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + title: { display: true, text: 'Subcarriers 數量', font: { weight: 'bold' } }, + grid: { color: '#ecf0f1' } + }, + x: { + title: { display: true, text: 'MER (dB)', font: { weight: 'bold' } }, + grid: { display: false } + } + }, + plugins: { + title: { + display: true, + text: `顯示中頻道: ${channelName}`, + font: { size: 15, weight: 'bold' }, + color: '#2c3e50' + }, + legend: { display: false }, + tooltip: { + backgroundColor: 'rgba(44, 62, 80, 0.9)', + titleFont: { size: 14 }, + bodyFont: { size: 14 }, + padding: 10, + callbacks: { + label: function(context) { + return ` ${context.raw} 個 Subcarriers`; + } + } + } + } + } + }); +} + // ============================================================================ // 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning) // ============================================================================ @@ -6588,6 +6867,160 @@ async def get_lock_status(host: str = Query(None)): return {"status": "success", "data": {}} +================================================================================ +FILE: routers/diagnostics.py +================================================================================ +# --- routers/diagnostics.py --- +import re +import asyncssh +import logging +from fastapi import APIRouter, HTTPException, Query +from typing import Dict, Any + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"]) + +@router.get("/") +async def get_cm_diagnostics( + host: str = Query(...), + username: str = Query(...), + password: str = Query(...), + mac: str = Query(..., description="Cable Modem MAC Address") +) -> Dict[str, Any]: + + mac = mac.strip().lower() + if not mac: + raise HTTPException(status_code=400, detail="MAC Address is required") + + result_data = { + "mac": mac, + "ip": "N/A", + "state": "N/A", + "cpe_count": 0, + "phy": { + "tx_power": None, + "rx_power": None + }, + "upstreams": [], + "mer_channels": {} + } + + try: + async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: + print("\n🚀 [Diagnostics] 開始診斷 MAC: " + mac) + + # ========================================== + # 1. 查詢基本狀態 + # ========================================== + cmd_base = "show cable modem " + mac + " | nomore" + res_base = await conn.run(cmd_base, check=False) + if res_base.exit_status == 0 and res_base.stdout: + for line in res_base.stdout.splitlines(): + if mac in line.lower(): + tokens = line.split() + for t in tokens: + if re.match(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$", t): + result_data["ip"] = t + elif re.match(r"^[a-z]-online|online|offline|init.*|reject", t, re.IGNORECASE): + result_data["state"] = t + nums = [t for t in tokens if t.isdigit()] + if nums: + result_data["cpe_count"] = int(nums[-1]) + break + + # ========================================== + # 2. 查詢 PHY 狀態 (💡 修復:移除寫死的 us/oad 判斷,改用通用特徵) + # ========================================== + cmd_phy = "show cable modem " + mac + " phy | nomore" + res_phy = await conn.run(cmd_phy, check=False) + if res_phy.exit_status == 0 and res_phy.stdout: + tx_list = [] + rx_list = [] + for line in res_phy.stdout.splitlines(): + line_lower = line.lower() + if mac in line_lower: + tokens = line.split() + if len(tokens) >= 6: + ch_name = tokens[1] + # 💡 只要名稱包含 ":" 和 "/",就認定是合法的通道 (例如 Oa32, Oad32, Us32) + if ":" in ch_name and "/" in ch_name: + try: + snr_val = float(tokens[4]) + result_data["upstreams"].append({"channel": ch_name, "snr": snr_val}) + except: + pass + try: + tx_val = float(tokens[3].split('/')[0]) + rx_list_val = float(tokens[5].split('/')[0]) + tx_list.append(tx_val) + rx_list.append(rx_list_val) + except: + pass + + if tx_list: result_data["phy"]["tx_power"] = round(sum(tx_list)/len(tx_list), 2) + if rx_list: result_data["phy"]["rx_power"] = round(sum(rx_list)/len(rx_list), 2) + + # ========================================== + # 3. 尋找所有 OFDM 通道 + # ========================================== + cmd_help = "show cable modem " + mac + " ofdm-channel ?" + res_help = await conn.run(cmd_help, check=False) + text_to_search = (res_help.stdout or "") + "\n" + (res_help.stderr or "") + + cmd_verb = "show cable modem " + mac + " verbose | nomore" + res_verb = await conn.run(cmd_verb, check=False) + text_to_search += "\n" + (res_verb.stdout or "") + + matches = re.findall(r"\b(Of(?:dm)?\d*[\d/:]+)\b", text_to_search, re.IGNORECASE) + ofdm_channels = [] + for m in matches: + clean_m = m.lower() + if clean_m.startswith("of"): + clean_m = "Of" + clean_m[2:] + if clean_m not in ofdm_channels: + ofdm_channels.append(clean_m) + ofdm_channels.sort() + + # ========================================== + # 4. 對每一個 OFDM 通道查詢 MER + # ========================================== + for ch in ofdm_channels: + cmd_mer = "show cable modem " + mac + " ofdm-channel " + ch + " mer | nomore" + res_mer = await conn.run(cmd_mer, check=False) + + histogram = {} + min_mer = 999 + + if res_mer.exit_status == 0 and res_mer.stdout: + mer_lines = re.finditer(r"(\d+)\s*(?:db|dB)?\s*\|[^|]*\|?\s*(\**)", res_mer.stdout, re.IGNORECASE) + for match in mer_lines: + db_val = match.group(1) + stars = match.group(2) + if stars: + histogram[db_val] = len(stars) * 100 + db_int = int(db_val) + if db_int < min_mer: + min_mer = db_int + + health = "unknown" + if histogram and min_mer != 999: + if min_mer >= 41: health = "good" + elif min_mer >= 38: health = "warning" + else: health = "critical" + + result_data["mer_channels"][ch] = { + "histogram": histogram, + "health": health, + "min_mer": min_mer if min_mer != 999 else "N/A" + } + + print("✅ [Diagnostics] 解析結果: " + str(result_data)) + return {"status": "success", "data": result_data} + + except Exception as e: + logger.error("CM Diagnostics Error: " + str(e)) + raise HTTPException(status_code=500, detail="設備連線或查詢失敗: " + str(e)) + ================================================================================ FILE: routers/query.py ================================================================================ diff --git a/index.html b/index.html index 83b31dc..03c7a37 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,7 @@ + @@ -68,6 +69,7 @@
@@ -154,8 +156,76 @@ + + +
+

CM 深度診斷與 MER 分析

+ + +
+ + + + +
+ + + +
- +
diff --git a/main.py b/main.py index d331b95..4dd233d 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,7 @@ from contextlib import asynccontextmanager import database # 引入我們剛剛拆分出來的路由模組 -from routers import query, config, terminal, lock, leaf_options, backup +from routers import query, config, terminal, lock, leaf_options, backup, diagnostics @asynccontextmanager async def lifespan(app: FastAPI): @@ -28,6 +28,7 @@ app.include_router(config.router, prefix="/api/v1") app.include_router(leaf_options.router, prefix="/api/v1") app.include_router(lock.router, prefix="/api/v1") app.include_router(backup.router, prefix="/api/v1") +app.include_router(diagnostics.router, prefix="/api/v1") # WebSocket 通常獨立於 API 版本之外,所以不加前綴 app.include_router(terminal.router) diff --git a/merge_code.py b/merge_code.py index 781d136..01fcf23 100644 --- a/merge_code.py +++ b/merge_code.py @@ -1,19 +1,19 @@ import os +import os +import argparse + # ========================================== # 設定區 # ========================================== -# 輸出的合併檔案名稱 -OUTPUT_FILE = "all_code.txt" - # ⚠️ 嚴格排除的資料夾 (完全不掃描這些目錄) EXCLUDE_DIRS = { "__pycache__", ".git", ".vscode", # VS Code 設定檔 ".continue", # Continue.dev 設定檔 - "cmts_api_env", # 🚨 Python 虛擬環境 (極度重要!絕對要排除) - "venv", # 其他常見的虛擬環境名稱 + "cmts_api_env", # 🚨 Python 虛擬環境 + "venv", ".venv", "node_modules", "dist", @@ -22,27 +22,28 @@ EXCLUDE_DIRS = { # ⚠️ 排除的檔案副檔名 (不讀取這些格式) EXCLUDE_EXTENSIONS = { - ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", # 圖片 - ".pyc", ".pyo", ".pyd", # Python 編譯檔 - ".exe", ".dll", ".so", ".dylib", # 執行檔/函式庫 - ".zip", ".tar", ".gz", ".rar", # 壓縮檔 - ".pdf", ".doc", ".docx", # 文件檔 - ".sqlite3", ".db" # 資料庫 + ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", + ".pyc", ".pyo", ".pyd", + ".exe", ".dll", ".so", ".dylib", + ".zip", ".tar", ".gz", ".rar", + ".pdf", ".doc", ".docx", + ".sqlite3", ".db" } -# ⚠️ 排除的特定檔案名稱 (例如腳本自己、隱藏檔等) +# ⚠️ 排除的特定檔案名稱 EXCLUDE_FILES = { - "merge_code.py", # 排除這支腳本自己 - "all_code.txt", # 排除輸出的檔案 - ".DS_Store", # Mac 系統檔 - ".clineignore", # AI 輔助工具的忽略檔 - ".clinerules", # AI 輔助工具的規則檔 - ".gitignore", # Git 忽略檔 - "requirements.txt" # 依賴清單 (通常不需要給 AI 看,除非你要問套件問題) + "merge_code.py", # 排除這支腳本自己 + "all_code.txt", # 排除全域輸出的檔案 + "target_code.txt", # 排除指定輸出的檔案 (新增) + ".DS_Store", + ".clineignore", + ".clinerules", + ".gitignore", + "requirements.txt" } def should_process_file(filename): - """判斷該檔案是否應該被處理""" + """判斷該檔案是否應該被處理 (用於全域掃描)""" if filename in EXCLUDE_FILES: return False @@ -52,44 +53,87 @@ def should_process_file(filename): return True -def merge_files(): - # 取得當前腳本所在的目錄 (專案根目錄) +def merge_all_files(): + """模式一:全域掃描 (無參數時觸發)""" + output_file = "all_code.txt" root_dir = os.path.dirname(os.path.abspath(__file__)) - with open(OUTPUT_FILE, "w", encoding="utf-8") as outfile: - # 寫入一個總標題 + with open(output_file, "w", encoding="utf-8") as outfile: outfile.write("=" * 80 + "\n") - outfile.write("PROJECT SOURCE CODE EXPORT\n") + outfile.write("PROJECT SOURCE CODE EXPORT (FULL)\n") outfile.write("=" * 80 + "\n\n") - # 走訪目錄 for dirpath, dirnames, filenames in os.walk(root_dir): - # 排除不需要的目錄 (原地修改 dirnames 列表,os.walk 就不會進去) dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS] for filename in filenames: if should_process_file(filename): file_path = os.path.join(dirpath, filename) - # 計算相對路徑 (例如: routers/config.py) rel_path = os.path.relpath(file_path, root_dir) try: with open(file_path, "r", encoding="utf-8") as infile: content = infile.read() - - # 寫入漂亮的檔名標籤 outfile.write("\n" + "=" * 80 + "\n") outfile.write(f"FILE: {rel_path}\n") outfile.write("=" * 80 + "\n") - # 寫入檔案內容 outfile.write(content) outfile.write("\n") - print(f"✅ 已合併: {rel_path}") except Exception as e: print(f"❌ 讀取失敗 {rel_path}: {e}") - print(f"\n🎉 合併完成!所有程式碼已儲存至: {OUTPUT_FILE}") + print(f"\n🎉 全域合併完成!所有程式碼已儲存至: {output_file}") + +def merge_target_files(file_list): + """模式二:指定檔案合併 (-t 參數觸發)""" + output_file = "target_code.txt" + root_dir = os.path.dirname(os.path.abspath(__file__)) + + with open(output_file, "w", encoding="utf-8") as outfile: + outfile.write("=" * 80 + "\n") + outfile.write("TARGET SOURCE CODE EXPORT (SPECIFIC)\n") + outfile.write("=" * 80 + "\n\n") + + for rel_path in file_list: + file_path = os.path.join(root_dir, rel_path) + + if not os.path.exists(file_path): + print(f"❌ 找不到檔案 (已跳過): {rel_path}") + continue + if not os.path.isfile(file_path): + print(f"❌ 不是有效檔案 (已跳過): {rel_path}") + continue + + try: + with open(file_path, "r", encoding="utf-8") as infile: + content = infile.read() + outfile.write("\n" + "=" * 80 + "\n") + outfile.write(f"FILE: {rel_path}\n") + outfile.write("=" * 80 + "\n") + outfile.write(content) + outfile.write("\n") + print(f"✅ 已合併: {rel_path}") + except Exception as e: + print(f"❌ 讀取失敗 {rel_path}: {e}") + + print(f"\n🎯 指定合併完成!選定的程式碼已儲存至: {output_file}") if __name__ == "__main__": - merge_files() + # 使用 argparse 來解析終端機指令 + parser = argparse.ArgumentParser(description="合併專案程式碼供 AI 讀取") + parser.add_argument( + '-t', '--target', + nargs='+', # 允許接收一個或多個參數 + help="指定要合併的檔案路徑 (例如: -t main.py routers/api.py)" + ) + + args = parser.parse_args() + + # 根據是否有傳入 -t 參數,決定執行哪一種模式 + if args.target: + print("🔍 啟動 [指定模式]...") + merge_target_files(args.target) + else: + print("🔍 啟動 [全域模式]...") + merge_all_files() diff --git a/routers/diagnostics.py b/routers/diagnostics.py new file mode 100644 index 0000000..2bbd277 --- /dev/null +++ b/routers/diagnostics.py @@ -0,0 +1,150 @@ +# --- routers/diagnostics.py --- +import re +import asyncssh +import logging +from fastapi import APIRouter, HTTPException, Query +from typing import Dict, Any + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"]) + +@router.get("/") +async def get_cm_diagnostics( + host: str = Query(...), + username: str = Query(...), + password: str = Query(...), + mac: str = Query(..., description="Cable Modem MAC Address") +) -> Dict[str, Any]: + + mac = mac.strip().lower() + if not mac: + raise HTTPException(status_code=400, detail="MAC Address is required") + + result_data = { + "mac": mac, + "ip": "N/A", + "state": "N/A", + "cpe_count": 0, + "phy": { + "tx_power": None, + "rx_power": None + }, + "upstreams": [], + "mer_channels": {} + } + + try: + async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn: + print("\n🚀 [Diagnostics] 開始診斷 MAC: " + mac) + + # ========================================== + # 1. 查詢基本狀態 + # ========================================== + cmd_base = "show cable modem " + mac + " | nomore" + res_base = await conn.run(cmd_base, check=False) + if res_base.exit_status == 0 and res_base.stdout: + for line in res_base.stdout.splitlines(): + if mac in line.lower(): + tokens = line.split() + for t in tokens: + if re.match(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$", t): + result_data["ip"] = t + elif re.match(r"^[a-z]-online|online|offline|init.*|reject", t, re.IGNORECASE): + result_data["state"] = t + nums = [t for t in tokens if t.isdigit()] + if nums: + result_data["cpe_count"] = int(nums[-1]) + break + + # ========================================== + # 2. 查詢 PHY 狀態 (💡 修復:移除寫死的 us/oad 判斷,改用通用特徵) + # ========================================== + cmd_phy = "show cable modem " + mac + " phy | nomore" + res_phy = await conn.run(cmd_phy, check=False) + if res_phy.exit_status == 0 and res_phy.stdout: + tx_list = [] + rx_list = [] + for line in res_phy.stdout.splitlines(): + line_lower = line.lower() + if mac in line_lower: + tokens = line.split() + if len(tokens) >= 6: + ch_name = tokens[1] + # 💡 只要名稱包含 ":" 和 "/",就認定是合法的通道 (例如 Oa32, Oad32, Us32) + if ":" in ch_name and "/" in ch_name: + try: + snr_val = float(tokens[4]) + result_data["upstreams"].append({"channel": ch_name, "snr": snr_val}) + except: + pass + try: + tx_val = float(tokens[3].split('/')[0]) + rx_list_val = float(tokens[5].split('/')[0]) + tx_list.append(tx_val) + rx_list.append(rx_list_val) + except: + pass + + if tx_list: result_data["phy"]["tx_power"] = round(sum(tx_list)/len(tx_list), 2) + if rx_list: result_data["phy"]["rx_power"] = round(sum(rx_list)/len(rx_list), 2) + + # ========================================== + # 3. 尋找所有 OFDM 通道 + # ========================================== + cmd_help = "show cable modem " + mac + " ofdm-channel ?" + res_help = await conn.run(cmd_help, check=False) + text_to_search = (res_help.stdout or "") + "\n" + (res_help.stderr or "") + + cmd_verb = "show cable modem " + mac + " verbose | nomore" + res_verb = await conn.run(cmd_verb, check=False) + text_to_search += "\n" + (res_verb.stdout or "") + + matches = re.findall(r"\b(Of(?:dm)?\d*[\d/:]+)\b", text_to_search, re.IGNORECASE) + ofdm_channels = [] + for m in matches: + clean_m = m.lower() + if clean_m.startswith("of"): + clean_m = "Of" + clean_m[2:] + if clean_m not in ofdm_channels: + ofdm_channels.append(clean_m) + ofdm_channels.sort() + + # ========================================== + # 4. 對每一個 OFDM 通道查詢 MER + # ========================================== + for ch in ofdm_channels: + cmd_mer = "show cable modem " + mac + " ofdm-channel " + ch + " mer | nomore" + res_mer = await conn.run(cmd_mer, check=False) + + histogram = {} + min_mer = 999 + + if res_mer.exit_status == 0 and res_mer.stdout: + mer_lines = re.finditer(r"(\d+)\s*(?:db|dB)?\s*\|[^|]*\|?\s*(\**)", res_mer.stdout, re.IGNORECASE) + for match in mer_lines: + db_val = match.group(1) + stars = match.group(2) + if stars: + histogram[db_val] = len(stars) * 100 + db_int = int(db_val) + if db_int < min_mer: + min_mer = db_int + + health = "unknown" + if histogram and min_mer != 999: + if min_mer >= 41: health = "good" + elif min_mer >= 38: health = "warning" + else: health = "critical" + + result_data["mer_channels"][ch] = { + "histogram": histogram, + "health": health, + "min_mer": min_mer if min_mer != 999 else "N/A" + } + + print("✅ [Diagnostics] 解析結果: " + str(result_data)) + return {"status": "success", "data": result_data} + + except Exception as e: + logger.error("CM Diagnostics Error: " + str(e)) + raise HTTPException(status_code=500, detail="設備連線或查詢失敗: " + str(e)) \ No newline at end of file diff --git a/static/api.js b/static/api.js index fd04699..0664be0 100644 --- a/static/api.js +++ b/static/api.js @@ -188,3 +188,13 @@ export async function apiSyncLeafOptionsStream(host, paths, onProgress, configTy } } } + +export async function apiGetCmDiagnostics(host, username, password, mac) { + const url = `/api/v1/cm-diagnostics/?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&mac=${encodeURIComponent(mac)}`; + const response = await fetch(url); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || "伺服器發生錯誤"); + } + return response.json(); +} diff --git a/static/app.js b/static/app.js index 92bc959..b1152fb 100644 --- a/static/app.js +++ b/static/app.js @@ -12,7 +12,8 @@ import { apiGetFullConfig, apiClearCache, apiExecuteQuery, apiGetTreeFilters, apiSaveTreeFilters, apiSyncLeafOptionsStream, apiGetCmtsVersion, - apiGetScanStatus, apiGetLockStatus + apiGetScanStatus, apiGetLockStatus, + apiGetCmDiagnostics } from './api.js'; // 3. 共用工具模組 (Utils) @@ -612,6 +613,203 @@ async function fetchFullConfig(configType = 'running') { } } +// ============================================================================ +// 🌟 CM 一鍵診斷中心邏輯 (多頻道切換與紅綠燈實作) +// ============================================================================ +let merChartInstance = null; +let globalMerData = {}; // 暫存所有頻道的 MER 資料供切換使用 + +window.runCmDiagnostics = async function() { + const connInfo = getGlobalConnectionInfo(); + if (!connInfo) return; + + const macInput = document.getElementById('diagMacInput').value.trim(); + if (!macInput) return alert("請輸入目標 CM 的 MAC Address!"); + + const btn = document.getElementById('btnRunDiag'); + const msg = document.getElementById('diagStatusMsg'); + const resultArea = document.getElementById('diagResultArea'); + + btn.disabled = true; + btn.style.opacity = '0.7'; + msg.style.display = 'inline-block'; + resultArea.style.display = 'none'; + + try { + const result = await apiGetCmDiagnostics(connInfo.host, connInfo.user, connInfo.pass, macInput); + + if (result.status === 'success') { + const data = result.data; + + // 1. 填入基本資訊 + document.getElementById('diagResMac').textContent = data.mac; + document.getElementById('diagResIp').textContent = data.ip || 'N/A'; + document.getElementById('diagResCpe').textContent = data.cpe_count; + + const stateEl = document.getElementById('diagResState'); + stateEl.textContent = data.state || 'N/A'; + stateEl.style.color = (data.state && data.state.toLowerCase().includes('online')) ? '#27ae60' : '#e74c3c'; + + document.getElementById('diagResTx').textContent = data.phy.tx_power !== null ? data.phy.tx_power : 'N/A'; + document.getElementById('diagResRx').textContent = data.phy.rx_power !== null ? data.phy.rx_power : 'N/A'; + + // 2. 渲染上行 SNR (Upstreams) 標籤群 + const usContainer = document.getElementById('upstreamSnrContainer'); + usContainer.innerHTML = ''; + if (data.upstreams && data.upstreams.length > 0) { + data.upstreams.forEach(us => { + let color = '#e74c3c'; // 預設紅 (差: < 30) + if (us.snr >= 35) color = '#27ae60'; // 綠 (優: >= 35) + else if (us.snr >= 30) color = '#f39c12'; // 橘 (尚可: 30~34.9) + + usContainer.innerHTML += ` +
+ ${us.channel} : ${us.snr} +
+ `; + }); + } else { + usContainer.innerHTML = '無資料'; + } + + // 3. 處理 OFDM MER 頻道按鈕與圖表 + globalMerData = data.mer_channels; + const tabsContainer = document.getElementById('ofdmChannelTabs'); + tabsContainer.innerHTML = ''; + + const channels = Object.keys(globalMerData); + if (channels.length > 0) { + channels.forEach((ch, index) => { + const chData = globalMerData[ch]; + // 根據後端判斷的健康度上色 (>=41綠, 38~40橘, <=37紅) + let bgCol = '#bdc3c7'; // 預設灰色 (無資料) + if (chData.health === 'good') bgCol = '#27ae60'; + else if (chData.health === 'warning') bgCol = '#f39c12'; + else if (chData.health === 'critical') bgCol = '#e74c3c'; + + const btn = document.createElement('button'); + // 💡 拔除方正的 class,改用純手工膠囊樣式 + btn.style.boxSizing = 'border-box'; + btn.style.cursor = 'pointer'; + btn.style.backgroundColor = bgCol; + btn.style.color = '#ffffff'; + btn.style.padding = '6px 14px'; + btn.style.borderRadius = '20px'; // 🌟 完美的膠囊圓角 + btn.style.fontSize = '12px'; + btn.style.fontWeight = 'bold'; + btn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; + btn.style.transition = 'all 0.2s ease'; + + // 點擊與未點擊的狀態區分 (透明度與外框) + btn.style.opacity = index === 0 ? '1' : '0.5'; + btn.style.border = index === 0 ? '2px solid #2c3e50' : '2px solid transparent'; + + // 標籤文字加入最低 MER 數值提示 + btn.textContent = `${ch} (Min: ${chData.min_mer} dB)`; + + btn.onclick = () => { + // 重置所有按鈕外觀 + Array.from(tabsContainer.children).forEach(b => { + b.style.opacity = '0.5'; + b.style.border = '2px solid transparent'; + }); + // 突顯當前點擊的按鈕 + btn.style.opacity = '1'; + btn.style.border = '2px solid #2c3e50'; + // 重新繪圖 + renderMerChart(ch, chData.histogram); + }; + tabsContainer.appendChild(btn); + }); + + // 預設渲染第一個頻道的圖表 + renderMerChart(channels[0], globalMerData[channels[0]].histogram); + } else { + tabsContainer.innerHTML = '⚠️ 設備無回傳任何 OFDM 頻道 MER 數據'; + renderMerChart('無資料', {}); + } + + resultArea.style.display = 'flex'; + } + } catch (error) { + alert(`❌ 診斷失敗: ${error.message}`); + } finally { + btn.disabled = false; + btn.style.opacity = '1'; + msg.style.display = 'none'; + } +}; + +function renderMerChart(channelName, histogramData) { + if (merChartInstance) { + merChartInstance.destroy(); + } + const ctx = document.getElementById('merChart').getContext('2d'); + + if (!histogramData || Object.keys(histogramData).length === 0) { + merChartInstance = new Chart(ctx, { + type: 'bar', + data: { labels: ['無資料'], datasets: [{ label: 'Subcarriers', data: [0] }] }, + options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: '無法取得 MER 數據' } } } + }); + return; + } + + const labels = Object.keys(histogramData).sort((a, b) => parseInt(a) - parseInt(b)); + const dataPoints = labels.map(label => histogramData[label]); + + merChartInstance = new Chart(ctx, { + type: 'bar', + data: { + labels: labels.map(l => `${l} dB`), + datasets: [{ + label: 'Subcarriers 數量', + data: dataPoints, + backgroundColor: 'rgba(155, 89, 182, 0.6)', + borderColor: 'rgba(142, 68, 173, 1)', + borderWidth: 1, + borderRadius: 4, + hoverBackgroundColor: 'rgba(155, 89, 182, 0.9)' + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + title: { display: true, text: 'Subcarriers 數量', font: { weight: 'bold' } }, + grid: { color: '#ecf0f1' } + }, + x: { + title: { display: true, text: 'MER (dB)', font: { weight: 'bold' } }, + grid: { display: false } + } + }, + plugins: { + title: { + display: true, + text: `顯示中頻道: ${channelName}`, + font: { size: 15, weight: 'bold' }, + color: '#2c3e50' + }, + legend: { display: false }, + tooltip: { + backgroundColor: 'rgba(44, 62, 80, 0.9)', + titleFont: { size: 14 }, + bodyFont: { size: 14 }, + padding: 10, + callbacks: { + label: function(context) { + return ` ${context.raw} 個 Subcarriers`; + } + } + } + } + } + }); +} + // ============================================================================ // 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning) // ============================================================================ diff --git a/target_code.txt b/target_code.txt new file mode 100644 index 0000000..ecf620e --- /dev/null +++ b/target_code.txt @@ -0,0 +1,2806 @@ +================================================================================ +TARGET SOURCE CODE EXPORT (SPECIFIC) +================================================================================ + + +================================================================================ +FILE: static/api.js +================================================================================ +// --- static/api.js --- + +// ========================================== +// 1. 鎖定管理 (Lock Management) +// ========================================== +export async function apiAcquireLock(path, userId, username, host) { + const res = await fetch('/api/v1/locks/acquire', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, user_id: userId, username, host }) + }); + const data = await res.json(); + return { status: res.status, data }; +} + +export async function apiReleaseLock(path, userId, host) { + return fetch('/api/v1/locks/release', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, user_id: userId, host }) + }); +} + +export async function apiSendHeartbeat(path, userId, host) { + return fetch('/api/v1/locks/heartbeat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, user_id: userId, host }) + }); +} + +export async function apiGetLockStatus(host) { + // 帶入 host 參數,實現 IP 隔離查詢 + const url = host ? `/api/v1/locks/status?host=${encodeURIComponent(host)}` : '/api/v1/locks/status'; + const response = await fetch(url); + return response.json(); +} + +// ========================================== +// 2. 樹狀圖與選項快取 (Tree & Options) +// ========================================== +export async function apiGetFullConfig(host, username, password, skipFilter = false, configType = 'running') { + // 🌟 將 config_type 加入 URL 參數中 + let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&config_type=${configType}`; + + if (skipFilter) url += '&skip_filter=true'; + const response = await fetch(url); + return response.json(); +} + +// 🌟 1. 取得選項快取 (加上 configType 查詢參數) +export async function apiGetLeafOptions(host, configType = 'running') { + const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}&config_type=${configType}`); + return response.json(); +} + +// 🌟 2. 同步選項快取 (將 config_type 塞入 Body) +export async function apiSyncLeafOptions(host, paths, configType = 'running') { + return fetch('/api/v1/cmts-leaf-options/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ host, leaf_paths: paths, config_type: configType }) + }); +} + +// 🌟 3. 清除快取 (加上 configType 查詢參數) +export async function apiClearCache(host, paths, configType = 'running') { + const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(paths) + }); + if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`); + return response.json(); +} + +export async function apiGetCmtsVersion(host, username, password) { + const url = `/api/v1/cmts-version?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`; + const response = await fetch(url); + return response.json(); +} + +export async function apiGetScanStatus(host, configType = 'running') { + try { + const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}&config_type=${configType}`); + const data = await response.json(); + return data.is_scanning; + } catch (e) { + return false; + } +} + +// ========================================== +// 3. 指令生成與執行 (CLI Generation & Execution) +// ========================================== +export async function apiGenerateCli(diffs, interfaces) { + const response = await fetch('/api/v1/cmts-generate-cli', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ diffs, interfaces_with_admin_state: interfaces }) + }); + return response.json(); +} + +export async function apiExecuteConfig(script, host, username, password) { + const response = await fetch('/api/v1/cmts-config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ script, host, username, password }) + }); + return response.json(); +} + +// ========================================== +// 4. 系統設定與過濾器 (System Settings) +// ========================================== +// 🌟 加上 configType 參數 +export async function apiGetTreeFilters(configType = 'running') { + const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`); + return response.json(); +} + +// 🌟 加上 configType 參數 +export async function apiSaveTreeFilters(hiddenKeys, configType = 'running') { + const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hidden_keys: hiddenKeys, config_type: configType }) + }); + return response.json(); +} + +// ========================================== +// 5. MAC Domain 與綜合查詢 (MAC Domain & Query) +// ========================================== +export async function apiGetMacDomainList(host, username, password) { + const url = `/api/v1/cmts-mac-domain-list?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`; + const response = await fetch(url); + return response.json(); +} + +export async function apiGetMacDomainConfig(target, host, username, password) { + const url = `/api/v1/cmts-mac-domain-config?target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`; + const response = await fetch(url); + return response.json(); +} + +export async function apiExecuteQuery(queryType, target, host, username, password) { + const url = `/api/v1/cmts-query?query_type=${queryType}&target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`; + const response = await fetch(url); + return response.json(); +} + +// ========================================== +// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新) +// ========================================== +// 🌟 4. 串流掃描 API (將 config_type 塞入 Body) +export async function apiSyncLeafOptionsStream(host, paths, onProgress, configType = 'running') { + const response = await fetch('/api/v1/cmts-leaf-options/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ host, leaf_paths: paths, config_type: configType }) + }); + + if (!response.body) throw new Error("瀏覽器不支援 ReadableStream"); + + // 🌟 核心修改:讀取串流資料 + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; // 伺服器斷開連線 (任務完成) + + // 解碼二進位資料為文字 + const chunk = decoder.decode(value, { stream: true }); + + // 因為一次可能收到多行 JSON,我們用換行符號切開 + const lines = chunk.split("\n").filter(line => line.trim() !== ""); + + for (const line of lines) { + try { + const data = JSON.parse(line); + onProgress(data); // 將解析後的資料傳給 UI 介面 + } catch (e) { + console.error("JSON 解析錯誤:", e, line); + } + } + } +} + +export async function apiGetCmDiagnostics(host, username, password, mac) { + const url = `/api/v1/cm-diagnostics/?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&mac=${encodeURIComponent(mac)}`; + const response = await fetch(url); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || "伺服器發生錯誤"); + } + return response.json(); +} + + +================================================================================ +FILE: static/app.js +================================================================================ +/* --- static/app.js --- */ + +// ============================================================================ +// 📦 模組匯入區 (Imports) +// ============================================================================ + +// 1. 終端機模組 (Terminal) +import { initTerminal, connectWebSocket, disconnectWebSocket, injectCommand, downloadTerminal, isConnected } from './terminal.js'; + +// 2. API 網路請求模組 (API) +import { + apiGetFullConfig, apiClearCache, apiExecuteQuery, + apiGetTreeFilters, apiSaveTreeFilters, + apiSyncLeafOptionsStream, apiGetCmtsVersion, + apiGetScanStatus, apiGetLockStatus, + apiGetCmDiagnostics +} from './api.js'; + +// 3. 共用工具模組 (Utils) +import { getGlobalConnectionInfo } from './utils.js'; + +// 4. 樹狀圖 UI 模組 (Tree UI) +import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache } from './tree-ui.js'; + +// 5. 編輯模式與鎖定模組 (Edit Mode) +import { + releaseAllLocks, startEditFolder, startEditLeaf, + cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI, + hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID, currentEditElementId +} from './edit-mode.js'; + +// 6. MAC Domain 配置模組 (MAC Domain) +import { + hasMacDomainData, resetMacDomainData, loadMacDomainList, fetchMacDomainConfig, + populateFormFromData, revertFormChanges, toggleGroupVisibility, + loadDsGroupData, loadUsGroupData, generateMacDomainCLI, executeBondingConfig +} from './mac-domain.js'; + + +// ============================================================================ +// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽 +// ============================================================================ + +let idleTimer; +const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒) +let globalSSE = null; + +// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道 +function initGlobalSSE(mode = 'running') { + const connInfo = getGlobalConnectionInfo(); + if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE + if (globalSSE) globalSSE.close(); + globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`); + + globalSSE.onmessage = (event) => { + const data = JSON.parse(event.data); + const statusEl = document.getElementById('scan-status'); + + // 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定) + if (data.event === 'scan_start' || data.event === 'progress') { + setAdminButtonsState(true, "⏳ 系統更新中..."); + } + + if (data.event === 'scan_start') { + if (statusEl) { + statusEl.innerHTML = `⏳ 系統正在背景更新快取,請稍候...`; + statusEl.style.color = "#f39c12"; + } + } + else if (data.event === 'progress') { + if (statusEl) { + statusEl.innerHTML = `⏳ 背景掃描進度:${data.current} / ${data.total} (正在處理: ${data.current_path})`; + statusEl.style.color = "#f39c12"; + } + } + else if (data.event === 'done') { + if (statusEl) { + statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`; + statusEl.style.color = "#27ae60"; + setTimeout(() => statusEl.textContent = "", 5000); + } + // 掃描完成才解鎖 + setAdminButtonsState(false); + localStorage.removeItem('scanLockUntil'); + } + else if (data.event === 'error') { + if (statusEl) { + statusEl.innerHTML = `❌ 錯誤: ${data.message}`; + statusEl.style.color = "#e74c3c"; + } + // 發生錯誤也要解鎖 + setAdminButtonsState(false); + localStorage.removeItem('scanLockUntil'); + } + }; +} + +// 🌟 關鍵修復:當使用者按 F5 重整或關閉網頁時,主動切斷 SSE 連線 +window.addEventListener('beforeunload', () => { + if (globalSSE) { + globalSSE.close(); + globalSSE = null; + } +}); + +// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」(加入 Host 隔離) +let activeLockState = new Set(); +let lockPollingTimer = null; +let currentPollingHost = null; // 記錄當前輪詢的設備 IP +window.globalActiveLocks = {}; // 🌟 升級為二維結構:{ "10.14.110.4": { "alias": {...} } } +window.recentlyReleasedLocks = {}; // 🌟 新增:防閃爍冷卻表 (Optimistic UI Cooldown) + +function startLockStatusPolling() { + if (lockPollingTimer) clearInterval(lockPollingTimer); + + lockPollingTimer = setInterval(async () => { + const connInfo = getGlobalConnectionInfo(); + if (!connInfo || !connInfo.host) return; + const host = connInfo.host; + + // 🛡️ 跨設備防呆:如果使用者切換了 IP,必須清空舊設備的鎖定記憶,防止幽靈解鎖 + if (currentPollingHost !== host) { + activeLockState.clear(); + currentPollingHost = host; + } + + try { + const result = await apiGetLockStatus(host); + if (result.status === 'success') { + const currentLocks = result.data; + window.globalActiveLocks[host] = currentLocks; + const newLockState = new Set(); + const now = Date.now(); + + // 🎯 任務 1:處理「新增鎖定」或「持續鎖定」的節點 + for (const [path, lockInfo] of Object.entries(currentLocks)) { + const lockKey = `${host}@@${path}`; + + // 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒) + if (window.recentlyReleasedLocks[lockKey]) { + if (now - window.recentlyReleasedLocks[lockKey] < 8000) { + continue; // 跳過,不當作鎖定 + } else { + delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶 + } + } + + newLockState.add(lockKey); // 記憶體加入 Host 標籤 + + const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + // 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點 + const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); + + targetBtns.forEach(btn => { + // 略過目前正在編輯的那個實體按鈕 + if (btn.id === `edit-btn-${currentEditElementId}`) return; + + if (btn.innerText !== "🔒") { + btn.style.pointerEvents = 'none'; + btn.style.opacity = '0.2'; + + const btnPath = btn.getAttribute('data-path'); + if (btnPath === path) { + // 判斷是別人鎖的,還是自己在另一個視圖鎖的 + if (lockInfo.user_id !== SESSION_USER_ID) { + btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; + } else { + btn.title = `🔒 您正在另一個視圖編輯此項目`; + } + } else { + // 子節點被父節點鎖定 + btn.title = `🔒 父層級已被鎖定,無法編輯此項目`; + } + btn.innerText = "🔒"; + } + }); + } + + // 🔓 任務 2:處理「解除鎖定」的節點 + activeLockState.forEach(oldKey => { + const [oldHost, oldPath] = oldKey.split('@@'); + + // 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點 + if (oldHost === host && !newLockState.has(oldKey)) { + const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + // 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點 + const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`); + + targetBtns.forEach(btn => { + if (btn.innerText === "🔒") { + const btnPath = btn.getAttribute('data-path'); + let stillLockedByOther = false; + + // 檢查自己是否還在鎖定清單中 + if (currentLocks[btnPath]) { + stillLockedByOther = true; + } else { + // 檢查是否有其他父節點鎖定它 + for (const lockedPath of Object.keys(currentLocks)) { + if (btnPath.startsWith(lockedPath + "::")) { + stillLockedByOther = true; + break; + } + } + } + + if (!stillLockedByOther) { + btn.style.pointerEvents = 'auto'; + btn.style.opacity = '0.3'; + btn.title = "鎖定並編輯此項目"; + btn.innerText = "✏️"; + } + } + }); + } + }); + + // 💾 任務 3:更新特務的記憶 + activeLockState = newLockState; + } + } catch (error) { + console.warn("獲取鎖定狀態失敗:", error); + } + }, 15000); +} + +// 頁面載入與縮放初始化 +window.onload = () => { + initTerminal(); + resetIdleTimer(); // 頁面載入時啟動閒置計時器 + initGlobalSSE(); // 啟動全域廣播監聽 + startLockStatusPolling(); // 啟動鎖定狀態輪詢 + + // 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步 + const configTaskSelect = document.getElementById('configTask'); + if (configTaskSelect) { + configTaskSelect.dispatchEvent(new Event('change')); + } +}; +window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); }; + +// 閒置計時器重置邏輯 (超時將自動釋放所有編輯鎖定) +function resetIdleTimer() { + clearTimeout(idleTimer); + idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT); +} + +// 🌟 效能急救:節流閥 (Throttle) 機制 +let throttleTimer = false; +function throttledReset() { + if (!throttleTimer) { + resetIdleTimer(); + throttleTimer = true; + setTimeout(() => { throttleTimer = false; }, 2000); // 每 2 秒最多只觸發一次重設 + } +} +window.addEventListener('mousemove', throttledReset); +window.addEventListener('keydown', throttledReset); +window.addEventListener('click', throttledReset); + +// ============================================================================ +// 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State) +// ============================================================================ + +// 切換主頁籤 (終端機 / 設備查詢 / 設備配置 / 系統設定) +function switchTab(tabId, element) { + document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active')); + document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); + + const targetTab = document.getElementById(tabId); + if (targetTab) targetTab.classList.add('active'); + if (element) element.classList.add('active'); + + if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50); +} + +// 切換「設備查詢」內的子任務表單 +function switchQueryTask() { + document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active')); + const selectedTaskId = document.getElementById('queryTask').value; + const targetForm = document.getElementById(selectedTaskId); + if (targetForm) targetForm.classList.add('active'); +} + +// 🌟 新增一個變數來記錄上一次的樹狀圖模式 +let lastTreeMode = null; + +// 切換「設備配置」內的子任務表單 +function switchConfigTask() { + document.querySelectorAll('#config-tab .task-form').forEach(form => { + form.classList.remove('active'); + form.style.display = 'none'; + }); + + const selectedTaskId = document.getElementById('configTask').value; + + let targetFormId = selectedTaskId; + if (selectedTaskId === 'form-running-config' || selectedTaskId === 'form-full-config') { + targetFormId = 'form-tree-config'; + } + + const targetForm = document.getElementById(targetFormId); + if (targetForm) { + targetForm.classList.add('active'); + targetForm.style.display = 'block'; + + const titleSpan = document.getElementById('tree-form-title'); + const currentMode = selectedTaskId === 'form-full-config' ? 'full' : 'running'; + + if (titleSpan) { + titleSpan.textContent = currentMode === 'running' + ? '🌳 設備配置樹狀圖 (running-config)' + : '🌳 完整設備配置樹狀圖 (full configuration)'; + } + + if (targetFormId === 'form-tree-config') { + // 🌟 魔法所在:純視覺切換,不銷毀資料! + const runningContainer = document.getElementById('tree-container-running'); + const fullContainer = document.getElementById('tree-container-full'); + if (runningContainer) runningContainer.style.display = currentMode === 'running' ? 'block' : 'none'; + if (fullContainer) fullContainer.style.display = currentMode === 'full' ? 'block' : 'none'; + + if (lastTreeMode !== currentMode) { + const statusEl = document.getElementById('scan-status'); + if (statusEl) statusEl.textContent = ''; + + // 🌟 新增:如果使用者在載入途中切換模式,強制把沙漏文字隱藏! + const loadingMsg = document.getElementById('loading-message'); + if (loadingMsg) loadingMsg.style.display = 'none'; + + lastTreeMode = currentMode; + } + } + + initGlobalSSE(currentMode); + } +} + +// 啟動配置任務 (樹狀圖載入 或 MAC Domain 精靈) +function startConfigTask() { + if (!isConnected) { + alert("請先點擊畫面上方的「連線至 CMTS」成功後,再執行載入任務!"); + return; + } + switchConfigTask(); + const selectedTaskId = document.getElementById('configTask').value; + + if (selectedTaskId === 'form-bonding-config') { + if (hasMacDomainData() && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) return; + resetAllManagerData(); + loadMacDomainList(); + } + // 🌟 修正:正確的括號閉合,並根據下拉選單傳遞 configType + else if (selectedTaskId === 'form-running-config') { + fetchFullConfig('running'); + } + else if (selectedTaskId === 'form-full-config') { + fetchFullConfig('full'); // 🌟 補上 full 參數 + } +} + +// 切換查詢輸入框的啟用狀態 (全域指令不需輸入目標) +function toggleQueryInputs(mode) { + const type = document.getElementById('queryType' + mode).value; + const targetInput = document.getElementById('queryTarget' + mode); + const globalTypes = ['partial_mode', 'hop', 'flap_list', 'flap_sum']; + + if (globalTypes.includes(type)) { + targetInput.disabled = true; + targetInput.style.backgroundColor = '#e8ecef'; + targetInput.placeholder = "此查詢為全域指令,不需輸入目標"; + targetInput.value = ""; + } else { + targetInput.disabled = false; + targetInput.style.backgroundColor = '#f8f9fa'; + targetInput.placeholder = mode === 'Cm' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例: 13:0 (留白則查詢全體)"; + } +} + +// 重置所有管理介面的資料與 UI 狀態 +function resetAllManagerData() { + resetMacDomainData(); + + // 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM + clearTreeCache(); + + // 🛡️ [跨設備防護] 切換設備時,清空鎖定輪詢的記憶體 + if (typeof activeLockState !== 'undefined' && activeLockState.clear) { + activeLockState.clear(); + } + + const configArea = document.getElementById('macDomainConfigArea'); + if (configArea) configArea.style.display = 'none'; + + const previewEl = document.getElementById('cliPreviewContainer'); + if (previewEl) { + previewEl.textContent = ''; + previewEl.style.display = 'none'; + } + + const deployBtn = document.getElementById('btnDeployConfig'); + if (deployBtn) { + deployBtn.style.display = 'none'; + deployBtn.disabled = true; + } + + const mdSelect = document.getElementById('cfgMacDomain'); + if (mdSelect) mdSelect.innerHTML = ''; + + const fetchStatus = document.getElementById('fetchStatus'); + if (fetchStatus) { + fetchStatus.textContent = "請先讀取設備狀態..."; + fetchStatus.style.color = "#7f8c8d"; + } + + document.getElementById('queryTargetCm').value = ''; + document.getElementById('queryTargetRpd').value = ''; + + // 🌟 新增:切換設備連線後,自動重整備份歷史紀錄 + if (typeof loadBackupHistory === 'function') { + loadBackupHistory(); + } +} + +// ============================================================================ +// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config) +// ============================================================================ + +// 執行綜合查詢 (CM / RPD) - 🌟 導入前端模擬動態訊息流 +async function executeQuery(mode) { + if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕!"); + + const connInfo = getGlobalConnectionInfo(); + if (!connInfo) return; + + const queryType = document.getElementById('queryType' + mode).value; + const target = document.getElementById('queryTarget' + mode).value.trim(); + const isDebug = document.getElementById('debugQuery' + mode).checked; + + openModal(connInfo.host); + const outputEl = document.getElementById('modalOutput'); + + // 🌟 模擬動態訊息流邏輯 + const states = [ + `🔌 正在建立 SSH 連線至 ${connInfo.host}...`, + `📥 正在向設備發送查詢指令...`, + `🧩 正在等待設備回傳資料...` + ]; + let progressIdx = 0; + outputEl.innerHTML = `${states[0]}`; + + const progressInterval = setInterval(() => { + progressIdx++; + if (progressIdx < states.length) { + const textEl = document.getElementById('query-progress-text'); + if (textEl) textEl.innerText = states[progressIdx]; + } + }, 1500); // 每 1.5 秒切換一次狀態 + + try { + const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass); + clearInterval(progressInterval); // 收到結果,停止輪播 + + if (result.status === 'success') { + outputEl.style.color = "#e0e0e0"; + outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data; + } else { + outputEl.style.color = "#e74c3c"; + outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`; + } + } catch (error) { + clearInterval(progressInterval); // 發生錯誤,停止輪播 + outputEl.style.color = "#e74c3c"; + outputEl.textContent = `連線失敗: ${error}`; + } +} + +// 載入完整設備配置並生成樹狀圖,預設為 running (平順過渡終極版 🚀) +async function fetchFullConfig(configType = 'running') { + const loadingMsg = document.getElementById('loading-message'); + const treeContainer = document.getElementById(`tree-container-${configType}`); + + const connInfo = getGlobalConnectionInfo(); + if (!connInfo) return; + + if (loadingMsg) { + loadingMsg.style.display = 'inline-block'; + loadingMsg.style.color = '#f39c12'; // 🌟 確保初始狀態為橘色 + loadingMsg.innerHTML = '⏳ 準備連線至設備...'; + } + if (treeContainer) treeContainer.innerHTML = ''; + + let needAutoScan = false; + let progressInterval = null; + + try { + try { + const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass); + if (versionRes.status === 'success') { + const currentVersion = versionRes.version; + const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`); + const optData = await optRes.json(); + const cachedVersion = optData.__metadata__?.cmts_version; + + if (cachedVersion && cachedVersion !== currentVersion) { + const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`); + if (doClear) { + const allKeys = Object.keys(optData); + await apiClearCache(connInfo.host, allKeys, configType); + needAutoScan = true; + } + } + } + } catch (vErr) { + console.warn("版本檢查失敗,略過", vErr); + } + + const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`); + + if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`); + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + let finalTreeData = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + let lines = buffer.split('\n'); + buffer = lines.pop(); + + for (let line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + + if (data.status === 'progress') { + if (data.message.includes('正在下載')) { + let progress = 0; + if (progressInterval) clearInterval(progressInterval); + + progressInterval = setInterval(() => { + progress += (95 - progress) * 0.06; + if (loadingMsg) { + loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`; + } + }, 500); + } else { + if (progressInterval) { + clearInterval(progressInterval); + progressInterval = null; + if (loadingMsg) { + loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`; + loadingMsg.style.color = "#27ae60"; + } + await new Promise(resolve => setTimeout(resolve, 600)); + if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 🌟 恢復橘色 + } + if (loadingMsg) { + loadingMsg.style.color = "#f39c12"; // 🌟 確保橘色 + loadingMsg.innerHTML = data.message; + } + } + } + else if (data.status === 'success') { + finalTreeData = data.data; + } + else if (data.status === 'error') { + throw new Error(data.message); + } + } catch (e) { + console.warn("解析串流 JSON 失敗:", line); + } + } + } + + if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。"); + + window.currentTreeData = finalTreeData; + + const currentSelectedTask = document.getElementById('configTask').value; + const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config'; + + if (currentSelectedTask !== expectedTask) return; + + if (treeContainer) { + treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType); + if (loadingMsg) loadingMsg.style.display = 'none'; + + const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType); + if (isSomeoneScanning) { + alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。"); + lockScanButton(60000); + } else { + enableGlobalScanButton(); + if (needAutoScan) { + setTimeout(() => scanGlobalMissingOptions(configType), 500); + } + } + } + + } catch (error) { + if (treeContainer) treeContainer.innerHTML = `
❌ 連線或解析失敗: ${error.message}
`; + } finally { + if (progressInterval) clearInterval(progressInterval); + if (loadingMsg) { + loadingMsg.style.display = 'none'; + loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘色 + } + } +} + +// ============================================================================ +// 🌟 CM 一鍵診斷中心邏輯 (多頻道切換與紅綠燈實作) +// ============================================================================ +let merChartInstance = null; +let globalMerData = {}; // 暫存所有頻道的 MER 資料供切換使用 + +window.runCmDiagnostics = async function() { + const connInfo = getGlobalConnectionInfo(); + if (!connInfo) return; + + const macInput = document.getElementById('diagMacInput').value.trim(); + if (!macInput) return alert("請輸入目標 CM 的 MAC Address!"); + + const btn = document.getElementById('btnRunDiag'); + const msg = document.getElementById('diagStatusMsg'); + const resultArea = document.getElementById('diagResultArea'); + + btn.disabled = true; + btn.style.opacity = '0.7'; + msg.style.display = 'inline-block'; + resultArea.style.display = 'none'; + + try { + const result = await apiGetCmDiagnostics(connInfo.host, connInfo.user, connInfo.pass, macInput); + + if (result.status === 'success') { + const data = result.data; + + // 1. 填入基本資訊 + document.getElementById('diagResMac').textContent = data.mac; + document.getElementById('diagResIp').textContent = data.ip || 'N/A'; + document.getElementById('diagResCpe').textContent = data.cpe_count; + + const stateEl = document.getElementById('diagResState'); + stateEl.textContent = data.state || 'N/A'; + stateEl.style.color = (data.state && data.state.toLowerCase().includes('online')) ? '#27ae60' : '#e74c3c'; + + document.getElementById('diagResTx').textContent = data.phy.tx_power !== null ? data.phy.tx_power : 'N/A'; + document.getElementById('diagResRx').textContent = data.phy.rx_power !== null ? data.phy.rx_power : 'N/A'; + + // 2. 渲染上行 SNR (Upstreams) 標籤群 + const usContainer = document.getElementById('upstreamSnrContainer'); + usContainer.innerHTML = ''; + if (data.upstreams && data.upstreams.length > 0) { + data.upstreams.forEach(us => { + let color = '#e74c3c'; // 預設紅 (差: < 30) + if (us.snr >= 35) color = '#27ae60'; // 綠 (優: >= 35) + else if (us.snr >= 30) color = '#f39c12'; // 橘 (尚可: 30~34.9) + + usContainer.innerHTML += ` +
+ ${us.channel} : ${us.snr} +
+ `; + }); + } else { + usContainer.innerHTML = '無資料'; + } + + // 3. 處理 OFDM MER 頻道按鈕與圖表 + globalMerData = data.mer_channels; + const tabsContainer = document.getElementById('ofdmChannelTabs'); + tabsContainer.innerHTML = ''; + + const channels = Object.keys(globalMerData); + if (channels.length > 0) { + channels.forEach((ch, index) => { + const chData = globalMerData[ch]; + // 根據後端判斷的健康度上色 (>=41綠, 38~40橘, <=37紅) + let bgCol = '#bdc3c7'; + if (chData.health === 'good') bgCol = '#27ae60'; + else if (chData.health === 'warning') bgCol = '#f39c12'; + else if (chData.health === 'critical') bgCol = '#e74c3c'; + + const btn = document.createElement('button'); + btn.className = 'btn-modern'; + btn.style.backgroundColor = bgCol; + btn.style.opacity = index === 0 ? '1' : '0.5'; // 預設突顯第一個 + btn.style.border = index === 0 ? '2px solid #2c3e50' : '2px solid transparent'; + // 標籤文字加入最低 MER 數值提示 + btn.textContent = `${ch} (Min: ${chData.min_mer} dB)`; + + btn.onclick = () => { + // 重置所有按鈕外觀 + Array.from(tabsContainer.children).forEach(b => { + b.style.opacity = '0.5'; + b.style.border = '2px solid transparent'; + }); + // 突顯當前點擊的按鈕 + btn.style.opacity = '1'; + btn.style.border = '2px solid #2c3e50'; + // 重新繪圖 + renderMerChart(ch, chData.histogram); + }; + tabsContainer.appendChild(btn); + }); + + // 預設渲染第一個頻道的圖表 + renderMerChart(channels[0], globalMerData[channels[0]].histogram); + } else { + tabsContainer.innerHTML = '⚠️ 設備無回傳任何 OFDM 頻道 MER 數據'; + renderMerChart('無資料', {}); + } + + resultArea.style.display = 'flex'; + } + } catch (error) { + alert(`❌ 診斷失敗: ${error.message}`); + } finally { + btn.disabled = false; + btn.style.opacity = '1'; + msg.style.display = 'none'; + } +}; + +function renderMerChart(channelName, histogramData) { + if (merChartInstance) { + merChartInstance.destroy(); + } + const ctx = document.getElementById('merChart').getContext('2d'); + + if (!histogramData || Object.keys(histogramData).length === 0) { + merChartInstance = new Chart(ctx, { + type: 'bar', + data: { labels: ['無資料'], datasets: [{ label: 'Subcarriers', data: [0] }] }, + options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: '無法取得 MER 數據' } } } + }); + return; + } + + const labels = Object.keys(histogramData).sort((a, b) => parseInt(a) - parseInt(b)); + const dataPoints = labels.map(label => histogramData[label]); + + merChartInstance = new Chart(ctx, { + type: 'bar', + data: { + labels: labels.map(l => `${l} dB`), + datasets: [{ + label: 'Subcarriers 數量', + data: dataPoints, + backgroundColor: 'rgba(155, 89, 182, 0.6)', + borderColor: 'rgba(142, 68, 173, 1)', + borderWidth: 1, + borderRadius: 4, + hoverBackgroundColor: 'rgba(155, 89, 182, 0.9)' + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + title: { display: true, text: 'Subcarriers 數量', font: { weight: 'bold' } }, + grid: { color: '#ecf0f1' } + }, + x: { + title: { display: true, text: 'MER (dB)', font: { weight: 'bold' } }, + grid: { display: false } + } + }, + plugins: { + title: { + display: true, + text: `顯示中頻道: ${channelName}`, + font: { size: 15, weight: 'bold' }, + color: '#2c3e50' + }, + legend: { display: false }, + tooltip: { + backgroundColor: 'rgba(44, 62, 80, 0.9)', + titleFont: { size: 14 }, + bodyFont: { size: 14 }, + padding: 10, + callbacks: { + label: function(context) { + return ` ${context.raw} 個 Subcarriers`; + } + } + } + } + } + }); +} + +// ============================================================================ +// 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning) +// ============================================================================ + +// 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數 +async function enhanceInputWithOptions(path, elementId, mode = 'running') { + // 🌟 1. 取得當前連線的 IP + const connInfo = getGlobalConnectionInfo(); + if (!connInfo || !connInfo.host) return; + + try { + // 🌟 2. GET 請求:網址加上 host 參數 + const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`); + const optData = await optRes.json(); + + const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); + const leafCache = optData[cacheKey]; + + if (leafCache && leafCache.options && leafCache.options.length > 0) { + const container = document.getElementById(`container-${elementId}`); + const input = container.querySelector('.edit-input'); + + if (input) { + const listId = `dl-${elementId}`; + input.setAttribute('list', listId); + + let dataList = document.getElementById(listId); + if (!dataList) { + dataList = document.createElement('datalist'); + dataList.id = listId; + container.appendChild(dataList); + } + dataList.innerHTML = leafCache.options.map(opt => `