// --- static/edit-mode.js --- import { apiAcquireLock, apiReleaseLock, apiSendHeartbeat, apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig } from './api.js'; import { getGlobalConnectionInfo } from './utils.js'; // --- 安全跳脫 HTML 特殊字元,防止破版 --- function escapeHTML(str) { if (str === null || str === undefined) return ""; return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // ========================================== // 1. 全域狀態與鎖定管理 (Lock Management) // ========================================== const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); const ACTIVE_HEARTBEATS = {}; // 💡 追蹤當前編輯狀態 export let currentEditPath = null; export let currentEditElementId = null; export let currentEditIsSuccess = false; export let currentEditType = null; export async function releaseAllLocks() { const pathsToRelease = Object.keys(ACTIVE_HEARTBEATS); if (pathsToRelease.length === 0) return; const releasePromises = pathsToRelease.map(path => { clearInterval(ACTIVE_HEARTBEATS[path]); delete ACTIVE_HEARTBEATS[path]; return apiReleaseLock(path, SESSION_USER_ID) .catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err)); }); await Promise.all(releasePromises); alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。"); location.reload(); } async function sendHeartbeat(path) { try { const response = await apiSendHeartbeat(path, SESSION_USER_ID); if (!response.ok) { console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`); if (response.status === 404) { clearInterval(ACTIVE_HEARTBEATS[path]); delete ACTIVE_HEARTBEATS[path]; } } } catch (error) { console.error(`心跳請求發生錯誤 (${path}):`, error); } } // ========================================== // 2. 編輯模式啟動與取消 (Folder & Leaf) // ========================================== export async function startEditFolder(path, elementId) { const connInfo = getGlobalConnectionInfo(); const username = connInfo ? connInfo.user : 'admin'; try { const result = await apiAcquireLock(path, SESSION_USER_ID, username); if (result.status === 409) return alert(`⚠️ ${result.data.detail}`); if (result.data.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'); let optData = {}; try { optData = await apiGetLeafOptions(); } catch (e) { console.warn("無法取得選項快取", e); } const pathsToSync = []; const currentTime = Math.floor(Date.now() / 1000); const CACHE_TTL = 86400 * 7; leafContainers.forEach(container => { const origVal = container.getAttribute('data-original'); const rawPath = container.getAttribute('data-path'); // ✅ 修改這裡:新增 safeOrigVal const safeOrigVal = escapeHTML(origVal); let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); if (rawPath.endsWith('::no')) { cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal; } let leafCache = optData[cacheKey]; if (!leafCache || (currentTime - leafCache.updated_at >= CACHE_TTL)) { pathsToSync.push(cacheKey); } let inputHtml = ""; let hintAttr = ""; let hintIcon = ""; if (leafCache && leafCache.hint) { const safeHint = leafCache.hint.replace(/"/g, '"'); hintAttr = `title="${safeHint}"`; hintIcon = ``; } if (leafCache && leafCache.options && leafCache.options.length > 0) { let options = leafCache.options; if (origVal && !options.includes(origVal)) options = [origVal, ...options]; inputHtml = `${hintIcon}`; } else { // ✅ 修改這裡:把 value="${origVal}" 改成 value="${safeOrigVal}" inputHtml = `${hintIcon}`; } container.innerHTML = inputHtml; }); if (pathsToSync.length > 0) { console.log(`🚀 將 ${pathsToSync.length} 個欄位送入背景排程抓取...`); apiSyncLeafOptions(pathsToSync); } } } catch (error) { alert("❌ 無法連線到鎖定伺服器:" + error.message); } } export async function startEditLeaf(path, elementId) { const connInfo = getGlobalConnectionInfo(); const username = connInfo ? connInfo.user : 'admin'; try { const result = await apiAcquireLock(path, SESSION_USER_ID, username); if (result.status === 409) return alert(`⚠️ ${result.data.detail}`); if (result.data.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'); // ✅ 修改這裡:新增 safeOrigVal const safeOrigVal = escapeHTML(origVal); container.innerHTML = `⏳ 設備連線與載入選項中...`; try { let optData = await apiGetLeafOptions(); let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); 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)) { apiSyncLeafOptions([cacheKey]); let found = false; for (let i = 0; i < 10; i++) { await new Promise(resolve => setTimeout(resolve, 1000)); optData = await apiGetLeafOptions(); leafCache = optData[cacheKey]; if (leafCache && leafCache.options && leafCache.options.length > 0) { found = true; break; } } if (!found) console.warn("⏳ 等待選項超時,退回純文字模式"); } let inputHtml = ""; let hintAttr = ""; let hintIcon = ""; if (leafCache && leafCache.hint) { const safeHint = leafCache.hint.replace(/"/g, '"'); hintAttr = `title="${safeHint}"`; hintIcon = ``; } if (leafCache && leafCache.options && leafCache.options.length > 0) { let options = leafCache.options; if (origVal && !options.includes(origVal)) options = [origVal, ...options]; inputHtml = `${hintIcon}`; } else { // ✅ 修改這裡:使用 safeOrigVal inputHtml = `${hintIcon}`; } container.innerHTML = inputHtml; } catch (e) { console.warn("選項載入失敗", e); // ✅ 修改這裡:catch 區塊也要使用 safeOrigVal container.innerHTML = ``; } } } catch (error) { alert("❌ 無法連線到鎖定伺服器:" + error.message); } } export async function cancelEditFolder(path, elementId) { if (ACTIVE_HEARTBEATS[path]) { clearInterval(ACTIVE_HEARTBEATS[path]); delete ACTIVE_HEARTBEATS[path]; } try { await apiReleaseLock(path, 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'); // ✅ 修改這裡:還原時也要跳脫 const safeOrigVal = escapeHTML(origVal); container.innerHTML = `${safeOrigVal}`; }); } export async function cancelEditLeaf(path, elementId) { if (ACTIVE_HEARTBEATS[path]) { clearInterval(ACTIVE_HEARTBEATS[path]); delete ACTIVE_HEARTBEATS[path]; } try { await apiReleaseLock(path, 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'); // ✅ 修改這裡:還原時也要跳脫 const safeOrigVal = escapeHTML(origVal); container.innerHTML = `${safeOrigVal}`; } // ========================================== // 3. CLI 生成與預覽 (CLI Generation) // ========================================== function getInterfacesWithAdminState() { const nodes = document.querySelectorAll('.leaf-container'); const interfaces = new Set(); nodes.forEach(node => { const path = node.getAttribute('data-path'); if (path && path.endsWith('::admin-state')) { const interfacePath = path.replace('::admin-state', '').replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); interfaces.add(interfacePath); } }); return Array.from(interfaces); } export 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) return alert("沒有偵測到任何修改,無需生成指令!"); try { const result = await apiGenerateCli(diffs, getInterfacesWithAdminState()); if (result.status === 'success') { showCliPreviewModal(result.data); } else { alert("CLI 生成失敗: " + result.message); } } catch (error) { console.error("Error generating CLI:", error); alert("網路連線錯誤,無法生成 CLI"); } } export 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 result = await apiGenerateCli(diffs, getInterfacesWithAdminState()); if (result.status === 'success') { showCliPreviewModal(result.data); } else { alert("CLI 生成失敗: " + result.message); } } catch (error) { alert("網路連線錯誤,無法生成 CLI"); } } function showCliPreviewModal(cliScript) { const leftPane = document.getElementById('tree-container'); const resizer = document.getElementById('drag-resizer'); const rightPane = document.getElementById('side-cli-preview'); 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; 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; } } // ========================================== // 4. 側邊欄與執行邏輯 // ========================================== export 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) { const wrapper = document.getElementById(`display-wrapper-${currentEditElementId}`); if (wrapper) { 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') : ''); if (currentEditType === 'folder') await cancelEditFolder(currentEditPath, currentEditElementId); else await cancelEditLeaf(currentEditPath, currentEditElementId); 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) container.setAttribute('data-original', input.value.trim()); }); } 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); } export function executeSideCLI() { const finalScript = document.getElementById('side-cli-textarea').value; 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 = `${finalScript}`; executeGeneratedCLI(finalScript); } async function executeGeneratedCLI(script) { const connInfo = getGlobalConnectionInfo(); if (!connInfo) return; const outputEl = document.getElementById('side-execution-result'); try { const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass); 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'; outputEl.innerHTML = `${result.data}`; } 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 = `${highlightTerminalOutput(rawLog)}`; } } 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 = `連線或伺服器錯誤: ${error}`; } } // ========================================== // 5. 輔助 UI 函數 // ========================================== 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; 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'; } }); } function highlightTerminalOutput(text) { if (!text) return ''; const lines = text.split('\n'); const formattedLines = lines.map(line => { if (line.match(/(Aborted|Error|Warning|Failed|% Invalid|% Unknown|Bad command)/i)) { return `${line}`; } return line; }); return formattedLines.join('\n'); } function transformNoToActive(elementId, newCommand) { const wrapper = document.getElementById(`display-wrapper-${elementId}`); if (!wrapper) return; const parts = newCommand.trim().split(' '); const firstWord = parts[0]; const restCommand = parts.slice(1).join(' '); const safeRestCommand = escapeHTML(restCommand); const svgEnabled = ``; const iconHtml = `${svgEnabled}`; wrapper.innerHTML = ` ${iconHtml} ${firstWord} ${safeRestCommand} (已啟用,重整後歸檔) `; 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'; }