scm-harmonic-cmts-admin/static/app.js

2145 lines
102 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* --- static/app.js --- */
// ==========================================
// 1. 全域變數與初始化
// ==========================================
let term, fitAddon, ws;
let currentMacDomainData = null;
let isConnected = false;
// 💡 新增:追蹤當前編輯狀態
let currentEditPath = null;
let currentEditElementId = null;
let currentEditIsSuccess = false;
let currentEditType = null; // 💡 新增:用來記錄是 'folder' 還是 'leaf'
// 💤 全域閒置偵測
let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
window.onload = () => {
initTerminal();
resetIdleTimer(); // 頁面載入時啟動閒置計時器
};
window.onresize = () => { if (fitAddon) fitAddon.fit(); };
// ==========================================
// 💡 全域狀態與鎖定管理 (Lock Management)
// ==========================================
const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
const ACTIVE_HEARTBEATS = {};
// 💤 閒置超時,自動釋放所有鎖
async function releaseAllLocks() {
const pathsToRelease = Object.keys(ACTIVE_HEARTBEATS);
if (pathsToRelease.length === 0) return; // 如果沒有鎖,就不用執行
// 建立一個 Promise 陣列,來平行處理所有釋放請求
const releasePromises = pathsToRelease.map(path => {
// 1. 清除心跳計時器
clearInterval(ACTIVE_HEARTBEATS[path]);
delete ACTIVE_HEARTBEATS[path];
// 2. 發送釋放請求
return fetch('/api/v1/locks/release', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID })
}).catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
});
// 3. 等待所有釋放請求完成
await Promise.all(releasePromises);
// 4. 提示並重整頁面
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。");
location.reload();
}
// 💓 發送心跳,告訴後端「我還在編輯」,請延長鎖定時間
async function sendHeartbeat(path) {
try {
const response = await fetch('/api/v1/locks/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID })
});
if (!response.ok) {
console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`);
// 如果回傳 404代表後端的鎖已經被清掉了停止發送心跳
if (response.status === 404) {
clearInterval(ACTIVE_HEARTBEATS[path]);
delete ACTIVE_HEARTBEATS[path];
}
} else {
console.log(`💓 心跳已送出,鎖定已延長 (${path})`); // 測試用,確認沒問題後可刪除這行
}
} catch (error) {
console.error(`心跳請求發生錯誤 (${path}):`, error);
}
}
// 實作閒置計時器重置邏輯
function resetIdleTimer() {
clearTimeout(idleTimer);
idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT); // IDLE_TIMEOUT 是 300000
}
// 監聽使用者的互動事件,只要有動作就重置計時器
window.addEventListener('mousemove', resetIdleTimer);
window.addEventListener('keydown', resetIdleTimer);
window.addEventListener('click', resetIdleTimer);
// ==========================================
// 🌟 輔助函數:全部展開與全部收合
// ==========================================
function expandAll(elementId) {
const details = document.getElementById(`details-${elementId}`);
if (details) {
details.open = true; // 展開自己
const subDetails = details.querySelectorAll('details');
subDetails.forEach(d => d.open = true); // 展開所有子項目
}
}
function collapseAll(elementId) {
const details = document.getElementById(`details-${elementId}`);
if (details) {
const subDetails = details.querySelectorAll('details');
subDetails.forEach(d => d.open = false); // 收合所有子項目
details.open = false; // 收合自己
}
}
// 🎨 輔助函數:解析終端機輸出,僅將錯誤/警告行標紅
function highlightTerminalOutput(text) {
if (!text) return '';
// 將多行字串拆分成陣列,逐行檢查
const lines = text.split('\n');
const formattedLines = lines.map(line => {
// 偵測常見的錯誤與警告關鍵字 (不分大小寫)
// 您可以隨時在這裡擴充設備專屬的錯誤代碼,例如 '% Invalid'
if (line.match(/(Aborted|Error|Warning|Failed|% Invalid|% Unknown|Bad command)/i)) {
return `<span style="color: #ff6b6b; font-weight: bold;">${line}</span>`;
}
return line; // 沒有關鍵字的行,維持預設顏色 (白色)
});
return formattedLines.join('\n');
}
// ==========================================
// 💡 資料夾層級的編輯模式邏輯 (支援區塊級預先抓取)
// ==========================================
async function startEditFolder(path, elementId) {
const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin';
try {
const response = await fetch('/api/v1/locks/acquire', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID, username: username })
});
const result = await response.json();
if (response.status === 409) {
alert(`⚠️ ${result.detail}`);
return;
}
if (result.status === 'success') {
ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000);
document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
document.getElementById(`details-${elementId}`).open = true;
const contentDiv = document.getElementById(`content-${elementId}`);
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
// 🌟 1. 先取得目前的快取資料
let optData = {};
try {
const optRes = await fetch('/api/v1/cmts-leaf-options');
optData = await optRes.json();
} catch (e) {
console.warn("無法取得選項快取", e);
}
const pathsToSync = [];
const currentTime = Math.floor(Date.now() / 1000);
const CACHE_TTL = 86400 * 7;
// 🌟 2. 歷遍所有欄位,渲染 UI 並收集缺少的路徑
leafContainers.forEach(container => {
const origVal = container.getAttribute('data-original');
const rawPath = container.getAttribute('data-path');
// 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1]
let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
// 💡 攔截 no 指令:將查詢路徑改為實際指令
if (rawPath.endsWith('::no')) {
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
}
let leafCache = optData[cacheKey];
// 判斷是否需要排程更新 (沒快取,或是快取過期)
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) {
pathsToSync.push(cacheKey);
}
// 渲染 UI (加入提示與下拉選單邏輯)
let inputHtml = "";
let hintAttr = "";
let hintIcon = "";
if (leafCache && leafCache.hint) {
const safeHint = leafCache.hint.replace(/"/g, '&quot;');
hintAttr = `title="${safeHint}"`;
hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
}
if (leafCache && leafCache.options && leafCache.options.length > 0) {
let options = leafCache.options;
if (origVal && !options.includes(origVal)) {
options = [origVal, ...options];
}
inputHtml = `<select class="edit-input" data-path="${rawPath}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
options.forEach(opt => {
const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${opt}" ${isSelected}>${opt}</option>`;
});
inputHtml += `</select>${hintIcon}`;
} else {
// 退回純文字輸入
inputHtml = `<input type="text" class="edit-input" data-path="${rawPath}" value="${origVal}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">${hintIcon}`;
}
container.innerHTML = inputHtml;
});
// 🌟 3. 觸發背景抓取:將缺少的路徑一次性送給後端
if (pathsToSync.length > 0) {
console.log(`🚀 將 ${pathsToSync.length} 個欄位送入背景排程抓取...`);
fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: pathsToSync })
});
}
}
} catch (error) {
alert("❌ 無法連線到鎖定伺服器:" + error.message);
}
}
// 🔓 鎖定並開始編輯單一項目 (加入智慧等待機制)
async function startEditLeaf(path, elementId) {
const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin';
try {
const response = await fetch('/api/v1/locks/acquire', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID, username: username })
});
const result = await response.json();
if (response.status === 409) return alert(`⚠️ ${result.detail}`);
if (result.status === 'success') {
ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000);
document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original');
// 💡 顯示載入中提示
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
try {
let optRes = await fetch('/api/v1/cmts-leaf-options');
let optData = await optRes.json();
// 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1]
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
// 💡 攔截 no 指令:將查詢路徑改為實際指令
if (path.endsWith('::no')) {
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
}
let leafCache = optData[cacheKey];
const currentTime = Math.floor(Date.now() / 1000);
const CACHE_TTL = 86400 * 7;
// 💡 如果沒快取,觸發背景抓取,並啟動「智慧等待」
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) {
// 1. 呼叫背景任務
fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: [cacheKey] })
});
// 2. 每秒檢查一次,最多等 10 秒
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 1000)); // 暫停 1 秒
optRes = await fetch('/api/v1/cmts-leaf-options');
optData = await optRes.json();
leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) {
found = true;
break; // 找到了!跳出等待迴圈
}
}
if (!found) {
console.warn("⏳ 等待選項超時,退回純文字模式");
}
}
let inputHtml = "";
let hintAttr = "";
let hintIcon = "";
// 💡 處理 Hint 屬性與提示圖示
if (leafCache && leafCache.hint) {
// 替換雙引號避免 HTML 屬性破裂
const safeHint = leafCache.hint.replace(/"/g, '&quot;');
hintAttr = `title="${safeHint}"`;
// 加上一個小問號圖示,視覺上引導使用者懸停
hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
}
// 🌟 渲染精緻的下拉選單
if (leafCache && leafCache.options && leafCache.options.length > 0) {
let options = leafCache.options;
if (origVal && !options.includes(origVal)) {
options = [origVal, ...options];
}
inputHtml = `<select class="edit-input" data-path="${path}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
options.forEach(opt => {
const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${opt}" ${isSelected}>${opt}</option>`;
});
inputHtml += `</select>${hintIcon}`;
} else {
// ❌ 若為 list 且無選項,退回純文字輸入,但依然綁定提示
inputHtml = `<input type="text" class="edit-input" data-path="${path}" value="${origVal}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">${hintIcon}`;
}
container.innerHTML = inputHtml;
} catch (e) {
console.warn("選項載入失敗", e);
container.innerHTML = `<input type="text" class="edit-input" data-path="${path}" value="${origVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`;
}
}
} catch (error) {
alert("❌ 無法連線到鎖定伺服器:" + error.message);
}
}
async function cancelEditFolder(path, elementId) {
if (ACTIVE_HEARTBEATS[path]) {
clearInterval(ACTIVE_HEARTBEATS[path]);
delete ACTIVE_HEARTBEATS[path];
}
try {
await fetch('/api/v1/locks/release', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID })
});
} catch (error) {}
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none';
const contentDiv = document.getElementById(`content-${elementId}`);
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
leafContainers.forEach(container => {
const origVal = container.getAttribute('data-original');
container.innerHTML = `
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${origVal}</span>
`;
});
}
// 🔓 取消編輯單一項目
async function cancelEditLeaf(path, elementId) {
if (ACTIVE_HEARTBEATS[path]) {
clearInterval(ACTIVE_HEARTBEATS[path]);
delete ACTIVE_HEARTBEATS[path];
}
try {
await fetch('/api/v1/locks/release', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID })
});
} catch (error) {}
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none';
const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original');
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${origVal}</span>`;
}
// 💡 輔助函數:掃描畫面上所有支援 admin-state 的區塊路徑
function getInterfacesWithAdminState() {
const nodes = document.querySelectorAll('.leaf-container');
const interfaces = new Set();
nodes.forEach(node => {
const path = node.getAttribute('data-path');
// 如果這個節點是 admin-state
if (path && path.endsWith('::admin-state')) {
// 將路徑轉換為後端認得的介面格式 (拔除 ::admin-state、替換空白、拔除虛擬索引)
const interfacePath = path.replace('::admin-state', '').replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
interfaces.add(interfacePath);
}
});
return Array.from(interfaces);
}
// 🚀 Phase 3: 收集修改並呼叫後端轉譯 API
async function previewFolderCLI(path, elementId) {
// 💡 記錄當前正在編輯的節點資訊
currentEditPath = path;
currentEditElementId = elementId;
currentEditIsSuccess = false; // 預設為尚未成功
currentEditType = 'folder'; // 💡 標記為資料夾編輯
const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) return;
const inputs = contentDiv.querySelectorAll('.edit-input');
const diffs = [];
inputs.forEach(input => {
const container = input.closest('.leaf-container');
if (!container) return;
const originalVal = container.getAttribute('data-original') || "";
const newVal = input.value.trim();
const nodePath = container.getAttribute('data-path');
if (originalVal !== newVal && nodePath) {
diffs.push({
path: nodePath,
old_val: originalVal,
new_val: newVal
});
}
});
if (diffs.length === 0) {
alert("沒有偵測到任何修改,無需生成指令!");
return;
}
try {
const response = await fetch('/api/v1/cmts-generate-cli', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
diffs: diffs,
interfaces_with_admin_state: getInterfacesWithAdminState() // 💡 附上動態探測名單
})
});
const result = await response.json();
if (result.status === 'success') {
showCliPreviewModal(result.data);
} else {
alert("CLI 生成失敗: " + result.message);
}
} catch (error) {
console.error("Error generating CLI:", error);
alert("網路連線錯誤,無法生成 CLI");
}
}
// 🚀 預覽單一項目的 CLI
async function previewLeafCLI(path, elementId) {
currentEditPath = path;
currentEditElementId = elementId;
currentEditIsSuccess = false;
currentEditType = 'leaf'; // 💡 標記為單一項目編輯
const container = document.getElementById(`container-${elementId}`);
if (!container) return;
const input = container.querySelector('.edit-input');
if (!input) return;
const originalVal = container.getAttribute('data-original') || "";
const newVal = input.value.trim();
if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!");
const diffs = [{ path: path, old_val: originalVal, new_val: newVal }];
try {
const response = await fetch('/api/v1/cmts-generate-cli', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
diffs: diffs,
interfaces_with_admin_state: getInterfacesWithAdminState() // 💡 附上動態探測名單
})
});
const result = await response.json();
if (result.status === 'success') {
showCliPreviewModal(result.data);
} else {
alert("CLI 生成失敗: " + result.message);
}
} catch (error) {
alert("網路連線錯誤,無法生成 CLI");
}
}
// 🚀 Phase 3: 顯示側邊預覽區塊 (初始化為「預覽模式」)
function showCliPreviewModal(cliScript) {
const leftPane = document.getElementById('tree-container');
const resizer = document.getElementById('drag-resizer');
const rightPane = document.getElementById('side-cli-preview');
// --- UI 狀態重置為「預覽模式」 ---
document.getElementById('side-pane-title').innerHTML = '⚠️ 即將寫入的指令';
document.getElementById('side-pane-title').style.color = '#f1c40f';
document.getElementById('btn-side-cancel').style.display = 'inline-block';
document.getElementById('btn-side-confirm').style.display = 'inline-block';
document.getElementById('btn-side-close').style.display = 'none';
document.getElementById('side-cli-textarea').style.display = 'block';
document.getElementById('side-execution-result').style.display = 'none';
// 填入指令
document.getElementById('side-cli-textarea').value = cliScript;
// 設定初始 50/50 比例
leftPane.style.width = 'calc(50% - 11px)';
rightPane.style.width = 'calc(50% - 11px)';
resizer.style.display = 'flex';
rightPane.style.display = 'block';
if (!window.isResizerInitialized) {
initResizer();
window.isResizerInitialized = true;
}
}
// ❌ 隱藏側邊預覽區塊 (恢復左側 100% 寬度)
async function hideSideCLI() {
document.getElementById('tree-container').style.width = '100%';
document.getElementById('drag-resizer').style.display = 'none';
document.getElementById('side-cli-preview').style.display = 'none';
// 💡 根據編輯類型,呼叫對應的套用函數
if (currentEditIsSuccess && currentEditElementId && currentEditPath) {
// 🔍 檢查這個節點是不是 no 指令 (透過尋找我們剛加的 display-wrapper)
const wrapper = document.getElementById(`display-wrapper-${currentEditElementId}`);
if (wrapper) {
// 🌟 這是 no 指令的變身邏輯!
const container = document.getElementById(`container-${currentEditElementId}`);
const input = container ? container.querySelector('.edit-input') : null;
// 取得使用者編輯後的新指令
const newCommand = input ? input.value.trim() : (container ? container.getAttribute('data-original') : '');
// 1. 先正常解除後端鎖定 (這會還原原本的 HTML 結構)
if (currentEditType === 'folder') {
await cancelEditFolder(currentEditPath, currentEditElementId);
} else {
await cancelEditLeaf(currentEditPath, currentEditElementId);
}
// 2. 觸發原地變身!(這會把整個 wrapper 換成綠色打勾的樣式)
transformNoToActive(currentEditElementId, newCommand);
} else {
// 🌟 一般正常指令的套用邏輯 (維持您原本的寫法)
if (currentEditType === 'folder') {
await applyEditFolder(currentEditPath, currentEditElementId);
} else if (currentEditType === 'leaf') {
await applyEditLeaf(currentEditPath, currentEditElementId);
}
}
}
// 重置狀態
currentEditPath = null;
currentEditElementId = null;
currentEditIsSuccess = false;
currentEditType = null;
}
// 💡 新增:套用修改並解除鎖定
async function applyEditFolder(path, elementId) {
const contentDiv = document.getElementById(`content-${elementId}`);
if (contentDiv) {
const inputs = contentDiv.querySelectorAll('.edit-input');
inputs.forEach(input => {
const container = input.closest('.leaf-container');
if (container) {
const newVal = input.value.trim();
// 關鍵:將新值寫入 data-original
// 這樣稍後呼叫 cancelEditFolder 時,就會用這個新值來還原畫面
container.setAttribute('data-original', newVal);
}
});
}
// 呼叫現有的 cancelEditFolder 來解除後端鎖定,並將輸入框變回純文字
await cancelEditFolder(path, elementId);
}
// ✅ 成功寫入後套用單一項目的新值
async function applyEditLeaf(path, elementId) {
const container = document.getElementById(`container-${elementId}`);
if (container) {
const input = container.querySelector('.edit-input');
if (input) {
container.setAttribute('data-original', input.value.trim());
}
}
await cancelEditLeaf(path, elementId);
}
// 🚀 執行側邊區塊的指令 (切換為「執行模式」)
function executeSideCLI() {
const finalScript = document.getElementById('side-cli-textarea').value;
// --- UI 狀態切換為「執行結果模式」 ---
document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...';
document.getElementById('side-pane-title').style.color = '#3498db';
document.getElementById('btn-side-cancel').style.display = 'none';
document.getElementById('btn-side-confirm').style.display = 'none';
document.getElementById('btn-side-close').style.display = 'none';
document.getElementById('side-cli-textarea').style.display = 'none';
const resultBox = document.getElementById('side-execution-result');
resultBox.style.display = 'block';
// 💡 移除原本多餘的提示,直接以純白色顯示正在執行的腳本
resultBox.innerHTML = `<span style="color: #ecf0f1;">${finalScript}</span>`;
// 呼叫 API
executeGeneratedCLI(finalScript);
}
// 🖱️ 實作左右拖曳調整寬度引擎
function initResizer() {
const resizer = document.getElementById('drag-resizer');
const leftPane = document.getElementById('tree-container');
const rightPane = document.getElementById('side-cli-preview');
const container = document.getElementById('split-container');
let isResizing = false;
// 滑鼠按下分隔線:開始拖曳
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none'; // 防止拖曳時意外反白文字
});
// 滑鼠移動:動態計算寬度
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const containerRect = container.getBoundingClientRect();
let newLeftWidth = e.clientX - containerRect.left;
// 限制左右面板的最小/最大寬度 (20% ~ 80%),防止拉過頭
const minWidth = containerRect.width * 0.2;
const maxWidth = containerRect.width * 0.8;
if (newLeftWidth < minWidth) newLeftWidth = minWidth;
if (newLeftWidth > maxWidth) newLeftWidth = maxWidth;
// 轉換為百分比
const leftPercentage = (newLeftWidth / containerRect.width) * 100;
const rightPercentage = 100 - leftPercentage;
// 即時更新左右面板寬度
leftPane.style.width = `calc(${leftPercentage}% - 11px)`;
rightPane.style.width = `calc(${rightPercentage}% - 11px)`;
});
// 滑鼠放開:結束拖曳
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = 'default';
document.body.style.userSelect = 'auto'; // 恢復文字反白功能
}
});
}
// 🚀 Phase 3: 將腳本發送給設備執行 (輸出至側邊欄)
async function executeGeneratedCLI(script) {
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
const outputEl = document.getElementById('side-execution-result');
try {
const response = await fetch('/api/v1/cmts-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script: script,
host: connInfo.host,
username: connInfo.user,
password: connInfo.pass
})
});
const result = await response.json();
// 執行完畢,顯示「完成並關閉」按鈕
document.getElementById('btn-side-close').style.display = 'inline-block';
if (result.status === 'success') {
currentEditIsSuccess = true; // 💡 標記執行成功
document.getElementById('side-pane-title').innerHTML = '✅ 執行成功';
document.getElementById('side-pane-title').style.color = '#2ecc71';
// 💡 成功時,移除內部提示,直接顯示純淨的白色 Log
outputEl.innerHTML = `<span style="color: #ecf0f1;">${result.data}</span>`;
} else {
currentEditIsSuccess = false; // 💡 標記執行失敗
document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
document.getElementById('side-pane-title').style.color = '#ff6b6b';
// 💡 失敗時,移除內部提示,並透過輔助函數「僅針對錯誤行」進行紅色高亮
const rawLog = result.detail || result.message || '';
outputEl.innerHTML = `<span style="color: #ecf0f1;">${highlightTerminalOutput(rawLog)}</span>`;
}
} catch (error) {
document.getElementById('btn-side-close').style.display = 'inline-block';
document.getElementById('side-pane-title').innerHTML = '❌ 連線錯誤';
document.getElementById('side-pane-title').style.color = '#ff6b6b';
outputEl.innerHTML = `<span style="color: #ff6b6b; font-weight: bold;">連線或伺服器錯誤: ${error}</span>`;
}
}
// 🔌 外掛函數:為現有的 input 加上下拉選項 (不破壞原有結構)
async function enhanceInputWithOptions(path, elementId) {
try {
const optRes = await fetch('/api/v1/cmts-leaf-options');
const optData = await optRes.json();
// 🌟 建立乾淨的 cacheKey (轉換為空白格式,並全域拔除虛擬索引)
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const leafCache = optData[cacheKey];
const currentTime = Math.floor(Date.now() / 1000);
const CACHE_TTL = 86400 * 7;
if (leafCache && leafCache.options && leafCache.options.length > 0 && (currentTime - leafCache.updated_at < CACHE_TTL)) {
// ✅ 找到選項:建立 <datalist> 並綁定到現有的 input 上
const container = document.getElementById(`container-${elementId}`);
const input = container.querySelector('.edit-input');
if (input) {
const listId = `dl-${elementId}`;
input.setAttribute('list', listId); // 讓 input 知道要用哪個清單
// 建立清單元素
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 {
// ❌ 沒找到選項:觸發背景爬蟲,畫面維持原樣不變
fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// 🌟 傳送乾淨的 cacheKey 給後端爬蟲
body: JSON.stringify({ leaf_paths: [cacheKey] })
});
}
} catch (e) {
console.warn("選項外掛載入失敗,維持純文字輸入", e);
}
}
// 🚀 完美融合版:一鍵掃描缺失選項 (包含快取比對 + 防連點保護 + 僅掃描可見項目)
async function scanAllMissingOptions() {
const btn = document.getElementById('global-scan-btn');
const statusEl = document.getElementById('scan-status');
const treeContainer = document.getElementById('tree-container');
if (!treeContainer) return;
// 💡 1. 啟動防連點鎖定 (假設鎖定 60 秒,您可以自行調整)
const LOCK_DURATION_MS = 60000;
localStorage.setItem('scanLockUntil', Date.now() + LOCK_DURATION_MS);
// 立即將按鈕變灰並禁用
if (btn) {
btn.disabled = true;
btn.style.backgroundColor = "#bdc3c7";
btn.style.cursor = "not-allowed";
btn.innerText = "⏳ 掃描冷卻中...";
}
statusEl.innerHTML = `⏳ 正在比對快取資料...`;
statusEl.style.color = "#f39c12";
try {
// 💡 2. 取得目前的快取資料
const optRes = await fetch('/api/v1/cmts-leaf-options');
const optData = await optRes.json();
const currentTime = Math.floor(Date.now() / 1000);
const CACHE_TTL = 86400 * 7;
// 💡 3. 找出畫面上「所有」的設定項目,並過濾出「當前可見」的項目
const allContainers = treeContainer.querySelectorAll('.leaf-container');
const pathsToSync = [];
allContainers.forEach(container => {
// 🛡️ 核心效能優化:檢查這個元素是否被包在「未展開」的資料夾裡面
const isHiddenByFolder = container.closest('details:not([open])') !== null;
if (isHiddenByFolder) return; // 如果被隱藏,就直接跳過,不加入掃描清單!
const path = container.getAttribute('data-path');
if (path) {
// 🌟 轉換為空白格式後,全域拔除路徑中所有的虛擬索引 [0], [1]
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const leafCache = optData[cacheKey];
// 🌟 修正重點:
// 拿掉 options 的長度檢查。
// 只要「沒有這筆快取」或「快取已過期」,才需要重新抓取。
if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) {
if (!pathsToSync.includes(cacheKey)) {
pathsToSync.push(cacheKey);
}
}
}
});
if (pathsToSync.length === 0) {
statusEl.textContent = "✅ 當前展開的欄位都已有最新選項,無需掃描!";
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 3000);
// 🌟 補上這段:立即解除按鈕鎖定狀態
if (btn) {
btn.disabled = false;
btn.style.backgroundColor = ""; // 恢復預設顏色 (或填入您原本的紫色 #8e44ad)
btn.style.cursor = "pointer";
btn.innerHTML = "🔍 掃描畫面缺失選項";
}
localStorage.removeItem('scanLockUntil'); // 提早解除防連點鎖定
return;
}
// 💡 4. 呼叫後端 API 進行批次抓取
statusEl.innerHTML = `⏳ 正在背景掃描 <b>${pathsToSync.length}</b> 個欄位的選項...`;
statusEl.style.color = "#e67e22";
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: pathsToSync })
});
if (response.ok) {
// 🌟 修改這裡:不要再用 setTimeout 解鎖,而是啟動智慧監控
statusEl.innerHTML = `✅ 任務已送出!正在監控背景執行進度...`;
statusEl.style.color = "#3498db";
// 傳入我們剛剛收集到的 pathsToSync 開始監控
checkScanProgress(pathsToSync);
} else {
throw new Error("API 回應錯誤");
}
} catch (error) {
console.error("掃描請求失敗:", error);
statusEl.textContent = "❌ 掃描請求發送失敗,請檢查網路連線。";
statusEl.style.color = "#e74c3c";
}
}
// 🌟 最終修正版:智慧監控背景掃描進度
async function checkScanProgress(pendingPaths) {
const btn = document.getElementById('global-scan-btn');
const statusEl = document.getElementById('scan-status');
let attempts = 0;
const maxAttempts = 60; // 最多監控 5 分鐘 (每 5 秒查一次)
const interval = setInterval(async () => {
attempts++;
try {
// 1. 取得最新快取
const optRes = await fetch('/api/v1/cmts-leaf-options');
const optData = await optRes.json();
// 2. 🌟 關鍵修正:只檢查快取中「是否存在該路徑的紀錄」
const remainingPaths = pendingPaths.filter(path => {
const leafCache = optData[path];
return !leafCache; // 只要沒有紀錄,就代表後端還沒處理完
});
const doneCount = pendingPaths.length - remainingPaths.length;
if (remainingPaths.length === 0) {
// ✅ 全部抓完了!真正解除鎖定
clearInterval(interval);
localStorage.removeItem('scanLockUntil');
if (btn) {
btn.disabled = false;
btn.style.backgroundColor = "#8e44ad";
btn.style.cursor = "pointer";
btn.innerText = "🔍 一鍵掃描缺失選項";
}
statusEl.innerHTML = `✅ 掃描完美達成!快取已全面更新。`;
statusEl.style.color = "#27ae60";
setTimeout(() => statusEl.textContent = "", 5000);
} else if (attempts >= maxAttempts) {
// ⚠️ 超時防呆
clearInterval(interval);
localStorage.removeItem('scanLockUntil');
if (typeof enableGlobalScanButton === 'function') enableGlobalScanButton();
statusEl.innerHTML = `⚠️ 掃描耗時較長 (${doneCount}/${pendingPaths.length}),已轉為純背景執行。`;
statusEl.style.color = "#f39c12";
} else {
// ⏳ 更新進度條
statusEl.innerHTML = `⏳ 背景掃描進度:<b>${doneCount} / ${pendingPaths.length}</b> 完成...`;
}
} catch (e) {
console.warn("監控進度時發生網路錯誤,稍後重試...", e);
}
}, 5000); // 每 5 秒輪詢一次
}
// ==========================================
// 💡 編輯模式 UI 切換邏輯 (保留舊版單一節點編輯用)
// ==========================================
function renderEditModeUI(path, currentValue, elementId) {
const container = document.getElementById(elementId);
if (!container) return;
const safeValue = currentValue.replace(/'/g, "&#39;");
container.innerHTML = `
<input type="text" id="input-${elementId}" value="${safeValue}"
style="padding: 4px 8px; border: 2px solid #3498db; border-radius: 4px; font-family: Courier New, monospace; font-size: 14px; width: 250px; outline: none;">
<button onclick="previewCLI('${path}', '${elementId}')"
style="margin-left: 8px; background-color: #27ae60; color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 13px;">
✅ 預覽指令
</button>
<button onclick="cancelEdit('${path}', '${safeValue}', '${elementId}')"
style="margin-left: 4px; background-color: #e74c3c; color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 13px;">
❌ 取消
</button>
`;
}
function restoreViewModeUI(path, originalValue, elementId) {
const container = document.getElementById(elementId);
if (!container) return;
const safeValue = originalValue.replace(/'/g, "&#39;");
container.innerHTML = `
<span style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${originalValue}</span>
<span onclick="startEdit('${path}', '${safeValue}', '${elementId}')"
style="margin-left: 10px; cursor: pointer; font-size: 14px; opacity: 0.6;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="編輯此項目">✏️</span>
`;
}
function previewCLI(path, elementId) {
const newValue = document.getElementById(`input-${elementId}`).value;
alert(`【Phase 3 預覽】\n路徑: ${path}\n新值: ${newValue}\n\n即將實作轉譯為 CMTS CLI 的功能!`);
}
// ==========================================
// 2. UI 互動與頁籤切換
// ==========================================
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' && 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');
}
function switchConfigTask() {
document.querySelectorAll('#config-tab .task-form').forEach(form => {
form.classList.remove('active');
form.style.display = 'none';
});
const selectedTaskId = document.getElementById('configTask').value;
const targetForm = document.getElementById(selectedTaskId);
if (targetForm) {
targetForm.classList.add('active');
targetForm.style.display = 'block';
}
}
function startConfigTask() {
if (!isConnected) {
alert("請先點擊畫面上方的「連線至 CMTS」成功後再執行載入任務");
return;
}
switchConfigTask();
const selectedTaskId = document.getElementById('configTask').value;
if (selectedTaskId === 'form-bonding-config') {
if (currentMacDomainData && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) {
return;
}
resetAllManagerData();
loadMacDomainList();
} else if (selectedTaskId === 'form-full-config') {
fetchFullConfig();
}
}
async function loadMacDomainList() {
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
const mdSelect = document.getElementById('cfgMacDomain');
const statusSpan = document.getElementById('fetchStatus');
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>';
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
statusSpan.style.color = "#f39c12";
try {
const url = `/api/v1/cmts-mac-domain-list?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
const response = await fetch(url);
const result = await response.json();
if (result.status === 'success' && result.data.length > 0) {
mdSelect.innerHTML = '';
result.data.forEach(md => {
const option = document.createElement('option');
option.value = md;
option.textContent = md;
mdSelect.appendChild(option);
});
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
statusSpan.style.color = "#27ae60";
} else {
mdSelect.innerHTML = '<option value="">❌ 找不到資料</option>';
statusSpan.textContent = "無法解析清單,請確認設備狀態。";
statusSpan.style.color = "#e74c3c";
}
} catch (error) {
mdSelect.innerHTML = '<option value="">❌ 連線錯誤</option>';
statusSpan.textContent = "連線失敗:" + error;
statusSpan.style.color = "#e74c3c";
}
}
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 (留白則查詢全體)";
}
}
function resetAllManagerData() {
currentMacDomainData = null;
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 = '';
}
// ==========================================
// 3. 終端機 (Terminal) 與 WebSocket 邏輯
// ==========================================
function colorizeTerminalStream(text) {
if (text.includes('\x1b[')) return text;
let res = text;
res = res.replace(/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g, '\x1b[1;34m$1\x1b[0m');
res = res.replace(/\b([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\b/g, '\x1b[1;35m$1\x1b[0m');
res = res.replace(/\b(online|w-online|p-online|init\([^)]+\)|up|active|success|yes)\b/gi, '\x1b[1;32m$1\x1b[0m');
res = res.replace(/\b(offline|down|admin-down|error|fail|failed|shutdown|reject|invalid|no)\b/gi, '\x1b[1;31m$1\x1b[0m');
res = res.replace(/\b(Cable|GigabitEthernet|TenGigabitEthernet|Upstream|Downstream|Mac-domain|bonding-group|rpd)\b/gi, '\x1b[33m$1\x1b[0m');
return res;
}
function initTerminal() {
term = new Terminal({ cursorBlink: true, theme: { background: '#1e1e1e', foreground: '#e0e0e0' }, fontFamily: 'Courier New, monospace', fontSize: 15, scrollback: 100000 });
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal-container'));
fitAddon.fit();
term.writeln('Welcome to Harmonic cOS Web Terminal.');
term.writeln('Please confirm the target device above and click "連線至 CMTS"... \r\n');
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
});
}
function getGlobalConnectionInfo() {
const host = document.getElementById('cmtsHost').value.trim();
const user = document.getElementById('cmtsUser').value.trim();
const pass = document.getElementById('cmtsPass').value.trim();
if (!host || !user || !pass) {
alert("請在畫面上方完整輸入目標設備的 IP、帳號與密碼");
return null;
}
return { host, user, pass };
}
function connectWebSocket() {
if (ws && ws.readyState === WebSocket.OPEN) return;
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
resetAllManagerData();
term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`);
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/terminal?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
isConnected = true;
const btnLoadTask = document.getElementById('btn-load-task');
if (btnLoadTask) {
btnLoadTask.disabled = false;
btnLoadTask.style.backgroundColor = '#c0392b';
btnLoadTask.style.cursor = 'pointer';
}
document.getElementById('wsStatus').textContent = `狀態:已連線`;
document.getElementById('wsStatus').style.color = '#27ae60';
document.getElementById('btnConnect').style.display = 'none';
document.getElementById('btnDisconnect').style.display = 'inline-block';
document.getElementById('cmtsHost').disabled = true;
document.getElementById('cmtsUser').disabled = true;
document.getElementById('cmtsPass').disabled = true;
term.focus();
};
ws.onmessage = (event) => {
term.write(colorizeTerminalStream(event.data));
};
ws.onclose = () => {
isConnected = false;
const btnLoadTask = document.getElementById('btn-load-task');
if (btnLoadTask) {
btnLoadTask.disabled = true;
btnLoadTask.style.backgroundColor = '#95a5a6';
btnLoadTask.style.cursor = 'not-allowed';
}
document.getElementById('wsStatus').textContent = '狀態:已斷線';
document.getElementById('wsStatus').style.color = '#c0392b';
document.getElementById('btnConnect').style.display = 'inline-block';
document.getElementById('btnDisconnect').style.display = 'none';
document.getElementById('cmtsHost').disabled = false;
document.getElementById('cmtsUser').disabled = false;
document.getElementById('cmtsPass').disabled = false;
term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n');
resetAllManagerData();
};
}
function disconnectWebSocket() { if (ws) ws.close(); }
function injectCommand() {
if (!ws || ws.readyState !== WebSocket.OPEN) return alert("請先點擊「連線至 CMTS」");
const cmd = document.getElementById('fixedCmd').value;
ws.send(cmd + '\r');
term.focus();
}
function getTerminalText() {
if (!term) return '';
try {
if (term.hasSelection()) return term.getSelection();
const buffer = term.buffer.active;
let text = '';
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) text += line.translateToString(true) + '\n';
}
return text;
} catch (err) {
console.error("讀取終端機文字失敗:", err);
return '';
}
}
function downloadTerminal() {
const text = getTerminalText();
if (!text.trim()) return alert("沒有內容可下載!");
try {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
a.download = `cmts_terminal_log_${timestamp}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error("下載失敗:", err);
}
}
// ==========================================
// 4. 綜合查詢 API 呼叫
// ==========================================
async function executeQuery(mode) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert("請先點擊畫面上方的「連線至 CMTS」按鈕");
return;
}
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');
outputEl.innerHTML = `<span style="color: #f39c12;">正在向 ${connInfo.host} 查詢中,請稍候...</span>`;
try {
const url = `/api/v1/cmts-query?query_type=${queryType}&target=${encodeURIComponent(target)}&host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
const response = await fetch(url);
const result = await response.json();
if (result.status === 'success') {
outputEl.style.color = "#e0e0e0";
if (isDebug) {
outputEl.textContent = `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}`;
} else {
outputEl.textContent = result.data;
}
} else {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`;
}
} catch (error) {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `連線失敗: ${error}`;
}
}
// ==========================================
// 5. MAC Domain 配置精靈邏輯
// ==========================================
async function fetchMacDomainConfig() {
const target = document.getElementById('cfgMacDomain').value.trim();
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !target) return alert("請確認設備連線資訊與 MAC Domain");
document.getElementById('fetchStatus').textContent = "⏳ 正在讀取並解析設定中...";
document.getElementById('fetchStatus').style.color = "#f39c12";
try {
const url = `/api/v1/cmts-mac-domain-config?target=${encodeURIComponent(target)}&host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
const response = await fetch(url);
const result = await response.json();
if (result.status === 'success') {
currentMacDomainData = result.data;
populateFormFromData();
document.getElementById('macDomainConfigArea').style.display = 'block';
document.getElementById('fetchStatus').textContent = "✅ 讀取成功!";
document.getElementById('fetchStatus').style.color = "#27ae60";
} else {
alert("讀取失敗:" + result.detail);
document.getElementById('fetchStatus').textContent = "❌ 讀取失敗";
}
} catch (error) {
alert("連線錯誤:" + error);
}
}
function populateFormFromData() {
const d = currentMacDomainData;
document.getElementById('g_ip_prov').value = d.common_settings['ip-provisioning-mode'] || 'dual-stack';
document.getElementById('g_diplexer').value = d.common_settings['diplexer-band-edge'] || 'enabled';
document.getElementById('g_bat31').value = d.common_settings['cm-battery-mode-31-support'] || 'disabled';
document.getElementById('g_bat30').value = d.common_settings['cm-battery-mode-30-support'] || 'disabled';
document.getElementById('g_docsis40').value = d.common_settings['docsis40'] || 'disabled';
document.getElementById('g_ds_dyn').value = d.common_settings['ds-dynamic-bonding-group'] || 'disabled';
document.getElementById('g_us_dyn').value = d.common_settings['us-dynamic-bonding-group'] || 'disabled';
document.getElementById('b_admin').value = d.basic_channel_sets['admin-state'] || 'down';
document.getElementById('b_ds_pri').value = d.basic_channel_sets['ds-primary-set'] || '';
document.getElementById('b_ds_non_pri').value = d.basic_channel_sets['ds-non-primary-set'] || '';
document.getElementById('b_us_phy').value = d.basic_channel_sets['us-phy-channel-set'] || '';
document.getElementById('b_ds_ofdm').value = d.basic_channel_sets['ds-ofdm-set'] || '';
document.getElementById('b_us_ofdma').value = d.basic_channel_sets['us-ofdma-set'] || '';
const dsSelect = document.getElementById('select_ds_group');
dsSelect.innerHTML = '<option value="_new_">[ 新增 DS Group]</option>';
Object.keys(d.static_ds_bonding_groups).forEach(grp => dsSelect.innerHTML += `<option value="${grp}">${grp}</option>`);
if (Object.keys(d.static_ds_bonding_groups).length > 0) dsSelect.value = Object.keys(d.static_ds_bonding_groups)[0];
const usSelect = document.getElementById('select_us_group');
usSelect.innerHTML = '<option value="_new_">[ 新增 US Group]</option>';
Object.keys(d.static_us_bonding_groups).forEach(grp => usSelect.innerHTML += `<option value="${grp}">${grp}</option>`);
if (Object.keys(d.static_us_bonding_groups).length > 0) usSelect.value = Object.keys(d.static_us_bonding_groups)[0];
toggleGroupVisibility();
loadDsGroupData();
loadUsGroupData();
}
function revertFormChanges() {
if (!currentMacDomainData) return;
if (!confirm("確定要放棄目前的修改,還原回設備原始的設定值嗎?")) return;
populateFormFromData();
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 fetchStatus = document.getElementById('fetchStatus');
fetchStatus.textContent = "🔄 已還原為原始設定!";
fetchStatus.style.color = "#2980b9";
setTimeout(() => {
fetchStatus.textContent = "✅ 讀取成功!";
fetchStatus.style.color = "#27ae60";
}, 3000);
}
function toggleGroupVisibility() {
const dsDyn = document.getElementById('g_ds_dyn').value;
const usDyn = document.getElementById('g_us_dyn').value;
document.getElementById('section_group_ds').style.display = (dsDyn === 'disabled') ? 'block' : 'none';
document.getElementById('section_group_us').style.display = (usDyn === 'disabled') ? 'block' : 'none';
}
function loadDsGroupData() {
const grp = document.getElementById('select_ds_group').value;
const newGrpInput = document.getElementById('input_new_ds_group');
if (grp === '_new_') {
newGrpInput.style.display = 'inline-block';
document.getElementById('ds_g_admin').value = 'down';
document.getElementById('ds_g_down').value = '';
document.getElementById('ds_g_ofdm').value = '';
document.getElementById('ds_g_fdx').value = '';
} else {
newGrpInput.style.display = 'none';
const data = currentMacDomainData.static_ds_bonding_groups[grp];
document.getElementById('ds_g_admin').value = data['admin-state'] || 'down';
document.getElementById('ds_g_down').value = data['down-channel-set'] || '';
document.getElementById('ds_g_ofdm').value = data['ofdm-channel-set'] || '';
document.getElementById('ds_g_fdx').value = data['fdx-ofdm-channel-set'] || '';
}
}
function loadUsGroupData() {
const grp = document.getElementById('select_us_group').value;
const newGrpInput = document.getElementById('input_new_us_group');
if (grp === '_new_') {
newGrpInput.style.display = 'inline-block';
document.getElementById('us_g_admin').value = 'down';
document.getElementById('us_g_us').value = '';
document.getElementById('us_g_ofdma').value = '';
document.getElementById('us_g_fdx').value = '';
} else {
newGrpInput.style.display = 'none';
const data = currentMacDomainData.static_us_bonding_groups[grp];
document.getElementById('us_g_admin').value = data['admin-state'] || 'down';
document.getElementById('us_g_us').value = data['us-channel-set'] || '';
document.getElementById('us_g_ofdma').value = data['ofdma-channel-set'] || '';
document.getElementById('us_g_fdx').value = data['fdx-ofdma-channel-set'] || '';
}
}
function generateMacDomainCLI() {
const md = document.getElementById('cfgMacDomain').value.trim();
let cli = `! --- Harmonic MAC Domain Configuration SOP ---\n`;
cli += `! 1. [區塊] MAC Domain 全域與 Base 設定\n`;
cli += `cable mac-domain ${md} admin-state down\ncommit\n`;
const baseCmds = [
`ip-provisioning-mode ${document.getElementById('g_ip_prov').value}`,
`diplexer-band-edge control ${document.getElementById('g_diplexer').value}`,
`cm-battery-mode-31-support ${document.getElementById('g_bat31').value}`,
`cm-battery-mode-30-support ${document.getElementById('g_bat30').value}`,
`docsis40 ${document.getElementById('g_docsis40').value}`,
`ds-dynamic-bonding-group ${document.getElementById('g_ds_dyn').value}`,
`us-dynamic-bonding-group ${document.getElementById('g_us_dyn').value}`,
`ds-primary-set ${document.getElementById('b_ds_pri').value}`,
`ds-non-primary-set ${document.getElementById('b_ds_non_pri').value}`,
`us-phy-channel-set ${document.getElementById('b_us_phy').value}`,
`ds-ofdm-set ${document.getElementById('b_ds_ofdm').value}`,
`us-ofdma-set ${document.getElementById('b_us_ofdma').value}`
];
baseCmds.forEach(cmd => {
if (!cmd.endsWith(" ")) cli += `cable mac-domain ${md} ${cmd}\n`;
});
cli += `cable mac-domain ${md} admin-state ${document.getElementById('b_admin').value}\ncommit\n`;
if (document.getElementById('g_ds_dyn').value === 'disabled') {
const isNewDs = document.getElementById('select_ds_group').value === '_new_';
let dsName = isNewDs ? document.getElementById('input_new_ds_group').value.trim() : document.getElementById('select_ds_group').value;
if (dsName) {
cli += `\n! 2. [區塊] DS Bonding Group [${dsName}] 設定\n`;
if (!isNewDs) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} admin-state down\ncommit\n`;
if (document.getElementById('ds_g_down').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} down-channel-set ${document.getElementById('ds_g_down').value}\n`;
if (document.getElementById('ds_g_ofdm').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} ofdm-channel-set ${document.getElementById('ds_g_ofdm').value}\n`;
if (document.getElementById('ds_g_fdx').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} fdx-ofdm-channel-set ${document.getElementById('ds_g_fdx').value}\n`;
cli += `cable mac-domain ${md} ds-bonding-group ${dsName} admin-state ${document.getElementById('ds_g_admin').value}\ncommit\n`;
}
}
if (document.getElementById('g_us_dyn').value === 'disabled') {
const isNewUs = document.getElementById('select_us_group').value === '_new_';
let usName = isNewUs ? document.getElementById('input_new_us_group').value.trim() : document.getElementById('select_us_group').value;
if (usName) {
cli += `\n! 3. [區塊] US Bonding Group [${usName}] 設定\n`;
if (!isNewUs) cli += `cable mac-domain ${md} us-bonding-group ${usName} admin-state down\ncommit\n`;
if (document.getElementById('us_g_us').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} us-channel-set ${document.getElementById('us_g_us').value}\n`;
if (document.getElementById('us_g_ofdma').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} ofdma-channel-set ${document.getElementById('us_g_ofdma').value}\n`;
if (document.getElementById('us_g_fdx').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} fdx-ofdma-channel-set ${document.getElementById('us_g_fdx').value}\n`;
cli += `cable mac-domain ${md} us-bonding-group ${usName} admin-state ${document.getElementById('us_g_admin').value}\ncommit\n`;
}
}
const previewEl = document.getElementById('cliPreviewContainer');
previewEl.textContent = cli;
previewEl.style.display = 'block';
const deployBtn = document.getElementById('btnDeployConfig');
deployBtn.style.display = 'inline-block';
deployBtn.disabled = false;
}
async function executeBondingConfig() {
if (!confirm("⚠️ 警告:此操作將導致 MAC Domain 重新啟動,影響用戶服務。確定要執行嗎?")) return;
const cliScript = document.getElementById('cliPreviewContainer').textContent;
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
openModal(connInfo.host);
const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\n\n${cliScript}`;
try {
const response = await fetch('/api/v1/cmts-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script: cliScript,
host: connInfo.host,
username: connInfo.user,
password: connInfo.pass
})
});
const result = await response.json();
if (result.status === 'success') {
outputEl.style.color = "#e0e0e0";
outputEl.textContent = `✅ 設定執行完成!\n==================================================\n${result.data}`;
} else {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 設定執行失敗:\n${result.message}`;
}
} catch (error) {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
}
}
// ==========================================
// 💡 6. 完整設備配置樹狀圖邏輯 (支援資料夾層級鎖定與虛擬群組視覺優化)
// ==========================================
// 🌟 1. 在參數中加入 isParentCommandGroup用來實現「向下繼承」
// 🌟 加入 isParentCommandGroup 參數,實現向下繼承
function buildTree(node, path = '', isParentCommandGroup = false) {
let html = '';
if (!node || typeof node !== 'object') return html;
for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key;
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (typeof value === 'object' && value !== null) {
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
const folderSuffix = isCommandGroup ? '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>' : '';
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
const expandCollapseBtns = `
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
onmouseover="this.style.color='#3498db'" onmouseout="this.style.color='#95a5a6'" title="展開全部子項目">
${expandAllIcon}
</span>
<span onclick="event.stopPropagation(); event.preventDefault(); collapseAll('${elementId}')"
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
onmouseover="this.style.color='#e67e22'" onmouseout="this.style.color='#95a5a6'" title="收合全部子項目">
${collapseAllIcon}
</span>
</span>
`;
html += `
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';">
<summary class="tree-folder-header"
title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'"
onmouseout="this.style.backgroundColor='transparent'"
style="cursor: pointer; padding: 4px 8px; display: flex; align-items: center; outline: none; border-radius: 4px; transition: background-color 0.2s;">
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
<span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
<span class="icon-open" style="display: none;">${iconOpen}</span>
</span>
<b style="color: #2c3e50;">${key}</b>${folderSuffix}
${expandCollapseBtns}
<div style="margin-left: auto; display: flex; align-items: center;">
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組">
✏️
</span>
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
<button onclick="event.stopPropagation(); event.preventDefault(); cancelEditFolder('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;">❌ 取消</button>
</span>
</div>
</summary>
<div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;">
${buildTree(value, currentPath, isCommandGroup)}
</div>
</details>
`;
} else {
// 🌟 處理單一設定項目 (Leaf)
const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
// 💡 終極修正:完美兼容根目錄與子目錄的 no 指令
if (isNoCommand) {
// 1. 移除結尾的 " no" (子目錄) 或是整句剛好是 "no" (根目錄)
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
// 2. 如果 baseCmd 有東西,代表是子目錄;如果為空,代表是根目錄
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
}
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
const svgDisabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
// 💡 關鍵修正:加上 display: inline-block; 與 transform: translateY(1.5px); 強制下移
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
let displayContent = '';
if (isVirtualIndex) {
displayContent = `
${iconHtml(svgLeaf)}
<span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
<span class="leaf-value" style="${valueStyle}">${safeValue}</span>
</span>
`;
} else if (isNoCommand) {
// 為了方便後續的「原地變身」,我們加上了 id="display-wrapper-..."
displayContent = `
<span id="display-wrapper-${elementId}" style="display: inline-flex; align-items: center;">
${iconHtml(svgDisabled)}
<b style="color: #e74c3c;">no:</b>
<span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
<span class="leaf-value" style="color: #7f8c8d; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>
</span>
</span>
`;
} else {
displayContent = `
${iconHtml(svgLeaf)} <b>${key}</b>:
<span class="leaf-container" id="container-${elementId}" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
<span class="leaf-value" style="${valueStyle}">${safeValue}</span>
</span>
`;
}
html += `
<div class="tree-leaf-node"
title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'"
onmouseout="this.style.backgroundColor='transparent'"
style="padding: 4px 8px; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; display: flex; align-items: center; border-radius: 4px; transition: background-color 0.2s;">
${displayContent}
<div style="margin-left: auto; display: flex; align-items: center;">
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目">
✏️
</span>
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
<button onclick="event.stopPropagation(); event.preventDefault(); cancelEditLeaf('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;">❌ 取消</button>
</span>
</div>
</div>
`;
}
}
return html;
}
async function fetchFullConfig() {
const loadingMsg = document.getElementById('loading-message');
const treeContainer = document.getElementById('tree-container');
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
// 💡 1. 顯示標題旁邊的載入提示 (改為 inline-block 讓它排在同一行)
if (loadingMsg) loadingMsg.style.display = 'inline-block';
// 💡 2. 清空樹狀圖區塊,保持畫面乾淨
if (treeContainer) {
treeContainer.innerHTML = '';
}
try {
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
const response = await fetch(url);
const result = await response.json();
if (result.status === 'success') {
if (treeContainer) {
treeContainer.innerHTML = buildTree(result.data);
enableGlobalScanButton(); // 🌟 新增:樹狀圖成功渲染後,喚醒掃描按鈕
}
} else {
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 錯誤: ${result.message}</div>`;
}
} catch (error) {
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 連線或解析失敗: ${error.message}</div>`;
} finally {
// 💡 3. 完成後隱藏載入提示
if (loadingMsg) loadingMsg.style.display = 'none';
}
}
// ==========================================
// 7. 系統設定 (System Settings) 邏輯
// ==========================================
let currentHiddenKeys = [];
async function openSystemSettings() {
// 切換到此頁籤時,先默默讀取目前的隱藏名單
try {
const response = await fetch('/api/v1/settings/tree-filters');
const result = await response.json();
if (result.status === 'success') {
currentHiddenKeys = result.data;
}
} catch (error) {
console.error("讀取設定失敗:", error);
}
}
async function loadRealConfigForFilters() {
const connInfo = getGlobalConnectionInfo();
if (!connInfo) {
alert("請先在畫面上方輸入設備連線資訊!");
return;
}
const loadingMsg = document.getElementById('filter-loading-msg');
const container = document.getElementById('filter-checkboxes');
loadingMsg.style.display = 'inline-block';
container.style.display = 'none';
try {
// 💡 加上 skip_filter=true確保能抓到被隱藏的節點以便取消勾選
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true`;
const response = await fetch(url);
const result = await response.json();
if (result.status === 'success') {
container.style.display = 'block';
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys);
} else {
container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`;
}
} catch (error) {
container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線失敗: ${error.message}</div>`;
} finally {
loadingMsg.style.display = 'none';
}
}
// 🌟 加入 isParentCommandGroup 參數,讓系統設定也能完美繼承群組狀態
function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false) {
if (typeof data !== 'object' || data === null) return '';
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
for (const key in data) {
const value = data[key];
const currentPath = parentPath ? `${parentPath}::${key}` : key;
const isChecked = hiddenKeys.includes(currentPath) ? "checked" : "";
// 💡 統一在這裡解析 cliCommand讓資料夾與 Leaf 都能擁有 Hint並拔除虛擬索引 [0]
let cliCommand = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgGroupOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#3498db"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
const svgLeaf = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
const isFolder = typeof value === 'object' && value !== null && Object.keys(value).length > 0;
const onChangeEvent = isFolder ? 'toggleChildCheckboxes(this)' : 'updateParentCheckboxState(this)';
const checkboxHtml = `<input type="checkbox" value="${currentPath}" class="filter-checkbox" ${isChecked} onclick="event.stopPropagation();" onchange="${onChangeEvent}" style="margin-right: 8px; cursor: pointer; width: 14px; height: 14px; flex-shrink: 0;">`;
if (isFolder) {
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
const elementId = `filter-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
const folderSuffix = isCommandGroup ? `<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>` : '';
const expandAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 13 12 18 17 13"></polyline><polyline points="7 6 12 11 17 6"></polyline></svg>`;
const collapseAllIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 11 12 6 7 11"></polyline><polyline points="17 18 12 13 7 18"></polyline></svg>`;
const expandCollapseBtns = `
<span style="margin-left: 8px; display: inline-flex; gap: 6px; align-items: center;">
<span onclick="event.stopPropagation(); event.preventDefault(); expandAll('${elementId}')"
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
onmouseover="this.style.color='#3498db'" onmouseout="this.style.color='#95a5a6'" title="展開全部子項目">
${expandAllIcon}
</span>
<span onclick="event.stopPropagation(); event.preventDefault(); collapseAll('${elementId}')"
style="cursor: pointer; color: #95a5a6; transition: color 0.2s; display: flex; align-items: center;"
onmouseover="this.style.color='#e67e22'" onmouseout="this.style.color='#95a5a6'" title="收合全部子項目">
${collapseAllIcon}
</span>
</span>
`;
html += `
<details id="details-${elementId}" class="tree-folder" ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';">
<summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;">
<style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
${checkboxHtml}
<span style="margin-right: 6px; display: inline-flex; align-items: center; width: 18px; height: 18px;">
<span class="icon-closed" style="display: inline-block;">${iconClosed}</span>
<span class="icon-open" style="display: none;">${iconOpen}</span>
</span>
<span>${key}${folderSuffix}</span>
${expandCollapseBtns}
</summary>
<div class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
${buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup)}
</div>
</details>
`;
} else {
// 🌟 處理單一設定項目 (Leaf)
const isVirtualIndex = /^\[\d+\]$/.test(key);
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
const isNoCommand = (key === 'no');
// 預設的數值、冒號與圖示顯示
let valueHtml = `<span style="color: #16a085; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>`;
let colonHtml = `:`;
let currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#bdc3c7"><path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/></svg>`;
// 💡 統一 UI 顯示:紅色禁止符號 + 紅色 no: + 灰色數值
if (isNoCommand) {
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
cliCommand = baseCmd ? `${baseCmd} no ${safeValue}` : `no ${safeValue}`;
displayName = `<b style="color: #e74c3c;">no</b>`;
colonHtml = `:`;
valueHtml = `<span style="color: #7f8c8d; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${safeValue}</span>`;
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
}
html += `
<div class="tree-leaf-node"
onmouseover="this.style.backgroundColor='#f1f2f6'"
onmouseout="this.style.backgroundColor='transparent'"
style="padding: 2px 0; color: #34495e; display: flex; align-items: center; margin-top: 2px; border-radius: 4px; transition: background-color 0.2s;"
title="${cliCommand}">
${checkboxHtml}
<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; flex-shrink: 0;">
${currentIcon}
</span>
<span style="margin-right: 5px;">${displayName}${colonHtml}</span>
${valueHtml}
</div>
`;
}
}
html += '</div>';
return html;
}
// 🔽 當點擊「父節點」時:同步勾選/取消所有子節點
function toggleChildCheckboxes(parentCheckbox) {
const details = parentCheckbox.closest('details');
if (details) {
// 找到該資料夾內所有的子 Checkbox
const childCheckboxes = details.querySelectorAll('.filter-children-container .filter-checkbox');
childCheckboxes.forEach(cb => {
cb.checked = parentCheckbox.checked;
});
}
// 往上檢查是否需要更新更上層的父節點
updateParentCheckboxState(parentCheckbox);
}
// 🔼 當點擊「子節點」時:往上檢查父節點是否該被勾選
function updateParentCheckboxState(element) {
// 找到包住這個元素的上一層 details
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 checkboxes = document.querySelectorAll('.filter-checkbox:checked');
const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value);
try {
const response = await fetch('/api/v1/settings/tree-filters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden_keys: selectedHiddenKeys })
});
const result = await response.json();
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("儲存設定時發生錯誤!");
}
}
// ==========================================
// 8. Modal 彈出視窗控制
// ==========================================
function openModal(targetInfo) {
document.getElementById('outputModal').classList.add('active');
document.getElementById('modalTargetInfo').textContent = targetInfo ? `(${targetInfo})` : '';
document.body.style.overflow = 'hidden';
}
function closeModal(event) {
if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return;
document.getElementById('outputModal').classList.remove('active');
document.body.style.overflow = '';
}
// ==========================================
// 💡 掃描按鈕狀態管理 (支援跨重新整理鎖定)
// ==========================================
// 1. 網頁載入時,檢查是否還有背景任務在跑
document.addEventListener("DOMContentLoaded", () => {
checkScanButtonState();
});
function checkScanButtonState() {
const btn = document.getElementById('global-scan-btn');
if (!btn) return;
const scanLockUntil = localStorage.getItem('scanLockUntil');
const now = Date.now();
if (scanLockUntil && now < parseInt(scanLockUntil)) {
// 如果還在鎖定期間內,恢復鎖定狀態
lockScanButton(parseInt(scanLockUntil) - now);
}
}
// 2. 鎖定按鈕的 UI 變化
function lockScanButton(durationMs) {
const btn = document.getElementById('global-scan-btn');
if (!btn) return;
btn.disabled = true;
btn.style.backgroundColor = "#e67e22"; // 變成醒目的橘色
btn.style.cursor = "not-allowed";
btn.innerText = "⏳ 背景掃描執行中...";
// 設定計時器,時間到自動解鎖
setTimeout(() => {
// 只有當樹狀圖已經載入時,才恢復成可點擊的紫色
const treeContainer = document.getElementById('tree-container');
if (treeContainer && treeContainer.innerHTML.includes('leaf-container')) {
enableGlobalScanButton();
} else {
// 樹狀圖沒載入,退回初始灰階狀態
btn.disabled = true;
btn.style.backgroundColor = "#bdc3c7";
btn.innerText = "⏳ 請先載入樹狀圖";
}
localStorage.removeItem('scanLockUntil');
}, durationMs);
}
// 3. 提供給「樹狀圖載入完成」時呼叫的解鎖函數
function enableGlobalScanButton() {
const btn = document.getElementById('global-scan-btn');
if (!btn) return;
const scanLockUntil = localStorage.getItem('scanLockUntil');
// 如果目前沒有被鎖定,才把它變成可點擊的紫色
if (!scanLockUntil || Date.now() >= parseInt(scanLockUntil)) {
btn.disabled = false;
btn.style.backgroundColor = "#8e44ad"; // 恢復紫色
btn.style.cursor = "pointer";
btn.innerText = "🔍 掃描畫面缺失選項";
}
}
// ==========================================
// 🌟 新增:清除畫面選項快取功能 (精準可見度判定版)
// ==========================================
async function clearVisibleCache() {
const elements = document.querySelectorAll('.leaf-container');
const pathsToClear = [];
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) {
alert("畫面上沒有展開的選項可以清除!\n請先展開您想重抓的資料夾。");
return;
}
if (!confirm(`確定要清除畫面上 ${pathsToClear.length} 個選項的快取嗎?\n(清除後請重新點擊「一鍵掃描」)`)) return;
try {
const response = await fetch('/api/v1/clear_cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pathsToClear)
});
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
const result = await response.json();
alert(`✅ 成功清除 ${result.cleared_count} 筆快取!\n現在請點擊旁邊的「一鍵掃描缺失選項」來重新擷取。`);
} catch (error) {
console.error("清除快取失敗:", error);
alert("❌ 清除快取失敗,請檢查網路連線或後端 API 是否正確啟動。");
}
}
/**
* 將 no 指令原地變身為啟用狀態
* @param {string} elementId - 該節點的 elementId (例如 leaf-no-cable-...)
* @param {string} newCommand - 解除 no 之後的完整指令 (例如 "cable dynamic-secret tftp...")
*/
function transformNoToActive(elementId, newCommand) {
const wrapper = document.getElementById(`display-wrapper-${elementId}`);
if (!wrapper) return;
// 1. 解析指令:切出第一個字 (如 cable) 與後續指令
const parts = newCommand.trim().split(' ');
const firstWord = parts[0]; // "cable"
const restCommand = parts.slice(1).join(' '); // "dynamic-secret..."
// 💡 2. 換成視覺平衡的「空心綠圈圈 + 打勾」圖示
const svgEnabled = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#27ae60"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zM8.29 11.59L7 12.88l4 4 8-8-1.29-1.29L11 14.18z"/></svg>`;
const iconHtml = `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${svgEnabled}</span>`;
// 3. 執行原地替換 (In-place Update)
wrapper.innerHTML = `
${iconHtml}
<b style="color: #2c3e50;">${firstWord}</b>
<span class="leaf-container" style="display: inline-flex; align-items: center; min-height: 24px;">
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);">${restCommand}</span>
<span style="font-size: 0.8em; color: #f39c12; margin-left: 10px; border: 1px solid #f39c12; padding: 2px 6px; border-radius: 4px; background-color: #fffdf7;">(已啟用,重整後歸檔)</span>
</span>
`;
// 4. 隱藏該行的編輯按鈕,避免使用者在歸檔前重複編輯產生邏輯錯誤
const actionBtns = document.getElementById(`actions-${elementId}`);
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (actionBtns) actionBtns.style.display = 'none';
if (editBtn) editBtn.style.display = 'none';
}