Compare commits
No commits in common. "19d5a30305a256475ad4dbb68c6a2f26f0669be9" and "3cd7ce253ef90160633e5fae7cd7c9e0c870aee1" have entirely different histories.
19d5a30305
...
3cd7ce253e
661
all_code.txt
661
all_code.txt
|
|
@ -1,5 +1,5 @@
|
|||
================================================================================
|
||||
PROJECT SOURCE CODE EXPORT (FULL)
|
||||
PROJECT SOURCE CODE EXPORT
|
||||
================================================================================
|
||||
|
||||
|
||||
|
|
@ -680,7 +680,6 @@ FILE: index.html
|
|||
<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-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<!-- 引入獨立的 CSS 樣式表 -->
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
|
|
@ -741,7 +740,6 @@ 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;">
|
||||
<option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
|
||||
<option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
|
||||
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -751,8 +749,7 @@ FILE: index.html
|
|||
<div class="form-grid">
|
||||
<div class="form-row">
|
||||
<label>目標設備 (Cable Modem MAC)</label>
|
||||
<input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
|
||||
<datalist id="cm-mac-list"></datalist>
|
||||
<input type="text" id="queryTargetCm" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>查詢動作 (Action)</label>
|
||||
|
|
@ -801,8 +798,7 @@ FILE: index.html
|
|||
<div class="form-grid">
|
||||
<div class="form-row">
|
||||
<label>目標設備 (RPD VC:VS)</label>
|
||||
<input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
|
||||
<datalist id="rpd-vcvs-list"></datalist>
|
||||
<input type="text" id="queryTargetRpd" placeholder="例: 13:0 (留白則查詢全體)">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>查詢動作 (Action)</label>
|
||||
|
|
@ -830,77 +826,8 @@ FILE: index.html
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表單 3:CM 一鍵診斷中心 -->
|
||||
<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>
|
||||
|
||||
<!-- Tab 3: CMTS 設備配置 -->
|
||||
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
|
||||
|
|
@ -923,8 +850,9 @@ FILE: index.html
|
|||
|
||||
<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>
|
||||
<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;">
|
||||
<datalist id="mac-domain-list"></datalist>
|
||||
<select id="cfgMacDomain" style="width: 150px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
|
||||
<option value="">請先點擊上方載入任務...</option>
|
||||
</select>
|
||||
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
|
||||
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
|
||||
</div>
|
||||
|
|
@ -1784,7 +1712,7 @@ from contextlib import asynccontextmanager
|
|||
import database
|
||||
|
||||
# 引入我們剛剛拆分出來的路由模組
|
||||
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics
|
||||
from routers import query, config, terminal, lock, leaf_options, backup
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
|
@ -1805,7 +1733,6 @@ app.include_router(config.router, prefix="/api/v1")
|
|||
app.include_router(leaf_options.router, prefix="/api/v1")
|
||||
app.include_router(lock.router, prefix="/api/v1")
|
||||
app.include_router(backup.router, prefix="/api/v1")
|
||||
app.include_router(diagnostics.router, prefix="/api/v1")
|
||||
|
||||
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
|
||||
app.include_router(terminal.router)
|
||||
|
|
@ -2058,19 +1985,6 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
|
|||
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 可以即時更新)
|
||||
// ==========================================
|
||||
|
|
@ -2109,16 +2023,6 @@ 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
|
||||
|
|
@ -2137,9 +2041,7 @@ import {
|
|||
apiGetFullConfig, apiClearCache, apiExecuteQuery,
|
||||
apiGetTreeFilters, apiSaveTreeFilters,
|
||||
apiSyncLeafOptionsStream, apiGetCmtsVersion,
|
||||
apiGetScanStatus, apiGetLockStatus,
|
||||
apiGetCmDiagnostics,
|
||||
apiListCms, apiListRpds
|
||||
apiGetScanStatus, apiGetLockStatus
|
||||
} from './api.js';
|
||||
|
||||
// 3. 共用工具模組 (Utils)
|
||||
|
|
@ -2400,97 +2302,12 @@ function switchTab(tabId, element) {
|
|||
if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50);
|
||||
}
|
||||
|
||||
// 切換「設備查詢」內的子任務表單
|
||||
// ============================================================================
|
||||
// 🌟 設備查詢動態清單快取 (新增)
|
||||
// ============================================================================
|
||||
let cachedDeviceLists = { cms: null, rpds: null, host: null };
|
||||
|
||||
// 切換「設備查詢」內的子任務表單
|
||||
function switchQueryTask() {
|
||||
document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active'));
|
||||
const selectedTaskId = document.getElementById('queryTask').value;
|
||||
const targetForm = document.getElementById(selectedTaskId);
|
||||
if (targetForm) targetForm.classList.add('active');
|
||||
|
||||
// 🌟 觸發背景動態載入 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);
|
||||
});
|
||||
}
|
||||
|
||||
// 🌟 新增一個變數來記錄上一次的樹狀圖模式
|
||||
|
|
@ -2591,11 +2408,6 @@ function toggleQueryInputs(mode) {
|
|||
// 重置所有管理介面的資料與 UI 狀態
|
||||
function resetAllManagerData() {
|
||||
resetMacDomainData();
|
||||
|
||||
// 🌟 新增:清空動態下拉選單的快取,確保切換設備時重新抓取
|
||||
if (typeof cachedDeviceLists !== 'undefined') {
|
||||
cachedDeviceLists = { cms: null, rpds: null, host: null };
|
||||
}
|
||||
|
||||
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
|
||||
clearTreeCache();
|
||||
|
|
@ -2620,13 +2432,8 @@ function resetAllManagerData() {
|
|||
deployBtn.disabled = true;
|
||||
}
|
||||
|
||||
const mdInput = document.getElementById('cfgMacDomain');
|
||||
if (mdInput) {
|
||||
mdInput.value = '';
|
||||
mdInput.placeholder = '請先點擊上方載入任務...';
|
||||
}
|
||||
const mdDatalist = document.getElementById('mac-domain-list');
|
||||
if (mdDatalist) mdDatalist.innerHTML = '';
|
||||
const mdSelect = document.getElementById('cfgMacDomain');
|
||||
if (mdSelect) mdSelect.innerHTML = '<option value="">請先點擊上方載入任務...</option>';
|
||||
|
||||
const fetchStatus = document.getElementById('fetchStatus');
|
||||
if (fetchStatus) {
|
||||
|
|
@ -2834,203 +2641,6 @@ 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)
|
||||
// ============================================================================
|
||||
|
|
@ -4194,12 +3804,6 @@ export function connectWebSocket(resetCallback) {
|
|||
document.getElementById('cmtsUser').disabled = true;
|
||||
document.getElementById('cmtsPass').disabled = true;
|
||||
term.focus();
|
||||
|
||||
// 🌟 新增:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
|
||||
const queryTaskSelect = document.getElementById('queryTask');
|
||||
if (queryTaskSelect) {
|
||||
queryTaskSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
|
|
@ -4393,11 +3997,10 @@ button:hover { background-color: #3498db; }
|
|||
.task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; }
|
||||
|
||||
/* 網格系統:高空間利用率 */
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; margin-bottom: 15px; justify-content: start; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 15px; justify-content: start; }
|
||||
.form-row { display: flex; flex-direction: column; gap: 4px; }
|
||||
.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 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; }
|
||||
|
||||
/* 表單底部操作區 */
|
||||
|
|
@ -5690,45 +5293,34 @@ export async function loadMacDomainList() {
|
|||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo) return;
|
||||
|
||||
const mdInput = document.getElementById('cfgMacDomain');
|
||||
const mdDatalist = document.getElementById('mac-domain-list');
|
||||
const mdSelect = document.getElementById('cfgMacDomain');
|
||||
const statusSpan = document.getElementById('fetchStatus');
|
||||
|
||||
// 載入前:鎖定輸入框並更改提示
|
||||
mdInput.disabled = true;
|
||||
mdInput.value = '';
|
||||
mdInput.placeholder = '⏳ 查詢設備中...';
|
||||
if (mdDatalist) mdDatalist.innerHTML = '';
|
||||
|
||||
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>';
|
||||
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
|
||||
statusSpan.style.color = "#f39c12";
|
||||
|
||||
try {
|
||||
const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
|
||||
if (result.status === 'success' && result.data.length > 0) {
|
||||
// 載入成功:將資料塞入 datalist
|
||||
if (mdDatalist) {
|
||||
result.data.forEach(md => {
|
||||
const option = document.createElement('option');
|
||||
option.value = md;
|
||||
mdDatalist.appendChild(option);
|
||||
});
|
||||
}
|
||||
mdInput.placeholder = '例: 13:0/0.0 (可下拉或輸入)';
|
||||
mdSelect.innerHTML = '';
|
||||
result.data.forEach(md => {
|
||||
const option = document.createElement('option');
|
||||
option.value = md;
|
||||
option.textContent = md;
|
||||
mdSelect.appendChild(option);
|
||||
});
|
||||
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
|
||||
statusSpan.style.color = "#27ae60";
|
||||
} else {
|
||||
mdInput.placeholder = '❌ 找不到資料';
|
||||
mdSelect.innerHTML = '<option value="">❌ 找不到資料</option>';
|
||||
statusSpan.textContent = "無法解析清單,請確認設備狀態。";
|
||||
statusSpan.style.color = "#e74c3c";
|
||||
}
|
||||
} catch (error) {
|
||||
mdInput.placeholder = '❌ 連線錯誤';
|
||||
mdSelect.innerHTML = '<option value="">❌ 連線錯誤</option>';
|
||||
statusSpan.textContent = "連線失敗:" + error;
|
||||
statusSpan.style.color = "#e74c3c";
|
||||
} finally {
|
||||
// 載入結束:解鎖輸入框
|
||||
mdInput.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6996,167 +6588,11 @@ async def get_lock_status(host: str = Query(None)):
|
|||
return {"status": "success", "data": {}}
|
||||
|
||||
|
||||
================================================================================
|
||||
FILE: routers/diagnostics.py
|
||||
================================================================================
|
||||
# --- routers/diagnostics.py ---
|
||||
import re
|
||||
import asyncssh
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/cm-diagnostics", tags=["Diagnostics"])
|
||||
|
||||
@router.get("/")
|
||||
async def get_cm_diagnostics(
|
||||
host: str = Query(...),
|
||||
username: str = Query(...),
|
||||
password: str = Query(...),
|
||||
mac: str = Query(..., description="Cable Modem MAC Address")
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
mac = mac.strip().lower()
|
||||
if not mac:
|
||||
raise HTTPException(status_code=400, detail="MAC Address is required")
|
||||
|
||||
result_data = {
|
||||
"mac": mac,
|
||||
"ip": "N/A",
|
||||
"state": "N/A",
|
||||
"cpe_count": 0,
|
||||
"phy": {
|
||||
"tx_power": None,
|
||||
"rx_power": None
|
||||
},
|
||||
"upstreams": [],
|
||||
"mer_channels": {}
|
||||
}
|
||||
|
||||
try:
|
||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
||||
print("\n🚀 [Diagnostics] 開始診斷 MAC: " + mac)
|
||||
|
||||
# ==========================================
|
||||
# 1. 查詢基本狀態
|
||||
# ==========================================
|
||||
cmd_base = "show cable modem " + mac + " | nomore"
|
||||
res_base = await conn.run(cmd_base, check=False)
|
||||
if res_base.exit_status == 0 and res_base.stdout:
|
||||
for line in res_base.stdout.splitlines():
|
||||
if mac in line.lower():
|
||||
tokens = line.split()
|
||||
for t in tokens:
|
||||
if re.match(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$", t):
|
||||
result_data["ip"] = t
|
||||
elif re.match(r"^[a-z]-online|online|offline|init.*|reject", t, re.IGNORECASE):
|
||||
result_data["state"] = t
|
||||
nums = [t for t in tokens if t.isdigit()]
|
||||
if nums:
|
||||
result_data["cpe_count"] = int(nums[-1])
|
||||
break
|
||||
|
||||
# ==========================================
|
||||
# 2. 查詢 PHY 狀態 (💡 修復:移除寫死的 us/oad 判斷,改用通用特徵)
|
||||
# ==========================================
|
||||
cmd_phy = "show cable modem " + mac + " phy | nomore"
|
||||
res_phy = await conn.run(cmd_phy, check=False)
|
||||
if res_phy.exit_status == 0 and res_phy.stdout:
|
||||
tx_list = []
|
||||
rx_list = []
|
||||
for line in res_phy.stdout.splitlines():
|
||||
line_lower = line.lower()
|
||||
if mac in line_lower:
|
||||
tokens = line.split()
|
||||
if len(tokens) >= 6:
|
||||
ch_name = tokens[1]
|
||||
# 💡 只要名稱包含 ":" 和 "/",就認定是合法的通道 (例如 Oa32, Oad32, Us32)
|
||||
if ":" in ch_name and "/" in ch_name:
|
||||
try:
|
||||
snr_val = float(tokens[4])
|
||||
result_data["upstreams"].append({"channel": ch_name, "snr": snr_val})
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
tx_val = float(tokens[3].split('/')[0])
|
||||
rx_list_val = float(tokens[5].split('/')[0])
|
||||
tx_list.append(tx_val)
|
||||
rx_list.append(rx_list_val)
|
||||
except:
|
||||
pass
|
||||
|
||||
if tx_list: result_data["phy"]["tx_power"] = round(sum(tx_list)/len(tx_list), 2)
|
||||
if rx_list: result_data["phy"]["rx_power"] = round(sum(rx_list)/len(rx_list), 2)
|
||||
|
||||
# ==========================================
|
||||
# 3. 尋找所有 OFDM 通道
|
||||
# ==========================================
|
||||
cmd_help = "show cable modem " + mac + " ofdm-channel ?"
|
||||
res_help = await conn.run(cmd_help, check=False)
|
||||
text_to_search = (res_help.stdout or "") + "\n" + (res_help.stderr or "")
|
||||
|
||||
cmd_verb = "show cable modem " + mac + " verbose | nomore"
|
||||
res_verb = await conn.run(cmd_verb, check=False)
|
||||
text_to_search += "\n" + (res_verb.stdout or "")
|
||||
|
||||
matches = re.findall(r"\b(Of(?:dm)?\d*[\d/:]+)\b", text_to_search, re.IGNORECASE)
|
||||
ofdm_channels = []
|
||||
for m in matches:
|
||||
clean_m = m.lower()
|
||||
if clean_m.startswith("of"):
|
||||
clean_m = "Of" + clean_m[2:]
|
||||
if clean_m not in ofdm_channels:
|
||||
ofdm_channels.append(clean_m)
|
||||
ofdm_channels.sort()
|
||||
|
||||
# ==========================================
|
||||
# 4. 對每一個 OFDM 通道查詢 MER
|
||||
# ==========================================
|
||||
for ch in ofdm_channels:
|
||||
cmd_mer = "show cable modem " + mac + " ofdm-channel " + ch + " mer | nomore"
|
||||
res_mer = await conn.run(cmd_mer, check=False)
|
||||
|
||||
histogram = {}
|
||||
min_mer = 999
|
||||
|
||||
if res_mer.exit_status == 0 and res_mer.stdout:
|
||||
mer_lines = re.finditer(r"(\d+)\s*(?:db|dB)?\s*\|[^|]*\|?\s*(\**)", res_mer.stdout, re.IGNORECASE)
|
||||
for match in mer_lines:
|
||||
db_val = match.group(1)
|
||||
stars = match.group(2)
|
||||
if stars:
|
||||
histogram[db_val] = len(stars) * 100
|
||||
db_int = int(db_val)
|
||||
if db_int < min_mer:
|
||||
min_mer = db_int
|
||||
|
||||
health = "unknown"
|
||||
if histogram and min_mer != 999:
|
||||
if min_mer >= 41: health = "good"
|
||||
elif min_mer >= 38: health = "warning"
|
||||
else: health = "critical"
|
||||
|
||||
result_data["mer_channels"][ch] = {
|
||||
"histogram": histogram,
|
||||
"health": health,
|
||||
"min_mer": min_mer if min_mer != 999 else "N/A"
|
||||
}
|
||||
|
||||
print("✅ [Diagnostics] 解析結果: " + str(result_data))
|
||||
return {"status": "success", "data": result_data}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("CM Diagnostics Error: " + str(e))
|
||||
raise HTTPException(status_code=500, detail="設備連線或查詢失敗: " + str(e))
|
||||
|
||||
================================================================================
|
||||
FILE: routers/query.py
|
||||
================================================================================
|
||||
# --- routers/query.py ---
|
||||
import re
|
||||
import asyncssh
|
||||
import asyncio
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from netmiko import ConnectHandler
|
||||
from shared import CMTS_DEVICE
|
||||
|
|
@ -7402,57 +6838,6 @@ async def get_cmts_version(
|
|||
if net_connect:
|
||||
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
|
||||
|
|
|
|||
84
index.html
84
index.html
|
|
@ -8,7 +8,6 @@
|
|||
<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-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<!-- 引入獨立的 CSS 樣式表 -->
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
|
|
@ -69,7 +68,6 @@
|
|||
<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-rpd-query">📡 RPD 狀態綜合查詢</option>
|
||||
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -79,8 +77,7 @@
|
|||
<div class="form-grid">
|
||||
<div class="form-row">
|
||||
<label>目標設備 (Cable Modem MAC)</label>
|
||||
<input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
|
||||
<datalist id="cm-mac-list"></datalist>
|
||||
<input type="text" id="queryTargetCm" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>查詢動作 (Action)</label>
|
||||
|
|
@ -129,8 +126,7 @@
|
|||
<div class="form-grid">
|
||||
<div class="form-row">
|
||||
<label>目標設備 (RPD VC:VS)</label>
|
||||
<input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
|
||||
<datalist id="rpd-vcvs-list"></datalist>
|
||||
<input type="text" id="queryTargetRpd" placeholder="例: 13:0 (留白則查詢全體)">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>查詢動作 (Action)</label>
|
||||
|
|
@ -158,77 +154,8 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表單 3:CM 一鍵診斷中心 -->
|
||||
<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>
|
||||
|
||||
<!-- Tab 3: CMTS 設備配置 -->
|
||||
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
|
||||
|
|
@ -251,8 +178,9 @@
|
|||
|
||||
<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>
|
||||
<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;">
|
||||
<datalist id="mac-domain-list"></datalist>
|
||||
<select id="cfgMacDomain" style="width: 150px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
|
||||
<option value="">請先點擊上方載入任務...</option>
|
||||
</select>
|
||||
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
|
||||
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
|
||||
</div>
|
||||
|
|
|
|||
3
main.py
3
main.py
|
|
@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
|
|||
import database
|
||||
|
||||
# 引入我們剛剛拆分出來的路由模組
|
||||
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics
|
||||
from routers import query, config, terminal, lock, leaf_options, backup
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
|
@ -28,7 +28,6 @@ app.include_router(config.router, prefix="/api/v1")
|
|||
app.include_router(leaf_options.router, prefix="/api/v1")
|
||||
app.include_router(lock.router, prefix="/api/v1")
|
||||
app.include_router(backup.router, prefix="/api/v1")
|
||||
app.include_router(diagnostics.router, prefix="/api/v1")
|
||||
|
||||
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
|
||||
app.include_router(terminal.router)
|
||||
|
|
|
|||
112
merge_code.py
112
merge_code.py
|
|
@ -1,19 +1,19 @@
|
|||
import os
|
||||
|
||||
import os
|
||||
import argparse
|
||||
|
||||
# ==========================================
|
||||
# 設定區
|
||||
# ==========================================
|
||||
# 輸出的合併檔案名稱
|
||||
OUTPUT_FILE = "all_code.txt"
|
||||
|
||||
# ⚠️ 嚴格排除的資料夾 (完全不掃描這些目錄)
|
||||
EXCLUDE_DIRS = {
|
||||
"__pycache__",
|
||||
".git",
|
||||
".vscode", # VS Code 設定檔
|
||||
".continue", # Continue.dev 設定檔
|
||||
"cmts_api_env", # 🚨 Python 虛擬環境
|
||||
"venv",
|
||||
"cmts_api_env", # 🚨 Python 虛擬環境 (極度重要!絕對要排除)
|
||||
"venv", # 其他常見的虛擬環境名稱
|
||||
".venv",
|
||||
"node_modules",
|
||||
"dist",
|
||||
|
|
@ -22,28 +22,27 @@ EXCLUDE_DIRS = {
|
|||
|
||||
# ⚠️ 排除的檔案副檔名 (不讀取這些格式)
|
||||
EXCLUDE_EXTENSIONS = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
|
||||
".pyc", ".pyo", ".pyd",
|
||||
".exe", ".dll", ".so", ".dylib",
|
||||
".zip", ".tar", ".gz", ".rar",
|
||||
".pdf", ".doc", ".docx",
|
||||
".sqlite3", ".db"
|
||||
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", # 圖片
|
||||
".pyc", ".pyo", ".pyd", # Python 編譯檔
|
||||
".exe", ".dll", ".so", ".dylib", # 執行檔/函式庫
|
||||
".zip", ".tar", ".gz", ".rar", # 壓縮檔
|
||||
".pdf", ".doc", ".docx", # 文件檔
|
||||
".sqlite3", ".db" # 資料庫
|
||||
}
|
||||
|
||||
# ⚠️ 排除的特定檔案名稱
|
||||
# ⚠️ 排除的特定檔案名稱 (例如腳本自己、隱藏檔等)
|
||||
EXCLUDE_FILES = {
|
||||
"merge_code.py", # 排除這支腳本自己
|
||||
"all_code.txt", # 排除全域輸出的檔案
|
||||
"target_code.txt", # 排除指定輸出的檔案 (新增)
|
||||
".DS_Store",
|
||||
".clineignore",
|
||||
".clinerules",
|
||||
".gitignore",
|
||||
"requirements.txt"
|
||||
"merge_code.py", # 排除這支腳本自己
|
||||
"all_code.txt", # 排除輸出的檔案
|
||||
".DS_Store", # Mac 系統檔
|
||||
".clineignore", # AI 輔助工具的忽略檔
|
||||
".clinerules", # AI 輔助工具的規則檔
|
||||
".gitignore", # Git 忽略檔
|
||||
"requirements.txt" # 依賴清單 (通常不需要給 AI 看,除非你要問套件問題)
|
||||
}
|
||||
|
||||
def should_process_file(filename):
|
||||
"""判斷該檔案是否應該被處理 (用於全域掃描)"""
|
||||
"""判斷該檔案是否應該被處理"""
|
||||
if filename in EXCLUDE_FILES:
|
||||
return False
|
||||
|
||||
|
|
@ -53,87 +52,44 @@ def should_process_file(filename):
|
|||
|
||||
return True
|
||||
|
||||
def merge_all_files():
|
||||
"""模式一:全域掃描 (無參數時觸發)"""
|
||||
output_file = "all_code.txt"
|
||||
def merge_files():
|
||||
# 取得當前腳本所在的目錄 (專案根目錄)
|
||||
root_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
with open(output_file, "w", encoding="utf-8") as outfile:
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as outfile:
|
||||
# 寫入一個總標題
|
||||
outfile.write("=" * 80 + "\n")
|
||||
outfile.write("PROJECT SOURCE CODE EXPORT (FULL)\n")
|
||||
outfile.write("PROJECT SOURCE CODE EXPORT\n")
|
||||
outfile.write("=" * 80 + "\n\n")
|
||||
|
||||
# 走訪目錄
|
||||
for dirpath, dirnames, filenames in os.walk(root_dir):
|
||||
# 排除不需要的目錄 (原地修改 dirnames 列表,os.walk 就不會進去)
|
||||
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
|
||||
|
||||
for filename in filenames:
|
||||
if should_process_file(filename):
|
||||
file_path = os.path.join(dirpath, filename)
|
||||
# 計算相對路徑 (例如: routers/config.py)
|
||||
rel_path = os.path.relpath(file_path, root_dir)
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as infile:
|
||||
content = infile.read()
|
||||
|
||||
# 寫入漂亮的檔名標籤
|
||||
outfile.write("\n" + "=" * 80 + "\n")
|
||||
outfile.write(f"FILE: {rel_path}\n")
|
||||
outfile.write("=" * 80 + "\n")
|
||||
# 寫入檔案內容
|
||||
outfile.write(content)
|
||||
outfile.write("\n")
|
||||
|
||||
print(f"✅ 已合併: {rel_path}")
|
||||
except Exception as e:
|
||||
print(f"❌ 讀取失敗 {rel_path}: {e}")
|
||||
|
||||
print(f"\n🎉 全域合併完成!所有程式碼已儲存至: {output_file}")
|
||||
|
||||
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}")
|
||||
print(f"\n🎉 合併完成!所有程式碼已儲存至: {OUTPUT_FILE}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 使用 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()
|
||||
merge_files()
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
# --- 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))
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
# --- routers/query.py ---
|
||||
import re
|
||||
import asyncssh
|
||||
import asyncio
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from netmiko import ConnectHandler
|
||||
from shared import CMTS_DEVICE
|
||||
|
|
@ -246,54 +244,3 @@ async def get_cmts_version(
|
|||
finally:
|
||||
if net_connect:
|
||||
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": []}
|
||||
|
|
|
|||
|
|
@ -151,19 +151,6 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
|
|||
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 可以即時更新)
|
||||
// ==========================================
|
||||
|
|
@ -201,13 +188,3 @@ 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();
|
||||
}
|
||||
|
|
|
|||
300
static/app.js
300
static/app.js
|
|
@ -12,9 +12,7 @@ import {
|
|||
apiGetFullConfig, apiClearCache, apiExecuteQuery,
|
||||
apiGetTreeFilters, apiSaveTreeFilters,
|
||||
apiSyncLeafOptionsStream, apiGetCmtsVersion,
|
||||
apiGetScanStatus, apiGetLockStatus,
|
||||
apiGetCmDiagnostics,
|
||||
apiListCms, apiListRpds
|
||||
apiGetScanStatus, apiGetLockStatus
|
||||
} from './api.js';
|
||||
|
||||
// 3. 共用工具模組 (Utils)
|
||||
|
|
@ -275,97 +273,12 @@ function switchTab(tabId, element) {
|
|||
if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50);
|
||||
}
|
||||
|
||||
// 切換「設備查詢」內的子任務表單
|
||||
// ============================================================================
|
||||
// 🌟 設備查詢動態清單快取 (新增)
|
||||
// ============================================================================
|
||||
let cachedDeviceLists = { cms: null, rpds: null, host: null };
|
||||
|
||||
// 切換「設備查詢」內的子任務表單
|
||||
function switchQueryTask() {
|
||||
document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active'));
|
||||
const selectedTaskId = document.getElementById('queryTask').value;
|
||||
const targetForm = document.getElementById(selectedTaskId);
|
||||
if (targetForm) targetForm.classList.add('active');
|
||||
|
||||
// 🌟 觸發背景動態載入 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);
|
||||
});
|
||||
}
|
||||
|
||||
// 🌟 新增一個變數來記錄上一次的樹狀圖模式
|
||||
|
|
@ -466,11 +379,6 @@ function toggleQueryInputs(mode) {
|
|||
// 重置所有管理介面的資料與 UI 狀態
|
||||
function resetAllManagerData() {
|
||||
resetMacDomainData();
|
||||
|
||||
// 🌟 新增:清空動態下拉選單的快取,確保切換設備時重新抓取
|
||||
if (typeof cachedDeviceLists !== 'undefined') {
|
||||
cachedDeviceLists = { cms: null, rpds: null, host: null };
|
||||
}
|
||||
|
||||
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
|
||||
clearTreeCache();
|
||||
|
|
@ -495,13 +403,8 @@ function resetAllManagerData() {
|
|||
deployBtn.disabled = true;
|
||||
}
|
||||
|
||||
const mdInput = document.getElementById('cfgMacDomain');
|
||||
if (mdInput) {
|
||||
mdInput.value = '';
|
||||
mdInput.placeholder = '請先點擊上方載入任務...';
|
||||
}
|
||||
const mdDatalist = document.getElementById('mac-domain-list');
|
||||
if (mdDatalist) mdDatalist.innerHTML = '';
|
||||
const mdSelect = document.getElementById('cfgMacDomain');
|
||||
if (mdSelect) mdSelect.innerHTML = '<option value="">請先點擊上方載入任務...</option>';
|
||||
|
||||
const fetchStatus = document.getElementById('fetchStatus');
|
||||
if (fetchStatus) {
|
||||
|
|
@ -709,203 +612,6 @@ 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)
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -18,45 +18,34 @@ export async function loadMacDomainList() {
|
|||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo) return;
|
||||
|
||||
const mdInput = document.getElementById('cfgMacDomain');
|
||||
const mdDatalist = document.getElementById('mac-domain-list');
|
||||
const mdSelect = document.getElementById('cfgMacDomain');
|
||||
const statusSpan = document.getElementById('fetchStatus');
|
||||
|
||||
// 載入前:鎖定輸入框並更改提示
|
||||
mdInput.disabled = true;
|
||||
mdInput.value = '';
|
||||
mdInput.placeholder = '⏳ 查詢設備中...';
|
||||
if (mdDatalist) mdDatalist.innerHTML = '';
|
||||
|
||||
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>';
|
||||
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
|
||||
statusSpan.style.color = "#f39c12";
|
||||
|
||||
try {
|
||||
const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
|
||||
if (result.status === 'success' && result.data.length > 0) {
|
||||
// 載入成功:將資料塞入 datalist
|
||||
if (mdDatalist) {
|
||||
result.data.forEach(md => {
|
||||
const option = document.createElement('option');
|
||||
option.value = md;
|
||||
mdDatalist.appendChild(option);
|
||||
});
|
||||
}
|
||||
mdInput.placeholder = '例: 13:0/0.0 (可下拉或輸入)';
|
||||
mdSelect.innerHTML = '';
|
||||
result.data.forEach(md => {
|
||||
const option = document.createElement('option');
|
||||
option.value = md;
|
||||
option.textContent = md;
|
||||
mdSelect.appendChild(option);
|
||||
});
|
||||
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
|
||||
statusSpan.style.color = "#27ae60";
|
||||
} else {
|
||||
mdInput.placeholder = '❌ 找不到資料';
|
||||
mdSelect.innerHTML = '<option value="">❌ 找不到資料</option>';
|
||||
statusSpan.textContent = "無法解析清單,請確認設備狀態。";
|
||||
statusSpan.style.color = "#e74c3c";
|
||||
}
|
||||
} catch (error) {
|
||||
mdInput.placeholder = '❌ 連線錯誤';
|
||||
mdSelect.innerHTML = '<option value="">❌ 連線錯誤</option>';
|
||||
statusSpan.textContent = "連線失敗:" + error;
|
||||
statusSpan.style.color = "#e74c3c";
|
||||
} finally {
|
||||
// 載入結束:解鎖輸入框
|
||||
mdInput.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,11 +115,10 @@ button:hover { background-color: #3498db; }
|
|||
.task-form.active { display: block; animation: fadeIn 0.3s ease-in-out; }
|
||||
|
||||
/* 網格系統:高空間利用率 */
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; margin-bottom: 15px; justify-content: start; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 15px; justify-content: start; }
|
||||
.form-row { display: flex; flex-direction: column; gap: 4px; }
|
||||
.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 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; }
|
||||
|
||||
/* 表單底部操作區 */
|
||||
|
|
|
|||
|
|
@ -73,12 +73,6 @@ export function connectWebSocket(resetCallback) {
|
|||
document.getElementById('cmtsUser').disabled = true;
|
||||
document.getElementById('cmtsPass').disabled = true;
|
||||
term.focus();
|
||||
|
||||
// 🌟 新增:連線成功後,自動觸發一次查詢任務的 change 事件,讓背景預載 Datalist
|
||||
const queryTaskSelect = document.getElementById('queryTask');
|
||||
if (queryTaskSelect) {
|
||||
queryTaskSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
|
|
|
|||
1905
target_code.txt
1905
target_code.txt
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue