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

560 lines
25 KiB
JavaScript
Raw Permalink 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
} 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)
// ==========================================
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, '&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 => {
// ✅ 修改這裡:新增 safeOpt並替換 value 與顯示文字
const safeOpt = escapeHTML(opt);
const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
});
inputHtml += `</select>${hintIcon}`;
} else {
// ✅ 修改這裡:把 value="${origVal}" 改成 value="${safeOrigVal}"
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;
});
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 = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
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, '&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 => {
// ✅ 修改這裡:新增 safeOpt
const safeOpt = escapeHTML(opt);
const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
});
inputHtml += `</select>${hintIcon}`;
} else {
// ✅ 修改這裡:使用 safeOrigVal
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) {
console.warn("選項載入失敗", e);
// ✅ 修改這裡catch 區塊也要使用 safeOrigVal
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) {
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 = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
});
}
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 = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
}
// ==========================================
// 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 = `<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');
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 = `<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>`;
}
}
// ==========================================
// 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 `<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';
}