feat: 智能視覺診斷 (Visual Diagnostics) :

- CM 一鍵診斷中心:整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。
This commit is contained in:
swpa 2026-06-04 18:09:45 +08:00
parent 3cd7ce253e
commit 8bc68b8bf1
8 changed files with 3753 additions and 41 deletions

View File

@ -1,5 +1,5 @@
================================================================================ ================================================================================
PROJECT SOURCE CODE EXPORT PROJECT SOURCE CODE EXPORT (FULL)
================================================================================ ================================================================================
@ -680,6 +680,7 @@ FILE: index.html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script> <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script> <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- 引入獨立的 CSS 樣式表 --> <!-- 引入獨立的 CSS 樣式表 -->
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
@ -740,6 +741,7 @@ FILE: index.html
<select id="queryTask" onchange="switchQueryTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;"> <select id="queryTask" onchange="switchQueryTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;">
<option value="form-cm-query">🔎 Cable 狀態綜合查詢</option> <option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
<option value="form-rpd-query">📡 RPD 狀態綜合查詢</option> <option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
</select> </select>
</div> </div>
@ -826,8 +828,76 @@ FILE: index.html
</div> </div>
</div> </div>
</div> </div>
<!-- 表單 3CM 一鍵診斷中心 -->
<div id="form-cm-diagnostics" class="task-form">
<h3 style="margin-top: 0; font-size: 18px; color: #9b59b6; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">CM 深度診斷與 MER 分析</h3>
<!-- 搜尋區塊 -->
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
<label style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC</label>
<input type="text" id="diagMacInput" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
<button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
<span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
</div>
<!-- 結果顯示區塊 (預設隱藏) -->
<div id="diagResultArea" style="display: none; gap: 20px; flex-direction: column;">
<!-- 上半部:基本資訊與 PHY 狀態 -->
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<!-- 基本資訊卡片 -->
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #3498db; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📄 基本資訊</h4>
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d; width: 40%;">MAC Address</th><td id="diagResMac" style="font-weight: bold; color: #2c3e50;">-</td></tr>
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">IP Address</th><td id="diagResIp" style="color: #2980b9; font-family: monospace;">-</td></tr>
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">State</th><td id="diagResState" style="font-weight: bold;">-</td></tr>
<tr><th style="padding: 8px 0; color: #7f8c8d;">CPE Count</th><td id="diagResCpe">-</td></tr>
</table>
</div>
<!-- PHY 狀態卡片 -->
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #2ecc71; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📡 TX / RX 綜合實體層狀態</h4>
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
<tr style="border-bottom: 1px solid #ecf0f1;">
<th style="padding: 8px 0; color: #7f8c8d; width: 50%;">Avg TX Power (dBmV)</th>
<td id="diagResTx" style="font-weight: bold;">-</td>
</tr>
<tr>
<th style="padding: 8px 0; color: #7f8c8d;">Avg RX Power (dBmV)</th>
<td id="diagResRx" style="font-weight: bold;">-</td>
</tr>
</table>
<!-- 🌟 新增:上行通道標籤區塊 -->
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px dashed #bdc3c7;">
<div style="color: #7f8c8d; font-size: 13px; font-weight: bold; margin-bottom: 8px;">上行通道 SNR (dB) 分布:</div>
<div id="upstreamSnrContainer" style="display: flex; flex-wrap: wrap; gap: 8px;">
<!-- 動態生成標籤 -->
</div>
</div>
</div>
</div>
<!-- 下半部Chart.js MER 圖表 -->
<div style="background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #9b59b6; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
<span>📊 OFDM MER 分布圖 <span style="font-size: 13px; color: #7f8c8d; font-weight: normal;">(點擊頻道切換圖表,最低 MER ≥ 41dB 判定為優)</span></span>
<!-- 🌟 新增:動態 OFDM 頻道切換按鈕區塊 -->
<div id="ofdmChannelTabs" style="display: flex; gap: 10px; flex-wrap: wrap;">
<!-- 動態生成按鈕 -->
</div>
</h4>
<div style="position: relative; height: 280px; width: 100%;">
<canvas id="merChart"></canvas>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- Tab 3: CMTS 設備配置 --> <!-- Tab 3: CMTS 設備配置 -->
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;"> <div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
@ -1712,7 +1782,7 @@ from contextlib import asynccontextmanager
import database import database
# 引入我們剛剛拆分出來的路由模組 # 引入我們剛剛拆分出來的路由模組
from routers import query, config, terminal, lock, leaf_options, backup from routers import query, config, terminal, lock, leaf_options, backup, diagnostics
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): 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(leaf_options.router, prefix="/api/v1")
app.include_router(lock.router, prefix="/api/v1") app.include_router(lock.router, prefix="/api/v1")
app.include_router(backup.router, prefix="/api/v1") app.include_router(backup.router, prefix="/api/v1")
app.include_router(diagnostics.router, prefix="/api/v1")
# WebSocket 通常獨立於 API 版本之外,所以不加前綴 # WebSocket 通常獨立於 API 版本之外,所以不加前綴
app.include_router(terminal.router) 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 FILE: static/app.js
@ -2041,7 +2122,8 @@ import {
apiGetFullConfig, apiClearCache, apiExecuteQuery, apiGetFullConfig, apiClearCache, apiExecuteQuery,
apiGetTreeFilters, apiSaveTreeFilters, apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion, apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 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 += `
<div style="background-color: ${color}; color: white; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; box-shadow: 0 1px 3px rgba(0,0,0,0.2);">
${us.channel} : ${us.snr}
</div>
`;
});
} else {
usContainer.innerHTML = '<span style="color: #bdc3c7; font-size: 13px;">無資料</span>';
}
// 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 = '<span style="color: #e74c3c; font-size: 14px; font-weight: bold;">⚠️ 設備無回傳任何 OFDM 頻道 MER 數據</span>';
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) // 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
// ============================================================================ // ============================================================================
@ -6588,6 +6867,160 @@ async def get_lock_status(host: str = Query(None)):
return {"status": "success", "data": {}} 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 FILE: routers/query.py
================================================================================ ================================================================================

View File

@ -8,6 +8,7 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script> <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script> <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- 引入獨立的 CSS 樣式表 --> <!-- 引入獨立的 CSS 樣式表 -->
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
@ -68,6 +69,7 @@
<select id="queryTask" onchange="switchQueryTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;"> <select id="queryTask" onchange="switchQueryTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;">
<option value="form-cm-query">🔎 Cable 狀態綜合查詢</option> <option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
<option value="form-rpd-query">📡 RPD 狀態綜合查詢</option> <option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
</select> </select>
</div> </div>
@ -154,8 +156,76 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 表單 3CM 一鍵診斷中心 -->
<div id="form-cm-diagnostics" class="task-form">
<h3 style="margin-top: 0; font-size: 18px; color: #9b59b6; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">CM 深度診斷與 MER 分析</h3>
<!-- 搜尋區塊 -->
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
<label style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC</label>
<input type="text" id="diagMacInput" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
<button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
<span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
</div>
<!-- 結果顯示區塊 (預設隱藏) -->
<div id="diagResultArea" style="display: none; gap: 20px; flex-direction: column;">
<!-- 上半部:基本資訊與 PHY 狀態 -->
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<!-- 基本資訊卡片 -->
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #3498db; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📄 基本資訊</h4>
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d; width: 40%;">MAC Address</th><td id="diagResMac" style="font-weight: bold; color: #2c3e50;">-</td></tr>
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">IP Address</th><td id="diagResIp" style="color: #2980b9; font-family: monospace;">-</td></tr>
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">State</th><td id="diagResState" style="font-weight: bold;">-</td></tr>
<tr><th style="padding: 8px 0; color: #7f8c8d;">CPE Count</th><td id="diagResCpe">-</td></tr>
</table>
</div>
<!-- PHY 狀態卡片 -->
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #2ecc71; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📡 TX / RX 綜合實體層狀態</h4>
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
<tr style="border-bottom: 1px solid #ecf0f1;">
<th style="padding: 8px 0; color: #7f8c8d; width: 50%;">Avg TX Power (dBmV)</th>
<td id="diagResTx" style="font-weight: bold;">-</td>
</tr>
<tr>
<th style="padding: 8px 0; color: #7f8c8d;">Avg RX Power (dBmV)</th>
<td id="diagResRx" style="font-weight: bold;">-</td>
</tr>
</table>
<!-- 🌟 新增:上行通道標籤區塊 -->
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px dashed #bdc3c7;">
<div style="color: #7f8c8d; font-size: 13px; font-weight: bold; margin-bottom: 8px;">上行通道 SNR (dB) 分布:</div>
<div id="upstreamSnrContainer" style="display: flex; flex-wrap: wrap; gap: 8px;">
<!-- 動態生成標籤 -->
</div>
</div>
</div>
</div>
<!-- 下半部Chart.js MER 圖表 -->
<div style="background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #9b59b6; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
<span>📊 OFDM MER 分布圖 <span style="font-size: 13px; color: #7f8c8d; font-weight: normal;">(點擊頻道切換圖表,最低 MER ≥ 41dB 判定為優)</span></span>
<!-- 🌟 新增:動態 OFDM 頻道切換按鈕區塊 -->
<div id="ofdmChannelTabs" style="display: flex; gap: 10px; flex-wrap: wrap;">
<!-- 動態生成按鈕 -->
</div>
</h4>
<div style="position: relative; height: 280px; width: 100%;">
<canvas id="merChart"></canvas>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- Tab 3: CMTS 設備配置 --> <!-- Tab 3: CMTS 設備配置 -->
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;"> <div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">

View File

@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
import database import database
# 引入我們剛剛拆分出來的路由模組 # 引入我們剛剛拆分出來的路由模組
from routers import query, config, terminal, lock, leaf_options, backup from routers import query, config, terminal, lock, leaf_options, backup, diagnostics
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): 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(leaf_options.router, prefix="/api/v1")
app.include_router(lock.router, prefix="/api/v1") app.include_router(lock.router, prefix="/api/v1")
app.include_router(backup.router, prefix="/api/v1") app.include_router(backup.router, prefix="/api/v1")
app.include_router(diagnostics.router, prefix="/api/v1")
# WebSocket 通常獨立於 API 版本之外,所以不加前綴 # WebSocket 通常獨立於 API 版本之外,所以不加前綴
app.include_router(terminal.router) app.include_router(terminal.router)

View File

@ -1,19 +1,19 @@
import os import os
import os
import argparse
# ========================================== # ==========================================
# 設定區 # 設定區
# ========================================== # ==========================================
# 輸出的合併檔案名稱
OUTPUT_FILE = "all_code.txt"
# ⚠️ 嚴格排除的資料夾 (完全不掃描這些目錄) # ⚠️ 嚴格排除的資料夾 (完全不掃描這些目錄)
EXCLUDE_DIRS = { EXCLUDE_DIRS = {
"__pycache__", "__pycache__",
".git", ".git",
".vscode", # VS Code 設定檔 ".vscode", # VS Code 設定檔
".continue", # Continue.dev 設定檔 ".continue", # Continue.dev 設定檔
"cmts_api_env", # 🚨 Python 虛擬環境 (極度重要!絕對要排除) "cmts_api_env", # 🚨 Python 虛擬環境
"venv", # 其他常見的虛擬環境名稱 "venv",
".venv", ".venv",
"node_modules", "node_modules",
"dist", "dist",
@ -22,27 +22,28 @@ EXCLUDE_DIRS = {
# ⚠️ 排除的檔案副檔名 (不讀取這些格式) # ⚠️ 排除的檔案副檔名 (不讀取這些格式)
EXCLUDE_EXTENSIONS = { EXCLUDE_EXTENSIONS = {
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", # 圖片 ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
".pyc", ".pyo", ".pyd", # Python 編譯檔 ".pyc", ".pyo", ".pyd",
".exe", ".dll", ".so", ".dylib", # 執行檔/函式庫 ".exe", ".dll", ".so", ".dylib",
".zip", ".tar", ".gz", ".rar", # 壓縮檔 ".zip", ".tar", ".gz", ".rar",
".pdf", ".doc", ".docx", # 文件檔 ".pdf", ".doc", ".docx",
".sqlite3", ".db" # 資料庫 ".sqlite3", ".db"
} }
# ⚠️ 排除的特定檔案名稱 (例如腳本自己、隱藏檔等) # ⚠️ 排除的特定檔案名稱
EXCLUDE_FILES = { EXCLUDE_FILES = {
"merge_code.py", # 排除這支腳本自己 "merge_code.py", # 排除這支腳本自己
"all_code.txt", # 排除輸出的檔案 "all_code.txt", # 排除全域輸出的檔案
".DS_Store", # Mac 系統檔 "target_code.txt", # 排除指定輸出的檔案 (新增)
".clineignore", # AI 輔助工具的忽略檔 ".DS_Store",
".clinerules", # AI 輔助工具的規則檔 ".clineignore",
".gitignore", # Git 忽略檔 ".clinerules",
"requirements.txt" # 依賴清單 (通常不需要給 AI 看,除非你要問套件問題) ".gitignore",
"requirements.txt"
} }
def should_process_file(filename): def should_process_file(filename):
"""判斷該檔案是否應該被處理""" """判斷該檔案是否應該被處理 (用於全域掃描)"""
if filename in EXCLUDE_FILES: if filename in EXCLUDE_FILES:
return False return False
@ -52,44 +53,87 @@ def should_process_file(filename):
return True return True
def merge_files(): def merge_all_files():
# 取得當前腳本所在的目錄 (專案根目錄) """模式一:全域掃描 (無參數時觸發)"""
output_file = "all_code.txt"
root_dir = os.path.dirname(os.path.abspath(__file__)) 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("=" * 80 + "\n")
outfile.write("PROJECT SOURCE CODE EXPORT\n") outfile.write("PROJECT SOURCE CODE EXPORT (FULL)\n")
outfile.write("=" * 80 + "\n\n") outfile.write("=" * 80 + "\n\n")
# 走訪目錄
for dirpath, dirnames, filenames in os.walk(root_dir): for dirpath, dirnames, filenames in os.walk(root_dir):
# 排除不需要的目錄 (原地修改 dirnames 列表os.walk 就不會進去)
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS] dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
for filename in filenames: for filename in filenames:
if should_process_file(filename): if should_process_file(filename):
file_path = os.path.join(dirpath, filename) file_path = os.path.join(dirpath, filename)
# 計算相對路徑 (例如: routers/config.py)
rel_path = os.path.relpath(file_path, root_dir) rel_path = os.path.relpath(file_path, root_dir)
try: try:
with open(file_path, "r", encoding="utf-8") as infile: with open(file_path, "r", encoding="utf-8") as infile:
content = infile.read() content = infile.read()
# 寫入漂亮的檔名標籤
outfile.write("\n" + "=" * 80 + "\n") outfile.write("\n" + "=" * 80 + "\n")
outfile.write(f"FILE: {rel_path}\n") outfile.write(f"FILE: {rel_path}\n")
outfile.write("=" * 80 + "\n") outfile.write("=" * 80 + "\n")
# 寫入檔案內容
outfile.write(content) outfile.write(content)
outfile.write("\n") outfile.write("\n")
print(f"✅ 已合併: {rel_path}") print(f"✅ 已合併: {rel_path}")
except Exception as e: except Exception as e:
print(f"❌ 讀取失敗 {rel_path}: {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__": 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()

150
routers/diagnostics.py Normal file
View File

@ -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))

View File

@ -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();
}

View File

@ -12,7 +12,8 @@ import {
apiGetFullConfig, apiClearCache, apiExecuteQuery, apiGetFullConfig, apiClearCache, apiExecuteQuery,
apiGetTreeFilters, apiSaveTreeFilters, apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion, apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 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 += `
<div style="background-color: ${color}; color: white; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; box-shadow: 0 1px 3px rgba(0,0,0,0.2);">
${us.channel} : ${us.snr}
</div>
`;
});
} else {
usContainer.innerHTML = '<span style="color: #bdc3c7; font-size: 13px;">無資料</span>';
}
// 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 = '<span style="color: #e74c3c; font-size: 14px; font-weight: bold;">⚠️ 設備無回傳任何 OFDM 頻道 MER 數據</span>';
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) // 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
// ============================================================================ // ============================================================================

2806
target_code.txt Normal file

File diff suppressed because it is too large Load Diff