scm-harmonic-cmts-admin/static/edit-mode.js

1110 lines
50 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/edit-mode.js ---
import {
apiAcquireLock, apiReleaseLock, apiSendHeartbeat,
apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig,
apiSyncLeafOptionsStream
} 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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ==========================================
// 1. 全域狀態與鎖定管理 (Lock Management)
// ==========================================
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
const ACTIVE_HEARTBEATS = {};
// 🌟 新增:用來防禦「秒按取消」導致的非同步渲染競爭危害
const activeEditSessions = new Set();
export let currentEditPath = null;
export let currentEditElementId = null;
export let currentEditIsSuccess = false;
export let currentEditType = null;
export let currentEditDiffs = [];
export async function releaseAllLocks() {
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
if (keysToRelease.length === 0) return;
const releasePromises = keysToRelease.map(key => {
const [host, path] = key.split('@@');
clearInterval(ACTIVE_HEARTBEATS[key]);
delete ACTIVE_HEARTBEATS[key];
return apiReleaseLock(path, SESSION_USER_ID, host)
.catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
});
await Promise.all(releasePromises);
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。");
location.reload();
}
async function sendHeartbeat(path, host, intervalId, lockKey) {
try {
const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
if (!response.ok) {
console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`);
if (response.status === 404) {
clearInterval(intervalId);
if (ACTIVE_HEARTBEATS[lockKey] === intervalId) {
delete ACTIVE_HEARTBEATS[lockKey];
}
}
}
} catch (error) {
console.error(`心跳請求發生錯誤 (${path}):`, error);
}
}
// ==========================================
// 2. 編輯模式啟動與取消 (Folder & Leaf)
// ==========================================
export async function startEditFolder(path, elementId) {
// 🚨 防護 1防止連點或重複執行
if (activeEditSessions.has(elementId)) return;
activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖
const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
// 立即讓按鈕呈現讀取中,防止使用者焦慮連點
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.pointerEvents = 'none';
editBtn.innerText = "⏳";
}
try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
// 🚨 防護 2如果在等待後端上鎖的期間使用者已經按了取消
if (!activeEditSessions.has(elementId)) {
if (result.status === 200 || (result.data && result.data.status === 'success')) {
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
apiReleaseLock(path, SESSION_USER_ID, host); // 默默把剛拿到的鎖還回去
}
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️";
}
return; // 立即中斷,絕不觸碰 UI
}
if (result.status === 409) {
activeEditSessions.delete(elementId);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
return alert(`⚠️ ${result.data.detail}`);
}
if (result.data.status === 'success') {
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
let intervalId;
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId;
// 🌟 立即更新全域鎖定狀態 (加入 Host 隔離)
if (!window.globalActiveLocks) window.globalActiveLocks = {};
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
// 🌟 確保不在冷卻名單中 (防止手速過快)
if (window.recentlyReleasedLocks) {
delete window.recentlyReleasedLocks[`${host}@@${path}`];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:連同所有子節點一起秒級鎖定
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
const btnPath = btn.getAttribute('data-path');
if (btnPath === path) {
btn.title = `🔒 您正在另一個視圖編輯此項目`;
} else {
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
}
btn.innerText = "🔒";
}
});
if (editBtn) editBtn.style.display = 'none';
const actionBtn = document.getElementById(`actions-${elementId}`);
if (actionBtn) actionBtn.style.display = 'inline-block';
const detailsEl = document.getElementById(`details-${elementId}`);
if (detailsEl) detailsEl.open = true;
const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) {
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
return;
}
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
let optData = {};
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
// 🚨 防護 3如果等待 API 期間使用者按了取消,立刻中斷後續渲染
if (!activeEditSessions.has(elementId)) return;
const pathsToSync = [];
const containersArray = Array.from(leafContainers);
const CHUNK_SIZE = 50;
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
// 🚨 防護 4如果分批渲染期間使用者按了取消立刻中斷
if (!activeEditSessions.has(elementId)) return;
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
chunk.forEach(container => {
const origVal = container.getAttribute('data-original');
const rawPath = container.getAttribute('data-path');
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) pathsToSync.push(cacheKey);
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 safeOpt = escapeHTML(opt);
const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
});
inputHtml += `</select>${hintIcon}`;
} else {
inputHtml = `<input type="text" class="edit-input" data-path="${rawPath}" value="${safeOrigVal}" ${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;
});
await new Promise(resolve => setTimeout(resolve, 0));
}
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
}
} catch (error) {
activeEditSessions.delete(elementId);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
alert("❌ 無法連線到鎖定伺服器:" + error.message);
}
}
export async function startEditLeaf(path, elementId) {
// 🚨 防護 1防止連點或重複執行
if (activeEditSessions.has(elementId)) return;
activeEditSessions.add(elementId);
const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.pointerEvents = 'none';
editBtn.innerText = "⏳";
}
try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
// 🚨 防護 2如果在等待後端上鎖的期間使用者已經按了取消
if (!activeEditSessions.has(elementId)) {
if (result.status === 200 || (result.data && result.data.status === 'success')) {
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
apiReleaseLock(path, SESSION_USER_ID, host);
}
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️";
}
return;
}
if (result.status === 409) {
activeEditSessions.delete(elementId);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
return alert(`⚠️ ${result.data.detail}`);
}
if (result.data.status === 'success') {
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
let intervalId;
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId;
if (!window.globalActiveLocks) window.globalActiveLocks = {};
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
if (window.recentlyReleasedLocks) {
delete window.recentlyReleasedLocks[`${host}@@${path}`];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
btn.title = `🔒 您正在另一個視圖編輯此項目`;
btn.innerText = "🔒";
}
});
if (editBtn) editBtn.style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original');
const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
try {
let optData = await apiGetLeafOptions(host, currentMode);
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
let leafCache = optData[cacheKey];
if (!leafCache) {
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
let found = false;
for (let i = 0; i < 10; i++) {
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions(host, currentMode);
leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) {
found = true; break;
}
}
}
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
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="${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 safeOpt = escapeHTML(opt);
const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
});
inputHtml += `</select>${hintIcon}`;
} else {
inputHtml = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" ${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) {
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
container.innerHTML = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" 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) {
activeEditSessions.delete(elementId);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
alert("❌ 無法連線到鎖定伺服器:" + error.message);
}
}
export async function cancelEditFolder(path, elementId) {
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
const connInfo = getGlobalConnectionInfo();
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
if (ACTIVE_HEARTBEATS[lockKey]) {
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
delete ACTIVE_HEARTBEATS[lockKey];
}
// 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
delete window.globalActiveLocks[host][path];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:連同所有子節點一起秒級解除鎖定
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
const btnPath = btn.getAttribute('data-path');
let stillLockedByOther = false;
// 確保解除鎖定時,不會誤解開被其他父節點鎖定的子節點
if (window.globalActiveLocks && window.globalActiveLocks[host]) {
const hostLocks = window.globalActiveLocks[host];
if (hostLocks[btnPath]) {
stillLockedByOther = true;
} else {
for (const lockedPath of Object.keys(hostLocks)) {
if (btnPath.startsWith(lockedPath + "::")) {
stillLockedByOther = true;
break;
}
}
}
}
if (!stillLockedByOther) {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
}
});
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.display = 'inline-block';
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
}
const actionBtns = document.getElementById(`actions-${elementId}`);
if (actionBtns) actionBtns.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 = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
});
// 🌟 聯動防呆機制:如果右側預覽視窗正在顯示這個節點的指令,連帶關閉它,防止無鎖寫入
if (currentEditElementId === elementId) {
document.querySelectorAll('.tree-view-instance').forEach(pane => {
pane.style.flex = '1';
pane.style.width = '100%';
pane.style.maxWidth = 'none';
});
const rightPane = document.getElementById('side-cli-preview');
if (rightPane) {
rightPane.style.flex = '1';
rightPane.style.maxWidth = '50%';
rightPane.style.width = '';
rightPane.style.display = 'none';
}
document.getElementById('drag-resizer').style.display = 'none';
currentEditPath = null;
currentEditElementId = null;
currentEditIsSuccess = false;
currentEditType = null;
}
}
export async function cancelEditLeaf(path, elementId) {
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
const connInfo = getGlobalConnectionInfo();
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
if (ACTIVE_HEARTBEATS[lockKey]) {
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
delete ACTIVE_HEARTBEATS[lockKey];
}
// 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
delete window.globalActiveLocks[host][path];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
});
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.display = 'inline-block';
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
}
const actionBtns = document.getElementById(`actions-${elementId}`);
if (actionBtns) actionBtns.style.display = 'none';
const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original');
const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
// 🌟 聯動防呆機制:如果右側預覽視窗正在顯示這個節點的指令,連帶關閉它,防止無鎖寫入
if (currentEditElementId === elementId) {
document.querySelectorAll('.tree-view-instance').forEach(pane => {
pane.style.flex = '1';
pane.style.width = '100%';
pane.style.maxWidth = 'none';
});
const rightPane = document.getElementById('side-cli-preview');
if (rightPane) {
rightPane.style.flex = '1';
rightPane.style.maxWidth = '50%';
rightPane.style.width = '';
rightPane.style.display = 'none';
}
document.getElementById('drag-resizer').style.display = 'none';
currentEditPath = null;
currentEditElementId = null;
currentEditIsSuccess = false;
currentEditType = null;
}
}
// ==========================================
// 3. CLI 生成與預覽 (CLI Generation)
// ==========================================
function getInterfacesWithAdminState() {
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
if (!activeContainer) return [];
const nodes = activeContainer.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("沒有偵測到任何修改,無需生成指令!");
currentEditDiffs = diffs;
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 }];
currentEditDiffs = diffs;
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 leftPanes = document.querySelectorAll('.tree-view-instance');
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('btn-side-close').textContent = '關閉';
document.getElementById('side-cli-textarea').style.display = 'block';
document.getElementById('side-execution-result').style.display = 'none';
const textarea = document.getElementById('side-cli-textarea');
textarea.value = cliScript;
textarea.readOnly = false;
textarea.style.backgroundColor = '#1e1e1e';
textarea.style.color = '#ecf0f1';
// 🛡️ 安全修改:解除 Flexbox 均分限制,讓 JS 拖拉的 width 生效
leftPanes.forEach(pane => {
pane.style.flex = 'none';
pane.style.maxWidth = 'none';
pane.style.width = 'calc(50% - 11px)';
});
rightPane.style.flex = 'none';
rightPane.style.maxWidth = 'none';
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() {
// 🌟 完美修復:明確恢復 Flexbox 伸展能力與 100% 寬度
document.querySelectorAll('.tree-view-instance').forEach(pane => {
pane.style.flex = '1'; // 恢復 HTML 原本賦予的彈性伸展能力
pane.style.width = '100%'; // 強制撐滿整個父容器
pane.style.maxWidth = 'none';
});
const rightPane = document.getElementById('side-cli-preview');
if (rightPane) {
rightPane.style.flex = '1';
rightPane.style.maxWidth = '50%'; // 恢復 HTML 原本的 50% 限制
rightPane.style.width = '';
rightPane.style.display = 'none';
}
document.getElementById('drag-resizer').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 = `<span style="color: #ecf0f1;">${finalScript}</span>`;
executeGeneratedCLI(finalScript);
}
async function executeGeneratedCLI(script) {
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
const outputEl = document.getElementById('side-execution-result');
// 1. 建立即時 Log 視窗 UI (無邊界滿版融合風格)
outputEl.innerHTML = `
<div id="edit-log-container" style="width: 100%; height: 100%; background: transparent; border: none; padding: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #ecf0f1; font-size: 14px; line-height: 1.5; text-align: left; margin: 0; box-sizing: border-box;">
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] ⏳ 正在透過 SSH 寫入指令,請勿關閉視窗...</div>
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
</div>
`;
const logContainer = document.getElementById('edit-log-container');
// 🌟 修正 1: 捲動目標必須是真正有捲軸的容器
const scrollTarget = 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 reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let isSuccess = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
if (data.status === 'progress') {
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
} else if (data.status === 'success') {
isSuccess = true;
logContainer.innerHTML += `
<div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 14px;">[${timeStr}] ${data.message}</div>
`;
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在同步快取...';
document.getElementById('side-pane-title').style.color = '#f39c12';
} else if (data.status === 'error') {
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
if (data.output) {
logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
}
document.getElementById('btn-side-close').style.display = 'inline-block';
document.getElementById('side-pane-title').innerHTML = '❌ 執行失敗';
document.getElementById('side-pane-title').style.color = '#ff6b6b';
}
// 自動捲動到最底部
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
}
// 4. 串流結束後,執行快取同步
if (isSuccess) {
currentEditIsSuccess = true;
const pathsToSync = currentEditDiffs.map(d => {
let cacheKey = d.path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (d.path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + d.old_val;
return cacheKey;
});
const uniquePaths = [...new Set(pathsToSync)];
try {
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
let isSyncDone = false;
// 🌟 修正 2: 先啟動 SSE 監聽 (避免錯過 done 事件)
const streamPromise = apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
const titleEl = document.getElementById('side-pane-title');
if (data.event === 'done' || data.status === 'done') {
isSyncDone = true;
if (titleEl) {
titleEl.innerHTML = '✅ 寫入成功!快取同步完成';
titleEl.style.color = '#2ecc71';
}
document.getElementById('btn-side-close').style.display = 'inline-block';
logContainer.innerHTML += `<br><span style="color: #2ecc71; font-weight: bold;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
} else if (data.event === 'error' || data.status === 'error') {
isSyncDone = true;
if (titleEl) {
titleEl.innerHTML = '⚠️ 寫入成功 (但同步快取失敗)';
titleEl.style.color = '#e74c3c';
}
document.getElementById('btn-side-close').style.display = 'inline-block';
logContainer.innerHTML += `<br><span style="color: #e74c3c; font-weight: bold;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`;
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
}, currentMode);
// 🌟 修正 3: 再觸發後端同步任務
apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass);
// 🌟 修正 4: 加入 8 秒防呆機制,如果後端默默做完沒通知,自動放行
setTimeout(() => {
if (!isSyncDone) {
const titleEl = document.getElementById('side-pane-title');
if (titleEl) {
titleEl.innerHTML = '✅ 寫入成功!(快取同步已於背景完成)';
titleEl.style.color = '#2ecc71';
}
document.getElementById('btn-side-close').style.display = 'inline-block';
logContainer.innerHTML += `<br><span style="color: #f39c12; font-weight: bold;">[系統] 快取同步已於背景執行,您可以關閉此面板。</span>`;
if (scrollTarget) scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
}, 8000);
await streamPromise;
} catch (syncErr) {
console.error("同步設定失敗:", syncErr);
document.getElementById('btn-side-close').style.display = 'inline-block';
}
} else {
currentEditIsSuccess = false;
}
} 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';
logContainer.innerHTML += `<div style="color: #ff6b6b; font-weight: bold; margin-top: 10px;">連線或伺服器錯誤: ${error}</div>`;
}
}
// ==========================================
// 5. 輔助 UI 函數
// ==========================================
function initResizer() {
const resizer = document.getElementById('drag-resizer');
const leftPanes = document.querySelectorAll('.tree-view-instance');
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;
leftPanes.forEach(pane => pane.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 `<span style="color: #ff6b6b; font-weight: bold;">${line}</span>`;
}
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 = `<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>`;
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);">${safeRestCommand}</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>
`;
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';
}
// ============================================================================
// 💻 開發者工具:預覽節點完整指令 (唯讀 Debug 模式)
// ============================================================================
export async function previewNodeCLI(btnElement, rootPath) {
let container = btnElement.closest('details');
if (container) {
const elementId = container.id.replace('details-', '');
if (container.dataset.loaded !== 'true') {
await window.lazyLoadFolder(elementId, false);
}
}
const targetNode = container || btnElement.closest('.tree-leaf-node');
if (!targetNode) return;
const leafNodes = targetNode.querySelectorAll('.leaf-container');
const interfaceGroups = {};
leafNodes.forEach(node => {
const path = node.getAttribute('data-path') || "";
const val = node.getAttribute('data-original');
if (!path) return;
const parts = path.split('::');
const paramName = parts.pop();
let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');
if (!interfaceGroups[interfacePath]) {
interfaceGroups[interfacePath] = [];
}
interfaceGroups[interfacePath].push({ param: paramName, val: val });
});
if (Object.keys(interfaceGroups).length === 0 && targetNode.classList.contains('tree-leaf-node')) {
const singleNode = targetNode.querySelector('.leaf-container');
if (singleNode) {
const path = singleNode.getAttribute('data-path');
const val = singleNode.getAttribute('data-original');
const parts = path.split('::');
const paramName = parts.pop();
let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');
interfaceGroups[interfacePath] = [{ param: paramName, val: val }];
}
}
if (Object.keys(interfaceGroups).length === 0) {
return alert("此節點下沒有任何數值可供預覽!");
}
let cliLines = [];
cliLines.push("! --- Read-Only Configuration Preview ---");
for (const [interfacePath, params] of Object.entries(interfaceGroups)) {
cliLines.push(`! Node: ${interfacePath || 'Global'}`);
params.forEach(({param, val}) => {
if (/^\[\d+\]$/.test(param)) {
if (val) {
cliLines.push(interfacePath ? `${interfacePath} ${val}` : val);
}
} else if (param === 'no') {
cliLines.push(interfacePath ? `${interfacePath} no ${val}` : `no ${val}`);
} else {
let line = interfacePath ? `${interfacePath} ${param}` : param;
if (val) line += ` ${val}`;
cliLines.push(line);
}
});
cliLines.push("!");
}
const finalScript = cliLines.join('\n');
showReadOnlyCliPreviewModal(finalScript, rootPath);
}
function showReadOnlyCliPreviewModal(cliScript, rootPath) {
const leftPanes = document.querySelectorAll('.tree-view-instance');
const resizer = document.getElementById('drag-resizer');
const rightPane = document.getElementById('side-cli-preview');
document.getElementById('side-pane-title').innerHTML = `🔍 [唯讀預覽] ${rootPath}`;
document.getElementById('side-pane-title').style.color = '#3498db';
document.getElementById('btn-side-confirm').style.display = 'none';
document.getElementById('btn-side-cancel').style.display = 'none';
const closeBtn = document.getElementById('btn-side-close');
closeBtn.style.display = 'inline-block';
closeBtn.textContent = '關閉預覽';
const textarea = document.getElementById('side-cli-textarea');
textarea.style.display = 'block';
textarea.value = cliScript;
textarea.readOnly = true;
textarea.style.backgroundColor = '#2c3e50';
document.getElementById('side-execution-result').style.display = 'none';
// 🛡️ 安全修改:解除 Flexbox 均分限制,讓 JS 拖拉的 width 生效
leftPanes.forEach(pane => {
pane.style.flex = 'none';
pane.style.maxWidth = 'none';
pane.style.width = 'calc(50% - 11px)';
});
if (rightPane) {
rightPane.style.flex = 'none';
rightPane.style.maxWidth = 'none';
rightPane.style.width = 'calc(50% - 11px)';
rightPane.style.display = 'block';
}
if (resizer) resizer.style.display = 'flex';
if (!window.isResizerInitialized && typeof initResizer === 'function') {
initResizer();
window.isResizerInitialized = true;
}
}
// --- EOF (檔案結束) ---