Compare commits
3 Commits
2cbbdb7b0c
...
3cd7ce253e
| Author | SHA1 | Date |
|---|---|---|
|
|
3cd7ce253e | |
|
|
ecedee49d2 | |
|
|
6698e34292 |
862
all_code.txt
862
all_code.txt
File diff suppressed because it is too large
Load Diff
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;">
|
||||
<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-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>
|
||||
<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>
|
||||
|
||||
|
|
@ -469,14 +467,16 @@
|
|||
</div>
|
||||
|
||||
<!-- 獨立的彈出式輸出視窗 (Modal) -->
|
||||
<div id="outputModal" class="modal-overlay" onclick="closeModal(event)">
|
||||
<div class="modal-container" onclick="event.stopPropagation()">
|
||||
<div id="outputModal" class="modal-overlay">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<div class="modal-title" style="flex-grow: 1; display: flex; align-items: center;">
|
||||
<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>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre id="modalOutput" class="readonly-terminal">等待執行中...</pre>
|
||||
|
|
|
|||
|
|
@ -149,35 +149,51 @@ async def delete_backup(backup_id: str):
|
|||
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
|
||||
return {"status": "success", "message": "快照已成功刪除"}
|
||||
|
||||
@router.post("/snapshot", summary="手動建立設備快照")
|
||||
@router.post("/snapshot", summary="手動建立設備快照 (串流版)")
|
||||
async def create_snapshot(req: SnapshotRequest):
|
||||
try:
|
||||
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
|
||||
parsed_tree = parse_cli_to_tree(raw_cli)
|
||||
async def backup_streamer():
|
||||
try:
|
||||
# 1. 廣播:正在連線
|
||||
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
backup_id = await insert_config_backup(
|
||||
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
|
||||
)
|
||||
# 2. 廣播:正在下載
|
||||
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
|
||||
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
|
||||
|
||||
if not backup_id:
|
||||
return {"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}
|
||||
# 3. 廣播:正在解析
|
||||
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
||||
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"快照 '{req.snapshot_name}' 建立成功!",
|
||||
"backup_id": backup_id,
|
||||
"host": req.host
|
||||
}
|
||||
# 4. 廣播:寫入資料庫
|
||||
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
|
||||
backup_id = await insert_config_backup(
|
||||
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:
|
||||
logger.error(f"❌ 建立快照失敗: {e}")
|
||||
return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}
|
||||
if not backup_id:
|
||||
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
|
||||
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="分析設備當前配置與快照的差異")
|
||||
async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ router = APIRouter(prefix="/locks", tags=["Lock Management"])
|
|||
# ==========================================
|
||||
# 💡 全域鎖定表 (In-Memory Lock Table)
|
||||
# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
|
||||
# ⚠️ 嚴格規則:Lock Key 絕對不包含 config_type,確保 running 與 full 視圖共用同一把鎖!
|
||||
# ==========================================
|
||||
ACTIVE_LOCKS: Dict[str, dict] = {}
|
||||
LOCK_TIMEOUT = 120
|
||||
|
||||
# 🌟 新增 host 欄位
|
||||
# 🌟 請求模型嚴格排除 config_type
|
||||
class LockAcquireReq(BaseModel):
|
||||
host: str
|
||||
path: str
|
||||
|
|
@ -62,6 +63,7 @@ async def acquire_lock(req: LockAcquireReq):
|
|||
if conflict_lock:
|
||||
raise HTTPException(status_code=409, detail=error_msg)
|
||||
|
||||
# ⚠️ 嚴格綁定 host 與 path,跨視圖共用
|
||||
lock_key = f"{req.host}@@{req.path}"
|
||||
ACTIVE_LOCKS[lock_key] = {
|
||||
"user_id": req.user_id,
|
||||
|
|
|
|||
399
static/app.js
399
static/app.js
|
|
@ -25,7 +25,7 @@ import { buildTree, buildRealFilterTree, expandAll, collapseAll, clearTreeCache
|
|||
import {
|
||||
releaseAllLocks, startEditFolder, startEditLeaf,
|
||||
cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
|
||||
hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID
|
||||
hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID, currentEditElementId
|
||||
} from './edit-mode.js';
|
||||
|
||||
// 6. MAC Domain 配置模組 (MAC Domain)
|
||||
|
|
@ -102,9 +102,12 @@ window.addEventListener('beforeunload', () => {
|
|||
}
|
||||
});
|
||||
|
||||
// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」
|
||||
// 🌟 特務的記憶體:記錄「上一次輪詢時,處於鎖定狀態的路徑」(加入 Host 隔離)
|
||||
let activeLockState = new Set();
|
||||
let lockPollingTimer = null;
|
||||
let currentPollingHost = null; // 記錄當前輪詢的設備 IP
|
||||
window.globalActiveLocks = {}; // 🌟 升級為二維結構:{ "10.14.110.4": { "alias": {...} } }
|
||||
window.recentlyReleasedLocks = {}; // 🌟 新增:防閃爍冷卻表 (Optimistic UI Cooldown)
|
||||
|
||||
function startLockStatusPolling() {
|
||||
if (lockPollingTimer) clearInterval(lockPollingTimer);
|
||||
|
|
@ -112,43 +115,100 @@ function startLockStatusPolling() {
|
|||
lockPollingTimer = setInterval(async () => {
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo || !connInfo.host) return;
|
||||
const host = connInfo.host;
|
||||
|
||||
// 🛡️ 跨設備防呆:如果使用者切換了 IP,必須清空舊設備的鎖定記憶,防止幽靈解鎖
|
||||
if (currentPollingHost !== host) {
|
||||
activeLockState.clear();
|
||||
currentPollingHost = host;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await apiGetLockStatus(connInfo.host);
|
||||
const result = await apiGetLockStatus(host);
|
||||
if (result.status === 'success') {
|
||||
const currentLocks = result.data;
|
||||
window.globalActiveLocks[host] = currentLocks;
|
||||
const newLockState = new Set();
|
||||
const now = Date.now();
|
||||
|
||||
// 🎯 任務 1:處理「新增鎖定」或「持續鎖定」的節點
|
||||
for (const [path, lockInfo] of Object.entries(currentLocks)) {
|
||||
if (lockInfo.user_id !== SESSION_USER_ID) {
|
||||
newLockState.add(path);
|
||||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
|
||||
|
||||
targetBtns.forEach(btn => {
|
||||
if (btn.innerText !== "🔒") {
|
||||
btn.style.pointerEvents = 'none';
|
||||
btn.style.opacity = '0.2';
|
||||
btn.title = `🔒 此區塊正由 [${lockInfo.username}] 編輯中`;
|
||||
btn.innerText = "🔒";
|
||||
}
|
||||
});
|
||||
const lockKey = `${host}@@${path}`;
|
||||
|
||||
// 🛡️ 防閃爍機制:如果這個鎖是我們剛剛才主動釋放的,忽略後端傳來的舊狀態 (冷卻 8 秒)
|
||||
if (window.recentlyReleasedLocks[lockKey]) {
|
||||
if (now - window.recentlyReleasedLocks[lockKey] < 8000) {
|
||||
continue; // 跳過,不當作鎖定
|
||||
} else {
|
||||
delete window.recentlyReleasedLocks[lockKey]; // 超過冷卻時間,清除記憶
|
||||
}
|
||||
}
|
||||
|
||||
newLockState.add(lockKey); // 記憶體加入 Host 標籤
|
||||
|
||||
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
|
||||
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .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:處理「解除鎖定」的節點
|
||||
activeLockState.forEach(oldPath => {
|
||||
if (!newLockState.has(oldPath)) {
|
||||
activeLockState.forEach(oldKey => {
|
||||
const [oldHost, oldPath] = oldKey.split('@@');
|
||||
|
||||
// 🛡️ 嚴格限制:只解除「當前設備」且「已不在新鎖定清單中」的節點
|
||||
if (oldHost === host && !newLockState.has(oldKey)) {
|
||||
const safePath = oldPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"]`);
|
||||
// 🌟 擴大選擇器:同時選取完全匹配的節點,以及所有子節點
|
||||
const targetBtns = document.querySelectorAll(`.edit-btn[data-path="${safePath}"], .edit-btn[data-path^="${safePath}::"]`);
|
||||
|
||||
targetBtns.forEach(btn => {
|
||||
if (btn.innerText === "🔒") {
|
||||
btn.style.pointerEvents = 'auto';
|
||||
btn.style.opacity = '0.3';
|
||||
btn.title = "鎖定並編輯此項目";
|
||||
btn.innerText = "✏️";
|
||||
const btnPath = btn.getAttribute('data-path');
|
||||
let stillLockedByOther = false;
|
||||
|
||||
// 檢查自己是否還在鎖定清單中
|
||||
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
|
||||
clearTreeCache();
|
||||
|
||||
// 🛡️ [跨設備防護] 切換設備時,清空鎖定輪詢的記憶體
|
||||
if (typeof activeLockState !== 'undefined' && activeLockState.clear) {
|
||||
activeLockState.clear();
|
||||
}
|
||||
|
||||
const configArea = document.getElementById('macDomainConfigArea');
|
||||
if (configArea) configArea.style.display = 'none';
|
||||
|
|
@ -360,7 +425,7 @@ function resetAllManagerData() {
|
|||
// 🌟 3. 設備查詢與全域配置載入 (Query & Full Config)
|
||||
// ============================================================================
|
||||
|
||||
// 執行綜合查詢 (CM / RPD)
|
||||
// 執行綜合查詢 (CM / RPD) - 🌟 導入前端模擬動態訊息流
|
||||
async function executeQuery(mode) {
|
||||
if (!isConnected) return alert("請先點擊畫面上方的「連線至 CMTS」按鈕!");
|
||||
|
||||
|
|
@ -373,10 +438,28 @@ async function executeQuery(mode) {
|
|||
|
||||
openModal(connInfo.host);
|
||||
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 {
|
||||
const result = await apiExecuteQuery(queryType, target, connInfo.host, connInfo.user, connInfo.pass);
|
||||
clearInterval(progressInterval); // 收到結果,停止輪播
|
||||
|
||||
if (result.status === 'success') {
|
||||
outputEl.style.color = "#e0e0e0";
|
||||
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}`;
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(progressInterval); // 發生錯誤,停止輪播
|
||||
outputEl.style.color = "#e74c3c";
|
||||
outputEl.textContent = `連線失敗: ${error}`;
|
||||
}
|
||||
|
|
@ -400,6 +484,7 @@ async function fetchFullConfig(configType = 'running') {
|
|||
|
||||
if (loadingMsg) {
|
||||
loadingMsg.style.display = 'inline-block';
|
||||
loadingMsg.style.color = '#f39c12'; // 🌟 確保初始狀態為橘色
|
||||
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
|
||||
}
|
||||
if (treeContainer) treeContainer.innerHTML = '';
|
||||
|
|
@ -408,7 +493,6 @@ async function fetchFullConfig(configType = 'running') {
|
|||
let progressInterval = null;
|
||||
|
||||
try {
|
||||
// --- 1. 版本守門員 (維持原樣) ---
|
||||
try {
|
||||
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
|
||||
if (versionRes.status === 'success') {
|
||||
|
|
@ -430,7 +514,6 @@ async function fetchFullConfig(configType = 'running') {
|
|||
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}`);
|
||||
|
||||
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
|
||||
|
|
@ -465,26 +548,20 @@ async function fetchFullConfig(configType = 'running') {
|
|||
}
|
||||
}, 500);
|
||||
} else {
|
||||
// 🌟 魔法核心:當收到下一個狀態時,先平順收尾動畫
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
|
||||
// 1. 強制填滿 100% 並給予成功提示
|
||||
if (loadingMsg) {
|
||||
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
|
||||
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色增加爽感
|
||||
loadingMsg.style.color = "#27ae60";
|
||||
}
|
||||
|
||||
// 2. 刻意停頓 0.6 秒,讓大腦接收視覺回饋
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
|
||||
// 3. 恢復原本的顏色
|
||||
if (loadingMsg) loadingMsg.style.color = "";
|
||||
if (loadingMsg) loadingMsg.style.color = "#f39c12"; // 🌟 恢復橘色
|
||||
}
|
||||
if (loadingMsg) {
|
||||
loadingMsg.style.color = "#f39c12"; // 🌟 確保橘色
|
||||
loadingMsg.innerHTML = data.message;
|
||||
}
|
||||
|
||||
// 停頓結束後,才顯示新的狀態 (例如:🧩 正在解析配置...)
|
||||
if (loadingMsg) loadingMsg.innerHTML = data.message;
|
||||
}
|
||||
}
|
||||
else if (data.status === 'success') {
|
||||
|
|
@ -503,7 +580,6 @@ async function fetchFullConfig(configType = 'running') {
|
|||
|
||||
window.currentTreeData = finalTreeData;
|
||||
|
||||
// --- 3. 防呆攔截與畫面渲染 ---
|
||||
const currentSelectedTask = document.getElementById('configTask').value;
|
||||
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
|
||||
|
||||
|
|
@ -531,7 +607,7 @@ async function fetchFullConfig(configType = 'running') {
|
|||
if (progressInterval) clearInterval(progressInterval);
|
||||
if (loadingMsg) {
|
||||
loadingMsg.style.display = 'none';
|
||||
loadingMsg.style.color = ""; // 確保重置顏色
|
||||
loadingMsg.style.color = "#f39c12"; // 🌟 確保重置為橘色
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -794,12 +870,11 @@ async function openSystemSettings() {
|
|||
}
|
||||
}
|
||||
|
||||
// 載入真實設備配置以供過濾器勾選
|
||||
// 載入真實設備配置以供過濾器勾選 - 🌟 升級為真實動態訊息流 (重用現有串流 API)
|
||||
async function loadRealConfigForFilters() {
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!");
|
||||
|
||||
// 🌟 動態抓取過濾器模式
|
||||
const modeSelect = document.getElementById('filter-mode-select');
|
||||
const mode = modeSelect ? modeSelect.value : 'running';
|
||||
|
||||
|
|
@ -807,26 +882,89 @@ async function loadRealConfigForFilters() {
|
|||
const container = document.getElementById('filter-checkboxes');
|
||||
|
||||
loadingMsg.style.display = 'inline-block';
|
||||
loadingMsg.style.color = '#f39c12'; // 🌟 確保橘色
|
||||
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
|
||||
container.style.display = 'none';
|
||||
|
||||
try {
|
||||
// 🌟 根據模式載入對應的配置檔 (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();
|
||||
let progressInterval = null; // 🌟 新增:進度條計時器
|
||||
|
||||
if (result.status === 'success') {
|
||||
container.style.display = 'block';
|
||||
// 🌟 傳遞 mode 給 buildRealFilterTree
|
||||
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys, false, mode);
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`;
|
||||
try {
|
||||
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}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
|
||||
|
||||
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) {
|
||||
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 {
|
||||
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
|
||||
loadingMsg.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
|
@ -886,9 +1024,8 @@ async function saveSystemSettings() {
|
|||
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
|
||||
let allBackupsData = [];
|
||||
|
||||
// 1. 建立新快照
|
||||
// 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
|
||||
async function createSnapshot() {
|
||||
// 🛡️ 防線一:必須先連線才能備份
|
||||
if (!isConnected) {
|
||||
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行備份任務!\n(確保您清楚目前正在操作哪一台設備)");
|
||||
}
|
||||
|
|
@ -899,7 +1036,6 @@ async function createSnapshot() {
|
|||
const snapshotName = document.getElementById('snapshotName').value.trim();
|
||||
if (!snapshotName) return alert("請輸入快照名稱!");
|
||||
|
||||
// 🟢 抓取描述輸入框的值
|
||||
const snapshotDescription = document.getElementById('snapshotDescription')?.value.trim() || "";
|
||||
const configType = document.getElementById('backupConfigType').value;
|
||||
const btn = document.getElementById('btnCreateSnapshot');
|
||||
|
|
@ -907,6 +1043,10 @@ async function createSnapshot() {
|
|||
|
||||
btn.disabled = true;
|
||||
msg.style.display = 'inline-block';
|
||||
msg.style.color = '#f39c12'; // 🌟 確保橘色
|
||||
msg.innerHTML = '⏳ 準備連線至設備...';
|
||||
|
||||
let progressInterval = null; // 🌟 新增:進度條計時器
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/backups/snapshot', {
|
||||
|
|
@ -917,25 +1057,84 @@ async function createSnapshot() {
|
|||
username: connInfo.user,
|
||||
password: connInfo.pass,
|
||||
snapshot_name: snapshotName,
|
||||
description: snapshotDescription, // 🟢 將描述傳給後端
|
||||
description: snapshotDescription,
|
||||
config_type: configType
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
alert(`✅ ${result.message}`);
|
||||
document.getElementById('snapshotName').value = ''; // 清空名稱輸入框
|
||||
if (document.getElementById('snapshotDescription')) {
|
||||
document.getElementById('snapshotDescription').value = ''; // 🟢 清空描述輸入框
|
||||
|
||||
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
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) {
|
||||
alert(`❌ 發生錯誤: ${error.message}`);
|
||||
} finally {
|
||||
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
|
||||
btn.disabled = false;
|
||||
msg.style.display = 'none';
|
||||
}
|
||||
|
|
@ -950,7 +1149,8 @@ async function loadBackupHistory() {
|
|||
const tbody = document.getElementById('backup-history-tbody');
|
||||
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 {
|
||||
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
|
||||
|
|
@ -1109,8 +1309,9 @@ async function restoreBackup(snapshotId, snapshotName, backupHost) {
|
|||
|
||||
// 1. 移除進度條,改為純文字百分比動態顯示
|
||||
modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
|
||||
// 🌟 修正:將原本的藍色改為橘色
|
||||
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');
|
||||
|
||||
|
|
@ -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-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>
|
||||
<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;">`;
|
||||
commands.forEach(cmd => {
|
||||
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) {
|
||||
if (!confirm(`⚠️ 警告:即將將 ${commands.length} 條指令寫入設備並 Commit!\n確定要繼續嗎?`)) return;
|
||||
|
||||
// 1. 優雅地將按鈕反灰 (Disabled)
|
||||
const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore'];
|
||||
// 1. 優雅地將按鈕反灰 (Disabled),包含關閉按鈕
|
||||
const btnIds = ['btn-view-diff', 'btn-view-cli', 'btn-confirm-restore', 'btn-modal-close'];
|
||||
btnIds.forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) {
|
||||
|
|
@ -1209,7 +1427,6 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
|
|||
const modalOutput = document.getElementById('modalOutput');
|
||||
|
||||
// 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸)
|
||||
// 移除 min-height 和 overflow-y,讓它完全撐滿父容器
|
||||
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 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');
|
||||
|
||||
try {
|
||||
// 3. 發送 Fetch 請求 (不使用 await response.json())
|
||||
const response = await fetch(`/api/v1/backups/${snapshotId}/restore`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: connInfo.host, username: connInfo.user, password: connInfo.pass, commands: commands })
|
||||
});
|
||||
|
||||
// 4. 處理 ReadableStream (串流接收 NDJSON)
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
|
|
@ -1235,13 +1450,8 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
|
|||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// 將新收到的位元組解碼並加入緩衝區
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// 用換行符號切割出完整的 JSON 字串
|
||||
let lines = buffer.split('\n');
|
||||
|
||||
// 把最後一行 (可能還沒傳完的半截字串) 留到下一回合處理
|
||||
buffer = lines.pop();
|
||||
|
||||
for (let line of lines) {
|
||||
|
|
@ -1250,11 +1460,9 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
|
|||
const data = JSON.parse(line);
|
||||
const timeStr = new Date().toLocaleTimeString('en-US', { hour12: false });
|
||||
|
||||
// 根據狀態渲染不同顏色的 Log
|
||||
if (data.status === 'progress') {
|
||||
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
|
||||
} else if (data.status === 'success') {
|
||||
// 🌟 魔法核心:清理設備回傳的字串,移除 echoed 的 "commit" 指令
|
||||
let cleanOutput = data.output || '無';
|
||||
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>
|
||||
`;
|
||||
document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`;
|
||||
|
||||
// 成功後,隱藏確認按鈕,並恢復關閉按鈕
|
||||
const confirmBtn = document.getElementById('btn-confirm-restore');
|
||||
if (confirmBtn) confirmBtn.style.display = 'none';
|
||||
|
||||
const closeBtn = document.getElementById('btn-modal-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.disabled = false;
|
||||
closeBtn.style.opacity = '1';
|
||||
closeBtn.style.cursor = 'pointer';
|
||||
closeBtn.style.pointerEvents = 'auto';
|
||||
}
|
||||
} else if (data.status === 'error') {
|
||||
logContainer.innerHTML += `<div style="margin-top: 10px; color: #e74c3c; font-weight: bold;">[${timeStr}] ${data.message}</div>`;
|
||||
if (data.output) {
|
||||
logContainer.innerHTML += `<div style="color: #c0392b; margin-left: 10px; border-left: 2px solid #c0392b; padding-left: 8px;">設備回傳: ${data.output}</div>`;
|
||||
}
|
||||
// 發生錯誤時,恢復按鈕讓使用者可以重試或關閉
|
||||
restoreButtonsState(btnIds);
|
||||
}
|
||||
|
||||
// 自動捲動到最底部
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
} catch (e) {
|
||||
console.warn("解析串流 JSON 失敗:", line);
|
||||
|
|
@ -1314,10 +1532,13 @@ function openModal(targetInfo) {
|
|||
}
|
||||
|
||||
// 關閉共用輸出彈出視窗
|
||||
function closeModal(event) {
|
||||
if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return;
|
||||
function closeModal() {
|
||||
document.getElementById('outputModal').classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
|
||||
// 清除動態加入的確認按鈕,避免污染下次開啟
|
||||
const confirmBtn = document.getElementById('btn-confirm-restore');
|
||||
if (confirmBtn) confirmBtn.remove();
|
||||
}
|
||||
|
||||
// 網頁載入時,檢查是否還有背景任務在跑 (維持掃描按鈕狀態)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ function escapeHTML(str) {
|
|||
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
|
||||
const ACTIVE_HEARTBEATS = {};
|
||||
|
||||
// 🌟 新增:用來防禦「秒按取消」導致的非同步渲染競爭危害
|
||||
const activeEditSessions = new Set();
|
||||
|
||||
export let currentEditPath = null;
|
||||
export let currentEditElementId = null;
|
||||
export let currentEditIsSuccess = false;
|
||||
|
|
@ -69,6 +72,10 @@ async function sendHeartbeat(path, host, intervalId, lockKey) {
|
|||
// ==========================================
|
||||
|
||||
export async function startEditFolder(path, elementId) {
|
||||
// 🚨 防護 1:防止連點或重複執行
|
||||
if (activeEditSessions.has(elementId)) return;
|
||||
activeEditSessions.add(elementId); // 立即標記進入編輯狀態的意圖
|
||||
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
const username = connInfo ? connInfo.user : 'admin';
|
||||
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 editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||||
if (editBtn) {
|
||||
editBtn.style.pointerEvents = 'none';
|
||||
editBtn.innerText = "⏳";
|
||||
}
|
||||
|
||||
try {
|
||||
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 (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);
|
||||
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';
|
||||
|
||||
const actionBtn = document.getElementById(`actions-${elementId}`);
|
||||
|
|
@ -105,12 +170,17 @@ export async function startEditFolder(path, elementId) {
|
|||
let optData = {};
|
||||
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 CHUNK_SIZE = 50;
|
||||
|
||||
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
||||
// 🚨 防護 4:如果分批渲染期間使用者按了取消,立刻中斷
|
||||
if (!activeEditSessions.has(elementId)) return;
|
||||
|
||||
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
chunk.forEach(container => {
|
||||
|
|
@ -157,11 +227,20 @@ export async function startEditFolder(path, elementId) {
|
|||
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
|
||||
}
|
||||
} catch (error) {
|
||||
activeEditSessions.delete(elementId);
|
||||
if (editBtn) {
|
||||
editBtn.style.pointerEvents = 'auto';
|
||||
editBtn.innerText = "✏️";
|
||||
}
|
||||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startEditLeaf(path, elementId) {
|
||||
// 🚨 防護 1:防止連點或重複執行
|
||||
if (activeEditSessions.has(elementId)) return;
|
||||
activeEditSessions.add(elementId);
|
||||
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
const username = connInfo ? connInfo.user : 'admin';
|
||||
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 editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||||
if (editBtn) {
|
||||
editBtn.style.pointerEvents = 'none';
|
||||
editBtn.innerText = "⏳";
|
||||
}
|
||||
|
||||
try {
|
||||
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 (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);
|
||||
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';
|
||||
|
||||
const container = document.getElementById(`container-${elementId}`);
|
||||
const origVal = container.getAttribute('data-original');
|
||||
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 {
|
||||
let optData = await apiGetLeafOptions(host, currentMode);
|
||||
|
|
@ -198,6 +327,7 @@ export async function startEditLeaf(path, elementId) {
|
|||
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
|
||||
let found = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
optData = await apiGetLeafOptions(host, currentMode);
|
||||
leafCache = optData[cacheKey];
|
||||
|
|
@ -207,6 +337,8 @@ export async function startEditLeaf(path, elementId) {
|
|||
}
|
||||
}
|
||||
|
||||
if (!activeEditSessions.has(elementId)) return; // 🚨 防護
|
||||
|
||||
let inputHtml = "";
|
||||
let hintAttr = "";
|
||||
let hintIcon = "";
|
||||
|
|
@ -233,15 +365,23 @@ export async function startEditLeaf(path, elementId) {
|
|||
}
|
||||
container.innerHTML = inputHtml;
|
||||
} 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;">`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
activeEditSessions.delete(elementId);
|
||||
if (editBtn) {
|
||||
editBtn.style.pointerEvents = 'auto';
|
||||
editBtn.innerText = "✏️";
|
||||
}
|
||||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelEditFolder(path, elementId) {
|
||||
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
|
||||
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
const host = connInfo ? connInfo.host : '';
|
||||
const lockKey = `${host}@@${path}`;
|
||||
|
|
@ -250,10 +390,59 @@ export async function cancelEditFolder(path, elementId) {
|
|||
clearInterval(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';
|
||||
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
||||
// 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
|
||||
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 leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||||
|
|
@ -287,6 +476,8 @@ export async function cancelEditFolder(path, elementId) {
|
|||
}
|
||||
|
||||
export async function cancelEditLeaf(path, elementId) {
|
||||
activeEditSessions.delete(elementId); // 🚨 防護:註銷編輯狀態,強制中斷背景渲染迴圈
|
||||
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
const host = connInfo ? connInfo.host : '';
|
||||
const lockKey = `${host}@@${path}`;
|
||||
|
|
@ -295,10 +486,38 @@ export async function cancelEditLeaf(path, elementId) {
|
|||
clearInterval(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';
|
||||
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
||||
// 🌟 關鍵修復:寫入防閃爍冷卻表必須在 await 之前!防止 API 延遲期間被輪詢重新上鎖
|
||||
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 origVal = container.getAttribute('data-original');
|
||||
|
|
@ -528,7 +747,7 @@ async function applyEditLeaf(path, elementId) {
|
|||
export function executeSideCLI() {
|
||||
const finalScript = document.getElementById('side-cli-textarea').value;
|
||||
document.getElementById('side-pane-title').innerHTML = '⏳ 正在寫入設備...';
|
||||
document.getElementById('side-pane-title').style.color = '#3498db';
|
||||
document.getElementById('side-pane-title').style.color = '#f39c12'; // 🌟 統一載入中狀態為橘色 (原為 #3498db)
|
||||
document.getElementById('btn-side-cancel').style.display = 'none';
|
||||
document.getElementById('btn-side-confirm').style.display = 'none';
|
||||
document.getElementById('btn-side-close').style.display = 'none';
|
||||
|
|
|
|||
|
|
@ -263,6 +263,15 @@ export async function executeBondingConfig() {
|
|||
const outputEl = document.getElementById('modalOutput');
|
||||
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 {
|
||||
const result = await apiExecuteConfig(cliScript, connInfo.host, connInfo.user, connInfo.pass);
|
||||
if (result.status === 'success') {
|
||||
|
|
@ -275,5 +284,13 @@ export async function executeBondingConfig() {
|
|||
} catch (error) {
|
||||
outputEl.style.color = "#e74c3c";
|
||||
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
|
||||
} finally {
|
||||
// 恢復關閉按鈕
|
||||
if (closeBtn) {
|
||||
closeBtn.disabled = false;
|
||||
closeBtn.style.opacity = '1';
|
||||
closeBtn.style.cursor = 'pointer';
|
||||
closeBtn.style.pointerEvents = 'auto';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,8 +132,6 @@ button:hover { background-color: #3498db; }
|
|||
.modal-overlay.active .modal-container { transform: translateY(0); }
|
||||
.modal-header { background: #2c3e50; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; color: white; flex-shrink: 0; border-bottom: 1px solid #34495e; }
|
||||
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
|
||||
.modal-close { background: none; border: none; color: #bdc3c7; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.modal-close:hover { color: #e74c3c; }
|
||||
/* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */
|
||||
.modal-body {
|
||||
flex-grow: 1;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
// --- static/tree-ui.js ---
|
||||
|
||||
import { SESSION_USER_ID, currentEditElementId } from './edit-mode.js';
|
||||
|
||||
// ==========================================
|
||||
// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
|
||||
// ==========================================
|
||||
|
|
@ -116,6 +118,44 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
|||
</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(`
|
||||
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
|
||||
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
||||
|
|
@ -143,9 +183,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
|||
🔍
|
||||
</span>
|
||||
<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;"
|
||||
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組">
|
||||
✏️
|
||||
style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
|
||||
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
|
||||
${lockText}
|
||||
</span>
|
||||
<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>
|
||||
|
|
@ -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 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 = '';
|
||||
|
||||
if (isVirtualIndex) {
|
||||
|
|
@ -221,9 +299,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
|||
🔍
|
||||
</span>
|
||||
<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;"
|
||||
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目">
|
||||
✏️
|
||||
style="cursor: pointer; font-size: 14px; opacity: ${lockOpacity}; pointer-events: ${lockPointer}; transition: opacity 0.2s;"
|
||||
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=${lockOpacity}" title="${lockTitle}">
|
||||
${lockText}
|
||||
</span>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue