refactor: 關鍵的 UX 與安全性升級。
- 1. 移除隱含風險的關閉方式,並實作嚴格的「執行中鎖定 (Execution Lock)」,能有效防止幽靈寫入與併發衝突。 - 2. 跨視圖併發防護 (Cross-View Concurrency)。
This commit is contained in:
parent
2cbbdb7b0c
commit
6698e34292
322
all_code.txt
322
all_code.txt
|
|
@ -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="關閉面板">×</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()">×</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);
|
|
||||||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
||||||
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
|
|
||||||
|
|
||||||
targetBtns.forEach(btn => {
|
// 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒)
|
||||||
if (btn.innerText !== "🔒") {
|
if (window.recentlyReleasedLocks[lockKey]) {
|
||||||
btn.style.pointerEvents = 'none';
|
if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
|
||||||
btn.style.opacity = '0.2';
|
continue; // 跳過,不當作鎖定
|
||||||
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
|
} else {
|
||||||
btn.innerText = "🔒";
|
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:處理「解除鎖定」的節點
|
// 🔓 任務 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,
|
||||||
|
|
|
||||||
16
index.html
16
index.html
|
|
@ -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="關閉面板">×</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()">×</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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
126
static/app.js
126
static/app.js
|
|
@ -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);
|
|
||||||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
||||||
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
|
|
||||||
|
|
||||||
targetBtns.forEach(btn => {
|
// 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒)
|
||||||
if (btn.innerText !== "🔒") {
|
if (window.recentlyReleasedLocks[lockKey]) {
|
||||||
btn.style.pointerEvents = 'none';
|
if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
|
||||||
btn.style.opacity = '0.2';
|
continue; // 跳過,不當作鎖定
|
||||||
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
|
} else {
|
||||||
btn.innerText = "🔒";
|
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:處理「解除鎖定」的節點
|
// 🔓 任務 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態)
|
// 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態)
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue