diff --git a/all_code.txt b/all_code.txt index 3912e25..b9792b1 100644 --- a/all_code.txt +++ b/all_code.txt @@ -971,12 +971,10 @@ FILE: index.html
等待執行中...@@ -1800,28 +1800,33 @@ FILE: PROJECT_STATE.md - [x] 調整 `init_db.py` 為非同步啟動腳本。 ### Phase 2: 設備配置備份與快照機制 (Completed) -- [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`)。 +- [x] **資料庫擴充**:在 PostgreSQL 中建立 `config_backups` 資料表 (包含 `id`, `host`, `timestamp`, `raw_cli`, `parsed_tree`, `snapshot_name`, `description`)。 - [x] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表 (馬卡龍色系與 Flexbox 佈局)。 - [x] **後端 API 實作**:完成手動建立快照與取得歷史快照列表的 API 路由。 - [x] **前端 UI 優化與防禦性編程**:完成滿版視覺重構、原生日期選擇器整合,並實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。 +### Phase 3: 智慧差異還原與歷史預覽 (Completed) +- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。 +- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕),並加入跨設備還原阻斷機制。 +- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。 +- [x] **前端串流接收器 (Streaming UI)**:升級 `executeSmartRestore`,使用 `ReadableStream` 即時渲染後端傳來的 SSH 逐行執行 Log,並具備自動捲動功能。 +- [x] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**: + - 實作 `/api/v1/backups/{id}/restore` API,採用 `StreamingResponse` (NDJSON 串流回應)。 + - 建立安全的逐行寫入機制 (Fail-safe),遇錯立即停止、發送 `abort` 撤銷變更,並回傳錯誤 Chunk。 +- [x] **效能與體驗優化**:完成 DOM 陣列渲染優化、Terminal 資源回收 (防殭屍連線)、精準 Prompt 偵測,以及 UI 視窗連動防呆機制。 + --- ## 🚧 目前開發階段 (Current Phase) -### Phase 3: 智慧差異還原與歷史預覽 (Smart Diff Recovery & Preview) -- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。 -- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕)。 -- [x] **後端 Diff 引擎實作**:完成 `/api/v1/backups/{id}/diff`,成功生成絕對路徑指令陣列。 -- [ ] **前端串流接收器 (Streaming UI)**:升級 `executeSmartRestore`,使用 ReadableStream 即時渲染後端傳來的 SSH 逐行執行 Log。 -- [ ] **後端 SSH 交易寫入管道 (Transactional SSH Pipeline)**: - - 實作 `/api/v1/backups/{id}/restore` API,改為 Streaming Response (串流回應)。 - - 建立安全的逐行寫入機制 (Fail-safe),遇錯立即停止並回傳錯誤 Chunk。 +### Phase 4: 自動化防護與進階管理 (Automation & Advanced Management) +- [ ] **自動備份攔截 (Auto-Backup Interceptor)**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制,確保每次變更前都有還原點。 +- [ ] **備份保留策略 (Retention Policy)**:實作定期清理過期或過多歷史快照的機制,防止資料庫無限膨脹。 --- ## 🐛 已知問題與未來計畫 (Known Issues & Backlog) -- **[未來計畫] 自動備份攔截**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制。 +- 目前系統運行穩定,各項併發鎖定與 UI 狀態連動皆已完善。等待進入 Phase 4 開發。 ================================================================================ @@ -2049,7 +2054,7 @@ import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache import { releaseAllLocks, startEditFolder, startEditLeaf, cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI, - hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID + hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID, currentEditElementId } from './edit-mode.js'; // 6. MAC Domain 配置模組 (MAC Domain) @@ -2126,9 +2131,12 @@ window.addEventListener('beforeunload', () => { } }); -// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」 +// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」(加入 Host 隔離) let activeLockState = new Set(); let lockPollingTimer = null; +let currentPollingHost = null; // 記錄當前輪詢的設備 IP +window.globalActiveLocks = {}; // 🌟 升級為二維結構:{ "10.14.110.4": { "alias": {...} } } +window.recentlyReleasedLocks = {}; // 🌟 新增:防閃爍冷卻表 (Optimistic UI Cooldown) function startLockStatusPolling() { if (lockPollingTimer) clearInterval(lockPollingTimer); @@ -2136,34 +2144,64 @@ function startLockStatusPolling() { lockPollingTimer = setInterval(async () => { const connInfo = getGlobalConnectionInfo(); if (!connInfo || !connInfo.host) return; + const host = connInfo.host; + + // 🛡️ 跨設備防呆:如果使用者切換了 IP,必須清空舊設備的鎖定記憶,防止幽靈解鎖 + if (currentPollingHost !== host) { + activeLockState.clear(); + currentPollingHost = host; + } try { - const result = await apiGetLockStatus(connInfo.host); + const result = await apiGetLockStatus(host); if (result.status === 'success') { const currentLocks = result.data; + window.globalActiveLocks[host] = currentLocks; const newLockState = new Set(); + const now = Date.now(); // 🎯 任務 1:處理「新增鎖定」或「持續鎖定」的節點 for (const [path, lockInfo] of Object.entries(currentLocks)) { - if (lockInfo.user_id !== SESSION_USER_ID) { - newLockState.add(path); - const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); - - targetBtns.forEach(btn => { - if (btn.innerText !== "🔒") { - btn.style.pointerEvents = 'none'; - btn.style.opacity = '0.2'; - btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; - btn.innerText = "🔒"; - } - }); + const lockKey = `${host}@@${path}`; + + // 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒) + if (window.recentlyReleasedLocks[lockKey]) { + if (now - window.recentlyReleasedLocks[lockKey] < 8000) { + continue; // 跳過,不當作鎖定 + } else { + delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶 + } } + + newLockState.add(lockKey); // 記憶體加入 Host 標籤 + + const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); + + targetBtns.forEach(btn => { + // 略過目前正在編輯的那個實體按鈕 + if (btn.id === `edit-btn-${currentEditElementId}`) return; + + if (btn.innerText !== "🔒") { + btn.style.pointerEvents = 'none'; + btn.style.opacity = '0.2'; + // 判斷是別人鎖的,還是自己在另一個視圖鎖的 + if (lockInfo.user_id !== SESSION_USER_ID) { + btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; + } else { + btn.title = `🔒 您正在另一個視圖編輯此項目`; + } + btn.innerText = "🔒"; + } + }); } // 🔓 任務 2:處理「解除鎖定」的節點 - activeLockState.forEach(oldPath => { - if (!newLockState.has(oldPath)) { + activeLockState.forEach(oldKey => { + const [oldHost, oldPath] = oldKey.split('@@'); + + // 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點 + if (oldHost === host && !newLockState.has(oldKey)) { const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); @@ -2346,6 +2384,11 @@ function resetAllManagerData() { // 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM clearTreeCache(); + + // 🛡️ [跨設備防護] 切換設備時,清空鎖定輪詢的記憶體 + if (typeof activeLockState !== 'undefined' && activeLockState.clear) { + activeLockState.clear(); + } const configArea = document.getElementById('macDomainConfigArea'); if (configArea) configArea.style.display = 'none'; @@ -3182,9 +3225,26 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) { - `; + // 動態插入確認按鈕到 modal-action-container + const actionContainer = document.getElementById('modal-action-container'); + if (actionContainer) { + const oldConfirmBtn = document.getElementById('btn-confirm-restore'); + if (oldConfirmBtn) oldConfirmBtn.remove(); + + const confirmBtn = document.createElement('button'); + confirmBtn.id = 'btn-confirm-restore'; + confirmBtn.className = 'btn-modern btn-save'; + confirmBtn.style.backgroundColor = '#e74c3c'; + confirmBtn.style.padding = '6px 12px'; + confirmBtn.style.fontSize = '13px'; + confirmBtn.innerHTML = '⚠️ 確認無誤,立即寫入設備並 Commit'; + confirmBtn.onclick = () => executeSmartRestore(snapshotId, snapshotName, commands); + + actionContainer.insertBefore(confirmBtn, document.getElementById('btn-modal-close')); + } + diffHtml += `
`;
commands.forEach(cmd => {
if (cmd.startsWith('no ')) diffHtml += `- ${cmd}\n`;
@@ -3213,8 +3273,8 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit!\n確定要繼續嗎?`)) return;
- // 1. 優雅地將按鈕反灰 (Disabled)
- const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore'];
+ // 1. 優雅地將按鈕反灰 (Disabled),包含關閉按鈕
+ const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore', 'btn-modal-close'];
btnIds.forEach(id => {
const btn = document.getElementById(id);
if (btn) {
@@ -3233,7 +3293,6 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const modalOutput = document.getElementById('modalOutput');
// 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸)
- // 移除 min-height 和 overflow-y,讓它完全撐滿父容器
modalOutput.innerHTML = `
[系統] ⏳ 正在透過 SSH 寫入還原指令,請勿關閉視窗...
@@ -3243,14 +3302,12 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const logContainer = document.getElementById('restore-log-container');
try {
- // 3. 發送 Fetch 請求 (不使用 await response.json())
const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands })
});
- // 4. 處理 ReadableStream (串流接收 NDJSON)
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
@@ -3259,13 +3316,8 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const { done, value } = await reader.read();
if (done) break;
- // 將新收到的位元組解碼並加入緩衝區
buffer += decoder.decode(value, { stream: true });
-
- // 用換行符號切割出完整的 JSON 字串
let lines = buffer.split('\n');
-
- // 把最後一行 (可能還沒傳完的半截字串) 留到下一回合處理
buffer = lines.pop();
for (let line of lines) {
@@ -3274,11 +3326,9 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const data = JSON.parse(line);
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
- // 根據狀態渲染不同顏色的 Log
if (data.status === 'progress') {
logContainer.innerHTML += `[${timeStr}] ${data.message}`;
} else if (data.status === 'success') {
- // 🌟 魔法核心:清理設備回傳的字串,移除 echoed 的 "commit" 指令
let cleanOutput = data.output || '無';
cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim();
@@ -3287,16 +3337,26 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
設備 Commit 回傳:
${cleanOutput}
`;
document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`;
+
+ // 成功後,隱藏確認按鈕,並恢復關閉按鈕
+ const confirmBtn = document.getElementById('btn-confirm-restore');
+ if (confirmBtn) confirmBtn.style.display = 'none';
+
+ const closeBtn = document.getElementById('btn-modal-close');
+ if (closeBtn) {
+ closeBtn.disabled = false;
+ closeBtn.style.opacity = '1';
+ closeBtn.style.cursor = 'pointer';
+ closeBtn.style.pointerEvents = 'auto';
+ }
} else if (data.status === 'error') {
logContainer.innerHTML += `[${timeStr}] ${data.message}`;
if (data.output) {
logContainer.innerHTML += `設備回傳: ${data.output}`;
}
- // 發生錯誤時,恢復按鈕讓使用者可以重試或關閉
restoreButtonsState(btnIds);
}
- // 自動捲動到最底部
logContainer.scrollTop = logContainer.scrollHeight;
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
@@ -3338,10 +3398,13 @@ function openModal(targetInfo) {
}
// 關閉共用輸出彈出視窗
-function closeModal(event) {
- if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return;
+function closeModal() {
document.getElementById('outputModal').classList.remove('active');
document.body.style.overflow = '';
+
+ // 清除動態加入的確認按鈕,避免污染下次開啟
+ const confirmBtn = document.getElementById('btn-confirm-restore');
+ if (confirmBtn) confirmBtn.remove();
}
// 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態)
@@ -3788,8 +3851,6 @@ button:hover { background-color: #3498db; }
.modal-overlay.active .modal-container { transform: translateY(0); }
.modal-header { background: #2c3e50; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; color: white; flex-shrink: 0; border-bottom: 1px solid #34495e; }
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
-.modal-close { background: none; border: none; color: #bdc3c7; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; }
-.modal-close:hover { color: #e74c3c; }
/* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */
.modal-body {
flex-grow: 1;
@@ -4022,6 +4083,26 @@ export async function startEditFolder(path, elementId) {
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}"]`).forEach(btn => {
+ if (btn.id !== `edit-btn-${elementId}`) {
+ btn.style.pointerEvents = 'none';
+ btn.style.opacity = '0.2';
+ btn.title = `🔒 您正在另一個視圖編輯此項目`;
+ btn.innerText = "🔒";
+ }
+ });
+
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) editBtn.style.display = 'none';
@@ -4115,6 +4196,26 @@ export async function startEditLeaf(path, elementId) {
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}"]`).forEach(btn => {
+ if (btn.id !== `edit-btn-${elementId}`) {
+ btn.style.pointerEvents = 'none';
+ btn.style.opacity = '0.2';
+ btn.title = `🔒 您正在另一個視圖編輯此項目`;
+ btn.innerText = "🔒";
+ }
+ });
+
document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
@@ -4188,6 +4289,24 @@ export async function cancelEditFolder(path, elementId) {
}
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
+ // 🌟 寫入防閃爍冷卻表 (8秒內忽略後端的舊狀態)
+ if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
+ window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
+
+ // 🌟 立即解除全域鎖定狀態 (加入 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 = "✏️";
+ }
+ });
+
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none';
@@ -4233,6 +4352,24 @@ export async function cancelEditLeaf(path, elementId) {
}
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
+ // 🌟 寫入防閃爍冷卻表 (8秒內忽略後端的舊狀態)
+ if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
+ window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
+
+ // 🌟 立即解除全域鎖定狀態 (加入 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 = "✏️";
+ }
+ });
+
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none';
@@ -5095,6 +5232,15 @@ export async function executeBondingConfig() {
const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...\n\n${cliScript}`;
+ // 鎖定關閉按鈕 (Execution Lock)
+ const closeBtn = document.getElementById('btn-modal-close');
+ if (closeBtn) {
+ closeBtn.disabled = true;
+ closeBtn.style.opacity = '0.5';
+ closeBtn.style.cursor = 'not-allowed';
+ closeBtn.style.pointerEvents = 'none';
+ }
+
try {
const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success') {
@@ -5107,6 +5253,14 @@ export async function executeBondingConfig() {
} catch (error) {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
+ } finally {
+ // 恢復關閉按鈕
+ if (closeBtn) {
+ closeBtn.disabled = false;
+ closeBtn.style.opacity = '1';
+ closeBtn.style.cursor = 'pointer';
+ closeBtn.style.pointerEvents = 'auto';
+ }
}
}
@@ -5135,6 +5289,8 @@ FILE: static/tree-ui.js
================================================================================
// --- static/tree-ui.js ---
+import { SESSION_USER_ID, currentEditElementId } from './edit-mode.js';
+
// ==========================================
// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
// ==========================================
@@ -5251,6 +5407,27 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
`;
+ // 🌟 延遲渲染防護:取得當前設備 IP,並檢查此節點是否已被鎖定 (跨設備隔離)
+ const currentHost = document.getElementById('cmtsHost')?.value.trim();
+ let isLocked = false;
+ let lockTitle = "鎖定並編輯此群組";
+ let lockText = "✏️";
+ let lockOpacity = "0.3";
+ let lockPointer = "auto";
+
+ if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) {
+ const lockInfo = window.globalActiveLocks[currentHost][currentPath];
+ if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
+ isLocked = true;
+ lockText = "🔒";
+ lockOpacity = "0.2";
+ lockPointer = "none";
+ lockTitle = lockInfo.user_id !== SESSION_USER_ID
+ ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
+ : `🔒 您正在另一個視圖編輯此項目`;
+ }
+ }
+
htmlParts.push(`
- ✏️
+ style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
+ onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
+ ${lockText}
@@ -5310,6 +5487,27 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
const iconHtml = (icon) => `${icon}`;
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
+ // 🌟 延遲渲染防護:取得當前設備 IP,並檢查此節點是否已被鎖定 (跨設備隔離)
+ const currentHost = document.getElementById('cmtsHost')?.value.trim();
+ let isLocked = false;
+ let lockTitle = "鎖定並編輯此項目";
+ let lockText = "✏️";
+ let lockOpacity = "0.3";
+ let lockPointer = "auto";
+
+ if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) {
+ const lockInfo = window.globalActiveLocks[currentHost][currentPath];
+ if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
+ isLocked = true;
+ lockText = "🔒";
+ lockOpacity = "0.2";
+ lockPointer = "none";
+ lockTitle = lockInfo.user_id !== SESSION_USER_ID
+ ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
+ : `🔒 您正在另一個視圖編輯此項目`;
+ }
+ }
+
let displayContent = '';
if (isVirtualIndex) {
@@ -5356,9 +5554,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
🔍
- ✏️
+ style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
+ onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
+ ${lockText}
@@ -5937,11 +6135,12 @@ router = APIRouter(prefix="/locks", tags=["Lock Management"])
# ==========================================
# 💡 全域鎖定表 (In-Memory Lock Table)
# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
+# ⚠️ 嚴格規則:Lock Key 絕對不包含 config_type,確保 running 與 full 視圖共用同一把鎖!
# ==========================================
ACTIVE_LOCKS: Dict[str, dict] = {}
LOCK_TIMEOUT = 120
-# 🌟 新增 host 欄位
+# 🌟 請求模型嚴格排除 config_type
class LockAcquireReq(BaseModel):
host: str
path: str
@@ -5990,6 +6189,7 @@ async def acquire_lock(req: LockAcquireReq):
if conflict_lock:
raise HTTPException(status_code=409, detail=error_msg)
+ # ⚠️ 嚴格綁定 host 與 path,跨視圖共用
lock_key = f"{req.host}@@{req.path}"
ACTIVE_LOCKS[lock_key] = {
"user_id": req.user_id,
diff --git a/index.html b/index.html
index 431fad8..83b31dc 100644
--- a/index.html
+++ b/index.html
@@ -299,12 +299,10 @@
⚠️ 即將寫入的指令
-
+
-
- ×
@@ -469,14 +467,16 @@
-
-
+
+
-
+
📄 執行結果
-
+
+
+
+
-
等待執行中...
diff --git a/routers/lock.py b/routers/lock.py
index 283c3aa..fb5c1a4 100644
--- a/routers/lock.py
+++ b/routers/lock.py
@@ -9,11 +9,12 @@ router = APIRouter(prefix="/locks", tags=["Lock Management"])
# ==========================================
# 💡 全域鎖定表 (In-Memory Lock Table)
# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
+# ⚠️ 嚴格規則:Lock Key 絕對不包含 config_type,確保 running 與 full 視圖共用同一把鎖!
# ==========================================
ACTIVE_LOCKS: Dict[str, dict] = {}
LOCK_TIMEOUT = 120
-# 🌟 新增 host 欄位
+# 🌟 請求模型嚴格排除 config_type
class LockAcquireReq(BaseModel):
host: str
path: str
@@ -62,6 +63,7 @@ async def acquire_lock(req: LockAcquireReq):
if conflict_lock:
raise HTTPException(status_code=409, detail=error_msg)
+ # ⚠️ 嚴格綁定 host 與 path,跨視圖共用
lock_key = f"{req.host}@@{req.path}"
ACTIVE_LOCKS[lock_key] = {
"user_id": req.user_id,
diff --git a/static/app.js b/static/app.js
index d77cce3..9dde413 100644
--- a/static/app.js
+++ b/static/app.js
@@ -25,7 +25,7 @@ import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache
import {
releaseAllLocks, startEditFolder, startEditLeaf,
cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
- hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID
+ hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID, currentEditElementId
} from './edit-mode.js';
// 6. MAC Domain 配置模組 (MAC Domain)
@@ -102,9 +102,12 @@ window.addEventListener('beforeunload', () => {
}
});
-// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」
+// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」(加入 Host 隔離)
let activeLockState = new Set();
let lockPollingTimer = null;
+let currentPollingHost = null; // 記錄當前輪詢的設備 IP
+window.globalActiveLocks = {}; // 🌟 升級為二維結構:{ "10.14.110.4": { "alias": {...} } }
+window.recentlyReleasedLocks = {}; // 🌟 新增:防閃爍冷卻表 (Optimistic UI Cooldown)
function startLockStatusPolling() {
if (lockPollingTimer) clearInterval(lockPollingTimer);
@@ -112,34 +115,64 @@ function startLockStatusPolling() {
lockPollingTimer = setInterval(async () => {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
+ const host = connInfo.host;
+
+ // 🛡️ 跨設備防呆:如果使用者切換了 IP,必須清空舊設備的鎖定記憶,防止幽靈解鎖
+ if (currentPollingHost !== host) {
+ activeLockState.clear();
+ currentPollingHost = host;
+ }
try {
- const result = await apiGetLockStatus(connInfo.host);
+ const result = await apiGetLockStatus(host);
if (result.status === 'success') {
const currentLocks = result.data;
+ window.globalActiveLocks[host] = currentLocks;
const newLockState = new Set();
+ const now = Date.now();
// 🎯 任務 1:處理「新增鎖定」或「持續鎖定」的節點
for (const [path, lockInfo] of Object.entries(currentLocks)) {
- if (lockInfo.user_id !== SESSION_USER_ID) {
- newLockState.add(path);
- const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
- const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
-
- targetBtns.forEach(btn => {
- if (btn.innerText !== "🔒") {
- btn.style.pointerEvents = 'none';
- btn.style.opacity = '0.2';
- btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
- btn.innerText = "🔒";
- }
- });
+ const lockKey = `${host}@@${path}`;
+
+ // 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒)
+ if (window.recentlyReleasedLocks[lockKey]) {
+ if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
+ continue; // 跳過,不當作鎖定
+ } else {
+ delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶
+ }
}
+
+ newLockState.add(lockKey); // 記憶體加入 Host 標籤
+
+ const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
+
+ targetBtns.forEach(btn => {
+ // 略過目前正在編輯的那個實體按鈕
+ if (btn.id === `edit-btn-${currentEditElementId}`) return;
+
+ if (btn.innerText !== "🔒") {
+ btn.style.pointerEvents = 'none';
+ btn.style.opacity = '0.2';
+ // 判斷是別人鎖的,還是自己在另一個視圖鎖的
+ if (lockInfo.user_id !== SESSION_USER_ID) {
+ btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
+ } else {
+ btn.title = `🔒 您正在另一個視圖編輯此項目`;
+ }
+ btn.innerText = "🔒";
+ }
+ });
}
// 🔓 任務 2:處理「解除鎖定」的節點
- activeLockState.forEach(oldPath => {
- if (!newLockState.has(oldPath)) {
+ activeLockState.forEach(oldKey => {
+ const [oldHost, oldPath] = oldKey.split('@@');
+
+ // 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
+ if (oldHost === host && !newLockState.has(oldKey)) {
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
@@ -322,6 +355,11 @@ function resetAllManagerData() {
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
clearTreeCache();
+
+ // 🛡️ [跨設備防護] 切換設備時,清空鎖定輪詢的記憶體
+ if (typeof activeLockState !== 'undefined' && activeLockState.clear) {
+ activeLockState.clear();
+ }
const configArea = document.getElementById('macDomainConfigArea');
if (configArea) configArea.style.display = 'none';
@@ -1158,9 +1196,26 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
-
`;
+ // 動態插入確認按鈕到 modal-action-container
+ const actionContainer = document.getElementById('modal-action-container');
+ if (actionContainer) {
+ const oldConfirmBtn = document.getElementById('btn-confirm-restore');
+ if (oldConfirmBtn) oldConfirmBtn.remove();
+
+ const confirmBtn = document.createElement('button');
+ confirmBtn.id = 'btn-confirm-restore';
+ confirmBtn.className = 'btn-modern btn-save';
+ confirmBtn.style.backgroundColor = '#e74c3c';
+ confirmBtn.style.padding = '6px 12px';
+ confirmBtn.style.fontSize = '13px';
+ confirmBtn.innerHTML = '⚠️ 確認無誤,立即寫入設備並 Commit';
+ confirmBtn.onclick = () => executeSmartRestore(snapshotId, snapshotName, commands);
+
+ actionContainer.insertBefore(confirmBtn, document.getElementById('btn-modal-close'));
+ }
+
diffHtml += ``;
commands.forEach(cmd => {
if (cmd.startsWith('no ')) diffHtml += `- ${cmd}\n`;
@@ -1189,8 +1244,8 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit!\n確定要繼續嗎?`)) return;
- // 1. 優雅地將按鈕反灰 (Disabled)
- const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore'];
+ // 1. 優雅地將按鈕反灰 (Disabled),包含關閉按鈕
+ const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore', 'btn-modal-close'];
btnIds.forEach(id => {
const btn = document.getElementById(id);
if (btn) {
@@ -1209,7 +1264,6 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const modalOutput = document.getElementById('modalOutput');
// 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸)
- // 移除 min-height 和 overflow-y,讓它完全撐滿父容器
modalOutput.innerHTML = `
[系統] ⏳ 正在透過 SSH 寫入還原指令,請勿關閉視窗...
@@ -1219,14 +1273,12 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const logContainer = document.getElementById('restore-log-container');
try {
- // 3. 發送 Fetch 請求 (不使用 await response.json())
const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands })
});
- // 4. 處理 ReadableStream (串流接收 NDJSON)
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
@@ -1235,13 +1287,8 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const { done, value } = await reader.read();
if (done) break;
- // 將新收到的位元組解碼並加入緩衝區
buffer += decoder.decode(value, { stream: true });
-
- // 用換行符號切割出完整的 JSON 字串
let lines = buffer.split('\n');
-
- // 把最後一行 (可能還沒傳完的半截字串) 留到下一回合處理
buffer = lines.pop();
for (let line of lines) {
@@ -1250,11 +1297,9 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const data = JSON.parse(line);
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
- // 根據狀態渲染不同顏色的 Log
if (data.status === 'progress') {
logContainer.innerHTML += `[${timeStr}] ${data.message}`;
} else if (data.status === 'success') {
- // 🌟 魔法核心:清理設備回傳的字串,移除 echoed 的 "commit" 指令
let cleanOutput = data.output || '無';
cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim();
@@ -1263,16 +1308,26 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
設備 Commit 回傳:
${cleanOutput}
`;
document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`;
+
+ // 成功後,隱藏確認按鈕,並恢復關閉按鈕
+ const confirmBtn = document.getElementById('btn-confirm-restore');
+ if (confirmBtn) confirmBtn.style.display = 'none';
+
+ const closeBtn = document.getElementById('btn-modal-close');
+ if (closeBtn) {
+ closeBtn.disabled = false;
+ closeBtn.style.opacity = '1';
+ closeBtn.style.cursor = 'pointer';
+ closeBtn.style.pointerEvents = 'auto';
+ }
} else if (data.status === 'error') {
logContainer.innerHTML += `[${timeStr}] ${data.message}`;
if (data.output) {
logContainer.innerHTML += `設備回傳: ${data.output}`;
}
- // 發生錯誤時,恢復按鈕讓使用者可以重試或關閉
restoreButtonsState(btnIds);
}
- // 自動捲動到最底部
logContainer.scrollTop = logContainer.scrollHeight;
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
@@ -1314,10 +1369,13 @@ function openModal(targetInfo) {
}
// 關閉共用輸出彈出視窗
-function closeModal(event) {
- if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return;
+function closeModal() {
document.getElementById('outputModal').classList.remove('active');
document.body.style.overflow = '';
+
+ // 清除動態加入的確認按鈕,避免污染下次開啟
+ const confirmBtn = document.getElementById('btn-confirm-restore');
+ if (confirmBtn) confirmBtn.remove();
}
// 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態)
diff --git a/static/edit-mode.js b/static/edit-mode.js
index b77c306..2cd7245 100644
--- a/static/edit-mode.js
+++ b/static/edit-mode.js
@@ -86,6 +86,26 @@ export async function startEditFolder(path, elementId) {
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}"]`).forEach(btn => {
+ if (btn.id !== `edit-btn-${elementId}`) {
+ btn.style.pointerEvents = 'none';
+ btn.style.opacity = '0.2';
+ btn.title = `🔒 您正在另一個視圖編輯此項目`;
+ btn.innerText = "🔒";
+ }
+ });
+
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) editBtn.style.display = 'none';
@@ -179,6 +199,26 @@ export async function startEditLeaf(path, elementId) {
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}"]`).forEach(btn => {
+ if (btn.id !== `edit-btn-${elementId}`) {
+ btn.style.pointerEvents = 'none';
+ btn.style.opacity = '0.2';
+ btn.title = `🔒 您正在另一個視圖編輯此項目`;
+ btn.innerText = "🔒";
+ }
+ });
+
document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
@@ -252,6 +292,24 @@ export async function cancelEditFolder(path, elementId) {
}
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
+ // 🌟 寫入防閃爍冷卻表 (8秒內忽略後端的舊狀態)
+ if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
+ window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
+
+ // 🌟 立即解除全域鎖定狀態 (加入 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 = "✏️";
+ }
+ });
+
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none';
@@ -297,6 +355,24 @@ export async function cancelEditLeaf(path, elementId) {
}
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
+ // 🌟 寫入防閃爍冷卻表 (8秒內忽略後端的舊狀態)
+ if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
+ window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
+
+ // 🌟 立即解除全域鎖定狀態 (加入 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 = "✏️";
+ }
+ });
+
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none';
diff --git a/static/mac-domain.js b/static/mac-domain.js
index d60d26f..8379a0c 100644
--- a/static/mac-domain.js
+++ b/static/mac-domain.js
@@ -263,6 +263,15 @@ export async function executeBondingConfig() {
const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...\n\n${cliScript}`;
+ // 鎖定關閉按鈕 (Execution Lock)
+ const closeBtn = document.getElementById('btn-modal-close');
+ if (closeBtn) {
+ closeBtn.disabled = true;
+ closeBtn.style.opacity = '0.5';
+ closeBtn.style.cursor = 'not-allowed';
+ closeBtn.style.pointerEvents = 'none';
+ }
+
try {
const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success') {
@@ -275,5 +284,13 @@ export async function executeBondingConfig() {
} catch (error) {
outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
+ } finally {
+ // 恢復關閉按鈕
+ if (closeBtn) {
+ closeBtn.disabled = false;
+ closeBtn.style.opacity = '1';
+ closeBtn.style.cursor = 'pointer';
+ closeBtn.style.pointerEvents = 'auto';
+ }
}
}
diff --git a/static/style.css b/static/style.css
index d40828a..f5a065d 100644
--- a/static/style.css
+++ b/static/style.css
@@ -132,8 +132,6 @@ button:hover { background-color: #3498db; }
.modal-overlay.active .modal-container { transform: translateY(0); }
.modal-header { background: #2c3e50; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; color: white; flex-shrink: 0; border-bottom: 1px solid #34495e; }
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
-.modal-close { background: none; border: none; color: #bdc3c7; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; }
-.modal-close:hover { color: #e74c3c; }
/* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */
.modal-body {
flex-grow: 1;
diff --git a/static/tree-ui.js b/static/tree-ui.js
index 567004b..39e0bfb 100644
--- a/static/tree-ui.js
+++ b/static/tree-ui.js
@@ -1,5 +1,7 @@
// --- static/tree-ui.js ---
+import { SESSION_USER_ID, currentEditElementId } from './edit-mode.js';
+
// ==========================================
// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
// ==========================================
@@ -116,6 +118,27 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
`;
+ // 🌟 延遲渲染防護:取得當前設備 IP,並檢查此節點是否已被鎖定 (跨設備隔離)
+ const currentHost = document.getElementById('cmtsHost')?.value.trim();
+ let isLocked = false;
+ let lockTitle = "鎖定並編輯此群組";
+ let lockText = "✏️";
+ let lockOpacity = "0.3";
+ let lockPointer = "auto";
+
+ if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) {
+ const lockInfo = window.globalActiveLocks[currentHost][currentPath];
+ if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
+ isLocked = true;
+ lockText = "🔒";
+ lockOpacity = "0.2";
+ lockPointer = "none";
+ lockTitle = lockInfo.user_id !== SESSION_USER_ID
+ ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
+ : `🔒 您正在另一個視圖編輯此項目`;
+ }
+ }
+
htmlParts.push(`
- ✏️
+ style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
+ onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
+ ${lockText}
@@ -175,6 +198,27 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
const iconHtml = (icon) => `${icon}`;
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
+ // 🌟 延遲渲染防護:取得當前設備 IP,並檢查此節點是否已被鎖定 (跨設備隔離)
+ const currentHost = document.getElementById('cmtsHost')?.value.trim();
+ let isLocked = false;
+ let lockTitle = "鎖定並編輯此項目";
+ let lockText = "✏️";
+ let lockOpacity = "0.3";
+ let lockPointer = "auto";
+
+ if (currentHost && window.globalActiveLocks && window.globalActiveLocks[currentHost] && window.globalActiveLocks[currentHost][currentPath]) {
+ const lockInfo = window.globalActiveLocks[currentHost][currentPath];
+ if (!(lockInfo.user_id === SESSION_USER_ID && elementId === currentEditElementId)) {
+ isLocked = true;
+ lockText = "🔒";
+ lockOpacity = "0.2";
+ lockPointer = "none";
+ lockTitle = lockInfo.user_id !== SESSION_USER_ID
+ ? `🔒 此區塊正由 [${lockInfo.username}] 編輯中`
+ : `🔒 您正在另一個視圖編輯此項目`;
+ }
+ }
+
let displayContent = '';
if (isVirtualIndex) {
@@ -221,9 +265,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
🔍
- ✏️
+ style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
+ onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
+ ${lockText}