scm-harmonic-cmts-admin/target_code.txt

2807 lines
144 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

================================================================================
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
================================================================================
/* --- static/app.js --- */
// ============================================================================
// 📦 模組匯入區 (Imports)
// ============================================================================
// 1. 終端機模組 (Terminal)
import { initTerminal, connectWebSocket, disconnectWebSocket, injectCommand, downloadTerminal, isConnected } from './terminal.js';
// 2. API 網路請求模組 (API)
import {
apiGetFullConfig, apiClearCache, apiExecuteQuery,
apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics
} from './api.js';
// 3. 共用工具模組 (Utils)
import { getGlobalConnectionInfo } from './utils.js';
// 4. 樹狀圖 UI 模組 (Tree UI)
import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache } from './tree-ui.js';
// 5. 編輯模式與鎖定模組 (Edit Mode)
import {
releaseAllLocks, startEditFolder, startEditLeaf,
cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID, currentEditElementId
} from './edit-mode.js';
// 6. MAC Domain 配置模組 (MAC Domain)
import {
hasMacDomainData, resetMacDomainData, loadMacDomainList, fetchMacDomainConfig,
populateFormFromData, revertFormChanges, toggleGroupVisibility,
loadDsGroupData, loadUsGroupData, generateMacDomainCLI, executeBondingConfig
} from './mac-domain.js';
// ============================================================================
// 🌟 1. 全域生命週期、閒置偵測與 SSE 廣播監聽
// ============================================================================
let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
let globalSSE = null;
// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道
function initGlobalSSE(mode = 'running') {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE
if (globalSSE) globalSSE.close();
globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
globalSSE.onmessage = (event) => {
const data = JSON.parse(event.data);
const statusEl = document.getElementById('scan-status');
// 🌟 關鍵修復:只要收到「開始」或「進度」訊號,一律強制鎖定按鈕!(確保中途加入的使用者也會被鎖定)
if (data.event === 'scan_start' || data.event === 'progress') {
setAdminButtonsState(true, "⏳ 系統更新中...");
}
if (data.event === 'scan_start') {
if (statusEl) {
statusEl.innerHTML = `⏳ 系統正在背景更新快取,請稍候...`;
statusEl.style.color = "#f39c12";
}
}
else if (data.event === 'progress') {
if (statusEl) {
statusEl.innerHTML = `⏳ 背景掃描進度:<b>${data.current} / ${data.total}</b> (正在處理: ${data.current_path})`;
statusEl.style.color = "#f39c12";
}
}
else if (data.event === 'done') {
if (statusEl) {
statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 5000);
}
// 掃描完成才解鎖
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
}
else if (data.event === 'error') {
if (statusEl) {
statusEl.innerHTML = `❌ 錯誤: ${data.message}`;
statusEl.style.color = "#e74c3c";
}
// 發生錯誤也要解鎖
setAdminButtonsState(false);
localStorage.removeItem('scanLockUntil');
}
};
}
// 🌟 關鍵修復:當使用者按 F5 重整或關閉網頁時,主動切斷 SSE 連線
window.addEventListener('beforeunload', () => {
if (globalSSE) {
globalSSE.close();
globalSSE = null;
}
});
// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」(加入 Host 隔離)
let activeLockState = new Set();
let lockPollingTimer = null;
let currentPollingHost = null; // 記錄當前輪詢的設備 IP
window.globalActiveLocks = {}; // 🌟 升級為二維結構:{ "10.14.110.4": { "alias": {...} } }
window.recentlyReleasedLocks = {}; // 🌟 新增:防閃爍冷卻表 (Optimistic UI Cooldown)
function startLockStatusPolling() {
if (lockPollingTimer) clearInterval(lockPollingTimer);
lockPollingTimer = setInterval(async () => {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
const host = connInfo.host;
// 🛡️ 跨設備防呆:如果使用者切換了 IP必須清空舊設備的鎖定記憶防止幽靈解鎖
if (currentPollingHost !== host) {
activeLockState.clear();
currentPollingHost = host;
}
try {
const result = await apiGetLockStatus(host);
if (result.status === 'success') {
const currentLocks = result.data;
window.globalActiveLocks[host] = currentLocks;
const newLockState = new Set();
const now = Date.now();
// 🎯 任務 1處理「新增鎖定」或「持續鎖定」的節點
for (const [path, lockInfo] of Object.entries(currentLocks)) {
const lockKey = `${host}@@${path}`;
// 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒)
if (window.recentlyReleasedLocks[lockKey]) {
if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
continue; // 跳過,不當作鎖定
} else {
delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶
}
}
newLockState.add(lockKey); // 記憶體加入 Host 標籤
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
targetBtns.forEach(btn => {
// 略過目前正在編輯的那個實體按鈕
if (btn.id === `edit-btn-${currentEditElementId}`) return;
if (btn.innerText !== "🔒") {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
const btnPath = btn.getAttribute('data-path');
if (btnPath === path) {
// 判斷是別人鎖的,還是自己在另一個視圖鎖的
if (lockInfo.user_id !== SESSION_USER_ID) {
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
} else {
btn.title = `🔒 您正在另一個視圖編輯此項目`;
}
} else {
// 子節點被父節點鎖定
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
}
btn.innerText = "🔒";
}
});
}
// 🔓 任務 2處理「解除鎖定」的節點
activeLockState.forEach(oldKey => {
const [oldHost, oldPath] = oldKey.split('@@');
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
if (oldHost === host && !newLockState.has(oldKey)) {
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
targetBtns.forEach(btn => {
if (btn.innerText === "🔒") {
const btnPath = btn.getAttribute('data-path');
let stillLockedByOther = false;
// 檢查自己是否還在鎖定清單中
if (currentLocks[btnPath]) {
stillLockedByOther = true;
} else {
// 檢查是否有其他父節點鎖定它
for (const lockedPath of Object.keys(currentLocks)) {
if (btnPath.startsWith(lockedPath + "::")) {
stillLockedByOther = true;
break;
}
}
}
if (!stillLockedByOther) {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
}
});
}
});
// 💾 任務 3更新特務的記憶
activeLockState = newLockState;
}
} catch (error) {
console.warn("獲取鎖定狀態失敗:", error);
}
}, 15000);
}
// 頁面載入與縮放初始化
window.onload = () => {
initTerminal();
resetIdleTimer(); // 頁面載入時啟動閒置計時器
initGlobalSSE(); // 啟動全域廣播監聽
startLockStatusPolling(); // 啟動鎖定狀態輪詢
// 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步
const configTaskSelect = document.getElementById('configTask');
if (configTaskSelect) {
configTaskSelect.dispatchEvent(new Event('change'));
}
};
window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); };
// 閒置計時器重置邏輯 (超時將自動釋放所有編輯鎖定)
function resetIdleTimer() {
clearTimeout(idleTimer);
idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT);
}
// 🌟 效能急救:節流閥 (Throttle) 機制
let throttleTimer = false;
function throttledReset() {
if (!throttleTimer) {
resetIdleTimer();
throttleTimer = true;
setTimeout(() => { throttleTimer = false; }, 2000); // 每 2 秒最多只觸發一次重設
}
}
window.addEventListener('mousemove', throttledReset);
window.addEventListener('keydown', throttledReset);
window.addEventListener('click', throttledReset);
// ============================================================================
// 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State)
// ============================================================================
// 切換主頁籤 (終端機 / 設備查詢 / 設備配置 / 系統設定)
function switchTab(tabId, element) {
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
const targetTab = document.getElementById(tabId);
if (targetTab) targetTab.classList.add('active');
if (element) element.classList.add('active');
if(tabId === 'cli-tab' && typeof fitAddon !== 'undefined' && fitAddon) setTimeout(() => fitAddon.fit(), 50);
}
// 切換「設備查詢」內的子任務表單
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');
}
// 🌟 新增一個變數來記錄上一次的樹狀圖模式
let lastTreeMode = null;
// 切換「設備配置」內的子任務表單
function switchConfigTask() {
document.querySelectorAll('#config-tab .task-form').forEach(form => {
form.classList.remove('active');
form.style.display = 'none';
});
const selectedTaskId = document.getElementById('configTask').value;
let targetFormId = selectedTaskId;
if (selectedTaskId === 'form-running-config' || selectedTaskId === 'form-full-config') {
targetFormId = 'form-tree-config';
}
const targetForm = document.getElementById(targetFormId);
if (targetForm) {
targetForm.classList.add('active');
targetForm.style.display = 'block';
const titleSpan = document.getElementById('tree-form-title');
const currentMode = selectedTaskId === 'form-full-config' ? 'full' : 'running';
if (titleSpan) {
titleSpan.textContent = currentMode === 'running'
? '🌳 設備配置樹狀圖 (running-config)'
: '🌳 完整設備配置樹狀圖 (full configuration)';
}
if (targetFormId === 'form-tree-config') {
// 🌟 魔法所在:純視覺切換,不銷毀資料!
const runningContainer = document.getElementById('tree-container-running');
const fullContainer = document.getElementById('tree-container-full');
if (runningContainer) runningContainer.style.display = currentMode === 'running' ? 'block' : 'none';
if (fullContainer) fullContainer.style.display = currentMode === 'full' ? 'block' : 'none';
if (lastTreeMode !== currentMode) {
const statusEl = document.getElementById('scan-status');
if (statusEl) statusEl.textContent = '';
// 🌟 新增:如果使用者在載入途中切換模式,強制把沙漏文字隱藏!
const loadingMsg = document.getElementById('loading-message');
if (loadingMsg) loadingMsg.style.display = 'none';
lastTreeMode = currentMode;
}
}
initGlobalSSE(currentMode);
}
}
// 啟動配置任務 (樹狀圖載入 或 MAC Domain 精靈)
function startConfigTask() {
if (!isConnected) {
alert("請先點擊畫面上方的「連線至 CMTS」成功後再執行載入任務");
return;
}
switchConfigTask();
const selectedTaskId = document.getElementById('configTask').value;
if (selectedTaskId === 'form-bonding-config') {
if (hasMacDomainData() && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) return;
resetAllManagerData();
loadMacDomainList();
}
// 🌟 修正:正確的括號閉合,並根據下拉選單傳遞 configType
else if (selectedTaskId === 'form-running-config') {
fetchFullConfig('running');
}
else if (selectedTaskId === 'form-full-config') {
fetchFullConfig('full'); // 🌟 補上 full 參數
}
}
// 切換查詢輸入框的啟用狀態 (全域指令不需輸入目標)
function toggleQueryInputs(mode) {
const type = document.getElementById('queryType' + mode).value;
const targetInput = document.getElementById('queryTarget' + mode);
const globalTypes = ['partial_mode', 'hop', 'flap_list', 'flap_sum'];
if (globalTypes.includes(type)) {
targetInput.disabled = true;
targetInput.style.backgroundColor = '#e8ecef';
targetInput.placeholder = "此查詢為全域指令,不需輸入目標";
targetInput.value = "";
} else {
targetInput.disabled = false;
targetInput.style.backgroundColor = '#f8f9fa';
targetInput.placeholder = mode === 'Cm' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例: 13:0 (留白則查詢全體)";
}
}
// 重置所有管理介面的資料與 UI 狀態
function resetAllManagerData() {
resetMacDomainData();
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
clearTreeCache();
// 🛡️ [跨設備防護] 切換設備時,清空鎖定輪詢的記憶體
if (typeof activeLockState !== 'undefined' && activeLockState.clear) {
activeLockState.clear();
}
const configArea = document.getElementById('macDomainConfigArea');
if (configArea) configArea.style.display = 'none';
const previewEl = document.getElementById('cliPreviewContainer');
if (previewEl) {
previewEl.textContent = '';
previewEl.style.display = 'none';
}
const deployBtn = document.getElementById('btnDeployConfig');
if (deployBtn) {
deployBtn.style.display = 'none';
deployBtn.disabled = true;
}
const mdSelect = document.getElementById('cfgMacDomain');
if (mdSelect) mdSelect.innerHTML = '<option value="">請先點擊上方載入任務...</option>';
const fetchStatus = document.getElementById('fetchStatus');
if (fetchStatus) {
fetchStatus.textContent = "請先讀取設備狀態...";
fetchStatus.style.color = "#7f8c8d";
}
document.getElementById('queryTargetCm').value = '';
document.getElementById('queryTargetRpd').value = '';
// 🌟 新增:切換設備連線後,自動重整備份歷史紀錄
if (typeof loadBackupHistory === 'function') {
loadBackupHistory();
}
}
// ============================================================================
// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config)
// ============================================================================
// 執行綜合查詢 (CM / RPD) - 🌟 導入前端模擬動態訊息流
async function executeQuery(mode) {
if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕");
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
const queryType = document.getElementById('queryType' + mode).value;
const target = document.getElementById('queryTarget' + mode).value.trim();
const isDebug = document.getElementById('debugQuery' + mode).checked;
openModal(connInfo.host);
const outputEl = document.getElementById('modalOutput');
// 🌟 模擬動態訊息流邏輯
const states = [
`🔌 正在建立 SSH 連線至 ${connInfo.host}...`,
`📥 正在向設備發送查詢指令...`,
`🧩 正在等待設備回傳資料...`
];
let progressIdx = 0;
outputEl.innerHTML = `<span id="query-progress-text" style="color: #f39c12; font-weight: bold;">${states[0]}</span>`;
const progressInterval = setInterval(() => {
progressIdx++;
if (progressIdx < states.length) {
const textEl = document.getElementById('query-progress-text');
if (textEl) textEl.innerText = states[progressIdx];
}
}, 1500); // 每 1.5 秒切換一次狀態
try {
const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass);
clearInterval(progressInterval); // 收到結果,停止輪播
if (result.status === 'success') {
outputEl.style.color = "#e0e0e0";
outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data;
} else {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`;
}
} catch (error) {
clearInterval(progressInterval); // 發生錯誤,停止輪播
outputEl.style.color = "#e74c3c";
outputEl.textContent = `連線失敗: ${error}`;
}
}
// 載入完整設備配置並生成樹狀圖,預設為 running (平順過渡終極版 🚀)
async function fetchFullConfig(configType = 'running') {
const loadingMsg = document.getElementById('loading-message');
const treeContainer = document.getElementById(`tree-container-${configType}`);
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
if (loadingMsg) {
loadingMsg.style.display = 'inline-block';
loadingMsg.style.color = '#f39c12'; // 🌟 確保初始狀態為橘色
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
}
if (treeContainer) treeContainer.innerHTML = '';
let needAutoScan = false;
let progressInterval = null;
try {
try {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') {
const currentVersion = versionRes.version;
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
const optData = await optRes.json();
const cachedVersion = optData.__metadata__?.cmts_version;
if (cachedVersion && cachedVersion !== currentVersion) {
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本${currentVersion}\n快取紀錄版本${cachedVersion}\n\n為確保指令正確系統強烈建議清空舊版快取。是否立即清空`);
if (doClear) {
const allKeys = Object.keys(optData);
await apiClearCache(connInfo.host, allKeys, configType);
needAutoScan = true;
}
}
}
} catch (vErr) {
console.warn("版本檢查失敗,略過", vErr);
}
const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`);
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let finalTreeData = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
progress += (95 - progress) * 0.06;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
}
}, 500);
} else {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
loadingMsg.style.color = "#27ae60";
}
await new Promise(resolve => setTimeout(resolve, 600));
if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 🌟 恢復橘色
}
if (loadingMsg) {
loadingMsg.style.color = "#f39c12"; // 🌟 確保橘色
loadingMsg.innerHTML = data.message;
}
}
}
else if (data.status === 'success') {
finalTreeData = data.data;
}
else if (data.status === 'error') {
throw new Error(data.message);
}
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
}
if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。");
window.currentTreeData = finalTreeData;
const currentSelectedTask = document.getElementById('configTask').value;
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
if (currentSelectedTask !== expectedTask) return;
if (treeContainer) {
treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
if (loadingMsg) loadingMsg.style.display = 'none';
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
if (isSomeoneScanning) {
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
lockScanButton(60000);
} else {
enableGlobalScanButton();
if (needAutoScan) {
setTimeout(() => scanGlobalMissingOptions(configType), 500);
}
}
}
} catch (error) {
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 連線或解析失敗: ${error.message}</div>`;
} finally {
if (progressInterval) clearInterval(progressInterval);
if (loadingMsg) {
loadingMsg.style.display = 'none';
loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘色
}
}
}
// ============================================================================
// 🌟 CM 一鍵診斷中心邏輯 (多頻道切換與紅綠燈實作)
// ============================================================================
let merChartInstance = null;
let globalMerData = {}; // 暫存所有頻道的 MER 資料供切換使用
window.runCmDiagnostics = async function() {
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
const macInput = document.getElementById('diagMacInput').value.trim();
if (!macInput) return alert("請輸入目標 CM 的 MAC Address");
const btn = document.getElementById('btnRunDiag');
const msg = document.getElementById('diagStatusMsg');
const resultArea = document.getElementById('diagResultArea');
btn.disabled = true;
btn.style.opacity = '0.7';
msg.style.display = 'inline-block';
resultArea.style.display = 'none';
try {
const result = await apiGetCmDiagnostics(connInfo.host, connInfo.user, connInfo.pass, macInput);
if (result.status === 'success') {
const data = result.data;
// 1. 填入基本資訊
document.getElementById('diagResMac').textContent = data.mac;
document.getElementById('diagResIp').textContent = data.ip || 'N/A';
document.getElementById('diagResCpe').textContent = data.cpe_count;
const stateEl = document.getElementById('diagResState');
stateEl.textContent = data.state || 'N/A';
stateEl.style.color = (data.state && data.state.toLowerCase().includes('online')) ? '#27ae60' : '#e74c3c';
document.getElementById('diagResTx').textContent = data.phy.tx_power !== null ? data.phy.tx_power : 'N/A';
document.getElementById('diagResRx').textContent = data.phy.rx_power !== null ? data.phy.rx_power : 'N/A';
// 2. 渲染上行 SNR (Upstreams) 標籤群
const usContainer = document.getElementById('upstreamSnrContainer');
usContainer.innerHTML = '';
if (data.upstreams && data.upstreams.length > 0) {
data.upstreams.forEach(us => {
let color = '#e74c3c'; // 預設紅 (差: < 30)
if (us.snr >= 35) color = '#27ae60'; // 綠 (優: >= 35)
else if (us.snr >= 30) color = '#f39c12'; // 橘 (尚可: 30~34.9)
usContainer.innerHTML += `
<div style="background-color: ${color}; color: white; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; box-shadow: 0 1px 3px rgba(0,0,0,0.2);">
${us.channel} : ${us.snr}
</div>
`;
});
} else {
usContainer.innerHTML = '<span style="color: #bdc3c7; font-size: 13px;">無資料</span>';
}
// 3. 處理 OFDM MER 頻道按鈕與圖表
globalMerData = data.mer_channels;
const tabsContainer = document.getElementById('ofdmChannelTabs');
tabsContainer.innerHTML = '';
const channels = Object.keys(globalMerData);
if (channels.length > 0) {
channels.forEach((ch, index) => {
const chData = globalMerData[ch];
// 根據後端判斷的健康度上色 (>=41綠, 38~40橘, <=37紅)
let bgCol = '#bdc3c7';
if (chData.health === 'good') bgCol = '#27ae60';
else if (chData.health === 'warning') bgCol = '#f39c12';
else if (chData.health === 'critical') bgCol = '#e74c3c';
const btn = document.createElement('button');
btn.className = 'btn-modern';
btn.style.backgroundColor = bgCol;
btn.style.opacity = index === 0 ? '1' : '0.5'; // 預設突顯第一個
btn.style.border = index === 0 ? '2px solid #2c3e50' : '2px solid transparent';
// 標籤文字加入最低 MER 數值提示
btn.textContent = `${ch} (Min: ${chData.min_mer} dB)`;
btn.onclick = () => {
// 重置所有按鈕外觀
Array.from(tabsContainer.children).forEach(b => {
b.style.opacity = '0.5';
b.style.border = '2px solid transparent';
});
// 突顯當前點擊的按鈕
btn.style.opacity = '1';
btn.style.border = '2px solid #2c3e50';
// 重新繪圖
renderMerChart(ch, chData.histogram);
};
tabsContainer.appendChild(btn);
});
// 預設渲染第一個頻道的圖表
renderMerChart(channels[0], globalMerData[channels[0]].histogram);
} else {
tabsContainer.innerHTML = '<span style="color: #e74c3c; font-size: 14px; font-weight: bold;">⚠️ 設備無回傳任何 OFDM 頻道 MER 數據</span>';
renderMerChart('無資料', {});
}
resultArea.style.display = 'flex';
}
} catch (error) {
alert(`❌ 診斷失敗: ${error.message}`);
} finally {
btn.disabled = false;
btn.style.opacity = '1';
msg.style.display = 'none';
}
};
function renderMerChart(channelName, histogramData) {
if (merChartInstance) {
merChartInstance.destroy();
}
const ctx = document.getElementById('merChart').getContext('2d');
if (!histogramData || Object.keys(histogramData).length === 0) {
merChartInstance = new Chart(ctx, {
type: 'bar',
data: { labels: ['無資料'], datasets: [{ label: 'Subcarriers', data: [0] }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: '無法取得 MER 數據' } } }
});
return;
}
const labels = Object.keys(histogramData).sort((a, b) => parseInt(a) - parseInt(b));
const dataPoints = labels.map(label => histogramData[label]);
merChartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: labels.map(l => `${l} dB`),
datasets: [{
label: 'Subcarriers 數量',
data: dataPoints,
backgroundColor: 'rgba(155, 89, 182, 0.6)',
borderColor: 'rgba(142, 68, 173, 1)',
borderWidth: 1,
borderRadius: 4,
hoverBackgroundColor: 'rgba(155, 89, 182, 0.9)'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Subcarriers 數量', font: { weight: 'bold' } },
grid: { color: '#ecf0f1' }
},
x: {
title: { display: true, text: 'MER (dB)', font: { weight: 'bold' } },
grid: { display: false }
}
},
plugins: {
title: {
display: true,
text: `顯示中頻道: ${channelName}`,
font: { size: 15, weight: 'bold' },
color: '#2c3e50'
},
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(44, 62, 80, 0.9)',
titleFont: { size: 14 },
bodyFont: { size: 14 },
padding: 10,
callbacks: {
label: function(context) {
return ` ${context.raw} 個 Subcarriers`;
}
}
}
}
}
});
}
// ============================================================================
// 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
// ============================================================================
// 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數
async function enhanceInputWithOptions(path, elementId, mode = 'running') {
// 🌟 1. 取得當前連線的 IP
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
try {
// 🌟 2. GET 請求:網址加上 host 參數
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
const optData = await optRes.json();
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) {
const container = document.getElementById(`container-${elementId}`);
const input = container.querySelector('.edit-input');
if (input) {
const listId = `dl-${elementId}`;
input.setAttribute('list', listId);
let dataList = document.getElementById(listId);
if (!dataList) {
dataList = document.createElement('datalist');
dataList.id = listId;
container.appendChild(dataList);
}
dataList.innerHTML = leafCache.options.map(opt => `<option value="${opt}">`).join('');
}
} else {
// 🌟 3. POST 請求:把 host 塞進 body 裡面
fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey], config_type: mode })
});
}
} catch (e) {
console.warn("選項外掛載入失敗,維持純文字輸入", e);
}
}
// ============================================================================
// 🌟 核心功能:執行掃描 (廣播升級版),加上 mode 參數
// ============================================================================
// 🌟 新增:遞迴提取所有快取 Key 的輔助函數 (不依賴網頁 DOM)
function extractAllCacheKeys(node, path = '') {
let keys = [];
for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key;
if (typeof value === 'object' && value !== null) {
keys = keys.concat(extractAllCacheKeys(value, currentPath));
} else {
const origVal = (value === null ? '' : value).toString();
let cacheKey = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (currentPath.endsWith('::no')) {
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
}
keys.push(cacheKey);
}
}
return keys;
}
async function performScan(isGlobal, mode = 'running') {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
if (isSomeoneScanning) {
alert("目前已有其他使用者正在執行掃描,請稍候共享更新結果!");
return;
}
const treeContainer = document.getElementById(`tree-container-${mode}`);
if (!treeContainer) return;
const statusEl = document.getElementById('scan-status');
try {
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
const optData = await optRes.json();
const pathsToSync = [];
if (isGlobal) {
// 🌟 全域掃描:直接從原始 JSON 資料遞迴提取,不依賴 DOM
if (!window.currentTreeData) return alert("請先載入設備配置!");
const allKeys = extractAllCacheKeys(window.currentTreeData);
allKeys.forEach(cacheKey => {
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
pathsToSync.push(cacheKey);
}
});
} else {
// 🌟 局部掃描:維持原樣,只抓畫面上看得到的 (已展開的)
const allContainers = treeContainer.querySelectorAll('.leaf-container');
allContainers.forEach(container => {
const isHiddenByFolder = container.closest('details:not([open])') !== null;
if (!isHiddenByFolder) {
const path = container.getAttribute('data-path');
if (path) {
const origVal = container.getAttribute('data-original') || '';
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
pathsToSync.push(cacheKey);
}
}
}
});
}
if (pathsToSync.length === 0) {
statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項,無需掃描!" : "✅ 局部展開的欄位都已有最新選項,無需掃描!";
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 3000);
return;
}
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, leaf_paths: pathsToSync, config_type: mode })
});
const result = await response.json();
if (result.status === 'busy') {
alert(result.message);
}
} catch (error) {
console.error("掃描請求發送失敗:", error);
statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。";
statusEl.style.color = "#e74c3c";
}
}
// ============================================================================
// 🌟 核心功能:清除快取 (廣播升級版),加上 mode 參數
// ============================================================================
async function performClear(isGlobal, mode = 'running') {
// 🌟 1. 取得當前連線的 IP
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
// 🌟 2. 檢查掃描狀態時,傳入 host
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
if (isSomeoneScanning) {
alert("⚠️ 系統正在進行全域選項掃描更新中!\n為避免資料衝突請稍候掃描完成再執行清除動作。");
return;
}
let pathsToClear = [];
if (isGlobal) {
try {
// 🌟 3. GET 請求:網址加上 host 參數
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
const optData = await optRes.json();
pathsToClear = Object.keys(optData).filter(k => k !== '__metadata__');
} catch (e) {
console.error(e);
return alert("❌ 無法取得全域快取清單。");
}
} else {
const treeContainer = document.getElementById(`tree-container-${mode}`);
const elements = treeContainer ? treeContainer.querySelectorAll('.leaf-container') : [];
elements.forEach(el => {
const isHiddenByFolder = el.closest('details:not([open])') !== null;
if (!isHiddenByFolder) {
const path = el.getAttribute('data-path');
if (path) {
const cacheKey = path.replace(/::/g, ' ');
if (!pathsToClear.includes(cacheKey)) pathsToClear.push(cacheKey);
}
}
});
}
if (pathsToClear.length === 0) return alert(isGlobal ? "目前沒有任何快取可以清除!" : "局部沒有展開的選項可以清除!\n請先展開您想重抓的資料夾。");
const msg = isGlobal
? `確定要清除「全域」共 ${pathsToClear.length} 個選項的快取嗎?\n(這將會清空所有已儲存的下拉選單資料)`
: `確定要清除局部 ${pathsToClear.length} 個選項的快取嗎?\n(清除後將自動為您重新掃描)`;
if (!confirm(msg)) return;
try {
// 🌟 4. 呼叫清除 API 時,傳入 host
const result = await apiClearCache(connInfo.host, pathsToClear, mode);
const statusEl = document.getElementById('scan-status');
if (statusEl) {
statusEl.textContent = `✅ 成功清除 ${result.cleared_count} 筆快取!準備重新掃描...`;
statusEl.style.color = "#27ae60";
}
// 清除完畢後,自動呼叫簡化版的 performScan
await performScan(isGlobal, mode);
} catch (error) {
console.error("清除快取失敗:", error);
alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。");
}
}
// 綁定給 HTML 呼叫的 4 個獨立函數
function scanVisibleMissingOptions() {
performScan(false);
}
function scanGlobalMissingOptions() {
// 🌟 動態抓取當前選擇的模式,傳給 performScan
const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
performScan(true, mode);
}
function clearVisibleCache() {
performClear(false);
}
function clearGlobalCache() {
// 🌟 動態抓取當前選擇的模式,傳給 performClear
const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
performClear(true, mode);
}
// ============================================================================
// 🌟 5. 系統設定與過濾器 (System Settings & Filters)
// ============================================================================
let currentHiddenKeys = [];
// 🌟 新增:切換過濾器模式時觸發
async function switchFilterMode() {
const container = document.getElementById('filter-checkboxes');
if (container) container.style.display = 'none'; // 先隱藏樹狀圖,強迫使用者重新載入
await openSystemSettings();
}
// 開啟系統設定並讀取目前的過濾器清單
async function openSystemSettings() {
// 🌟 動態抓取過濾器模式
const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running';
try {
const result = await apiGetTreeFilters(mode);
if (result.status === 'success') currentHiddenKeys = result.data;
} catch (error) {
console.error("讀取設定失敗:", error);
}
}
// 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API)
async function loadRealConfigForFilters() {
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!");
const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running';
const loadingMsg = document.getElementById('filter-loading-msg');
const container = document.getElementById('filter-checkboxes');
loadingMsg.style.display = 'inline-block';
loadingMsg.style.color = '#f39c12'; // 🌟 確保橘色
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
container.style.display = 'none';
let progressInterval = null; // 🌟 新增:進度條計時器
try {
const url = `/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let finalTreeData = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
// 🌟 導入百分比動畫邏輯
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
progress += (95 - progress) * 0.06;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (${Math.round(progress)}%)...`;
}
}, 500);
} else {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (100%) - 下載完成!`;
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色
}
await new Promise(resolve => setTimeout(resolve, 600));
if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 恢復橘色
}
if (loadingMsg) {
loadingMsg.style.color = '#f39c12';
loadingMsg.innerHTML = data.message;
}
}
}
else if (data.status === 'success') {
finalTreeData = data.data;
}
else if (data.status === 'error') {
throw new Error(data.message);
}
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
}
if (finalTreeData) {
container.style.display = 'block';
container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode);
} else {
throw new Error("未收到完整的配置資料");
}
} catch (error) {
container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線或解析失敗: ${error.message}</div>`;
} finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
loadingMsg.style.display = 'none';
}
}
// 當點擊「父節點」時:同步勾選/取消所有子節點
function toggleChildCheckboxes(parentCheckbox) {
const details = parentCheckbox.closest('details');
if (details) {
const childCheckboxes = details.querySelectorAll('.filter-children-container .filter-checkbox');
childCheckboxes.forEach(cb => cb.checked = parentCheckbox.checked);
}
updateParentCheckboxState(parentCheckbox);
}
// 當點擊「子節點」時:往上檢查父節點是否該被勾選
function updateParentCheckboxState(element) {
const parentDetails = element.closest('.filter-children-container')?.closest('details');
if (!parentDetails) return;
const parentCheckbox = parentDetails.querySelector('summary .filter-checkbox');
const childCheckboxes = parentDetails.querySelectorAll('.filter-children-container .filter-checkbox');
if (parentCheckbox && childCheckboxes.length > 0) {
const allChecked = Array.from(childCheckboxes).every(cb => cb.checked);
parentCheckbox.checked = allChecked;
updateParentCheckboxState(parentCheckbox);
}
}
// 儲存系統設定 (過濾器)
async function saveSystemSettings() {
// 🌟 動態抓取過濾器模式
const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running';
const checkboxes = document.querySelectorAll('.filter-checkbox:checked');
const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value);
try {
const result = await apiSaveTreeFilters(selectedHiddenKeys, mode);
if (result.status === 'success') {
const statusText = document.getElementById('settings-status');
statusText.style.display = 'inline-block';
currentHiddenKeys = selectedHiddenKeys;
setTimeout(() => { statusText.style.display = 'none'; }, 3000);
}
} catch (error) {
console.error("儲存設定失敗:", error);
alert("儲存設定時發生錯誤!");
}
}
// ============================================================================
// 🌟 新增:設備備份與還原 (Backup & Restore)
// ============================================================================
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
let allBackupsData = [];
// 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
async function createSnapshot() {
if (!isConnected) {
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)");
}
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
const snapshotName = document.getElementById('snapshotName').value.trim();
if (!snapshotName) return alert("請輸入快照名稱!");
const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || "";
const configType = document.getElementById('backupConfigType').value;
const btn = document.getElementById('btnCreateSnapshot');
const msg = document.getElementById('backup-status-msg');
btn.disabled = true;
msg.style.display = 'inline-block';
msg.style.color = '#f39c12'; // 🌟 確保橘色
msg.innerHTML = '⏳ 準備連線至設備...';
let progressInterval = null; // 🌟 新增:進度條計時器
try {
const response = await fetch('/api/v1/backups/snapshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: connInfo.host,
username: connInfo.user,
password: connInfo.pass,
snapshot_name: snapshotName,
description: snapshotDescription,
config_type: configType
})
});
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let isSuccess = false;
let finalMessage = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
// 🌟 導入百分比動畫邏輯
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
progress += (95 - progress) * 0.06;
if (msg) {
msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
}
}, 500);
} else {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
if (msg) {
msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
msg.style.color = "#27ae60"; // 瞬間變綠色
}
await new Promise(resolve => setTimeout(resolve, 600));
if (msg) msg.style.color = "#f39c12"; // 恢復橘色
}
if (msg) {
msg.style.color = '#f39c12';
msg.innerHTML = data.message;
}
}
} else if (data.status === 'success') {
isSuccess = true;
finalMessage = data.message;
} else if (data.status === 'error') {
throw new Error(data.message);
}
} catch (e) {
if (e.message) throw e;
console.warn("解析串流 JSON 失敗:", line);
}
}
}
if (isSuccess) {
alert(`✅ ${finalMessage}`);
document.getElementById('snapshotName').value = '';
if (document.getElementById('snapshotDescription')) {
document.getElementById('snapshotDescription').value = '';
}
loadBackupHistory();
}
} catch (error) {
alert(`❌ 發生錯誤: ${error.message}`);
} finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
btn.disabled = false;
msg.style.display = 'none';
}
}
// 2. 載入歷史紀錄列表 (維持 IP 綁定,避免後端報錯)
async function loadBackupHistory() {
// 🌟 補回取得當前畫面上設定的 IP
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
const tbody = document.getElementById('backup-history-tbody');
if (!tbody) return;
// 🌟 修正:將原本的灰色改為橘色並加粗
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
try {
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
const response = await fetch(`/api/v1/backups/?host=${encodeURIComponent(connInfo.host)}&config_type=all`);
const result = await response.json();
if (result.status === 'success') {
allBackupsData = result.data;
applyBackupFilters(); // 載入後立刻套用目前的過濾條件進行渲染
} else {
// 加入防呆:如果後端回傳 422 (detail),也能正確印出錯誤而不是 undefined
const errMsg = result.message || (result.detail ? JSON.stringify(result.detail) : '未知錯誤');
tbody.innerHTML = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 載入失敗: ${errMsg}</td></tr>`;
}
} catch (error) {
tbody.innerHTML = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #e74c3c;">❌ 連線失敗: ${error.message}</td></tr>`;
}
}
// 💡 前端多維度過濾器邏輯
window.applyBackupFilters = function() {
const tbody = document.getElementById('backup-history-tbody');
if (!tbody) return;
// 取得所有過濾條件 (加上 trim() 避免不小心多敲了空白鍵)
const keyword = (document.getElementById('filter-keyword')?.value || '').trim().toLowerCase();
const type = document.getElementById('filter-type')?.value || '';
const dateStart = document.getElementById('filter-date-start')?.value;
const dateEnd = document.getElementById('filter-date-end')?.value;
// 進行陣列過濾
const filteredData = allBackupsData.filter(item => {
// 💡 終極防呆版:確保變數存在,並同時搜尋名稱與描述
const safeName = (item.snapshot_name || '').toLowerCase();
const safeDesc = (item.description || '').toLowerCase();
const matchKeyword = safeName.includes(keyword) || safeDesc.includes(keyword);
const matchType = type === '' || item.config_type === type;
let matchDate = true;
const itemDate = new Date(item.timestamp);
if (dateStart) {
const start = new Date(dateStart);
start.setHours(0, 0, 0, 0);
if (itemDate < start) matchDate = false;
}
if (dateEnd) {
const end = new Date(dateEnd);
end.setHours(23, 59, 59, 999);
if (itemDate > end) matchDate = false;
}
return matchKeyword && matchType && matchDate;
});
// 渲染過濾後的結果
if (filteredData.length > 0) {
tbody.innerHTML = filteredData.map(item => `
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
<!-- 🟢 新增描述欄位,加上防撐破設計與 title 提示 -->
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
</td>
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
<td style="padding: 12px 10px; text-align: right;">
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁️ 檢視</button>
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save" style="padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
</td>
</tr>
`).join('');
} else {
// 🟢 修正沒有資料時colspan 改為 5
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
}
};
// 3. 檢視特定快照內容
async function viewBackup(backupId) {
openModal("載入快照中...");
const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `<span style="color: #f39c12;">⏳ 正在向資料庫撈取快照資料,請稍候...</span>`;
try {
const response = await fetch(`/api/v1/backups/${backupId}`);
const result = await response.json();
if (result.status === 'success') {
// 確保這行真的有加進去,且檔案有按下 Ctrl+S 儲存!
const data = result.data;
// 🟤 將描述加入彈出視窗的標頭中
const descText = data.description ? data.description : '無';
const infoHeader = `【快照名稱】: ${data.snapshot_name}\n【備份描述】: ${descText}\n【建立時間】: ${new Date(data.timestamp).toLocaleString()}\n【設備 IP】: ${data.host}\n==================================================\n\n`;
// 如果後端有回傳 raw_cli 就優先顯示,否則將 parsed_tree 轉為字串顯示
const content = data.raw_cli ? data.raw_cli : JSON.stringify(data.parsed_tree, null, 2);
outputEl.style.color = "#e0e0e0";
outputEl.textContent = infoHeader + content;
} else {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 讀取失敗: ${result.message}`;
}
} catch (error) {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 連線失敗: ${error.message}`;
}
}
// 4. 刪除快照
async function deleteBackup(backupId) {
if (!confirm("⚠️ 確定要永久刪除這筆備份紀錄嗎?此動作無法復原!")) return;
try {
const response = await fetch(`/api/v1/backups/${backupId}`, { method: 'DELETE' });
const result = await response.json();
if (result.status === 'success') {
loadBackupHistory(); // 刪除成功後重新整理列表
} else {
alert(`❌ 刪除失敗: ${result.message}`);
}
} catch (error) {
alert(`❌ 發生錯誤: ${error.message}`);
}
}
// 5. 請求設備差異分析 (安全還原第一步)
// 🌟 接收第三個參數 backupHost
async function restoreBackup(snapshotId, snapshotName, backupHost) {
// 🛡️ 防線一:必須先連線
if (!isConnected) {
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行還原任務");
}
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
// 🛡️ 防線二:嚴格阻斷跨設備還原
if (backupHost && backupHost !== connInfo.host) {
alert(
`🚨 【跨設備還原阻斷】🚨\n\n` +
`這份快照屬於設備:${backupHost}\n` +
`您目前連線的設備:${connInfo.host}\n\n` +
`⚠️ 系統目前禁止跨設備還原,以防止嚴重的網路衝突。請先連線至正確的設備再執行此操作!`
);
return; // 直接中斷,不進行後續 API 呼叫
}
const modal = document.getElementById('outputModal');
const modalOutput = document.getElementById('modalOutput');
const modalTargetInfo = document.getElementById('modalTargetInfo');
// 1. 移除進度條,改為純文字百分比動態顯示
modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
// 🌟 修正:將原本的藍色改為橘色
modalOutput.innerHTML = `
<div id="diff-progress-text" style="color: #f39c12; margin-bottom: 10px; font-weight: bold; font-size: 15px;">🔍 正在抓取設備當前配置,並與快照進行深度比對... (0%)</div>
`;
modal.classList.add('active');
// 啟動漸進式模擬進度邏輯 (最高停在 95%)
let progress = 0;
const progressInterval = setInterval(() => {
progress += (95 - progress) * 0.08;
const text = document.getElementById('diff-progress-text');
if (text) {
text.innerText = `🔍 正在抓取設備當前配置,並與快照進行深度比對... (${Math.round(progress)}%)`;
}
}, 400);
try {
const response = await fetch(`/api/v1/backups/${snapshotId}/diff`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass })
});
const result = await response.json();
// API 回傳後,清除計時器並將進度填滿 100%
clearInterval(progressInterval);
const text = document.getElementById('diff-progress-text');
if (text) {
text.innerText = `🔍 正在抓取設備當前配置,並與快照進行深度比對... (100%) - 分析完成!`;
}
// 稍微延遲 0.5 秒讓使用者看清楚 100%,再渲染結果
setTimeout(() => {
if (result.status === 'success') {
const commands = result.data.commands;
let diffHtml = `<div style="margin-bottom: 15px; color: #ecf0f1;">以下是將設備還原至 <b>${snapshotName}</b> 所需執行的指令清單:</div>`;
if (commands.length === 0) {
diffHtml += `<div style="color: #27ae60; font-weight: bold; padding: 10px; background: rgba(39, 174, 96, 0.1); border-radius: 5px;">✅ 設備當前狀態與快照完全一致,無需進行任何還原動作!</div>`;
} else {
const safeCommands = JSON.stringify(commands).replace(/"/g, '&quot;');
// 2. 更改按鈕顏色:使用亮藍色 (#2980b9) 並加上陰影與邊框,凸顯立體感
modalTargetInfo.innerHTML = `
正在分析快照差異: ${snapshotName}
<span style="display: inline-flex; gap: 8px; margin-left: 35px; vertical-align: middle;">
<button id="btn-view-diff" onclick="document.getElementById('view-diff').style.display='block'; document.getElementById('view-cli').style.display='none';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">📊 邏輯差異</button>
<button id="btn-view-cli" onclick="document.getElementById('view-diff').style.display='none'; document.getElementById('view-cli').style.display='block';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">💻 原始執行指令 (Raw CLI)</button>
</span>
`;
// 動態插入確認按鈕到 modal-action-container
const actionContainer = document.getElementById('modal-action-container');
if (actionContainer) {
const oldConfirmBtn = document.getElementById('btn-confirm-restore');
if (oldConfirmBtn) oldConfirmBtn.remove();
const confirmBtn = document.createElement('button');
confirmBtn.id = 'btn-confirm-restore';
confirmBtn.className = 'btn-modern btn-save';
confirmBtn.style.backgroundColor = '#e74c3c';
confirmBtn.style.padding = '6px 12px';
confirmBtn.style.fontSize = '13px';
confirmBtn.innerHTML = '⚠️ 確認無誤,立即寫入設備並 Commit';
confirmBtn.onclick = () => executeSmartRestore(snapshotId, snapshotName, commands);
actionContainer.insertBefore(confirmBtn, document.getElementById('btn-modal-close'));
}
diffHtml += `<pre id="view-diff" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: block;">`;
commands.forEach(cmd => {
if (cmd.startsWith('no ')) diffHtml += `<span style="color: #e74c3c;">- ${cmd}</span>\n`;
else diffHtml += `<span style="color: #2ecc71;">+ ${cmd}</span>\n`;
});
diffHtml += `</pre>`;
diffHtml += `<pre id="view-cli" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: none; color: #ecf0f1;">`;
commands.forEach(cmd => { diffHtml += `${cmd}\n`; });
diffHtml += `<span style="color: #f39c12;">commit</span>\n`;
diffHtml += `</pre>`;
}
modalOutput.innerHTML = diffHtml;
} else {
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 差異分析失敗:\n${result.message}</span>`;
}
}, 500);
} catch (error) {
clearInterval(progressInterval);
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}</span>`;
}
}
// 6. 真正執行差異還原 (串接後端 Streaming API)
window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit\n確定要繼續嗎`)) return;
// 1. 優雅地將按鈕反灰 (Disabled),包含關閉按鈕
const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore', 'btn-modal-close'];
btnIds.forEach(id => {
const btn = document.getElementById(id);
if (btn) {
btn.disabled = true;
btn.style.opacity = '0.5';
btn.style.cursor = 'not-allowed';
btn.style.pointerEvents = 'none';
if (id === 'btn-confirm-restore') {
btn.innerText = '⏳ 正在寫入設備中...';
btn.style.backgroundColor = '#7f8c8d';
}
}
});
const connInfo = getGlobalConnectionInfo();
const modalOutput = document.getElementById('modalOutput');
// 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸)
modalOutput.innerHTML = `
<div id="restore-log-container" style="width: 100%; height: 100%; background: transparent; border: none; padding: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #ecf0f1; font-size: 14px; line-height: 1.5; text-align: left; box-sizing: border-box;">
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] ⏳ 正在透過 SSH 寫入還原指令,請勿關閉視窗...</div>
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
</div>
`;
const logContainer = document.getElementById('restore-log-container');
try {
const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands })
});
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
if (data.status === 'progress') {
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
} else if (data.status === 'success') {
let cleanOutput = data.output || '無';
cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim();
logContainer.innerHTML += `
<div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 15px;">[${timeStr}] ${data.message}</div>
<div style="color: #ecf0f1; margin-top: 5px; border-top: 1px dashed #555; padding-top: 5px;">設備 Commit 回傳:<br><span style="color: #bdc3c7;">${cleanOutput}</span></div>
`;
document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`;
// 成功後,隱藏確認按鈕,並恢復關閉按鈕
const confirmBtn = document.getElementById('btn-confirm-restore');
if (confirmBtn) confirmBtn.style.display = 'none';
const closeBtn = document.getElementById('btn-modal-close');
if (closeBtn) {
closeBtn.disabled = false;
closeBtn.style.opacity = '1';
closeBtn.style.cursor = 'pointer';
closeBtn.style.pointerEvents = 'auto';
}
} else if (data.status === 'error') {
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
if (data.output) {
logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
}
restoreButtonsState(btnIds);
}
logContainer.scrollTop = logContainer.scrollHeight;
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
}
} catch (error) {
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">❌ 系統錯誤:無法連線至後端伺服器。<br>${error.message}</div>`;
restoreButtonsState(btnIds);
}
};
// 輔助函數:恢復按鈕狀態 (請放在 executeSmartRestore 下方)
function restoreButtonsState(btnIds) {
btnIds.forEach(id => {
const btn = document.getElementById(id);
if (btn) {
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
btn.style.pointerEvents = 'auto';
if (id === 'btn-confirm-restore') {
btn.innerText = '⚠️ 確認無誤,重新嘗試寫入';
btn.style.backgroundColor = '#e74c3c';
}
}
});
}
// ============================================================================
// 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States)
// ============================================================================
// 開啟共用輸出彈出視窗
function openModal(targetInfo) {
document.getElementById('outputModal').classList.add('active');
document.getElementById('modalTargetInfo').textContent = targetInfo ? `(${targetInfo})` : '';
document.body.style.overflow = 'hidden';
}
// 關閉共用輸出彈出視窗
function closeModal() {
document.getElementById('outputModal').classList.remove('active');
document.body.style.overflow = '';
// 清除動態加入的確認按鈕,避免污染下次開啟
const confirmBtn = document.getElementById('btn-confirm-restore');
if (confirmBtn) confirmBtn.remove();
}
// 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態)
document.addEventListener("DOMContentLoaded", checkScanButtonState);
// ==========================================
// 🛡️ 系統管理員雙重解鎖與 4 顆按鈕連動邏輯
// ==========================================
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
document.addEventListener('DOMContentLoaded', () => {
const appTitle = document.getElementById('app-title');
function unlockAdminFeatures() {
ADMIN_BTN_IDS.forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.style.display = 'inline-block';
});
// 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
btn.style.display = 'inline-block';
});
}
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('mode') === 'godmode') unlockAdminFeatures();
let clickCount = 0;
let clickTimer = null;
if (appTitle) {
appTitle.addEventListener('click', () => {
clickCount++;
if (clickCount === 5) {
unlockAdminFeatures();
alert('🔓 已解鎖系統維護者模式!');
clickCount = 0;
}
clearTimeout(clickTimer);
clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
});
}
});
// 統一控制 4 顆按鈕的狀態
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
ADMIN_BTN_IDS.forEach(id => {
const btn = document.getElementById(id);
if (btn) {
btn.disabled = isBusy;
if (isBusy) {
btn.classList.add('is-busy');
if (id.includes('scan')) btn.innerText = text;
} else {
btn.classList.remove('is-busy');
// 恢復原本的文字
if (id === 'btn-scan-visible') btn.innerText = "掃描局部缺失";
if (id === 'btn-scan-global') btn.innerText = "掃描全域缺失";
}
}
});
}
function checkScanButtonState() {
const btn = document.getElementById('btn-scan-visible');
if (!btn) return;
const scanLockUntil = localStorage.getItem('scanLockUntil');
if (scanLockUntil && Date.now() < parseInt(scanLockUntil)) {
lockScanButton(parseInt(scanLockUntil) - Date.now());
}
}
function lockScanButton(durationMs) {
setAdminButtonsState(true, "⏳ 背景掃描執行中...");
setTimeout(() => {
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
const treeContainer = document.getElementById(`tree-container-${currentMode}`);
if (treeContainer && treeContainer.innerHTML.includes('leaf-container')) {
enableGlobalScanButton();
} else {
setAdminButtonsState(true, "⏳ 請先載入樹狀圖");
}
localStorage.removeItem('scanLockUntil');
}, durationMs);
}
function enableGlobalScanButton() {
const scanLockUntil = localStorage.getItem('scanLockUntil');
if (!scanLockUntil || Date.now() >= parseInt(scanLockUntil)) {
setAdminButtonsState(false);
}
}
// ============================================================================
// 🌟 7. 全域函數掛載 (Window Bindings - 供 HTML onClick 呼叫)
// ============================================================================
// --- 終端機綁定 ---
window.connectWebSocket = () => connectWebSocket(resetAllManagerData);
window.disconnectWebSocket = disconnectWebSocket;
window.injectCommand = injectCommand;
window.downloadTerminal = downloadTerminal;
// --- 頁籤與任務切換 ---
window.switchTab = switchTab;
window.switchQueryTask = switchQueryTask;
window.switchConfigTask = switchConfigTask;
window.startConfigTask = startConfigTask;
window.toggleQueryInputs = toggleQueryInputs;
// --- 查詢與 MAC Domain 精靈 ---
window.executeQuery = executeQuery;
window.fetchMacDomainConfig = fetchMacDomainConfig;
window.revertFormChanges = revertFormChanges;
window.toggleGroupVisibility = toggleGroupVisibility;
window.loadDsGroupData = loadDsGroupData;
window.loadUsGroupData = loadUsGroupData;
window.generateMacDomainCLI = generateMacDomainCLI;
window.executeBondingConfig = executeBondingConfig;
window.loadMacDomainList = loadMacDomainList;
// --- 系統設定與快取掃描 ---
window.openSystemSettings = openSystemSettings;
window.loadRealConfigForFilters = loadRealConfigForFilters;
window.saveSystemSettings = saveSystemSettings;
window.scanVisibleMissingOptions = scanVisibleMissingOptions;
window.scanGlobalMissingOptions = scanGlobalMissingOptions;
window.clearVisibleCache = clearVisibleCache;
window.clearGlobalCache = clearGlobalCache;
window.previewNodeCLI = previewNodeCLI;
window.switchFilterMode = switchFilterMode;
// --- 樹狀圖展開與編輯操作 ---
window.expandAll = expandAll;
window.collapseAll = collapseAll;
window.startEditFolder = startEditFolder;
window.startEditLeaf = startEditLeaf;
window.cancelEditFolder = cancelEditFolder;
window.cancelEditLeaf = cancelEditLeaf;
window.previewFolderCLI = previewFolderCLI;
window.previewLeafCLI = previewLeafCLI;
window.hideSideCLI = hideSideCLI;
window.executeSideCLI = executeSideCLI;
// --- 其他 UI 輔助 ---
window.toggleChildCheckboxes = toggleChildCheckboxes;
window.updateParentCheckboxState = updateParentCheckboxState;
window.closeModal = closeModal;
// --- 備份與還原 ---
window.createSnapshot = createSnapshot;
window.loadBackupHistory = loadBackupHistory;
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>