Compare commits

...

3 Commits

Author SHA1 Message Date
swpa 3cd7ce253e refactor: 混合式架構 (Hybrid Approach) 的設定載入訊息設計。
-前端模擬動態流 (Simulated Streaming)
   -真實動態流 (True Streaming)
2026-06-03 17:24:08 +08:00
swpa ecedee49d2 fix: 修復系統中「鎖定狀態未向下繼承」的嚴重 UI/UX 漏洞。 2026-06-02 17:35:33 +08:00
swpa 6698e34292 refactor: 關鍵的 UX 與安全性升級。
- 1. 移除隱含風險的關閉方式,並實作嚴格的「執行中鎖定 (Execution Lock)」,能有效防止幽靈寫入與併發衝突。
- 2. 跨視圖併發防護 (Cross-View Concurrency)。
2026-06-01 16:20:34 +08:00
9 changed files with 1403 additions and 296 deletions

File diff suppressed because it is too large Load Diff

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

@ -149,35 +149,51 @@ async def delete_backup(backup_id: str):
return {"status": "error", "message": "刪除失敗或找不到該筆資料"} return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
return {"status": "success", "message": "快照已成功刪除"} return {"status": "success", "message": "快照已成功刪除"}
@router.post("/snapshot", summary="手動建立設備快照") @router.post("/snapshot", summary="手動建立設備快照 (串流版)")
async def create_snapshot(req: SnapshotRequest): async def create_snapshot(req: SnapshotRequest):
try: async def backup_streamer():
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type) try:
parsed_tree = parse_cli_to_tree(raw_cli) # 1. 廣播:正在連線
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
await asyncio.sleep(0.1)
backup_id = await insert_config_backup( # 2. 廣播:正在下載
host=req.host, yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
config_type=req.config_type, raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
raw_cli=raw_cli,
parsed_tree=parsed_tree,
snapshot_name=req.snapshot_name,
description=req.description, # 🟢 將描述傳遞給資料庫函數
is_auto=False
)
if not backup_id: # 3. 廣播:正在解析
return {"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"} yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
return { # 4. 廣播:寫入資料庫
"status": "success", yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
"message": f"快照 '{req.snapshot_name}' 建立成功!", backup_id = await insert_config_backup(
"backup_id": backup_id, host=req.host,
"host": req.host config_type=req.config_type,
} raw_cli=raw_cli,
parsed_tree=parsed_tree,
snapshot_name=req.snapshot_name,
description=req.description,
is_auto=False
)
except Exception as e: if not backup_id:
logger.error(f"❌ 建立快照失敗: {e}") yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"} return
# 5. 廣播:成功
yield json.dumps({
"status": "success",
"message": f"快照 '{req.snapshot_name}' 建立成功!",
"backup_id": backup_id,
"host": req.host
}) + "\n"
except Exception as e:
logger.error(f"❌ 建立快照失敗: {e}")
yield json.dumps({"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}) + "\n"
return StreamingResponse(backup_streamer(), media_type="application/x-ndjson")
@router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異") @router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異")
async def analyze_backup_diff(backup_id: str, req: RestoreRequest): async def analyze_backup_diff(backup_id: str, req: RestoreRequest):

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,43 +115,100 @@ 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, '\\"'); // 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒)
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`); if (window.recentlyReleasedLocks[lockKey]) {
if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
targetBtns.forEach(btn => { continue; // 跳過,不當作鎖定
if (btn.innerText !== "🔒") { } else {
btn.style.pointerEvents = 'none'; delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶
btn.style.opacity = '0.2'; }
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
btn.innerText = "🔒";
}
});
} }
newLockState.add(lockKey); // 記憶體加入 Host 標籤
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .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';
const btnPath = btn.getAttribute('data-path');
if (btnPath === path) {
// 判斷是別人鎖的,還是自己在另一個視圖鎖的
if (lockInfo.user_id !== SESSION_USER_ID) {
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
} else {
btn.title = `🔒 您正在另一個視圖編輯此項目`;
}
} 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}"], .edit-btn[data-path^="${safePath}::"]`);
targetBtns.forEach(btn => { targetBtns.forEach(btn => {
if (btn.innerText === "🔒") { if (btn.innerText === "🔒") {
btn.style.pointerEvents = 'auto'; const btnPath = btn.getAttribute('data-path');
btn.style.opacity = '0.3'; let stillLockedByOther = false;
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️"; // 檢查自己是否還在鎖定清單中
if (currentLocks[btnPath]) {
stillLockedByOther = true;
} else {
// 檢查是否有其他父節點鎖定它
for (const lockedPath of Object.keys(currentLocks)) {
if (btnPath.startsWith(lockedPath + "::")) {
stillLockedByOther = true;
break;
}
}
}
if (!stillLockedByOther) {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
} }
}); });
} }
@ -322,6 +382,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';
@ -360,7 +425,7 @@ function resetAllManagerData() {
// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config) // 🌟 3. 設備查詢與全域配置載入 (Query & Full Config)
// ============================================================================ // ============================================================================
// 執行綜合查詢 (CM / RPD) // 執行綜合查詢 (CM / RPD) - 🌟 導入前端模擬動態訊息流
async function executeQuery(mode) { async function executeQuery(mode) {
if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕"); if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕");
@ -373,10 +438,28 @@ async function executeQuery(mode) {
openModal(connInfo.host); openModal(connInfo.host);
const outputEl = document.getElementById('modalOutput'); const outputEl = document.getElementById('modalOutput');
outputEl.innerHTML = `<span style="color: #f39c12;">正在向 ${connInfo.host} 查詢中,請稍候...</span>`;
// 🌟 模擬動態訊息流邏輯
const states = [
`🔌 正在建立 SSH 連線至 ${connInfo.host}...`,
`📥 正在向設備發送查詢指令...`,
`🧩 正在等待設備回傳資料...`
];
let progressIdx = 0;
outputEl.innerHTML = `<span id="query-progress-text" style="color: #f39c12; font-weight: bold;">${states[0]}</span>`;
const progressInterval = setInterval(() => {
progressIdx++;
if (progressIdx < states.length) {
const textEl = document.getElementById('query-progress-text');
if (textEl) textEl.innerText = states[progressIdx];
}
}, 1500); // 每 1.5 秒切換一次狀態
try { try {
const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass); const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass);
clearInterval(progressInterval); // 收到結果,停止輪播
if (result.status === 'success') { if (result.status === 'success') {
outputEl.style.color = "#e0e0e0"; outputEl.style.color = "#e0e0e0";
outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data; outputEl.textContent = isDebug ? `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}` : result.data;
@ -385,6 +468,7 @@ async function executeQuery(mode) {
outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`; outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`;
} }
} catch (error) { } catch (error) {
clearInterval(progressInterval); // 發生錯誤,停止輪播
outputEl.style.color = "#e74c3c"; outputEl.style.color = "#e74c3c";
outputEl.textContent = `連線失敗: ${error}`; outputEl.textContent = `連線失敗: ${error}`;
} }
@ -400,6 +484,7 @@ async function fetchFullConfig(configType = 'running') {
if (loadingMsg) { if (loadingMsg) {
loadingMsg.style.display = 'inline-block'; loadingMsg.style.display = 'inline-block';
loadingMsg.style.color = '#f39c12'; // 🌟 確保初始狀態為橘色
loadingMsg.innerHTML = '⏳ 準備連線至設備...'; loadingMsg.innerHTML = '⏳ 準備連線至設備...';
} }
if (treeContainer) treeContainer.innerHTML = ''; if (treeContainer) treeContainer.innerHTML = '';
@ -408,7 +493,6 @@ async function fetchFullConfig(configType = 'running') {
let progressInterval = null; let progressInterval = null;
try { try {
// --- 1. 版本守門員 (維持原樣) ---
try { try {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass); const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') { if (versionRes.status === 'success') {
@ -430,7 +514,6 @@ async function fetchFullConfig(configType = 'running') {
console.warn("版本檢查失敗,略過", vErr); console.warn("版本檢查失敗,略過", vErr);
} }
// --- 2. 載入完整配置 (ReadableStream 串流接收) ---
const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`); const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`);
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`); if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
@ -465,26 +548,20 @@ async function fetchFullConfig(configType = 'running') {
} }
}, 500); }, 500);
} else { } else {
// 🌟 魔法核心:當收到下一個狀態時,先平順收尾動畫
if (progressInterval) { if (progressInterval) {
clearInterval(progressInterval); clearInterval(progressInterval);
progressInterval = null; progressInterval = null;
// 1. 強制填滿 100% 並給予成功提示
if (loadingMsg) { if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`; loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色增加爽感 loadingMsg.style.color = "#27ae60";
} }
// 2. 刻意停頓 0.6 秒,讓大腦接收視覺回饋
await new Promise(resolve => setTimeout(resolve, 600)); await new Promise(resolve => setTimeout(resolve, 600));
if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 🌟 恢復橘色
// 3. 恢復原本的顏色 }
if (loadingMsg) loadingMsg.style.color = ""; if (loadingMsg) {
loadingMsg.style.color = "#f39c12"; // 🌟 確保橘色
loadingMsg.innerHTML = data.message;
} }
// 停頓結束後,才顯示新的狀態 (例如:🧩 正在解析配置...)
if (loadingMsg) loadingMsg.innerHTML = data.message;
} }
} }
else if (data.status === 'success') { else if (data.status === 'success') {
@ -503,7 +580,6 @@ async function fetchFullConfig(configType = 'running') {
window.currentTreeData = finalTreeData; window.currentTreeData = finalTreeData;
// --- 3. 防呆攔截與畫面渲染 ---
const currentSelectedTask = document.getElementById('configTask').value; const currentSelectedTask = document.getElementById('configTask').value;
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config'; const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
@ -531,7 +607,7 @@ async function fetchFullConfig(configType = 'running') {
if (progressInterval) clearInterval(progressInterval); if (progressInterval) clearInterval(progressInterval);
if (loadingMsg) { if (loadingMsg) {
loadingMsg.style.display = 'none'; loadingMsg.style.display = 'none';
loadingMsg.style.color = ""; // 確保重置顏 loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘
} }
} }
} }
@ -794,12 +870,11 @@ async function openSystemSettings() {
} }
} }
// 載入真實設備配置以供過濾器勾選 // 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API)
async function loadRealConfigForFilters() { async function loadRealConfigForFilters() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!"); if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!");
// 🌟 動態抓取過濾器模式
const modeSelect = document.getElementById('filter-mode-select'); const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running'; const mode = modeSelect ? modeSelect.value : 'running';
@ -807,26 +882,89 @@ async function loadRealConfigForFilters() {
const container = document.getElementById('filter-checkboxes'); const container = document.getElementById('filter-checkboxes');
loadingMsg.style.display = 'inline-block'; loadingMsg.style.display = 'inline-block';
loadingMsg.style.color = '#f39c12'; // 🌟 確保橘色
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
container.style.display = 'none'; container.style.display = 'none';
try { let progressInterval = null; // 🌟 新增:進度條計時器
// 🌟 根據模式載入對應的配置檔 (Running 或 Full)
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
const response = await fetch(url);
const result = await response.json();
if (result.status === 'success') { try {
container.style.display = 'block'; const url = `/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
// 🌟 傳遞 mode 給 buildRealFilterTree const response = await fetch(url);
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys, false, mode);
} else { if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`; const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let finalTreeData = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
// 🌟 導入百分比動畫邏輯
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
progress += (95 - progress) * 0.06;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (${Math.round(progress)}%)...`;
}
}, 500);
} else {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
if (loadingMsg) {
loadingMsg.innerHTML = `📥 正在下載 ${mode} 配置檔 (100%) - 下載完成!`;
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色
}
await new Promise(resolve => setTimeout(resolve, 600));
if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 恢復橘色
}
if (loadingMsg) {
loadingMsg.style.color = '#f39c12';
loadingMsg.innerHTML = data.message;
}
}
}
else if (data.status === 'success') {
finalTreeData = data.data;
}
else if (data.status === 'error') {
throw new Error(data.message);
}
} catch (e) {
console.warn("解析串流 JSON 失敗:", line);
}
}
} }
if (finalTreeData) {
container.style.display = 'block';
container.innerHTML = buildRealFilterTree(finalTreeData, "", currentHiddenKeys, false, mode);
} else {
throw new Error("未收到完整的配置資料");
}
} catch (error) { } catch (error) {
container.style.display = 'block'; container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線失敗: ${error.message}</div>`; container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線或解析失敗: ${error.message}</div>`;
} finally { } finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
loadingMsg.style.display = 'none'; loadingMsg.style.display = 'none';
} }
} }
@ -886,9 +1024,8 @@ async function saveSystemSettings() {
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾 // 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
let allBackupsData = []; let allBackupsData = [];
// 1. 建立新快照 // 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
async function createSnapshot() { async function createSnapshot() {
// 🛡️ 防線一:必須先連線才能備份
if (!isConnected) { if (!isConnected) {
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)"); return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後再執行備份任務\n(確保您清楚目前正在操作哪一台設備)");
} }
@ -899,7 +1036,6 @@ async function createSnapshot() {
const snapshotName = document.getElementById('snapshotName').value.trim(); const snapshotName = document.getElementById('snapshotName').value.trim();
if (!snapshotName) return alert("請輸入快照名稱!"); if (!snapshotName) return alert("請輸入快照名稱!");
// 🟢 抓取描述輸入框的值
const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || ""; const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || "";
const configType = document.getElementById('backupConfigType').value; const configType = document.getElementById('backupConfigType').value;
const btn = document.getElementById('btnCreateSnapshot'); const btn = document.getElementById('btnCreateSnapshot');
@ -907,6 +1043,10 @@ async function createSnapshot() {
btn.disabled = true; btn.disabled = true;
msg.style.display = 'inline-block'; msg.style.display = 'inline-block';
msg.style.color = '#f39c12'; // 🌟 確保橘色
msg.innerHTML = '⏳ 準備連線至設備...';
let progressInterval = null; // 🌟 新增:進度條計時器
try { try {
const response = await fetch('/api/v1/backups/snapshot', { const response = await fetch('/api/v1/backups/snapshot', {
@ -917,25 +1057,84 @@ async function createSnapshot() {
username: connInfo.user, username: connInfo.user,
password: connInfo.pass, password: connInfo.pass,
snapshot_name: snapshotName, snapshot_name: snapshotName,
description: snapshotDescription, // 🟢 將描述傳給後端 description: snapshotDescription,
config_type: configType config_type: configType
}) })
}); });
const result = await response.json();
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
if (result.status === 'success') {
alert(`${result.message}`); const reader = response.body.getReader();
document.getElementById('snapshotName').value = ''; // 清空名稱輸入框 const decoder = new TextDecoder("utf-8");
if (document.getElementById('snapshotDescription')) { let buffer = "";
document.getElementById('snapshotDescription').value = ''; // 🟢 清空描述輸入框 let isSuccess = false;
let finalMessage = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
buffer = lines.pop();
for (let line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === 'progress') {
// 🌟 導入百分比動畫邏輯
if (data.message.includes('正在下載')) {
let progress = 0;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
progress += (95 - progress) * 0.06;
if (msg) {
msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
}
}, 500);
} else {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
if (msg) {
msg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
msg.style.color = "#27ae60"; // 瞬間變綠色
}
await new Promise(resolve => setTimeout(resolve, 600));
if (msg) msg.style.color = "#f39c12"; // 恢復橘色
}
if (msg) {
msg.style.color = '#f39c12';
msg.innerHTML = data.message;
}
}
} else if (data.status === 'success') {
isSuccess = true;
finalMessage = data.message;
} else if (data.status === 'error') {
throw new Error(data.message);
}
} catch (e) {
if (e.message) throw e;
console.warn("解析串流 JSON 失敗:", line);
}
} }
loadBackupHistory(); // 重新載入歷史紀錄 }
} else {
alert(`❌ 備份失敗: ${result.message}`); if (isSuccess) {
alert(`${finalMessage}`);
document.getElementById('snapshotName').value = '';
if (document.getElementById('snapshotDescription')) {
document.getElementById('snapshotDescription').value = '';
}
loadBackupHistory();
} }
} catch (error) { } catch (error) {
alert(`❌ 發生錯誤: ${error.message}`); alert(`❌ 發生錯誤: ${error.message}`);
} finally { } finally {
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
btn.disabled = false; btn.disabled = false;
msg.style.display = 'none'; msg.style.display = 'none';
} }
@ -950,7 +1149,8 @@ async function loadBackupHistory() {
const tbody = document.getElementById('backup-history-tbody'); const tbody = document.getElementById('backup-history-tbody');
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">⏳ 正在載入歷史紀錄...</td></tr>'; // 🌟 修正:將原本的灰色改為橘色並加粗
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
try { try {
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來 // 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
@ -1109,8 +1309,9 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
// 1. 移除進度條,改為純文字百分比動態顯示 // 1. 移除進度條,改為純文字百分比動態顯示
modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`; modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
// 🌟 修正:將原本的藍色改為橘色
modalOutput.innerHTML = ` modalOutput.innerHTML = `
<div id="diff-progress-text" style="color: #3498db; margin-bottom: 10px; font-weight: bold; font-size: 15px;">🔍 正在抓取設備當前配置並與快照進行深度比對... (0%)</div> <div id="diff-progress-text" style="color: #f39c12; margin-bottom: 10px; font-weight: bold; font-size: 15px;">🔍 正在抓取設備當前配置並與快照進行深度比對... (0%)</div>
`; `;
modal.classList.add('active'); modal.classList.add('active');
@ -1158,9 +1359,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 +1407,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 +1427,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 +1436,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 +1450,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 +1460,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 +1471,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 +1532,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

@ -24,6 +24,9 @@ function escapeHTML(str) {
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
const ACTIVE_HEARTBEATS = {}; const ACTIVE_HEARTBEATS = {};
// 🌟 新增:用來防禦「秒按取消」導致的非同步渲染競爭危害
const activeEditSessions = new Set();
export let currentEditPath = null; export let currentEditPath = null;
export let currentEditElementId = null; export let currentEditElementId = null;
export let currentEditIsSuccess = false; export let currentEditIsSuccess = false;
@ -69,6 +72,10 @@ async function sendHeartbeat(path, host, intervalId, lockKey) {
// ========================================== // ==========================================
export async function startEditFolder(path, elementId) { export async function startEditFolder(path, elementId) {
// 🚨 防護 1防止連點或重複執行
if (activeEditSessions.has(elementId)) return;
activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin'; const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : ''; const host = connInfo ? connInfo.host : '';
@ -76,9 +83,41 @@ export async function startEditFolder(path, elementId) {
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
// 立即讓按鈕呈現讀取中,防止使用者焦慮連點
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.pointerEvents = 'none';
editBtn.innerText = "⏳";
}
try { try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
// 🚨 防護 2如果在等待後端上鎖的期間使用者已經按了取消
if (!activeEditSessions.has(elementId)) {
if (result.status === 200 || (result.data && result.data.status === 'success')) {
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
apiReleaseLock(path, SESSION_USER_ID, host); // 默默把剛拿到的鎖還回去
}
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️";
}
return; // 立即中斷,絕不觸碰 UI
}
if (result.status === 409) {
activeEditSessions.delete(elementId);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
return alert(`⚠️ ${result.data.detail}`);
}
if (result.data.status === 'success') { if (result.data.status === 'success') {
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]); if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
@ -86,7 +125,33 @@ 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;
const editBtn = document.getElementById(`edit-btn-${elementId}`); // 🌟 立即更新全域鎖定狀態 (加入 Host 隔離)
if (!window.globalActiveLocks) window.globalActiveLocks = {};
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
// 🌟 確保不在冷卻名單中 (防止手速過快)
if (window.recentlyReleasedLocks) {
delete window.recentlyReleasedLocks[`${host}@@${path}`];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:連同所有子節點一起秒級鎖定
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
const btnPath = btn.getAttribute('data-path');
if (btnPath === path) {
btn.title = `🔒 您正在另一個視圖編輯此項目`;
} else {
btn.title = `🔒 父層級已被鎖定,無法編輯此項目`;
}
btn.innerText = "🔒";
}
});
if (editBtn) editBtn.style.display = 'none'; if (editBtn) editBtn.style.display = 'none';
const actionBtn = document.getElementById(`actions-${elementId}`); const actionBtn = document.getElementById(`actions-${elementId}`);
@ -105,12 +170,17 @@ export async function startEditFolder(path, elementId) {
let optData = {}; let optData = {};
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {} try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
const pathsToSync = []; // 🚨 防護 3如果等待 API 期間使用者按了取消,立刻中斷後續渲染
if (!activeEditSessions.has(elementId)) return;
const pathsToSync = [];
const containersArray = Array.from(leafContainers); const containersArray = Array.from(leafContainers);
const CHUNK_SIZE = 50; const CHUNK_SIZE = 50;
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) { for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
// 🚨 防護 4如果分批渲染期間使用者按了取消立刻中斷
if (!activeEditSessions.has(elementId)) return;
const chunk = containersArray.slice(i, i + CHUNK_SIZE); const chunk = containersArray.slice(i, i + CHUNK_SIZE);
chunk.forEach(container => { chunk.forEach(container => {
@ -157,11 +227,20 @@ export async function startEditFolder(path, elementId) {
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass); if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
} }
} catch (error) { } catch (error) {
activeEditSessions.delete(elementId);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
alert("❌ 無法連線到鎖定伺服器:" + error.message); alert("❌ 無法連線到鎖定伺服器:" + error.message);
} }
} }
export async function startEditLeaf(path, elementId) { export async function startEditLeaf(path, elementId) {
// 🚨 防護 1防止連點或重複執行
if (activeEditSessions.has(elementId)) return;
activeEditSessions.add(elementId);
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin'; const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : ''; const host = connInfo ? connInfo.host : '';
@ -169,9 +248,40 @@ export async function startEditLeaf(path, elementId) {
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running'; const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.pointerEvents = 'none';
editBtn.innerText = "⏳";
}
try { try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host); const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
// 🚨 防護 2如果在等待後端上鎖的期間使用者已經按了取消
if (!activeEditSessions.has(elementId)) {
if (result.status === 200 || (result.data && result.data.status === 'success')) {
// 🌟 補上防閃爍冷卻,防止輪詢抓到時間差
if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
apiReleaseLock(path, SESSION_USER_ID, host);
}
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️";
}
return;
}
if (result.status === 409) {
activeEditSessions.delete(elementId);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
return alert(`⚠️ ${result.data.detail}`);
}
if (result.data.status === 'success') { if (result.data.status === 'success') {
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]); if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
@ -179,14 +289,33 @@ 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;
document.getElementById(`edit-btn-${elementId}`).style.display = 'none'; if (!window.globalActiveLocks) window.globalActiveLocks = {};
if (!window.globalActiveLocks[host]) window.globalActiveLocks[host] = {};
window.globalActiveLocks[host][path] = { user_id: SESSION_USER_ID, username: username };
if (window.recentlyReleasedLocks) {
delete window.recentlyReleasedLocks[`${host}@@${path}`];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
btn.title = `🔒 您正在另一個視圖編輯此項目`;
btn.innerText = "🔒";
}
});
if (editBtn) editBtn.style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
const container = document.getElementById(`container-${elementId}`); const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`; // 🌟 修正:統一為標準橘色並加粗 (原為 #e67e22)
container.innerHTML = `<span style="font-size: 12px; color: #f39c12; margin-left: 5px; font-weight: bold;">⏳ 設備連線與載入選項中...</span>`;
try { try {
let optData = await apiGetLeafOptions(host, currentMode); let optData = await apiGetLeafOptions(host, currentMode);
@ -198,6 +327,7 @@ export async function startEditLeaf(path, elementId) {
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass); apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
let found = false; let found = false;
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions(host, currentMode); optData = await apiGetLeafOptions(host, currentMode);
leafCache = optData[cacheKey]; leafCache = optData[cacheKey];
@ -207,6 +337,8 @@ export async function startEditLeaf(path, elementId) {
} }
} }
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
let inputHtml = ""; let inputHtml = "";
let hintAttr = ""; let hintAttr = "";
let hintIcon = ""; let hintIcon = "";
@ -233,15 +365,23 @@ export async function startEditLeaf(path, elementId) {
} }
container.innerHTML = inputHtml; container.innerHTML = inputHtml;
} catch (e) { } catch (e) {
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
container.innerHTML = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`; container.innerHTML = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">`;
} }
} }
} catch (error) { } catch (error) {
activeEditSessions.delete(elementId);
if (editBtn) {
editBtn.style.pointerEvents = 'auto';
editBtn.innerText = "✏️";
}
alert("❌ 無法連線到鎖定伺服器:" + error.message); alert("❌ 無法連線到鎖定伺服器:" + error.message);
} }
} }
export async function cancelEditFolder(path, elementId) { export async function cancelEditFolder(path, elementId) {
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const host = connInfo ? connInfo.host : ''; const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`; const lockKey = `${host}@@${path}`;
@ -250,10 +390,59 @@ export async function cancelEditFolder(path, elementId) {
clearInterval(ACTIVE_HEARTBEATS[lockKey]); clearInterval(ACTIVE_HEARTBEATS[lockKey]);
delete ACTIVE_HEARTBEATS[lockKey]; delete ACTIVE_HEARTBEATS[lockKey];
} }
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; // 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
document.getElementById(`actions-${elementId}`).style.display = 'none'; if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
delete window.globalActiveLocks[host][path];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// 🌟 擴大選擇器:連同所有子節點一起秒級解除鎖定
document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
const btnPath = btn.getAttribute('data-path');
let stillLockedByOther = false;
// 確保解除鎖定時,不會誤解開被其他父節點鎖定的子節點
if (window.globalActiveLocks && window.globalActiveLocks[host]) {
const hostLocks = window.globalActiveLocks[host];
if (hostLocks[btnPath]) {
stillLockedByOther = true;
} else {
for (const lockedPath of Object.keys(hostLocks)) {
if (btnPath.startsWith(lockedPath + "::")) {
stillLockedByOther = true;
break;
}
}
}
}
if (!stillLockedByOther) {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
}
});
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.display = 'inline-block';
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
}
const actionBtns = document.getElementById(`actions-${elementId}`);
if (actionBtns) actionBtns.style.display = 'none';
const contentDiv = document.getElementById(`content-${elementId}`); const contentDiv = document.getElementById(`content-${elementId}`);
const leafContainers = contentDiv.querySelectorAll('.leaf-container'); const leafContainers = contentDiv.querySelectorAll('.leaf-container');
@ -287,6 +476,8 @@ export async function cancelEditFolder(path, elementId) {
} }
export async function cancelEditLeaf(path, elementId) { export async function cancelEditLeaf(path, elementId) {
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const host = connInfo ? connInfo.host : ''; const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`; const lockKey = `${host}@@${path}`;
@ -295,10 +486,38 @@ export async function cancelEditLeaf(path, elementId) {
clearInterval(ACTIVE_HEARTBEATS[lockKey]); clearInterval(ACTIVE_HEARTBEATS[lockKey]);
delete ACTIVE_HEARTBEATS[lockKey]; delete ACTIVE_HEARTBEATS[lockKey];
} }
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; // 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
document.getElementById(`actions-${elementId}`).style.display = 'none'; if (!window.recentlyReleasedLocks) window.recentlyReleasedLocks = {};
window.recentlyReleasedLocks[`${host}@@${path}`] = Date.now();
try { await apiReleaseLock(path, SESSION_USER_ID, host); } catch (error) {}
// 🌟 立即解除全域鎖定狀態 (加入 Host 隔離)
if (window.globalActiveLocks && window.globalActiveLocks[host] && window.globalActiveLocks[host][path]) {
delete window.globalActiveLocks[host][path];
}
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`).forEach(btn => {
if (btn.id !== `edit-btn-${elementId}`) {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
});
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) {
editBtn.style.display = 'inline-block';
editBtn.style.pointerEvents = 'auto';
editBtn.style.opacity = '0.3';
editBtn.title = "鎖定並編輯此項目";
editBtn.innerText = "✏️"; // 🌟 確保沙漏被清掉
}
const actionBtns = document.getElementById(`actions-${elementId}`);
if (actionBtns) actionBtns.style.display = 'none';
const container = document.getElementById(`container-${elementId}`); const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
@ -528,7 +747,7 @@ async function applyEditLeaf(path, elementId) {
export function executeSideCLI() { export function executeSideCLI() {
const finalScript = document.getElementById('side-cli-textarea').value; const finalScript = document.getElementById('side-cli-textarea').value;
document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...'; document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...';
document.getElementById('side-pane-title').style.color = '#3498db'; document.getElementById('side-pane-title').style.color = '#f39c12'; // 🌟 統一載入中狀態為橘色 (原為 #3498db)
document.getElementById('btn-side-cancel').style.display = 'none'; document.getElementById('btn-side-cancel').style.display = 'none';
document.getElementById('btn-side-confirm').style.display = 'none'; document.getElementById('btn-side-confirm').style.display = 'none';
document.getElementById('btn-side-close').style.display = 'none'; document.getElementById('btn-side-close').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,44 @@ 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]) {
const hostLocks = window.globalActiveLocks[currentHost];
// 1. 檢查自己是否被鎖定
if (hostLocks[currentPath]) {
const lockInfo = hostLocks[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}] 編輯中`
: `🔒 您正在另一個視圖編輯此項目`;
}
} else {
// 2. 檢查父層級是否被鎖定 (Cascading Lock)
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
if (currentPath.startsWith(lockedPath + "::")) {
isLocked = true;
lockText = "🔒";
lockOpacity = "0.2";
lockPointer = "none";
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`;
break;
}
}
}
}
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 +183,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 +215,44 @@ 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]) {
const hostLocks = window.globalActiveLocks[currentHost];
// 1. 檢查自己是否被鎖定
if (hostLocks[currentPath]) {
const lockInfo = hostLocks[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}] 編輯中`
: `🔒 您正在另一個視圖編輯此項目`;
}
} else {
// 2. 檢查父層級是否被鎖定 (Cascading Lock)
for (const [lockedPath, lockInfo] of Object.entries(hostLocks)) {
if (currentPath.startsWith(lockedPath + "::")) {
isLocked = true;
lockText = "🔒";
lockOpacity = "0.2";
lockPointer = "none";
lockTitle = `🔒 父層級已被鎖定,無法編輯此項目`;
break;
}
}
}
}
let displayContent = ''; let displayContent = '';
if (isVirtualIndex) { if (isVirtualIndex) {
@ -221,9 +299,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>