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
+
+
+
@@ -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 @@
+
+
+
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 => `