Compare commits

...

2 Commits

Author SHA1 Message Date
swpa 19d5a30305 refactor: UX 輸入欄位優化!透過 HTML5 <datalist> 結合背景非同步抓取,保留原本純文字輸入的彈性(支援模糊搜尋與手動輸入) 2026-06-05 13:52:31 +08:00
swpa 8bc68b8bf1 feat: 智能視覺診斷 (Visual Diagnostics) :
- CM 一鍵診斷中心:整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。
2026-06-05 13:52:02 +08:00
12 changed files with 3254 additions and 79 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>
@ -749,7 +751,8 @@ FILE: index.html
<div class="form-grid"> <div class="form-grid">
<div class="form-row"> <div class="form-row">
<label>目標設備 (Cable Modem MAC)</label> <label>目標設備 (Cable Modem MAC)</label>
<input type="text" id="queryTargetCm" placeholder="例: 6467.7240.4076 (留白則查詢全體)"> <input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
<datalist id="cm-mac-list"></datalist>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>查詢動作 (Action)</label> <label>查詢動作 (Action)</label>
@ -798,7 +801,8 @@ FILE: index.html
<div class="form-grid"> <div class="form-grid">
<div class="form-row"> <div class="form-row">
<label>目標設備 (RPD VC:VS)</label> <label>目標設備 (RPD VC:VS)</label>
<input type="text" id="queryTargetRpd" placeholder="例: 13:0 (留白則查詢全體)"> <input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
<datalist id="rpd-vcvs-list"></datalist>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>查詢動作 (Action)</label> <label>查詢動作 (Action)</label>
@ -826,6 +830,75 @@ 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" list="diag-cm-mac-list" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
<datalist id="diag-cm-mac-list"></datalist>
<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>
@ -850,9 +923,8 @@ FILE: index.html
<div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;"> <div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;">
<label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain</label> <label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain</label>
<select id="cfgMacDomain" style="width: 150px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;"> <input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
<option value="">請先點擊上方載入任務...</option> <datalist id="mac-domain-list"></datalist>
</select>
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button> <button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span> <span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
</div> </div>
@ -1712,7 +1784,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 +1805,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)
@ -1985,6 +2058,19 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
return response.json(); return response.json();
} }
// 🌟 新增:動態抓取設備清單 API
export async function apiListCms(host, username, password) {
const url = `/api/v1/list-cms?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
export async function apiListRpds(host, username, password) {
const url = `/api/v1/list-rpds?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
// ========================================== // ==========================================
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新) // 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
// ========================================== // ==========================================
@ -2023,6 +2109,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 +2137,9 @@ import {
apiGetFullConfig, apiClearCache, apiExecuteQuery, apiGetFullConfig, apiClearCache, apiExecuteQuery,
apiGetTreeFilters, apiSaveTreeFilters, apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion, apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics,
apiListCms, apiListRpds
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 3. 共用工具模組 (Utils)
@ -2302,12 +2400,97 @@ function switchTab(tabId, element) {
if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50); if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50);
} }
// 切換「設備查詢」內的子任務表單
// ============================================================================
// 🌟 設備查詢動態清單快取 (新增)
// ============================================================================
let cachedDeviceLists = { cms: null, rpds: null, host: null };
// 切換「設備查詢」內的子任務表單 // 切換「設備查詢」內的子任務表單
function switchQueryTask() { function switchQueryTask() {
document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active')); document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active'));
const selectedTaskId = document.getElementById('queryTask').value; const selectedTaskId = document.getElementById('queryTask').value;
const targetForm = document.getElementById(selectedTaskId); const targetForm = document.getElementById(selectedTaskId);
if (targetForm) targetForm.classList.add('active'); if (targetForm) targetForm.classList.add('active');
// 🌟 觸發背景動態載入 Datalist
loadDynamicDatalist(selectedTaskId);
}
// 🌟 新增:背景載入 Datalist 邏輯
async function loadDynamicDatalist(taskType) {
// 若尚未連線,則不觸發背景抓取 (不跳警告,默默 return)
if (!isConnected) return;
const host = document.getElementById('cmtsHost').value.trim();
const user = document.getElementById('cmtsUser').value.trim();
const pass = document.getElementById('cmtsPass').value.trim();
if (!host || !user || !pass) return;
// 若切換了設備 IP清空舊快取
if (cachedDeviceLists.host !== host) {
cachedDeviceLists = { cms: null, rpds: null, host: host };
}
if (taskType === 'form-cm-query' || taskType === 'form-cm-diagnostics') {
const inputId = taskType === 'form-cm-query' ? 'queryTargetCm' : 'diagMacInput';
const listId = taskType === 'form-cm-query' ? 'cm-mac-list' : 'diag-cm-mac-list';
const defaultPlaceholder = taskType === 'form-cm-query' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例如: 6467.7240.4076";
await fetchAndBindList('cms', inputId, listId, defaultPlaceholder, host, user, pass);
} else if (taskType === 'form-rpd-query') {
await fetchAndBindList('rpds', 'queryTargetRpd', 'rpd-vcvs-list', "例: 13:0 (留白則查詢全體)", host, user, pass);
}
}
// 🌟 新增:執行 API 呼叫與 UX 狀態切換
async function fetchAndBindList(type, inputId, listId, defaultPlaceholder, host, user, pass) {
const inputEl = document.getElementById(inputId);
const datalistEl = document.getElementById(listId);
if (!inputEl || !datalistEl) return;
// 如果已經有快取,直接綁定並結束
if (cachedDeviceLists[type]) {
populateDatalist(datalistEl, cachedDeviceLists[type]);
return;
}
// 狀態 1發出請求前 (鎖定輸入框,提示載入中)
inputEl.disabled = true;
inputEl.placeholder = "🔄 載入設備清單中...";
inputEl.style.backgroundColor = "#fdfefe";
try {
let data;
if (type === 'cms') {
data = await apiListCms(host, user, pass);
cachedDeviceLists.cms = data.cms || [];
} else {
data = await apiListRpds(host, user, pass);
cachedDeviceLists.rpds = data.rpds || [];
}
// 狀態 2請求成功 (塞入選項,恢復輸入框)
populateDatalist(datalistEl, cachedDeviceLists[type]);
inputEl.placeholder = defaultPlaceholder;
} catch (error) {
console.error(`[Background Task] Failed to load ${type}:`, error);
// 狀態 3請求失敗 (提示失敗,但仍解鎖讓使用者手動輸入)
inputEl.placeholder = "⚠️ 清單載入失敗,請手動輸入";
} finally {
inputEl.disabled = false;
inputEl.style.backgroundColor = ""; // 恢復預設背景色
}
}
// 🌟 新增:將陣列轉換為 <option> 塞入 <datalist>
function populateDatalist(datalistEl, items) {
datalistEl.innerHTML = '';
items.forEach(item => {
const option = document.createElement('option');
option.value = item;
datalistEl.appendChild(option);
});
} }
// 🌟 新增一個變數來記錄上一次的樹狀圖模式 // 🌟 新增一個變數來記錄上一次的樹狀圖模式
@ -2409,6 +2592,11 @@ function toggleQueryInputs(mode) {
function resetAllManagerData() { function resetAllManagerData() {
resetMacDomainData(); resetMacDomainData();
// 🌟 新增:清空動態下拉選單的快取,確保切換設備時重新抓取
if (typeof cachedDeviceLists !== 'undefined') {
cachedDeviceLists = { cms: null, rpds: null, host: null };
}
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM // 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
clearTreeCache(); clearTreeCache();
@ -2432,8 +2620,13 @@ function resetAllManagerData() {
deployBtn.disabled = true; deployBtn.disabled = true;
} }
const mdSelect = document.getElementById('cfgMacDomain'); const mdInput = document.getElementById('cfgMacDomain');
if (mdSelect) mdSelect.innerHTML = '<option value="">請先點擊上方載入任務...</option>'; if (mdInput) {
mdInput.value = '';
mdInput.placeholder = '請先點擊上方載入任務...';
}
const mdDatalist = document.getElementById('mac-domain-list');
if (mdDatalist) mdDatalist.innerHTML = '';
const fetchStatus = document.getElementById('fetchStatus'); const fetchStatus = document.getElementById('fetchStatus');
if (fetchStatus) { if (fetchStatus) {
@ -2641,6 +2834,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)
// ============================================================================ // ============================================================================
@ -3804,6 +4194,12 @@ export function connectWebSocket(resetCallback) {
document.getElementById('cmtsUser').disabled = true; document.getElementById('cmtsUser').disabled = true;
document.getElementById('cmtsPass').disabled = true; document.getElementById('cmtsPass').disabled = true;
term.focus(); term.focus();
// 🌟 新增:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
const queryTaskSelect = document.getElementById('queryTask');
if (queryTaskSelect) {
queryTaskSelect.dispatchEvent(new Event('change'));
}
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@ -3997,10 +4393,11 @@ button:hover { background-color: #3498db; }
.task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; } .task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; }
/* 網格系統:高空間利用率 */ /* 網格系統:高空間利用率 */
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 15px; justify-content: start; } .form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; margin-bottom: 15px; justify-content: start; }
.form-row { display: flex; flex-direction: column; gap: 4px; } .form-row { display: flex; flex-direction: column; gap: 4px; }
.form-row label { font-size: 12px; font-weight: bold; color: #34495e; } .form-row label { font-size: 12px; font-weight: bold; color: #34495e; }
.form-row input[type="text"], .form-row select { width: 100%; box-sizing: border-box; padding: 6px 8px; background-color: #f8f9fa; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; } .form-row input[type="text"], .form-row select { width: 100%; box-sizing: border-box; padding: 6px 8px; background-color: #f8f9fa; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; }
.form-row select { padding-right: 30px; text-overflow: ellipsis; } /* 🌟 確保下拉箭頭有足夠空間,不會遮擋文字 */
.form-row input[type="text"]:focus, .form-row select:focus { outline: none; border-color: #2980b9; background-color: #fff; } .form-row input[type="text"]:focus, .form-row select:focus { outline: none; border-color: #2980b9; background-color: #fff; }
/* 表單底部操作區 */ /* 表單底部操作區 */
@ -5293,34 +5690,45 @@ export async function loadMacDomainList() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return; if (!connInfo) return;
const mdSelect = document.getElementById('cfgMacDomain'); const mdInput = document.getElementById('cfgMacDomain');
const mdDatalist = document.getElementById('mac-domain-list');
const statusSpan = document.getElementById('fetchStatus'); const statusSpan = document.getElementById('fetchStatus');
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>'; // 載入前:鎖定輸入框並更改提示
mdInput.disabled = true;
mdInput.value = '';
mdInput.placeholder = '⏳ 查詢設備中...';
if (mdDatalist) mdDatalist.innerHTML = '';
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單..."; statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
statusSpan.style.color = "#f39c12"; statusSpan.style.color = "#f39c12";
try { try {
const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass); const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success' && result.data.length > 0) { if (result.status === 'success' && result.data.length > 0) {
mdSelect.innerHTML = ''; // 載入成功:將資料塞入 datalist
if (mdDatalist) {
result.data.forEach(md => { result.data.forEach(md => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = md; option.value = md;
option.textContent = md; mdDatalist.appendChild(option);
mdSelect.appendChild(option);
}); });
}
mdInput.placeholder = '例: 13:0/0.0 (可下拉或輸入)';
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。"; statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
statusSpan.style.color = "#27ae60"; statusSpan.style.color = "#27ae60";
} else { } else {
mdSelect.innerHTML = '<option value="">❌ 找不到資料</option>'; mdInput.placeholder = '❌ 找不到資料';
statusSpan.textContent = "無法解析清單,請確認設備狀態。"; statusSpan.textContent = "無法解析清單,請確認設備狀態。";
statusSpan.style.color = "#e74c3c"; statusSpan.style.color = "#e74c3c";
} }
} catch (error) { } catch (error) {
mdSelect.innerHTML = '<option value="">❌ 連線錯誤</option>'; mdInput.placeholder = '❌ 連線錯誤';
statusSpan.textContent = "連線失敗:" + error; statusSpan.textContent = "連線失敗:" + error;
statusSpan.style.color = "#e74c3c"; statusSpan.style.color = "#e74c3c";
} finally {
// 載入結束:解鎖輸入框
mdInput.disabled = false;
} }
} }
@ -6588,11 +6996,167 @@ 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
================================================================================ ================================================================================
# --- routers/query.py --- # --- routers/query.py ---
import re import re
import asyncssh
import asyncio
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from netmiko import ConnectHandler from netmiko import ConnectHandler
from shared import CMTS_DEVICE from shared import CMTS_DEVICE
@ -6838,6 +7402,57 @@ async def get_cmts_version(
if net_connect: if net_connect:
net_connect.disconnect() net_connect.disconnect()
@router.get("/list-cms", summary="獲取設備上所有的 CM MAC 清單")
async def list_cms(host: str, username: str, password: str):
try:
# 使用 asyncssh 建立非同步連線
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
# 加上 | nomore 徹底避開分頁問題,並設定 timeout 防止卡死
result = await conn.run("show cable modem | nomore", check=False, timeout=15.0)
if result.exit_status != 0 or not result.stdout:
return {"cms": []}
# 嚴謹的 Regex匹配 xxxx.xxxx.xxxx 或 xx:xx:xx:xx:xx:xx
mac_pattern = re.compile(r"([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
macs = []
for line in result.stdout.splitlines():
match = mac_pattern.search(line)
if match:
macs.append(match.group(1).lower())
# 去重複並排序
return {"cms": sorted(list(set(macs)))}
except Exception as e:
print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}")
return {"cms": []} # 發生錯誤 (如 Timeout, 空行) 安全回傳空陣列
@router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單")
async def list_rpds(host: str, username: str, password: str):
try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
result = await conn.run("show cable rpd | nomore", check=False, timeout=15.0)
if result.exit_status != 0 or not result.stdout:
return {"rpds": []}
# 嚴謹的 Regex匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6
vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])")
rpds = []
for line in result.stdout.splitlines():
match = vcvs_pattern.search(line)
if match:
rpds.append(match.group(1))
return {"rpds": sorted(list(set(rpds)))}
except Exception as e:
print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}")
return {"rpds": []}
================================================================================ ================================================================================
FILE: routers/config.py FILE: routers/config.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>
@ -77,7 +79,8 @@
<div class="form-grid"> <div class="form-grid">
<div class="form-row"> <div class="form-row">
<label>目標設備 (Cable Modem MAC)</label> <label>目標設備 (Cable Modem MAC)</label>
<input type="text" id="queryTargetCm" placeholder="例: 6467.7240.4076 (留白則查詢全體)"> <input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
<datalist id="cm-mac-list"></datalist>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>查詢動作 (Action)</label> <label>查詢動作 (Action)</label>
@ -126,7 +129,8 @@
<div class="form-grid"> <div class="form-grid">
<div class="form-row"> <div class="form-row">
<label>目標設備 (RPD VC:VS)</label> <label>目標設備 (RPD VC:VS)</label>
<input type="text" id="queryTargetRpd" placeholder="例: 13:0 (留白則查詢全體)"> <input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
<datalist id="rpd-vcvs-list"></datalist>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>查詢動作 (Action)</label> <label>查詢動作 (Action)</label>
@ -154,6 +158,75 @@
</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" list="diag-cm-mac-list" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
<datalist id="diag-cm-mac-list"></datalist>
<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>
@ -178,9 +251,8 @@
<div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;"> <div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;">
<label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain</label> <label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain</label>
<select id="cfgMacDomain" style="width: 150px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;"> <input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
<option value="">請先點擊上方載入任務...</option> <datalist id="mac-domain-list"></datalist>
</select>
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button> <button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span> <span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
</div> </div>

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

@ -1,5 +1,7 @@
# --- routers/query.py --- # --- routers/query.py ---
import re import re
import asyncssh
import asyncio
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from netmiko import ConnectHandler from netmiko import ConnectHandler
from shared import CMTS_DEVICE from shared import CMTS_DEVICE
@ -244,3 +246,54 @@ async def get_cmts_version(
finally: finally:
if net_connect: if net_connect:
net_connect.disconnect() net_connect.disconnect()
@router.get("/list-cms", summary="獲取設備上所有的 CM MAC 清單")
async def list_cms(host: str, username: str, password: str):
try:
# 使用 asyncssh 建立非同步連線
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
# 加上 | nomore 徹底避開分頁問題,並設定 timeout 防止卡死
result = await conn.run("show cable modem | nomore", check=False, timeout=15.0)
if result.exit_status != 0 or not result.stdout:
return {"cms": []}
# 嚴謹的 Regex匹配 xxxx.xxxx.xxxx 或 xx:xx:xx:xx:xx:xx
mac_pattern = re.compile(r"([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
macs = []
for line in result.stdout.splitlines():
match = mac_pattern.search(line)
if match:
macs.append(match.group(1).lower())
# 去重複並排序
return {"cms": sorted(list(set(macs)))}
except Exception as e:
print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}")
return {"cms": []} # 發生錯誤 (如 Timeout, 空行) 安全回傳空陣列
@router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單")
async def list_rpds(host: str, username: str, password: str):
try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
result = await conn.run("show cable rpd | nomore", check=False, timeout=15.0)
if result.exit_status != 0 or not result.stdout:
return {"rpds": []}
# 嚴謹的 Regex匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6
vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])")
rpds = []
for line in result.stdout.splitlines():
match = vcvs_pattern.search(line)
if match:
rpds.append(match.group(1))
return {"rpds": sorted(list(set(rpds)))}
except Exception as e:
print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}")
return {"rpds": []}

View File

@ -151,6 +151,19 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
return response.json(); return response.json();
} }
// 🌟 新增:動態抓取設備清單 API
export async function apiListCms(host, username, password) {
const url = `/api/v1/list-cms?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
export async function apiListRpds(host, username, password) {
const url = `/api/v1/list-rpds?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
// ========================================== // ==========================================
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新) // 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
// ========================================== // ==========================================
@ -188,3 +201,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,9 @@ import {
apiGetFullConfig, apiClearCache, apiExecuteQuery, apiGetFullConfig, apiClearCache, apiExecuteQuery,
apiGetTreeFilters, apiSaveTreeFilters, apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion, apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics,
apiListCms, apiListRpds
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 3. 共用工具模組 (Utils)
@ -273,12 +275,97 @@ function switchTab(tabId, element) {
if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50); if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50);
} }
// 切換「設備查詢」內的子任務表單
// ============================================================================
// 🌟 設備查詢動態清單快取 (新增)
// ============================================================================
let cachedDeviceLists = { cms: null, rpds: null, host: null };
// 切換「設備查詢」內的子任務表單 // 切換「設備查詢」內的子任務表單
function switchQueryTask() { function switchQueryTask() {
document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active')); document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active'));
const selectedTaskId = document.getElementById('queryTask').value; const selectedTaskId = document.getElementById('queryTask').value;
const targetForm = document.getElementById(selectedTaskId); const targetForm = document.getElementById(selectedTaskId);
if (targetForm) targetForm.classList.add('active'); if (targetForm) targetForm.classList.add('active');
// 🌟 觸發背景動態載入 Datalist
loadDynamicDatalist(selectedTaskId);
}
// 🌟 新增:背景載入 Datalist 邏輯
async function loadDynamicDatalist(taskType) {
// 若尚未連線,則不觸發背景抓取 (不跳警告,默默 return)
if (!isConnected) return;
const host = document.getElementById('cmtsHost').value.trim();
const user = document.getElementById('cmtsUser').value.trim();
const pass = document.getElementById('cmtsPass').value.trim();
if (!host || !user || !pass) return;
// 若切換了設備 IP清空舊快取
if (cachedDeviceLists.host !== host) {
cachedDeviceLists = { cms: null, rpds: null, host: host };
}
if (taskType === 'form-cm-query' || taskType === 'form-cm-diagnostics') {
const inputId = taskType === 'form-cm-query' ? 'queryTargetCm' : 'diagMacInput';
const listId = taskType === 'form-cm-query' ? 'cm-mac-list' : 'diag-cm-mac-list';
const defaultPlaceholder = taskType === 'form-cm-query' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例如: 6467.7240.4076";
await fetchAndBindList('cms', inputId, listId, defaultPlaceholder, host, user, pass);
} else if (taskType === 'form-rpd-query') {
await fetchAndBindList('rpds', 'queryTargetRpd', 'rpd-vcvs-list', "例: 13:0 (留白則查詢全體)", host, user, pass);
}
}
// 🌟 新增:執行 API 呼叫與 UX 狀態切換
async function fetchAndBindList(type, inputId, listId, defaultPlaceholder, host, user, pass) {
const inputEl = document.getElementById(inputId);
const datalistEl = document.getElementById(listId);
if (!inputEl || !datalistEl) return;
// 如果已經有快取,直接綁定並結束
if (cachedDeviceLists[type]) {
populateDatalist(datalistEl, cachedDeviceLists[type]);
return;
}
// 狀態 1發出請求前 (鎖定輸入框,提示載入中)
inputEl.disabled = true;
inputEl.placeholder = "🔄 載入設備清單中...";
inputEl.style.backgroundColor = "#fdfefe";
try {
let data;
if (type === 'cms') {
data = await apiListCms(host, user, pass);
cachedDeviceLists.cms = data.cms || [];
} else {
data = await apiListRpds(host, user, pass);
cachedDeviceLists.rpds = data.rpds || [];
}
// 狀態 2請求成功 (塞入選項,恢復輸入框)
populateDatalist(datalistEl, cachedDeviceLists[type]);
inputEl.placeholder = defaultPlaceholder;
} catch (error) {
console.error(`[Background Task] Failed to load ${type}:`, error);
// 狀態 3請求失敗 (提示失敗,但仍解鎖讓使用者手動輸入)
inputEl.placeholder = "⚠️ 清單載入失敗,請手動輸入";
} finally {
inputEl.disabled = false;
inputEl.style.backgroundColor = ""; // 恢復預設背景色
}
}
// 🌟 新增:將陣列轉換為 <option> 塞入 <datalist>
function populateDatalist(datalistEl, items) {
datalistEl.innerHTML = '';
items.forEach(item => {
const option = document.createElement('option');
option.value = item;
datalistEl.appendChild(option);
});
} }
// 🌟 新增一個變數來記錄上一次的樹狀圖模式 // 🌟 新增一個變數來記錄上一次的樹狀圖模式
@ -380,6 +467,11 @@ function toggleQueryInputs(mode) {
function resetAllManagerData() { function resetAllManagerData() {
resetMacDomainData(); resetMacDomainData();
// 🌟 新增:清空動態下拉選單的快取,確保切換設備時重新抓取
if (typeof cachedDeviceLists !== 'undefined') {
cachedDeviceLists = { cms: null, rpds: null, host: null };
}
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM // 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
clearTreeCache(); clearTreeCache();
@ -403,8 +495,13 @@ function resetAllManagerData() {
deployBtn.disabled = true; deployBtn.disabled = true;
} }
const mdSelect = document.getElementById('cfgMacDomain'); const mdInput = document.getElementById('cfgMacDomain');
if (mdSelect) mdSelect.innerHTML = '<option value="">請先點擊上方載入任務...</option>'; if (mdInput) {
mdInput.value = '';
mdInput.placeholder = '請先點擊上方載入任務...';
}
const mdDatalist = document.getElementById('mac-domain-list');
if (mdDatalist) mdDatalist.innerHTML = '';
const fetchStatus = document.getElementById('fetchStatus'); const fetchStatus = document.getElementById('fetchStatus');
if (fetchStatus) { if (fetchStatus) {
@ -612,6 +709,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)
// ============================================================================ // ============================================================================

View File

@ -18,34 +18,45 @@ export async function loadMacDomainList() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return; if (!connInfo) return;
const mdSelect = document.getElementById('cfgMacDomain'); const mdInput = document.getElementById('cfgMacDomain');
const mdDatalist = document.getElementById('mac-domain-list');
const statusSpan = document.getElementById('fetchStatus'); const statusSpan = document.getElementById('fetchStatus');
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>'; // 載入前:鎖定輸入框並更改提示
mdInput.disabled = true;
mdInput.value = '';
mdInput.placeholder = '⏳ 查詢設備中...';
if (mdDatalist) mdDatalist.innerHTML = '';
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單..."; statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
statusSpan.style.color = "#f39c12"; statusSpan.style.color = "#f39c12";
try { try {
const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass); const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success' && result.data.length > 0) { if (result.status === 'success' && result.data.length > 0) {
mdSelect.innerHTML = ''; // 載入成功:將資料塞入 datalist
if (mdDatalist) {
result.data.forEach(md => { result.data.forEach(md => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = md; option.value = md;
option.textContent = md; mdDatalist.appendChild(option);
mdSelect.appendChild(option);
}); });
}
mdInput.placeholder = '例: 13:0/0.0 (可下拉或輸入)';
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。"; statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
statusSpan.style.color = "#27ae60"; statusSpan.style.color = "#27ae60";
} else { } else {
mdSelect.innerHTML = '<option value="">❌ 找不到資料</option>'; mdInput.placeholder = '❌ 找不到資料';
statusSpan.textContent = "無法解析清單,請確認設備狀態。"; statusSpan.textContent = "無法解析清單,請確認設備狀態。";
statusSpan.style.color = "#e74c3c"; statusSpan.style.color = "#e74c3c";
} }
} catch (error) { } catch (error) {
mdSelect.innerHTML = '<option value="">❌ 連線錯誤</option>'; mdInput.placeholder = '❌ 連線錯誤';
statusSpan.textContent = "連線失敗:" + error; statusSpan.textContent = "連線失敗:" + error;
statusSpan.style.color = "#e74c3c"; statusSpan.style.color = "#e74c3c";
} finally {
// 載入結束:解鎖輸入框
mdInput.disabled = false;
} }
} }

View File

@ -115,10 +115,11 @@ button:hover { background-color: #3498db; }
.task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; } .task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; }
/* 網格系統:高空間利用率 */ /* 網格系統:高空間利用率 */
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 15px; justify-content: start; } .form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; margin-bottom: 15px; justify-content: start; }
.form-row { display: flex; flex-direction: column; gap: 4px; } .form-row { display: flex; flex-direction: column; gap: 4px; }
.form-row label { font-size: 12px; font-weight: bold; color: #34495e; } .form-row label { font-size: 12px; font-weight: bold; color: #34495e; }
.form-row input[type="text"], .form-row select { width: 100%; box-sizing: border-box; padding: 6px 8px; background-color: #f8f9fa; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; } .form-row input[type="text"], .form-row select { width: 100%; box-sizing: border-box; padding: 6px 8px; background-color: #f8f9fa; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; }
.form-row select { padding-right: 30px; text-overflow: ellipsis; } /* 🌟 確保下拉箭頭有足夠空間,不會遮擋文字 */
.form-row input[type="text"]:focus, .form-row select:focus { outline: none; border-color: #2980b9; background-color: #fff; } .form-row input[type="text"]:focus, .form-row select:focus { outline: none; border-color: #2980b9; background-color: #fff; }
/* 表單底部操作區 */ /* 表單底部操作區 */

View File

@ -73,6 +73,12 @@ export function connectWebSocket(resetCallback) {
document.getElementById('cmtsUser').disabled = true; document.getElementById('cmtsUser').disabled = true;
document.getElementById('cmtsPass').disabled = true; document.getElementById('cmtsPass').disabled = true;
term.focus(); term.focus();
// 🌟 新增:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
const queryTaskSelect = document.getElementById('queryTask');
if (queryTaskSelect) {
queryTaskSelect.dispatchEvent(new Event('change'));
}
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {

1905
target_code.txt Normal file

File diff suppressed because it is too large Load Diff