refactor: 關鍵的 UX 與安全性升級。

- 1. 移除隱含風險的關閉方式,並實作嚴格的「執行中鎖定 (Execution Lock)」,能有效防止幽靈寫入與併發衝突。
- 2. 跨視圖併發防護 (Cross-View Concurrency)。
This commit is contained in:
swpa 2026-06-01 16:20:34 +08:00
parent 2cbbdb7b0c
commit 6698e34292
8 changed files with 509 additions and 114 deletions

View File

@ -971,12 +971,10 @@ FILE: index.html
<!-- 頂部標題列 --> <!-- 頂部標題列 -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;">
<h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4> <h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
<div style="display: flex; align-items: center; gap: 10px;"> <div style="display: flex; align-items: center; gap: 20px;">
<button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button> <button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button>
<button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button> <button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
<button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button> <button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
<div style="width: 1px; height: 20px; background-color: #7f8c8d; margin: 0 5px;"></div>
<span onclick="hideSideCLI()" style="color: #bdc3c7; font-size: 26px; cursor: pointer; line-height: 1;" title="關閉面板">&times;</span>
</div> </div>
</div> </div>
@ -1141,14 +1139,16 @@ FILE: index.html
</div> </div>
<!-- 獨立的彈出式輸出視窗 (Modal) --> <!-- 獨立的彈出式輸出視窗 (Modal) -->
<div id="outputModal" class="modal-overlay" onclick="closeModal(event)"> <div id="outputModal" class="modal-overlay">
<div class="modal-container" onclick="event.stopPropagation()"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title"> <div class="modal-title" style="flex-grow: 1; display: flex; align-items: center;">
<span>📄 執行結果</span> <span>📄 執行結果</span>
<span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal;"></span> <span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal; margin-left: 10px; flex-grow: 1;"></span>
</div>
<div id="modal-action-container" style="display: flex; gap: 20px; align-items: center;">
<button id="btn-modal-close" class="btn-modern btn-disconnect" onclick="closeModal()">關閉視窗</button>
</div> </div>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<pre id="modalOutput" class="readonly-terminal">等待執行中...</pre> <pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>
@ -1800,28 +1800,33 @@ FILE: PROJECT_STATE.md
- [x] 調整 `init_db.py` 為非同步啟動腳本。 - [x] 調整 `init_db.py` 為非同步啟動腳本。
### Phase 2: 設備配置備份與快照機制 (Completed) ### 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] **前端 UI 實作**:完成「設備備份與還原」頁籤,包含建立快照表單與歷史紀錄列表 (馬卡龍色系與 Flexbox 佈局)。
- [x] **後端 API 實作**:完成手動建立快照與取得歷史快照列表的 API 路由。 - [x] **後端 API 實作**:完成手動建立快照與取得歷史快照列表的 API 路由。
- [x] **前端 UI 優化與防禦性編程**:完成滿版視覺重構、原生日期選擇器整合,並實作具備 Null-Safety 與 `.trim()` 容錯的多維度前端搜尋過濾器 (支援快照名稱 + 描述雙欄位比對)。 - [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) ## 🚧 目前開發階段 (Current Phase)
### Phase 3: 智慧差異還原與歷史預覽 (Smart Diff Recovery & Preview) ### Phase 4: 自動化防護與進階管理 (Automation & Advanced Management)
- [x] **歷史預覽 UI**:前端支援點擊歷史快照的「檢視」按鈕。 - [ ] **自動備份攔截 (Auto-Backup Interceptor)**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制,確保每次變更前都有還原點。
- [x] **前端安全還原防呆**:實作三階段安全還原流程 UI (包含 Diff 預覽與確認寫入按鈕)。 - [ ] **備份保留策略 (Retention Policy)**:實作定期清理過期或過多歷史快照的機制,防止資料庫無限膨脹。
- [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。
--- ---
## 🐛 已知問題與未來計畫 (Known Issues & Backlog) ## 🐛 已知問題與未來計畫 (Known Issues & Backlog)
- **[未來計畫] 自動備份攔截**:在執行任何 `generate_cli` (寫入設備變更) 之前,實作自動觸發背景備份 `running config` 的防呆機制 - 目前系統運行穩定,各項併發鎖定與 UI 狀態連動皆已完善。等待進入 Phase 4 開發。
================================================================================ ================================================================================
@ -2049,7 +2054,7 @@ import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache
import { import {
releaseAllLocks, startEditFolder, startEditLeaf, releaseAllLocks, startEditFolder, startEditLeaf,
cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI, cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID, currentEditElementId
} from './edit-mode.js'; } from './edit-mode.js';
// 6. MAC Domain 配置模組 (MAC Domain) // 6. MAC Domain 配置模組 (MAC Domain)
@ -2126,9 +2131,12 @@ window.addEventListener('beforeunload', () => {
} }
}); });
// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」 // 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」(加入 Host 隔離)
let activeLockState = new Set(); let activeLockState = new Set();
let lockPollingTimer = null; let lockPollingTimer = null;
let currentPollingHost = null; // 記錄當前輪詢的設備 IP
window.globalActiveLocks = {}; // 🌟 升級為二維結構:{ "10.14.110.4": { "alias": {...} } }
window.recentlyReleasedLocks = {}; // 🌟 新增:防閃爍冷卻表 (Optimistic UI Cooldown)
function startLockStatusPolling() { function startLockStatusPolling() {
if (lockPollingTimer) clearInterval(lockPollingTimer); if (lockPollingTimer) clearInterval(lockPollingTimer);
@ -2136,34 +2144,64 @@ function startLockStatusPolling() {
lockPollingTimer = setInterval(async () => { lockPollingTimer = setInterval(async () => {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return; if (!connInfo || !connInfo.host) return;
const host = connInfo.host;
// 🛡️ 跨設備防呆:如果使用者切換了 IP必須清空舊設備的鎖定記憶防止幽靈解鎖
if (currentPollingHost !== host) {
activeLockState.clear();
currentPollingHost = host;
}
try { try {
const result = await apiGetLockStatus(connInfo.host); const result = await apiGetLockStatus(host);
if (result.status === 'success') { if (result.status === 'success') {
const currentLocks = result.data; const currentLocks = result.data;
window.globalActiveLocks[host] = currentLocks;
const newLockState = new Set(); const newLockState = new Set();
const now = Date.now();
// 🎯 任務 1處理「新增鎖定」或「持續鎖定」的節點 // 🎯 任務 1處理「新增鎖定」或「持續鎖定」的節點
for (const [path, lockInfo] of Object.entries(currentLocks)) { for (const [path, lockInfo] of Object.entries(currentLocks)) {
if (lockInfo.user_id !== SESSION_USER_ID) { const lockKey = `${host}@@${path}`;
newLockState.add(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 safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
targetBtns.forEach(btn => { targetBtns.forEach(btn => {
// 略過目前正在編輯的那個實體按鈕
if (btn.id === `edit-btn-${currentEditElementId}`) return;
if (btn.innerText !== "🔒") { if (btn.innerText !== "🔒") {
btn.style.pointerEvents = 'none'; btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2'; btn.style.opacity = '0.2';
// 判斷是別人鎖的,還是自己在另一個視圖鎖的
if (lockInfo.user_id !== SESSION_USER_ID) {
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
} else {
btn.title = `🔒 您正在另一個視圖編輯此項目`;
}
btn.innerText = "🔒"; btn.innerText = "🔒";
} }
}); });
} }
}
// 🔓 任務 2處理「解除鎖定」的節點 // 🔓 任務 2處理「解除鎖定」的節點
activeLockState.forEach(oldPath => { activeLockState.forEach(oldKey => {
if (!newLockState.has(oldPath)) { const [oldHost, oldPath] = oldKey.split('@@');
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
if (oldHost === host && !newLockState.has(oldKey)) {
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
@ -2347,6 +2385,11 @@ function resetAllManagerData() {
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM // 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
clearTreeCache(); clearTreeCache();
// 🛡️ [跨設備防護] 切換設備時,清空鎖定輪詢的記憶體
if (typeof activeLockState !== 'undefined' && activeLockState.clear) {
activeLockState.clear();
}
const configArea = document.getElementById('macDomainConfigArea'); const configArea = document.getElementById('macDomainConfigArea');
if (configArea) configArea.style.display = 'none'; if (configArea) configArea.style.display = 'none';
@ -3182,9 +3225,26 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
<button id="btn-view-diff" onclick="document.getElementById('view-diff').style.display='block'; document.getElementById('view-cli').style.display='none';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">📊 邏輯差異</button> <button id="btn-view-diff" onclick="document.getElementById('view-diff').style.display='block'; document.getElementById('view-cli').style.display='none';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">📊 邏輯差異</button>
<button id="btn-view-cli" onclick="document.getElementById('view-diff').style.display='none'; document.getElementById('view-cli').style.display='block';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">💻 原始執行指令 (Raw CLI)</button> <button id="btn-view-cli" onclick="document.getElementById('view-diff').style.display='none'; document.getElementById('view-cli').style.display='block';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">💻 原始執行指令 (Raw CLI)</button>
</span> </span>
<button id="btn-confirm-restore" onclick="executeSmartRestore('${snapshotId}', '${snapshotName}', ${safeCommands})" class="btn-modern btn-save" style="position: absolute; right: 45px; top: 12px; background-color: #e74c3c; padding: 6px 12px; font-size: 13px; border-radius: 4px; color: white; border: none; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">⚠️ 確認無誤,立即寫入設備並 Commit</button>
`; `;
// 動態插入確認按鈕到 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 += `<pre id="view-diff" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: block;">`; diffHtml += `<pre id="view-diff" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: block;">`;
commands.forEach(cmd => { commands.forEach(cmd => {
if (cmd.startsWith('no ')) diffHtml += `<span style="color: #e74c3c;">- ${cmd}</span>\n`; if (cmd.startsWith('no ')) diffHtml += `<span style="color: #e74c3c;">- ${cmd}</span>\n`;
@ -3213,8 +3273,8 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
window.executeSmartRestore = async function(snapshotId, snapshotName, commands) { window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit\n確定要繼續嗎`)) return; if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit\n確定要繼續嗎`)) return;
// 1. 優雅地將按鈕反灰 (Disabled) // 1. 優雅地將按鈕反灰 (Disabled),包含關閉按鈕
const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore']; const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore', 'btn-modal-close'];
btnIds.forEach(id => { btnIds.forEach(id => {
const btn = document.getElementById(id); const btn = document.getElementById(id);
if (btn) { if (btn) {
@ -3233,7 +3293,6 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const modalOutput = document.getElementById('modalOutput'); const modalOutput = document.getElementById('modalOutput');
// 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸) // 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸)
// 移除 min-height 和 overflow-y讓它完全撐滿父容器
modalOutput.innerHTML = ` modalOutput.innerHTML = `
<div id="restore-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; box-sizing: border-box;"> <div id="restore-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; box-sizing: border-box;">
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] ⏳ 正在透過 SSH 寫入還原指令,請勿關閉視窗...</div> <div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] ⏳ 正在透過 SSH 寫入還原指令,請勿關閉視窗...</div>
@ -3243,14 +3302,12 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const logContainer = document.getElementById('restore-log-container'); const logContainer = document.getElementById('restore-log-container');
try { try {
// 3. 發送 Fetch 請求 (不使用 await response.json())
const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, { const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands }) body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands })
}); });
// 4. 處理 ReadableStream (串流接收 NDJSON)
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder("utf-8");
let buffer = ""; let buffer = "";
@ -3259,13 +3316,8 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
// 將新收到的位元組解碼並加入緩衝區
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
// 用換行符號切割出完整的 JSON 字串
let lines = buffer.split('\n'); let lines = buffer.split('\n');
// 把最後一行 (可能還沒傳完的半截字串) 留到下一回合處理
buffer = lines.pop(); buffer = lines.pop();
for (let line of lines) { for (let line of lines) {
@ -3274,11 +3326,9 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const data = JSON.parse(line); const data = JSON.parse(line);
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false }); const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
// 根據狀態渲染不同顏色的 Log
if (data.status === 'progress') { if (data.status === 'progress') {
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`; logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
} else if (data.status === 'success') { } else if (data.status === 'success') {
// 🌟 魔法核心:清理設備回傳的字串,移除 echoed 的 "commit" 指令
let cleanOutput = data.output || '無'; let cleanOutput = data.output || '無';
cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim(); cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim();
@ -3287,16 +3337,26 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
<div style="color: #ecf0f1; margin-top: 5px; border-top: 1px dashed #555; padding-top: 5px;">設備 Commit 回傳:<br><span style="color: #bdc3c7;">${cleanOutput}</span></div> <div style="color: #ecf0f1; margin-top: 5px; border-top: 1px dashed #555; padding-top: 5px;">設備 Commit 回傳:<br><span style="color: #bdc3c7;">${cleanOutput}</span></div>
`; `;
document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`; 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') { } else if (data.status === 'error') {
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`; logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
if (data.output) { if (data.output) {
logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`; logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
} }
// 發生錯誤時,恢復按鈕讓使用者可以重試或關閉
restoreButtonsState(btnIds); restoreButtonsState(btnIds);
} }
// 自動捲動到最底部
logContainer.scrollTop = logContainer.scrollHeight; logContainer.scrollTop = logContainer.scrollHeight;
} catch (e) { } catch (e) {
console.warn("解析串流 JSON 失敗:", line); console.warn("解析串流 JSON 失敗:", line);
@ -3338,10 +3398,13 @@ function openModal(targetInfo) {
} }
// 關閉共用輸出彈出視窗 // 關閉共用輸出彈出視窗
function closeModal(event) { function closeModal() {
if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return;
document.getElementById('outputModal').classList.remove('active'); document.getElementById('outputModal').classList.remove('active');
document.body.style.overflow = ''; 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-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-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-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 身體的多餘內距,讓內容可以貼齊邊緣 */ /* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */
.modal-body { .modal-body {
flex-grow: 1; flex-grow: 1;
@ -4022,6 +4083,26 @@ export async function startEditFolder(path, elementId) {
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000); intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId; 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}`); const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) editBtn.style.display = 'none'; if (editBtn) editBtn.style.display = 'none';
@ -4115,6 +4196,26 @@ export async function startEditLeaf(path, elementId) {
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000); intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId; 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(`edit-btn-${elementId}`).style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; 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) {} 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(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none'; 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) {} 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(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'none';
@ -5095,6 +5232,15 @@ export async function executeBondingConfig() {
const outputEl = document.getElementById('modalOutput'); const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\n\n${cliScript}`; outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\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 { try {
const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass); const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success') { if (result.status === 'success') {
@ -5107,6 +5253,14 @@ export async function executeBondingConfig() {
} catch (error) { } catch (error) {
outputEl.style.color = "#e74c3c"; outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`; 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 --- // --- static/tree-ui.js ---
import { SESSION_USER_ID, currentEditElementId } from './edit-mode.js';
// ========================================== // ==========================================
// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering) // 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
// ========================================== // ==========================================
@ -5251,6 +5407,27 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span> </span>
`; `;
// 🌟 延遲渲染防護:取得當前設備 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(` htmlParts.push(`
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;" <details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
@ -5278,9 +5455,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
🔍 🔍
</span> </span>
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')" <span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;" style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組"> onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
✏️ ${lockText}
</span> </span>
<span id="actions-${elementId}" style="display:none; margin-left: 10px;"> <span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button> <button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
@ -5310,6 +5487,27 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`; const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`; const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
// 🌟 延遲渲染防護:取得當前設備 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 = ''; let displayContent = '';
if (isVirtualIndex) { if (isVirtualIndex) {
@ -5356,9 +5554,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
🔍 🔍
</span> </span>
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')" <span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;" style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目"> onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
✏️ ${lockText}
</span> </span>
<span id="actions-${elementId}" style="display:none; margin-left: 10px;"> <span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button> <button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
@ -5937,11 +6135,12 @@ router = APIRouter(prefix="/locks", tags=["Lock Management"])
# ========================================== # ==========================================
# 💡 全域鎖定表 (In-Memory Lock Table) # 💡 全域鎖定表 (In-Memory Lock Table)
# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} } # 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
# ⚠️ 嚴格規則Lock Key 絕對不包含 config_type確保 running 與 full 視圖共用同一把鎖!
# ========================================== # ==========================================
ACTIVE_LOCKS: Dict[str, dict] = {} ACTIVE_LOCKS: Dict[str, dict] = {}
LOCK_TIMEOUT = 120 LOCK_TIMEOUT = 120
# 🌟 新增 host 欄位 # 🌟 請求模型嚴格排除 config_type
class LockAcquireReq(BaseModel): class LockAcquireReq(BaseModel):
host: str host: str
path: str path: str
@ -5990,6 +6189,7 @@ async def acquire_lock(req: LockAcquireReq):
if conflict_lock: if conflict_lock:
raise HTTPException(status_code=409, detail=error_msg) raise HTTPException(status_code=409, detail=error_msg)
# ⚠️ 嚴格綁定 host 與 path跨視圖共用
lock_key = f"{req.host}@@{req.path}" lock_key = f"{req.host}@@{req.path}"
ACTIVE_LOCKS[lock_key] = { ACTIVE_LOCKS[lock_key] = {
"user_id": req.user_id, "user_id": req.user_id,

View File

@ -299,12 +299,10 @@
<!-- 頂部標題列 --> <!-- 頂部標題列 -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;">
<h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4> <h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
<div style="display: flex; align-items: center; gap: 10px;"> <div style="display: flex; align-items: center; gap: 20px;">
<button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button> <button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button>
<button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button> <button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
<button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button> <button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
<div style="width: 1px; height: 20px; background-color: #7f8c8d; margin: 0 5px;"></div>
<span onclick="hideSideCLI()" style="color: #bdc3c7; font-size: 26px; cursor: pointer; line-height: 1;" title="關閉面板">&times;</span>
</div> </div>
</div> </div>
@ -469,14 +467,16 @@
</div> </div>
<!-- 獨立的彈出式輸出視窗 (Modal) --> <!-- 獨立的彈出式輸出視窗 (Modal) -->
<div id="outputModal" class="modal-overlay" onclick="closeModal(event)"> <div id="outputModal" class="modal-overlay">
<div class="modal-container" onclick="event.stopPropagation()"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title"> <div class="modal-title" style="flex-grow: 1; display: flex; align-items: center;">
<span>📄 執行結果</span> <span>📄 執行結果</span>
<span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal;"></span> <span id="modalTargetInfo" style="font-size: 13px; color: #bdc3c7; font-weight: normal; margin-left: 10px; flex-grow: 1;"></span>
</div>
<div id="modal-action-container" style="display: flex; gap: 20px; align-items: center;">
<button id="btn-modal-close" class="btn-modern btn-disconnect" onclick="closeModal()">關閉視窗</button>
</div> </div>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<pre id="modalOutput" class="readonly-terminal">等待執行中...</pre> <pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>

View File

@ -9,11 +9,12 @@ router = APIRouter(prefix="/locks", tags=["Lock Management"])
# ========================================== # ==========================================
# 💡 全域鎖定表 (In-Memory Lock Table) # 💡 全域鎖定表 (In-Memory Lock Table)
# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} } # 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
# ⚠️ 嚴格規則Lock Key 絕對不包含 config_type確保 running 與 full 視圖共用同一把鎖!
# ========================================== # ==========================================
ACTIVE_LOCKS: Dict[str, dict] = {} ACTIVE_LOCKS: Dict[str, dict] = {}
LOCK_TIMEOUT = 120 LOCK_TIMEOUT = 120
# 🌟 新增 host 欄位 # 🌟 請求模型嚴格排除 config_type
class LockAcquireReq(BaseModel): class LockAcquireReq(BaseModel):
host: str host: str
path: str path: str
@ -62,6 +63,7 @@ async def acquire_lock(req: LockAcquireReq):
if conflict_lock: if conflict_lock:
raise HTTPException(status_code=409, detail=error_msg) raise HTTPException(status_code=409, detail=error_msg)
# ⚠️ 嚴格綁定 host 與 path跨視圖共用
lock_key = f"{req.host}@@{req.path}" lock_key = f"{req.host}@@{req.path}"
ACTIVE_LOCKS[lock_key] = { ACTIVE_LOCKS[lock_key] = {
"user_id": req.user_id, "user_id": req.user_id,

View File

@ -25,7 +25,7 @@ import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache
import { import {
releaseAllLocks, startEditFolder, startEditLeaf, releaseAllLocks, startEditFolder, startEditLeaf,
cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI, cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID, currentEditElementId
} from './edit-mode.js'; } from './edit-mode.js';
// 6. MAC Domain 配置模組 (MAC Domain) // 6. MAC Domain 配置模組 (MAC Domain)
@ -102,9 +102,12 @@ window.addEventListener('beforeunload', () => {
} }
}); });
// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」 // 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」(加入 Host 隔離)
let activeLockState = new Set(); let activeLockState = new Set();
let lockPollingTimer = null; let lockPollingTimer = null;
let currentPollingHost = null; // 記錄當前輪詢的設備 IP
window.globalActiveLocks = {}; // 🌟 升級為二維結構:{ "10.14.110.4": { "alias": {...} } }
window.recentlyReleasedLocks = {}; // 🌟 新增:防閃爍冷卻表 (Optimistic UI Cooldown)
function startLockStatusPolling() { function startLockStatusPolling() {
if (lockPollingTimer) clearInterval(lockPollingTimer); if (lockPollingTimer) clearInterval(lockPollingTimer);
@ -112,34 +115,64 @@ function startLockStatusPolling() {
lockPollingTimer = setInterval(async () => { lockPollingTimer = setInterval(async () => {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return; if (!connInfo || !connInfo.host) return;
const host = connInfo.host;
// 🛡️ 跨設備防呆:如果使用者切換了 IP必須清空舊設備的鎖定記憶防止幽靈解鎖
if (currentPollingHost !== host) {
activeLockState.clear();
currentPollingHost = host;
}
try { try {
const result = await apiGetLockStatus(connInfo.host); const result = await apiGetLockStatus(host);
if (result.status === 'success') { if (result.status === 'success') {
const currentLocks = result.data; const currentLocks = result.data;
window.globalActiveLocks[host] = currentLocks;
const newLockState = new Set(); const newLockState = new Set();
const now = Date.now();
// 🎯 任務 1處理「新增鎖定」或「持續鎖定」的節點 // 🎯 任務 1處理「新增鎖定」或「持續鎖定」的節點
for (const [path, lockInfo] of Object.entries(currentLocks)) { for (const [path, lockInfo] of Object.entries(currentLocks)) {
if (lockInfo.user_id !== SESSION_USER_ID) { const lockKey = `${host}@@${path}`;
newLockState.add(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 safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
targetBtns.forEach(btn => { targetBtns.forEach(btn => {
// 略過目前正在編輯的那個實體按鈕
if (btn.id === `edit-btn-${currentEditElementId}`) return;
if (btn.innerText !== "🔒") { if (btn.innerText !== "🔒") {
btn.style.pointerEvents = 'none'; btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2'; btn.style.opacity = '0.2';
// 判斷是別人鎖的,還是自己在另一個視圖鎖的
if (lockInfo.user_id !== SESSION_USER_ID) {
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`; btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
} else {
btn.title = `🔒 您正在另一個視圖編輯此項目`;
}
btn.innerText = "🔒"; btn.innerText = "🔒";
} }
}); });
} }
}
// 🔓 任務 2處理「解除鎖定」的節點 // 🔓 任務 2處理「解除鎖定」的節點
activeLockState.forEach(oldPath => { activeLockState.forEach(oldKey => {
if (!newLockState.has(oldPath)) { const [oldHost, oldPath] = oldKey.split('@@');
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
if (oldHost === host && !newLockState.has(oldKey)) {
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
@ -323,6 +356,11 @@ function resetAllManagerData() {
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM // 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
clearTreeCache(); clearTreeCache();
// 🛡️ [跨設備防護] 切換設備時,清空鎖定輪詢的記憶體
if (typeof activeLockState !== 'undefined' && activeLockState.clear) {
activeLockState.clear();
}
const configArea = document.getElementById('macDomainConfigArea'); const configArea = document.getElementById('macDomainConfigArea');
if (configArea) configArea.style.display = 'none'; if (configArea) configArea.style.display = 'none';
@ -1158,9 +1196,26 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
<button id="btn-view-diff" onclick="document.getElementById('view-diff').style.display='block'; document.getElementById('view-cli').style.display='none';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">📊 邏輯差異</button> <button id="btn-view-diff" onclick="document.getElementById('view-diff').style.display='block'; document.getElementById('view-cli').style.display='none';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">📊 邏輯差異</button>
<button id="btn-view-cli" onclick="document.getElementById('view-diff').style.display='none'; document.getElementById('view-cli').style.display='block';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">💻 原始執行指令 (Raw CLI)</button> <button id="btn-view-cli" onclick="document.getElementById('view-diff').style.display='none'; document.getElementById('view-cli').style.display='block';" class="btn-modern" style="padding: 4px 10px; background: #2980b9; font-size: 13px; border-radius: 4px; color: white; border: 1px solid #2471a3; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;">💻 原始執行指令 (Raw CLI)</button>
</span> </span>
<button id="btn-confirm-restore" onclick="executeSmartRestore('${snapshotId}', '${snapshotName}', ${safeCommands})" class="btn-modern btn-save" style="position: absolute; right: 45px; top: 12px; background-color: #e74c3c; padding: 6px 12px; font-size: 13px; border-radius: 4px; color: white; border: none; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.2); transition: all 0.2s;"> 確認無誤立即寫入設備並 Commit</button>
`; `;
// 動態插入確認按鈕到 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 += `<pre id="view-diff" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: block;">`; diffHtml += `<pre id="view-diff" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto; display: block;">`;
commands.forEach(cmd => { commands.forEach(cmd => {
if (cmd.startsWith('no ')) diffHtml += `<span style="color: #e74c3c;">- ${cmd}</span>\n`; if (cmd.startsWith('no ')) diffHtml += `<span style="color: #e74c3c;">- ${cmd}</span>\n`;
@ -1189,8 +1244,8 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
window.executeSmartRestore = async function(snapshotId, snapshotName, commands) { window.executeSmartRestore = async function(snapshotId, snapshotName, commands) {
if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit\n確定要繼續嗎?`)) return; if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit\n確定要繼續嗎?`)) return;
// 1. 優雅地將按鈕反灰 (Disabled) // 1. 優雅地將按鈕反灰 (Disabled),包含關閉按鈕
const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore']; const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore', 'btn-modal-close'];
btnIds.forEach(id => { btnIds.forEach(id => {
const btn = document.getElementById(id); const btn = document.getElementById(id);
if (btn) { if (btn) {
@ -1209,7 +1264,6 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const modalOutput = document.getElementById('modalOutput'); const modalOutput = document.getElementById('modalOutput');
// 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸) // 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸)
// 移除 min-height 和 overflow-y讓它完全撐滿父容器
modalOutput.innerHTML = ` modalOutput.innerHTML = `
<div id="restore-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; box-sizing: border-box;"> <div id="restore-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; box-sizing: border-box;">
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] 正在透過 SSH 寫入還原指令請勿關閉視窗...</div> <div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] 正在透過 SSH 寫入還原指令請勿關閉視窗...</div>
@ -1219,14 +1273,12 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const logContainer = document.getElementById('restore-log-container'); const logContainer = document.getElementById('restore-log-container');
try { try {
// 3. 發送 Fetch 請求 (不使用 await response.json())
const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, { const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands }) body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands })
}); });
// 4. 處理 ReadableStream (串流接收 NDJSON)
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder("utf-8");
let buffer = ""; let buffer = "";
@ -1235,13 +1287,8 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
// 將新收到的位元組解碼並加入緩衝區
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
// 用換行符號切割出完整的 JSON 字串
let lines = buffer.split('\n'); let lines = buffer.split('\n');
// 把最後一行 (可能還沒傳完的半截字串) 留到下一回合處理
buffer = lines.pop(); buffer = lines.pop();
for (let line of lines) { for (let line of lines) {
@ -1250,11 +1297,9 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
const data = JSON.parse(line); const data = JSON.parse(line);
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false }); const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
// 根據狀態渲染不同顏色的 Log
if (data.status === 'progress') { if (data.status === 'progress') {
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`; logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
} else if (data.status === 'success') { } else if (data.status === 'success') {
// 🌟 魔法核心:清理設備回傳的字串,移除 echoed 的 "commit" 指令
let cleanOutput = data.output || '無'; let cleanOutput = data.output || '無';
cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim(); cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim();
@ -1263,16 +1308,26 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
<div style="color: #ecf0f1; margin-top: 5px; border-top: 1px dashed #555; padding-top: 5px;">設備 Commit 回傳<br><span style="color: #bdc3c7;">${cleanOutput}</span></div> <div style="color: #ecf0f1; margin-top: 5px; border-top: 1px dashed #555; padding-top: 5px;">設備 Commit 回傳<br><span style="color: #bdc3c7;">${cleanOutput}</span></div>
`; `;
document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`; 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') { } else if (data.status === 'error') {
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`; logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
if (data.output) { if (data.output) {
logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`; logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
} }
// 發生錯誤時,恢復按鈕讓使用者可以重試或關閉
restoreButtonsState(btnIds); restoreButtonsState(btnIds);
} }
// 自動捲動到最底部
logContainer.scrollTop = logContainer.scrollHeight; logContainer.scrollTop = logContainer.scrollHeight;
} catch (e) { } catch (e) {
console.warn("解析串流 JSON 失敗:", line); console.warn("解析串流 JSON 失敗:", line);
@ -1314,10 +1369,13 @@ function openModal(targetInfo) {
} }
// 關閉共用輸出彈出視窗 // 關閉共用輸出彈出視窗
function closeModal(event) { function closeModal() {
if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return;
document.getElementById('outputModal').classList.remove('active'); document.getElementById('outputModal').classList.remove('active');
document.body.style.overflow = ''; document.body.style.overflow = '';
// 清除動態加入的確認按鈕,避免污染下次開啟
const confirmBtn = document.getElementById('btn-confirm-restore');
if (confirmBtn) confirmBtn.remove();
} }
// 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態) // 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態)

View File

@ -86,6 +86,26 @@ export async function startEditFolder(path, elementId) {
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000); intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId; 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}`); const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) editBtn.style.display = 'none'; if (editBtn) editBtn.style.display = 'none';
@ -179,6 +199,26 @@ export async function startEditLeaf(path, elementId) {
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000); intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId; 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(`edit-btn-${elementId}`).style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; 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) {} 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(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none'; 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) {} 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(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'none';

View File

@ -263,6 +263,15 @@ export async function executeBondingConfig() {
const outputEl = document.getElementById('modalOutput'); const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\n\n${cliScript}`; outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\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 { try {
const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass); const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
if (result.status === 'success') { if (result.status === 'success') {
@ -275,5 +284,13 @@ export async function executeBondingConfig() {
} catch (error) { } catch (error) {
outputEl.style.color = "#e74c3c"; outputEl.style.color = "#e74c3c";
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`; outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
} finally {
// 恢復關閉按鈕
if (closeBtn) {
closeBtn.disabled = false;
closeBtn.style.opacity = '1';
closeBtn.style.cursor = 'pointer';
closeBtn.style.pointerEvents = 'auto';
}
} }
} }

View File

@ -132,8 +132,6 @@ button:hover { background-color: #3498db; }
.modal-overlay.active .modal-container { transform: translateY(0); } .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-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-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 身體的多餘內距,讓內容可以貼齊邊緣 */ /* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */
.modal-body { .modal-body {
flex-grow: 1; flex-grow: 1;

View File

@ -1,5 +1,7 @@
// --- static/tree-ui.js --- // --- static/tree-ui.js ---
import { SESSION_USER_ID, currentEditElementId } from './edit-mode.js';
// ========================================== // ==========================================
// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering) // 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
// ========================================== // ==========================================
@ -116,6 +118,27 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
</span> </span>
`; `;
// 🌟 延遲渲染防護:取得當前設備 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(` htmlParts.push(`
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;" <details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}" data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
@ -143,9 +166,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
🔍 🔍
</span> </span>
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')" <span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;" style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組"> onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
${lockText}
</span> </span>
<span id="actions-${elementId}" style="display:none; margin-left: 10px;"> <span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;"> 預覽指令</button> <button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;"> 預覽指令</button>
@ -175,6 +198,27 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`; const iconHtml = (icon) => `<span style="margin-right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px;">${icon}</span>`;
const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`; const valueStyle = `color: #16a085; margin-left: 5px; font-family: Courier New, monospace; display: inline-block; transform: translateY(1.5px);`;
// 🌟 延遲渲染防護:取得當前設備 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 = ''; let displayContent = '';
if (isVirtualIndex) { if (isVirtualIndex) {
@ -221,9 +265,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
🔍 🔍
</span> </span>
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')" <span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;" style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目"> onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
${lockText}
</span> </span>
<span id="actions-${elementId}" style="display:none; margin-left: 10px;"> <span id="actions-${elementId}" style="display:none; margin-left: 10px;">
<button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;"> 預覽指令</button> <button onclick="event.stopPropagation(); event.preventDefault(); previewLeafCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;"> 預覽指令</button>