refactor: UX 輸入欄位優化!透過 HTML5 <datalist> 結合背景非同步抓取,保留原本純文字輸入的彈性(支援模糊搜尋與手動輸入)

This commit is contained in:
swpa 2026-06-05 13:45:34 +08:00
parent 8bc68b8bf1
commit 19d5a30305
9 changed files with 423 additions and 960 deletions

View File

@ -751,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>
@ -800,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>
@ -836,7 +838,8 @@ FILE: index.html
<!-- 搜尋區塊 --> <!-- 搜尋區塊 -->
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;"> <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> <label style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC</label>
<input type="text" id="diagMacInput" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;"> <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> <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> <span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
</div> </div>
@ -920,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>
@ -2056,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 可以即時更新)
// ========================================== // ==========================================
@ -2123,7 +2138,8 @@ import {
apiGetTreeFilters, apiSaveTreeFilters, apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion, apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus, apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics apiGetCmDiagnostics,
apiListCms, apiListRpds
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 3. 共用工具模組 (Utils)
@ -2384,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);
});
} }
// 🌟 新增一個變數來記錄上一次的樹狀圖模式 // 🌟 新增一個變數來記錄上一次的樹狀圖模式
@ -2491,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();
@ -2514,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) {
@ -4083,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) => {
@ -4276,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; }
/* 表單底部操作區 */ /* 表單底部操作區 */
@ -5572,34 +5690,45 @@ export async function loadMacDomainList() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return; if (!connInfo) return;
const mdSelect = document.getElementById('cfgMacDomain'); const mdInput = document.getElementById('cfgMacDomain');
const mdDatalist = document.getElementById('mac-domain-list');
const statusSpan = document.getElementById('fetchStatus'); const statusSpan = document.getElementById('fetchStatus');
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>'; // 載入前:鎖定輸入框並更改提示
mdInput.disabled = true;
mdInput.value = '';
mdInput.placeholder = '⏳ 查詢設備中...';
if (mdDatalist) mdDatalist.innerHTML = '';
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單..."; statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
statusSpan.style.color = "#f39c12"; statusSpan.style.color = "#f39c12";
try { try {
const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass); const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success' && result.data.length > 0) { if (result.status === 'success' && result.data.length > 0) {
mdSelect.innerHTML = ''; // 載入成功:將資料塞入 datalist
if (mdDatalist) {
result.data.forEach(md => { result.data.forEach(md => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = md; option.value = md;
option.textContent = md; mdDatalist.appendChild(option);
mdSelect.appendChild(option);
}); });
}
mdInput.placeholder = '例: 13:0/0.0 (可下拉或輸入)';
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。"; statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
statusSpan.style.color = "#27ae60"; statusSpan.style.color = "#27ae60";
} else { } else {
mdSelect.innerHTML = '<option value="">❌ 找不到資料</option>'; mdInput.placeholder = '❌ 找不到資料';
statusSpan.textContent = "無法解析清單,請確認設備狀態。"; statusSpan.textContent = "無法解析清單,請確認設備狀態。";
statusSpan.style.color = "#e74c3c"; statusSpan.style.color = "#e74c3c";
} }
} catch (error) { } catch (error) {
mdSelect.innerHTML = '<option value="">❌ 連線錯誤</option>'; mdInput.placeholder = '❌ 連線錯誤';
statusSpan.textContent = "連線失敗:" + error; statusSpan.textContent = "連線失敗:" + error;
statusSpan.style.color = "#e74c3c"; statusSpan.style.color = "#e74c3c";
} finally {
// 載入結束:解鎖輸入框
mdInput.disabled = false;
} }
} }
@ -7026,6 +7155,8 @@ 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
@ -7271,6 +7402,57 @@ async def get_cmts_version(
if net_connect: if net_connect:
net_connect.disconnect() net_connect.disconnect()
@router.get("/list-cms", summary="獲取設備上所有的 CM MAC 清單")
async def list_cms(host: str, username: str, password: str):
try:
# 使用 asyncssh 建立非同步連線
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
# 加上 | nomore 徹底避開分頁問題,並設定 timeout 防止卡死
result = await conn.run("show cable modem | nomore", check=False, timeout=15.0)
if result.exit_status != 0 or not result.stdout:
return {"cms": []}
# 嚴謹的 Regex匹配 xxxx.xxxx.xxxx 或 xx:xx:xx:xx:xx:xx
mac_pattern = re.compile(r"([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
macs = []
for line in result.stdout.splitlines():
match = mac_pattern.search(line)
if match:
macs.append(match.group(1).lower())
# 去重複並排序
return {"cms": sorted(list(set(macs)))}
except Exception as e:
print(f"⚠️ [Background Task] 獲取 CM 清單失敗: {e}")
return {"cms": []} # 發生錯誤 (如 Timeout, 空行) 安全回傳空陣列
@router.get("/list-rpds", summary="獲取設備上所有的 RPD VC:VS 清單")
async def list_rpds(host: str, username: str, password: str):
try:
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
result = await conn.run("show cable rpd | nomore", check=False, timeout=15.0)
if result.exit_status != 0 or not result.stdout:
return {"rpds": []}
# 嚴謹的 Regex匹配 數字:數字 (例如 13:0),並使用 Negative Lookbehind/Lookahead 避免匹配到 MAC 或 IPv6
vcvs_pattern = re.compile(r"(?<![:\w])(\d{1,3}:\d{1,3})(?![:\w])")
rpds = []
for line in result.stdout.splitlines():
match = vcvs_pattern.search(line)
if match:
rpds.append(match.group(1))
return {"rpds": sorted(list(set(rpds)))}
except Exception as e:
print(f"⚠️ [Background Task] 獲取 RPD 清單失敗: {e}")
return {"rpds": []}
================================================================================ ================================================================================
FILE: routers/config.py FILE: routers/config.py

View File

@ -79,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>
@ -128,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>
@ -164,7 +166,8 @@
<!-- 搜尋區塊 --> <!-- 搜尋區塊 -->
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;"> <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> <label style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC</label>
<input type="text" id="diagMacInput" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;"> <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> <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> <span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
</div> </div>
@ -248,9 +251,8 @@
<div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;"> <div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;">
<label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain</label> <label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain</label>
<select id="cfgMacDomain" style="width: 150px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;"> <input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
<option value="">請先點擊上方載入任務...</option> <datalist id="mac-domain-list"></datalist>
</select>
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button> <button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span> <span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
</div> </div>

View File

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

View File

@ -151,6 +151,19 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
return response.json(); return response.json();
} }
// 🌟 新增:動態抓取設備清單 API
export async function apiListCms(host, username, password) {
const url = `/api/v1/list-cms?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
export async function apiListRpds(host, username, password) {
const url = `/api/v1/list-rpds?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
// ========================================== // ==========================================
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新) // 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
// ========================================== // ==========================================

View File

@ -13,7 +13,8 @@ import {
apiGetTreeFilters, apiSaveTreeFilters, apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion, apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus, apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics apiGetCmDiagnostics,
apiListCms, apiListRpds
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 3. 共用工具模組 (Utils)
@ -274,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);
});
} }
// 🌟 新增一個變數來記錄上一次的樹狀圖模式 // 🌟 新增一個變數來記錄上一次的樹狀圖模式
@ -381,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();
@ -404,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) {

View File

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

View File

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

View File

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

View File

@ -3,211 +3,6 @@ TARGET SOURCE CODE EXPORT (SPECIFIC)
================================================================================ ================================================================================
================================================================================
FILE: static/api.js
================================================================================
// --- static/api.js ---
// ==========================================
// 1. 鎖定管理 (Lock Management)
// ==========================================
export async function apiAcquireLock(path, userId, username, host) {
const res = await fetch('/api/v1/locks/acquire', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId, username, host })
});
const data = await res.json();
return { status: res.status, data };
}
export async function apiReleaseLock(path, userId, host) {
return fetch('/api/v1/locks/release', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId, host })
});
}
export async function apiSendHeartbeat(path, userId, host) {
return fetch('/api/v1/locks/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId, host })
});
}
export async function apiGetLockStatus(host) {
// 帶入 host 參數,實現 IP 隔離查詢
const url = host ? `/api/v1/locks/status?host=${encodeURIComponent(host)}` : '/api/v1/locks/status';
const response = await fetch(url);
return response.json();
}
// ==========================================
// 2. 樹狀圖與選項快取 (Tree & Options)
// ==========================================
export async function apiGetFullConfig(host, username, password, skipFilter = false, configType = 'running') {
// 🌟 將 config_type 加入 URL 參數中
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&config_type=${configType}`;
if (skipFilter) url += '&skip_filter=true';
const response = await fetch(url);
return response.json();
}
// 🌟 1. 取得選項快取 (加上 configType 查詢參數)
export async function apiGetLeafOptions(host, configType = 'running') {
const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}&config_type=${configType}`);
return response.json();
}
// 🌟 2. 同步選項快取 (將 config_type 塞入 Body)
export async function apiSyncLeafOptions(host, paths, configType = 'running') {
return fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
});
}
// 🌟 3. 清除快取 (加上 configType 查詢參數)
export async function apiClearCache(host, paths, configType = 'running') {
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(paths)
});
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
return response.json();
}
export async function apiGetCmtsVersion(host, username, password) {
const url = `/api/v1/cmts-version?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
export async function apiGetScanStatus(host, configType = 'running') {
try {
const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}&config_type=${configType}`);
const data = await response.json();
return data.is_scanning;
} catch (e) {
return false;
}
}
// ==========================================
// 3. 指令生成與執行 (CLI Generation & Execution)
// ==========================================
export async function apiGenerateCli(diffs, interfaces) {
const response = await fetch('/api/v1/cmts-generate-cli', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ diffs, interfaces_with_admin_state: interfaces })
});
return response.json();
}
export async function apiExecuteConfig(script, host, username, password) {
const response = await fetch('/api/v1/cmts-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ script, host, username, password })
});
return response.json();
}
// ==========================================
// 4. 系統設定與過濾器 (System Settings)
// ==========================================
// 🌟 加上 configType 參數
export async function apiGetTreeFilters(configType = 'running') {
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`);
return response.json();
}
// 🌟 加上 configType 參數
export async function apiSaveTreeFilters(hiddenKeys, configType = 'running') {
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden_keys: hiddenKeys, config_type: configType })
});
return response.json();
}
// ==========================================
// 5. MAC Domain 與綜合查詢 (MAC Domain & Query)
// ==========================================
export async function apiGetMacDomainList(host, username, password) {
const url = `/api/v1/cmts-mac-domain-list?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
export async function apiGetMacDomainConfig(target, host, username, password) {
const url = `/api/v1/cmts-mac-domain-config?target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
export async function apiExecuteQuery(queryType, target, host, username, password) {
const url = `/api/v1/cmts-query?query_type=${queryType}&target=${encodeURIComponent(target)}&host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
const response = await fetch(url);
return response.json();
}
// ==========================================
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
// ==========================================
// 🌟 4. 串流掃描 API (將 config_type 塞入 Body)
export async function apiSyncLeafOptionsStream(host, paths, onProgress, configType = 'running') {
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
});
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");
// 🌟 核心修改:讀取串流資料
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
while (true) {
const { done, value } = await reader.read();
if (done) break; // 伺服器斷開連線 (任務完成)
// 解碼二進位資料為文字
const chunk = decoder.decode(value, { stream: true });
// 因為一次可能收到多行 JSON我們用換行符號切開
const lines = chunk.split("\n").filter(line => line.trim() !== "");
for (const line of lines) {
try {
const data = JSON.parse(line);
onProgress(data); // 將解析後的資料傳給 UI 介面
} catch (e) {
console.error("JSON 解析錯誤:", e, line);
}
}
}
}
export async function apiGetCmDiagnostics(host, username, password, mac) {
const url = `/api/v1/cm-diagnostics/?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&mac=${encodeURIComponent(mac)}`;
const response = await fetch(url);
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "伺服器發生錯誤");
}
return response.json();
}
================================================================================ ================================================================================
FILE: static/app.js FILE: static/app.js
================================================================================ ================================================================================
@ -226,7 +21,8 @@ import {
apiGetTreeFilters, apiSaveTreeFilters, apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion, apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus, apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics apiGetCmDiagnostics,
apiListCms, apiListRpds
} from './api.js'; } from './api.js';
// 3. 共用工具模組 (Utils) // 3. 共用工具模組 (Utils)
@ -895,16 +691,28 @@ window.runCmDiagnostics = async function() {
channels.forEach((ch, index) => { channels.forEach((ch, index) => {
const chData = globalMerData[ch]; const chData = globalMerData[ch];
// 根據後端判斷的健康度上色 (>=41綠, 38~40橘, <=37紅) // 根據後端判斷的健康度上色 (>=41綠, 38~40橘, <=37紅)
let bgCol = '#bdc3c7'; let bgCol = '#bdc3c7'; // 預設灰色 (無資料)
if (chData.health === 'good') bgCol = '#27ae60'; if (chData.health === 'good') bgCol = '#27ae60';
else if (chData.health === 'warning') bgCol = '#f39c12'; else if (chData.health === 'warning') bgCol = '#f39c12';
else if (chData.health === 'critical') bgCol = '#e74c3c'; else if (chData.health === 'critical') bgCol = '#e74c3c';
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'btn-modern'; // 💡 拔除方正的 class改用純手工膠囊樣式
btn.style.boxSizing = 'border-box';
btn.style.cursor = 'pointer';
btn.style.backgroundColor = bgCol; btn.style.backgroundColor = bgCol;
btn.style.opacity = index === 0 ? '1' : '0.5'; // 預設突顯第一個 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'; btn.style.border = index === 0 ? '2px solid #2c3e50' : '2px solid transparent';
// 標籤文字加入最低 MER 數值提示 // 標籤文字加入最低 MER 數值提示
btn.textContent = `${ch} (Min: ${chData.min_mer} dB)`; btn.textContent = `${ch} (Min: ${chData.min_mer} dB)`;
@ -2095,712 +1903,3 @@ window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup; window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup; window.restoreBackup = restoreBackup;
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域 window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
================================================================================
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": [], # 存放所有上行通道的 SNR
"mer_channels": {} # 存放所有 OFDM 通道的 MER 數據與健康度
}
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 狀態 (精準提取 Upstreams 與 TX/RX 平均值)
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()
# Harmonic phy 表格: [0]MAC, [1]UPSTREAM_CH, [2]SID, [3]TXPWR, [4]USSNR, [5]RXPWR
if len(tokens) >= 6:
ch_name = tokens[1]
# 儲存上行通道 SNR (過濾掉純 Downstream 資訊行)
if ch_name.lower().startswith("us") or ch_name.lower().startswith("oad"):
try:
snr_val = float(tokens[4])
result_data["upstreams"].append({"channel": ch_name, "snr": snr_val})
except:
pass
# 收集 TX/RX 以計算平均值 (取代被拔除的單一 US SNR)
try:
tx_val = float(tokens[3].split('/')[0]) # 處理 33.00/1.6M 格式
rx_val = float(tokens[5].split('/')[0])
tx_list.append(tx_val)
rx_list.append(rx_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. 查詢 verbose 以獲取所有 OFDM Downstream Channels
cmd_verb = "show cable modem " + mac + " verbose | nomore"
res_verb = await conn.run(cmd_verb, check=False)
ofdm_channels = []
if res_verb.exit_status == 0 and res_verb.stdout:
# 抓取 Of32:0/0/0 格式
matches = re.findall(r"\b(Of(?:dm)?\d+[\d/:]+)\b", res_verb.stdout, re.IGNORECASE)
# 去除重複並排序
ofdm_channels = sorted(list(set(matches)))
# 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 # 用來找出該通道最低的 MER
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
if histogram:
# 依據您的 DOCSIS Spec 規則判斷健康度
health = "unknown"
if 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
}
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: index.html
================================================================================
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Harmonic CMTS Manager</title>
<link rel="icon" type="image/png" href="/static/my_icon.png">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- 引入獨立的 CSS 樣式表 -->
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<!-- 1. 標題加上 id="app-title" -->
<h1 id="app-title" style="cursor: default;">🎙️ Harmonic CMTS Admin</h1>
<div class="global-settings">
<label><strong>🌐 目標設備:</strong></label>
<input type="text" id="cmtsHost" placeholder="IP 地址" value="10.14.110.4" style="width: 130px;">
<input type="text" id="cmtsUser" placeholder="帳號" value="admin" style="width: 100px;">
<input type="password" id="cmtsPass" placeholder="密碼" value="nsgadmin" style="width: 100px;">
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
<button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
<button onclick="disconnectWebSocket()" id="btnDisconnect" class="btn-modern btn-disconnect" style="display: none;">斷開連線</button>
<span id="wsStatus" style="color: #7f8c8d; font-size: 14px; font-weight: bold;">狀態:未連線</span>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('cli-tab', this)">💻 True SSH Terminal</button>
<button class="tab-btn" onclick="switchTab('query-tab', this)">🔍 CMTS 狀態查詢</button>
<button class="tab-btn" onclick="switchTab('config-tab', this)">🛠️ CMTS 設備配置</button>
<!-- 💡 新增:系統設定頁籤 -->
<button class="tab-btn" onclick="switchTab('settings-tab', this); openSystemSettings();">⚙️ 系統設定</button>
<!-- 💡 新增:備份與還原頁籤 -->
<button class="tab-btn" onclick="switchTab('backup-tab', this); loadBackupHistory();">💾 設備備份與還原</button>
</div>
<!-- Tab 1: True SSH Terminal -->
<div id="cli-tab" class="tab-content active">
<div class="control-group">
<label><strong>快捷指令:</strong></label>
<select id="fixedCmd">
<option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
<option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
</select>
<button onclick="injectCommand()" class="btn-modern btn-load">送出指令</button>
<div style="margin-left: auto; display: flex; gap: 8px;">
<button onclick="downloadTerminal()" class="btn-modern btn-slate">💾 下載紀錄</button>
</div>
</div>
<div id="terminal-container"></div>
</div>
<!-- Tab 2: CMTS 狀態查詢 -->
<div id="query-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
<div class="manager-container">
<div class="control-group" style="background: #ffffff; padding: 15px 25px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
<label style="font-weight: bold; color: #2c3e50; font-size: 16px; margin-right: 10px;">📌 選擇查詢任務:</label>
<select id="queryTask" onchange="switchQueryTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;">
<option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
<option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
</select>
</div>
<!-- 表單 1Cable 狀態綜合查詢 -->
<div id="form-cm-query" class="task-form active">
<h3 style="margin-top: 0; font-size: 18px; color: #2980b9; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">Cable 狀態綜合查詢 (show cable modem...)</h3>
<div class="form-grid">
<div class="form-row">
<label>目標設備 (Cable Modem MAC)</label>
<input type="text" id="queryTargetCm" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
</div>
<div class="form-row">
<label>查詢動作 (Action)</label>
<select id="queryTypeCm" onchange="toggleQueryInputs('Cm')">
<optgroup label="Cable Modem 查詢 (支援特定目標或全體)">
<option value="base">基本狀態 (show cable modem)</option>
<option value="cpe">CPE 資訊 (cpe)</option>
<option value="cpe_dhcp">CPE DHCP (cpe dhcp)</option>
<option value="cpe_ipv6">CPE IPv6 (cpe ipv6)</option>
<option value="bonding">綁定摘要 (bonding)</option>
<option value="bonding_ds">下行綁定 (bonding downstream)</option>
<option value="bonding_us">上行綁定 (bonding upstream)</option>
<option value="phy">實體層狀態 (phy)</option>
<option value="verbose">詳細資訊 (verbose)</option>
<option value="ofdm_profile">OFDM Profile (ofdm-profile)</option>
<option value="ofdma_profile">OFDMA Profile (ofdma-profile)</option>
<option value="dhcp_verbose">DHCP 詳細資訊 (dhcp verbose)</option>
<option value="service_flow">Service Flow (service-flow)</option>
<option value="service_flow_verbose">Service Flow 詳細 (service-flow verbose)</option>
<option value="qos">QoS 資訊 (qos)</option>
<option value="uptime">運行時間 (uptime)</option>
<option value="ugs">UGS 資訊 (ugs)</option>
<option value="cm_status">CM 狀態 (cm-status)</option>
</optgroup>
<optgroup label="全域狀態查詢 (不分特定目標)">
<option value="partial_mode">Partial Mode (partial-mode)</option>
<option value="hop">Cable Hop (show cable hop)</option>
<option value="flap_list">Flap List (show cable flap-list)</option>
<option value="flap_sum">Flap Summary (show cable flap-sum)</option>
</optgroup>
</select>
</div>
</div>
<div class="form-actions">
<button onclick="executeQuery('Cm')" class="btn-modern btn-load">執行查詢</button>
<div style="display: flex; align-items: center;">
<input type="checkbox" id="debugQueryCm" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
<label for="debugQueryCm" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
</div>
</div>
</div>
<!-- 表單 2RPD 狀態綜合查詢 -->
<div id="form-rpd-query" class="task-form">
<h3 style="margin-top: 0; font-size: 18px; color: #e67e22; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">RPD 狀態綜合查詢 (show cable rpd...)</h3>
<div class="form-grid">
<div class="form-row">
<label>目標設備 (RPD VC:VS)</label>
<input type="text" id="queryTargetRpd" placeholder="例: 13:0 (留白則查詢全體)">
</div>
<div class="form-row">
<label>查詢動作 (Action)</label>
<select id="queryTypeRpd" onchange="toggleQueryInputs('Rpd')">
<option value="rpd_base">基本狀態 (show cable rpd)</option>
<option value="rpd_verbose">詳細資訊 (verbose)</option>
<option value="rpd_ptp_time">PTP Time Property (ptp time-property)</option>
<option value="rpd_ptp_verbose">PTP 詳細資訊 (ptp verbose)</option>
<option value="rpd_counters_map">Counters Map (counters map)</option>
<option value="rpd_capabilities">能力資訊 (capabilities)</option>
<option value="rpd_video_counters">Video Counters (video-channel counters)</option>
<option value="rpd_env_temp">環境溫度 (environment temperature)</option>
<option value="rpd_env_volt">環境電壓 (environment voltage)</option>
<option value="rpd_session">Session (session)</option>
<option value="rpd_reset_history">重啟歷史 (reset-history)</option>
<option value="rpd_port_transceiver">光模組資訊 (port-transceiver)</option>
</select>
</div>
</div>
<div class="form-actions">
<button onclick="executeQuery('Rpd')" class="btn-modern btn-load">執行查詢</button>
<div style="display: flex; align-items: center;">
<input type="checkbox" id="debugQueryRpd" style="width: 18px; height: 18px; margin: 0; cursor: pointer;">
<label for="debugQueryRpd" style="cursor: pointer; color: #7f8c8d; margin-left: 8px; font-size: 14px;">🐞 顯示底層指令 (Debug)</label>
</div>
</div>
</div>
<!-- 表單 3CM 一鍵診斷中心 -->
<div id="form-cm-diagnostics" class="task-form">
<h3 style="margin-top: 0; font-size: 18px; color: #9b59b6; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">CM 深度診斷與 MER 分析</h3>
<!-- 搜尋區塊 -->
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
<label style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC</label>
<input type="text" id="diagMacInput" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
<button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
<span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
</div>
<!-- 結果顯示區塊 (預設隱藏) -->
<div id="diagResultArea" style="display: none; gap: 20px; flex-direction: column;">
<!-- 上半部:基本資訊與 PHY 狀態 -->
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<!-- 基本資訊卡片 -->
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #3498db; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📄 基本資訊</h4>
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d; width: 40%;">MAC Address</th><td id="diagResMac" style="font-weight: bold; color: #2c3e50;">-</td></tr>
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">IP Address</th><td id="diagResIp" style="color: #2980b9; font-family: monospace;">-</td></tr>
<tr style="border-bottom: 1px solid #ecf0f1;"><th style="padding: 8px 0; color: #7f8c8d;">State</th><td id="diagResState" style="font-weight: bold;">-</td></tr>
<tr><th style="padding: 8px 0; color: #7f8c8d;">CPE Count</th><td id="diagResCpe">-</td></tr>
</table>
</div>
<!-- PHY 狀態卡片 -->
<div style="flex: 1; min-width: 300px; background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #2ecc71; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; margin-bottom: 15px;">📡 TX / RX 綜合實體層狀態</h4>
<table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 14px;">
<tr style="border-bottom: 1px solid #ecf0f1;">
<th style="padding: 8px 0; color: #7f8c8d; width: 50%;">Avg TX Power (dBmV)</th>
<td id="diagResTx" style="font-weight: bold;">-</td>
</tr>
<tr>
<th style="padding: 8px 0; color: #7f8c8d;">Avg RX Power (dBmV)</th>
<td id="diagResRx" style="font-weight: bold;">-</td>
</tr>
</table>
<!-- 🌟 新增:上行通道標籤區塊 -->
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px dashed #bdc3c7;">
<div style="color: #7f8c8d; font-size: 13px; font-weight: bold; margin-bottom: 8px;">上行通道 SNR (dB) 分布:</div>
<div id="upstreamSnrContainer" style="display: flex; flex-wrap: wrap; gap: 8px;">
<!-- 動態生成標籤 -->
</div>
</div>
</div>
</div>
<!-- 下半部Chart.js MER 圖表 -->
<div style="background: #fff; padding: 15px 20px; border-radius: 8px; border: 1px solid #e2e8f0; border-top: 4px solid #9b59b6; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<h4 style="margin-top: 0; color: #2c3e50; display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
<span>📊 OFDM MER 分布圖 <span style="font-size: 13px; color: #7f8c8d; font-weight: normal;">(點擊頻道切換圖表,最低 MER ≥ 41dB 判定為優)</span></span>
<!-- 🌟 新增:動態 OFDM 頻道切換按鈕區塊 -->
<div id="ofdmChannelTabs" style="display: flex; gap: 10px; flex-wrap: wrap;">
<!-- 動態生成按鈕 -->
</div>
</h4>
<div style="position: relative; height: 280px; width: 100%;">
<canvas id="merChart"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tab 3: CMTS 設備配置 -->
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
<div class="manager-container">
<div class="control-group" style="background: #ffffff; padding: 10px 15px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">📌 選擇配置任務:</label>
<select id="configTask" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
<!-- 🌟 拆分為兩個獨立的選項 -->
<option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option>
<option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option>
<option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
</select>
<button id="btn-load-task" onclick="startConfigTask()" class="btn-modern btn-load" style="margin-left: 10px;" disabled>🚀 載入任務</button>
</div>
<!-- 表單 3MAC Domain 配置精靈 -->
<div id="form-bonding-config" class="task-form active">
<h3 style="margin-top: 0; font-size: 18px; color: #c0392b; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">⚠️ MAC Domain 狀態感知配置精靈</h3>
<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>
<select id="cfgMacDomain" style="width: 150px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
<option value="">請先點擊上方載入任務...</option>
</select>
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
</div>
<div id="macDomainConfigArea" style="display: none; margin-top: 20px;">
<!-- Common Settings -->
<h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4>
<div class="form-grid">
<div class="form-row"><label>IP Provisioning Mode</label><select id="g_ip_prov"><option value="alternate">alternate</option><option value="dual-stack">dual-stack</option><option value="ipv4-only">ipv4-only</option><option value="ipv6-only">ipv6-only</option></select></div>
<div class="form-row"><label>Diplexer Band Edge Control</label><select id="g_diplexer"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
<div class="form-row"><label>CM Battery Mode 3.1</label><select id="g_bat31"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
<div class="form-row"><label>CM Battery Mode 3.0</label><select id="g_bat30"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
<div class="form-row"><label>DOCSIS 4.0</label><select id="g_docsis40"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
<div class="form-row"><label>DS Dynamic Bonding Group</label><select id="g_ds_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
<div class="form-row"><label>US Dynamic Bonding Group</label><select id="g_us_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
</div>
<!-- [Basic] DS/US Channel Sets -->
<h4 style="color: #27ae60; border-left: 4px solid #27ae60; padding-left: 8px; margin-top: 30px;">[Basic] DS/US Channel Sets</h4>
<div class="form-grid">
<div class="form-row"><label>Admin State</label><select id="b_admin"><option value="up">up</option><option value="down">down</option></select></div>
<div class="form-row"><label>DS Primary Set (0..157)</label><input type="text" id="b_ds_pri" placeholder="例: 0-2"></div>
<div class="form-row"><label>DS Non-Primary Set (0..157)</label><input type="text" id="b_ds_non_pri" placeholder="例: 3-4"></div>
<div class="form-row"><label>US PHY Channel Set (0..255)</label><input type="text" id="b_us_phy" placeholder="例: 0-3"></div>
<div class="form-row"><label>DS OFDM Set (0..7)</label><input type="text" id="b_ds_ofdm" placeholder="例: 0"></div>
<div class="form-row"><label>US OFDMA Set (0..1)</label><input type="text" id="b_us_ofdma" placeholder="例: 0"></div>
</div>
<!-- [Static] Downstream Bonding Groups -->
<div id="section_group_ds" style="margin-top: 30px;">
<h4 style="color: #8e44ad; border-left: 4px solid #8e44ad; padding-left: 8px;">[Static] Downstream Bonding Groups</h4>
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
<label>選擇要編輯的 DS Group</label>
<select id="select_ds_group" onchange="loadDsGroupData()" style="width: 200px;"></select>
<input type="text" id="input_new_ds_group" placeholder="輸入新 Group 名稱 (例: D4A)" style="display: none; width: 200px;">
</div>
<div class="form-grid">
<div class="form-row"><label>Admin State</label><select id="ds_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
<div class="form-row"><label>Down Channel Set (0..157)</label><input type="text" id="ds_g_down" placeholder="例: 0-4"></div>
<div class="form-row"><label>OFDM Channel Set (0..7)</label><input type="text" id="ds_g_ofdm" placeholder="例: 0-1"></div>
<div class="form-row"><label>FDX OFDM Channel Set (0..7)</label><input type="text" id="ds_g_fdx" placeholder="例: 0-2"></div>
</div>
</div>
<!-- [Static] Upstream Bonding Groups -->
<div id="section_group_us" style="margin-top: 30px;">
<h4 style="color: #f39c12; border-left: 4px solid #f39c12; padding-left: 8px;">[Static] Upstream Bonding Groups</h4>
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
<label>選擇要編輯的 US Group</label>
<select id="select_us_group" onchange="loadUsGroupData()" style="width: 200px;"></select>
<input type="text" id="input_new_us_group" placeholder="輸入新 Group 名稱 (例: U4A)" style="display: none; width: 200px;">
</div>
<div class="form-grid">
<div class="form-row"><label>Admin State</label><select id="us_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
<div class="form-row"><label>US Channel Set</label><input type="text" id="us_g_us" placeholder="例: 0-3.0"></div>
<div class="form-row"><label>OFDMA Channel Set (0..1)</label><input type="text" id="us_g_ofdma" placeholder="例: 0"></div>
<div class="form-row"><label>FDX OFDMA Channel Set (0..5)</label><input type="text" id="us_g_fdx" placeholder="例: 0-5"></div>
</div>
</div>
<!-- CLI 預覽區塊 -->
<div id="cliPreviewContainer" style="display: none; background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 6px; margin-top: 30px; margin-bottom: 20px; font-family: monospace; white-space: pre-wrap;"></div>
<div class="form-actions">
<button id="btnRevert" onclick="revertFormChanges()" class="btn-modern btn-disconnect" style="margin-right: 15px;">🔄 復原重填</button>
<button onclick="generateMacDomainCLI()" class="btn-modern btn-scan">⚙️ 產生絕對路徑指令預覽</button>
<button id="btnDeployConfig" onclick="executeBondingConfig()" class="btn-modern btn-save" style="display: none;" disabled>🚀 正式套用設定</button>
</div>
</div>
</div>
<!-- 表單 4設備配置樹狀圖 (共用容器) -->
<div id="form-tree-config" class="task-form" style="display: none;">
<!-- 頂部:標題與按鈕區塊 (維持原樣) -->
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 15px; width: 100%;">
<div style="display: flex; align-items: center; gap: 15px;">
<h3 style="margin: 0; font-size: 16px; color: #2c3e50;">
<span id="tree-form-title">🌲 設備配置樹狀圖</span>
</h3>
<span id="scan-status" style="color: #7f8c8d; font-size: 14px; font-weight: normal;"></span>
<span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; font-weight: normal;">
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
</span>
</div>
<div style="display: flex; gap: 10px;">
<button id="btn-scan-visible" onclick="scanVisibleMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描局部缺失</button>
<button id="btn-clear-visible" onclick="clearVisibleCache()" class="btn-modern btn-clear" style="display: none;">清除局部快取</button>
<button id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描全域缺失</button>
<button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none;">清除全域快取</button>
</div>
</div>
<!-- 🌟 左右分屏容器 (關鍵修改align-items: stretch 讓左右強制等高) -->
<div id="split-container" style="display: flex; align-items: stretch; width: 100%; position: relative; gap: 10px;">
<!-- 左側:樹狀圖 (保留 max-height: 600px超過才出捲軸) -->
<div id="tree-container-running" class="tree-view-instance" style="flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
<span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
</div>
<div id="tree-container-full" class="tree-view-instance" style="display: none; flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
<span style="color: #7f8c8d;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>
</div>
<!-- 🖱️ 拖曳分隔線 (拔除寫死的 height: 400px) -->
<div id="drag-resizer" style="display: none; width: 12px; cursor: col-resize; flex-shrink: 0; align-items: center; justify-content: center; z-index: 10;" title="左右拖曳調整寬度">
<div style="width: 4px; height: 40px; background-color: #bdc3c7; border-radius: 2px;"></div>
</div>
<!-- 右側CLI 預覽與執行結果外框 -->
<div id="side-cli-preview" style="flex: 1; min-width: 300px; max-width: 50%; display: none; box-sizing: border-box;">
<!-- 🌟 內部黑底容器 (使用 flex column 與 height: 100% 完美填滿外框) -->
<div style="display: flex; flex-direction: column; height: 100%; background: #1e1e1e; padding: 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border: 1px solid #34495e; box-sizing: border-box;">
<!-- 頂部標題列 -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;">
<h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
<div style="display: flex; align-items: center; gap: 20px;">
<button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button>
<button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
<button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
</div>
</div>
<!-- 模式一:指令預覽框 (🌟 拔除 height: 400px改用 flex: 1 自動填滿) -->
<textarea id="side-cli-textarea" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; outline: none; box-sizing: border-box; white-space: pre; overflow-x: auto; overflow-y: auto; margin: 0; display: block; resize: none; min-height: 150px;"></textarea>
<!-- 模式二:執行結果框 (🌟 拔除 height: 400px改用 flex: 1 自動填滿) -->
<div id="side-execution-result" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; box-sizing: border-box; overflow-x: auto; overflow-y: auto; margin: 0; display: none; white-space: pre; min-height: 150px;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 💡 Tab 4: 系統設定 (System Settings) -->
<div id="settings-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe; text-align: left;">
<div class="manager-container" style="display: block; max-width: 100%;">
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block;">
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
⚙️ 全域系統設定 (God Mode Filters)
</h3>
<div style="background: #f8f9fa; border-left: 4px solid #3498db; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
請勾選您希望在「完整設備配置樹狀圖」中 <b>隱藏</b> 的設定項目。<br>
<span style="color: #7f8c8d; font-size: 14px;">💡 點擊下方按鈕載入設備實時配置,您可以深入展開並勾選任何層級的節點進行過濾。</span>
</p>
</div>
<!-- 🌟 新增:過濾器模式切換下拉選單 -->
<div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
<label style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器:</label>
<select id="filter-mode-select" onchange="switchFilterMode()" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 14px; background-color: #fcf3f2; color: #c0392b;">
<option value="running">Running 配置過濾器</option>
<option value="full">Full 配置過濾器</option>
</select>
<button onclick="loadRealConfigForFilters()" class="btn-modern btn-load">
📥 載入設備配置以設定過濾
</button>
<span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span>
</div>
<!-- 樹狀 Checkbox 容器 -->
<div id="filter-checkboxes" style="margin: 15px 0; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 500px; overflow-y: auto; display: none;">
</div>
<div style="margin-top: 20px; border-top: 1px solid #ecf0f1; padding-top: 20px;">
<button onclick="saveSystemSettings()" class="btn-modern btn-connect" style="padding: 10px 20px; font-size: 15px;">
💾 儲存並套用設定
</button>
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
</div>
</div>
</div>
</div>
<!-- 💡 Tab 5: 設備備份與還原 -->
<div id="backup-tab" class="tab-content" style="overflow-y: auto; background-color: #f5f7fa;">
<div class="manager-container">
<!-- 上半部:建立新快照 -->
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
<!-- 💡 修正:移除負 margin讓底線與內容邊界對齊 (如同系統設定頁籤) -->
<div style="display: flex; justify-content: flex-start; align-items: center; gap: 15px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px;">
<h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px;">
📸 建立新快照
</h3>
<button onclick="createSnapshot()" id="btnCreateSnapshot" class="btn-modern btn-save" style="padding: 8px 20px; font-size: 14px; background-color: #2980b9; border: none; box-shadow: 0 2px 4px rgba(41, 128, 185, 0.3);">
🚀 立即執行備份
</button>
<span id="backup-status-msg" style="color: #e67e22; font-size: 14px; display: none; font-weight: bold;">
⏳ 正在連線設備並抓取設定,請稍候...
</span>
</div>
<!-- 表單網格區 -->
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 15px;">
<div>
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
快照名稱 <span style="color: #e74c3c;">*</span>
</label>
<input type="text" id="snapshotName" placeholder="例如: 例行備份、升級前備份" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; transition: border-color 0.2s;">
</div>
<div>
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
配置類型
</label>
<select id="backupConfigType" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; background-color: #f8f9fa; cursor: pointer;">
<option value="running">Running Config (運行中配置)</option>
<option value="full">Full Config (完整配置)</option>
</select>
</div>
</div>
<!-- 描述區 -->
<div style="margin-bottom: 0;">
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
備份描述 <span style="color: #7f8c8d; font-weight: normal; font-size: 13px;">(選填)</span>
</label>
<input type="text" id="snapshotDescription" placeholder="請簡述此次備份的目的,例如:升級至 Unify FDD firmware驗收通過" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px;">
</div>
</div>
<!-- 下半部:歷史紀錄列表 -->
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0; display: block;">
<!-- 💡 修正:移除負 margin讓底線與內容邊界對齊 -->
<div style="display: flex; align-items: center; gap: 20px; border-bottom: 2px solid #ecf0f1; padding-bottom: 12px; margin-bottom: 20px; flex-wrap: wrap;">
<h3 style="margin: 0; font-size: 18px; color: #2c3e50; display: flex; align-items: center; gap: 8px; white-space: nowrap;">
📁 歷史備份紀錄
</h3>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="text" id="filter-keyword" placeholder="🔍 搜尋名稱或描述..." class="edit-input" style="width: 180px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onkeyup="applyBackupFilters()">
<select id="filter-type" class="edit-input" style="width: 110px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onchange="applyBackupFilters()">
<option value="">所有類型</option>
<option value="running">running</option>
<option value="full">full</option>
</select>
<!-- 💡 修正:回歸原生 type="date",交由系統決定語系顯示 -->
<div style="display: flex; align-items: center; gap: 5px; border-left: 1px solid #bdc3c7; padding-left: 10px; margin-left: 2px;">
<input type="date" id="filter-date-start" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
<span style="color: #7f8c8d; font-size: 13px;">至</span>
<input type="date" id="filter-date-end" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
</div>
<button onclick="loadBackupHistory()" class="btn-modern btn-slate" style="margin-left: 5px; padding: 8px 20px; font-size: 14px;">
🔄 重新整理
</button>
</div>
</div>
<!-- 表格區塊 -->
<table style="width: 100%; border-collapse: collapse; text-align: left;">
<thead>
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
<th style="padding: 10px; color: #34495e; width: 20%;">時間</th>
<th style="padding: 10px; color: #34495e; width: 25%;">快照名稱</th>
<th style="padding: 10px; color: #34495e; width: 25%;">描述</th>
<th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
<th style="padding: 10px; color: #34495e; text-align: right; width: 20%;">操作</th>
</tr>
</thead>
<tbody id="backup-history-tbody">
<tr>
<td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">請點擊「重新整理」載入歷史紀錄...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 獨立的彈出式輸出視窗 (Modal) -->
<div id="outputModal" class="modal-overlay">
<div class="modal-container">
<div class="modal-header">
<div class="modal-title" style="flex-grow: 1; display: flex; align-items: center;">
<span>📄 執行結果</span>
<span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal; margin-left: 10px; flex-grow: 1;"></span>
</div>
<div id="modal-action-container" style="display: flex; gap: 20px; align-items: center;">
<button id="btn-modal-close" class="btn-modern btn-disconnect" onclick="closeModal()">關閉視窗</button>
</div>
</div>
<div class="modal-body">
<pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>
</div>
</div>
</div>
<!-- 引入獨立的 JavaScript 邏輯 -->
<script type="module" src="/static/app.js"></script>
</body>
</html>