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

View File

@ -79,7 +79,8 @@
<div class="form-grid">
<div class="form-row">
<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 class="form-row">
<label>查詢動作 (Action)</label>
@ -128,7 +129,8 @@
<div class="form-grid">
<div class="form-row">
<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 class="form-row">
<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;">
<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>
<span id="diagStatusMsg" style="margin-left: 15px; font-weight: bold; color: #f39c12; display: none;">⏳ 正在透過 SSH 採集設備數據...</span>
</div>
@ -248,9 +251,8 @@
<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>
<input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
<datalist id="mac-domain-list"></datalist>
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
<span id="fetchStatus" style="margin-left: 10px; font-size: 14px; color: #7f8c8d;">請先讀取設備狀態...</span>
</div>

View File

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

View File

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

View File

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

View File

@ -18,34 +18,45 @@ export async function loadMacDomainList() {
const connInfo = getGlobalConnectionInfo();
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');
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>';
// 載入前:鎖定輸入框並更改提示
mdInput.disabled = true;
mdInput.value = '';
mdInput.placeholder = '⏳ 查詢設備中...';
if (mdDatalist) mdDatalist.innerHTML = '';
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
statusSpan.style.color = "#f39c12";
try {
const result = await apiGetMacDomainList(connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success' && result.data.length > 0) {
mdSelect.innerHTML = '';
result.data.forEach(md => {
const option = document.createElement('option');
option.value = md;
option.textContent = md;
mdSelect.appendChild(option);
});
// 載入成功:將資料塞入 datalist
if (mdDatalist) {
result.data.forEach(md => {
const option = document.createElement('option');
option.value = md;
mdDatalist.appendChild(option);
});
}
mdInput.placeholder = '例: 13:0/0.0 (可下拉或輸入)';
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
statusSpan.style.color = "#27ae60";
} else {
mdSelect.innerHTML = '<option value="">❌ 找不到資料</option>';
mdInput.placeholder = '❌ 找不到資料';
statusSpan.textContent = "無法解析清單,請確認設備狀態。";
statusSpan.style.color = "#e74c3c";
}
} catch (error) {
mdSelect.innerHTML = '<option value="">❌ 連線錯誤</option>';
mdInput.placeholder = '❌ 連線錯誤';
statusSpan.textContent = "連線失敗:" + error;
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; }
/* 網格系統:高空間利用率 */
.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 label { font-size: 12px; font-weight: bold; color: #34495e; }
.form-row input[type="text"], .form-row select { width: 100%; box-sizing: border-box; padding: 6px 8px; background-color: #f8f9fa; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 13px; }
.form-row select { padding-right: 30px; text-overflow: ellipsis; } /* 🌟 確保下拉箭頭有足夠空間,不會遮擋文字 */
.form-row input[type="text"]:focus, .form-row select:focus { outline: none; border-color: #2980b9; background-color: #fff; }
/* 表單底部操作區 */

View File

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

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
================================================================================
@ -226,7 +21,8 @@ import {
apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics
apiGetCmDiagnostics,
apiListCms, apiListRpds
} from './api.js';
// 3. 共用工具模組 (Utils)
@ -895,16 +691,28 @@ window.runCmDiagnostics = async function() {
channels.forEach((ch, index) => {
const chData = globalMerData[ch];
// 根據後端判斷的健康度上色 (>=41綠, 38~40橘, <=37紅)
let bgCol = '#bdc3c7';
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');
btn.className = 'btn-modern';
// 💡 拔除方正的 class改用純手工膠囊樣式
btn.style.boxSizing = 'border-box';
btn.style.cursor = 'pointer';
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';
// 標籤文字加入最低 MER 數值提示
btn.textContent = `${ch} (Min: ${chData.min_mer} dB)`;
@ -2095,712 +1903,3 @@ window.viewBackup = viewBackup;
window.deleteBackup = deleteBackup;
window.restoreBackup = restoreBackup;
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>