Compare commits
2 Commits
3cd7ce253e
...
19d5a30305
| Author | SHA1 | Date |
|---|---|---|
|
|
19d5a30305 | |
|
|
8bc68b8bf1 |
659
all_code.txt
659
all_code.txt
|
|
@ -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>
|
||||||
|
|
||||||
|
<!-- 表單 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>
|
</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
|
||||||
result.data.forEach(md => {
|
if (mdDatalist) {
|
||||||
const option = document.createElement('option');
|
result.data.forEach(md => {
|
||||||
option.value = md;
|
const option = document.createElement('option');
|
||||||
option.textContent = md;
|
option.value = md;
|
||||||
mdSelect.appendChild(option);
|
mdDatalist.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
|
||||||
|
|
|
||||||
82
index.html
82
index.html
|
|
@ -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>
|
||||||
|
|
||||||
|
<!-- 表單 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>
|
</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>
|
||||||
|
|
|
||||||
3
main.py
3
main.py
|
|
@ -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)
|
||||||
|
|
|
||||||
112
merge_code.py
112
merge_code.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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": []}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
300
static/app.js
300
static/app.js
|
|
@ -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)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
result.data.forEach(md => {
|
if (mdDatalist) {
|
||||||
const option = document.createElement('option');
|
result.data.forEach(md => {
|
||||||
option.value = md;
|
const option = document.createElement('option');
|
||||||
option.textContent = md;
|
option.value = md;
|
||||||
mdSelect.appendChild(option);
|
mdDatalist.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
||||||
/* 表單底部操作區 */
|
/* 表單底部操作區 */
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue