2807 lines
144 KiB
Plaintext
2807 lines
144 KiB
Plaintext
|
|
================================================================================
|
|||
|
|
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, '"');
|
|||
|
|
|
|||
|
|
// 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>
|
|||
|
|
|
|||
|
|
<!-- 表單 1:Cable 狀態綜合查詢 -->
|
|||
|
|
<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>
|
|||
|
|
|
|||
|
|
<!-- 表單 2:RPD 狀態綜合查詢 -->
|
|||
|
|
<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>
|
|||
|
|
|
|||
|
|
<!-- 表單 3:CM 一鍵診斷中心 -->
|
|||
|
|
<div id="form-cm-diagnostics" class="task-form">
|
|||
|
|
<h3 style="margin-top: 0; font-size: 18px; color: #9b59b6; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">CM 深度診斷與 MER 分析</h3>
|
|||
|
|
|
|||
|
|
<!-- 搜尋區塊 -->
|
|||
|
|
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
|
|||
|
|
<label style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC:</label>
|
|||
|
|
<input type="text" id="diagMacInput" 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>
|
|||
|
|
|
|||
|
|
<!-- 表單 3:MAC 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>
|
|||
|
|
|