From c9a98d26c284e6b3bdee07796a3909bb92d3e7e0 Mon Sep 17 00:00:00 2001 From: swpa Date: Thu, 30 Apr 2026 18:52:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20Tree=20=E5=9F=BA?= =?UTF-8?q?=E7=A4=8E=E7=B7=A8=E8=BC=AF=E9=82=8F=E8=BC=AF=E8=A8=AD=E8=A8=88?= =?UTF-8?q?=20&=20=E7=88=B6=E5=AD=90=E5=B1=A4=E7=B4=9A=20Reservation=20?= =?UTF-8?q?=E9=98=B2=E8=AD=B7=E6=A9=9F=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 27 +++--- main.py | 3 +- routers/lock.py | 106 ++++++++++++++++++++++ static/app.js | 230 ++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 336 insertions(+), 30 deletions(-) create mode 100644 routers/lock.py diff --git a/index.html b/index.html index dbf111a..6564bba 100644 --- a/index.html +++ b/index.html @@ -250,23 +250,22 @@ diff --git a/main.py b/main.py index e33e4fd..4fb2004 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse # 引入我們剛剛拆分出來的路由模組 -from routers import query, config, terminal +from routers import query, config, terminal, lock app = FastAPI(title="Harmonic CMTS Manager", version="2.0") @@ -16,6 +16,7 @@ app.mount("/static", StaticFiles(directory="static"), name="static") app.include_router(query.router, prefix="/api/v1", tags=["Query"]) app.include_router(config.router, prefix="/api/v1", tags=["Config"]) app.include_router(terminal.router, tags=["Terminal"]) +app.include_router(lock.router, prefix="/api/v1/locks") # 根目錄路由:回傳前端 UI @app.get("/", response_class=HTMLResponse, tags=["UI"]) diff --git a/routers/lock.py b/routers/lock.py new file mode 100644 index 0000000..174fe0b --- /dev/null +++ b/routers/lock.py @@ -0,0 +1,106 @@ +# --- routers/lock.py --- +import time +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Dict, Optional + +router = APIRouter() + +# ========================================== +# 💡 全域鎖定表 (In-Memory Lock Table) +# 結構: { "path": {"user_id": "...", "username": "...", "expires_at": 1234567890.123} } +# ========================================== +ACTIVE_LOCKS: Dict[str, dict] = {} + +# 鎖定超時時間 (秒):前端需在此時間內發送 Heartbeat,否則自動釋放 +LOCK_TIMEOUT = 30 + +class LockAcquireReq(BaseModel): + path: str + user_id: str # 前端產生的 UUID (Session ID) + username: str # 登入的帳號 (用於 UI 提示,例如 "admin") + +class LockActionReq(BaseModel): + path: str + user_id: str + +def clean_expired_locks(): + """清除已經超時的鎖 (被動式清理)""" + current_time = time.time() + expired_paths = [path for path, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time] + for path in expired_paths: + del ACTIVE_LOCKS[path] + +def is_path_locked_by_others(target_path: str, user_id: str) -> tuple[Optional[dict], str]: + """ + 檢查路徑是否被其他人鎖定,並回傳 (衝突的鎖定資訊, 具體錯誤訊息) + """ + clean_expired_locks() + + for locked_path, lock_info in ACTIVE_LOCKS.items(): + if lock_info["user_id"] == user_id: + continue # 自己鎖定的不算衝突 + + # 💡 拆分判斷,回傳更精準的錯誤訊息 + if target_path == locked_path: + return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中" + + if target_path.startswith(locked_path + "."): + return lock_info, f"父層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法編輯子區塊" + + if locked_path.startswith(target_path + "."): + return lock_info, f"子層級 [{locked_path}] 已被 [{lock_info['username']}] 鎖定,無法鎖定整個父區塊" + + return None, "" + +# ========================================== +# 💡 API Endpoints +# ========================================== + +@router.post("/acquire") +async def acquire_lock(req: LockAcquireReq): + """請求鎖定特定路徑""" + # 接收回傳的鎖定資訊與具體訊息 + conflict_lock, error_msg = is_path_locked_by_others(req.path, req.user_id) + + if conflict_lock: + raise HTTPException( + status_code=409, + detail=error_msg # 💡 將精準的錯誤訊息傳給前端 + ) + + # 寫入或更新鎖定狀態 + ACTIVE_LOCKS[req.path] = { + "user_id": req.user_id, + "username": req.username, + "expires_at": time.time() + LOCK_TIMEOUT + } + return {"status": "success", "message": f"成功鎖定路徑: {req.path}"} + +@router.post("/heartbeat") +async def heartbeat_lock(req: LockActionReq): + """延長鎖定時間 (心跳偵測)""" + clean_expired_locks() + + if req.path not in ACTIVE_LOCKS: + raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取") + + if ACTIVE_LOCKS[req.path]["user_id"] != req.user_id: + raise HTTPException(status_code=403, detail="無權更新他人的鎖定") + + # 延長鎖定時間 + ACTIVE_LOCKS[req.path]["expires_at"] = time.time() + LOCK_TIMEOUT + return {"status": "success", "message": "心跳更新成功"} + +@router.post("/release") +async def release_lock(req: LockActionReq): + """主動釋放鎖定""" + if req.path in ACTIVE_LOCKS and ACTIVE_LOCKS[req.path]["user_id"] == req.user_id: + del ACTIVE_LOCKS[req.path] + return {"status": "success", "message": "鎖定已釋放"} + +@router.get("/status") +async def get_lock_status(): + """獲取目前所有被鎖定的路徑 (供前端 UI 標示 '編輯中' 狀態)""" + clean_expired_locks() + return {"status": "success", "data": ACTIVE_LOCKS} diff --git a/static/app.js b/static/app.js index c3a23bc..22cf44c 100644 --- a/static/app.js +++ b/static/app.js @@ -10,6 +10,170 @@ let isConnected = false; window.onload = initTerminal; window.onresize = () => { if (fitAddon) fitAddon.fit(); }; +// ========================================== +// 💡 全域狀態與鎖定管理 (Lock Management) +// ========================================== +const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9); +const ACTIVE_HEARTBEATS = {}; // 記錄正在打心跳的計時器: { "path": intervalId } + +async function sendHeartbeat(path) { + try { + await fetch('/api/v1/locks/heartbeat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: path, user_id: SESSION_USER_ID }) + }); + } catch (error) {} +} + +// ========================================== +// 💡 資料夾層級的編輯模式邏輯 (Node-Level Lock) +// ========================================== +async function startEditFolder(path, elementId) { + const connInfo = getGlobalConnectionInfo(); + const username = connInfo ? connInfo.user : 'admin'; + + try { + const response = await fetch('/api/v1/locks/acquire', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: path, user_id: SESSION_USER_ID, username: username }) + }); + + const result = await response.json(); + + if (response.status === 409) { + alert(`⚠️ ${result.detail}`); // 後端擋下衝突時顯示警告 + return; + } + + if (result.status === 'success') { + // 啟動心跳 + ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000); + + // UI 切換 + document.getElementById(`edit-btn-${elementId}`).style.display = 'none'; + document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; + document.getElementById(`details-${elementId}`).open = true; + + // 轉為輸入框 + const contentDiv = document.getElementById(`content-${elementId}`); + const leafContainers = contentDiv.querySelectorAll('.leaf-container'); + + leafContainers.forEach(container => { + const origVal = container.getAttribute('data-original'); + const nodePath = container.getAttribute('data-path'); + container.innerHTML = ` + + `; + }); + } + } catch (error) { + alert("❌ 無法連線到鎖定伺服器:" + error.message); + } +} + +async function cancelEditFolder(path, elementId) { + if (ACTIVE_HEARTBEATS[path]) { + clearInterval(ACTIVE_HEARTBEATS[path]); + delete ACTIVE_HEARTBEATS[path]; + } + + try { + await fetch('/api/v1/locks/release', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: path, user_id: SESSION_USER_ID }) + }); + } catch (error) {} + + document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block'; + document.getElementById(`actions-${elementId}`).style.display = 'none'; + + const contentDiv = document.getElementById(`content-${elementId}`); + const leafContainers = contentDiv.querySelectorAll('.leaf-container'); + + leafContainers.forEach(container => { + const origVal = container.getAttribute('data-original'); + container.innerHTML = ` + ${origVal} + `; + }); +} + +function previewFolderCLI(path, elementId) { + const contentDiv = document.getElementById(`content-${elementId}`); + const inputs = contentDiv.querySelectorAll('.edit-input'); + + let changes = []; + + // 掃描所有輸入框,找出被修改過的值 + inputs.forEach(input => { + const nodePath = input.getAttribute('data-path'); + const newVal = input.value; + const origVal = input.parentElement.getAttribute('data-original'); + + if (newVal !== origVal) { + changes.push(`- ${nodePath}:\n [舊] ${origVal} ➡️ [新] ${newVal}`); + } + }); + + if (changes.length === 0) { + alert("您沒有修改任何數值!"); + return; + } + + alert(`【Phase 3 預覽】\n鎖定區塊: ${path}\n\n偵測到的修改:\n${changes.join('\n\n')}\n\n即將實作轉譯為 CMTS CLI 的功能!`); +} + +// ========================================== +// 💡 編輯模式 UI 切換邏輯 +// ========================================== +function renderEditModeUI(path, currentValue, elementId) { + const container = document.getElementById(elementId); + if (!container) return; + + // 將原本的文字替換為 Input 與操作按鈕 + // 注意:這裡將單引號跳脫,避免 HTML 屬性解析錯誤 + const safeValue = currentValue.replace(/'/g, "'"); + + container.innerHTML = ` + + + + + + `; +} + +function restoreViewModeUI(path, originalValue, elementId) { + const container = document.getElementById(elementId); + if (!container) return; + + // 恢復成原本的文字與編輯按鈕 + const safeValue = originalValue.replace(/'/g, "'"); + container.innerHTML = ` + ${originalValue} + ✏️ + `; +} + +// Phase 3 的預留函數:預覽 CLI 指令 +function previewCLI(path, elementId) { + const newValue = document.getElementById(`input-${elementId}`).value; + alert(`【Phase 3 預覽】\n路徑: ${path}\n新值: ${newValue}\n\n即將實作轉譯為 CMTS CLI 的功能!`); +} + // ========================================== // 2. UI 互動與頁籤切換 // ========================================== @@ -584,9 +748,9 @@ async function executeBondingConfig() { } // ========================================== -// 💡 6. 完整設備配置樹狀圖邏輯 (從 HTML 移出) +// 💡 6. 完整設備配置樹狀圖邏輯 (支援資料夾層級鎖定) // ========================================== -function buildTree(data) { +function buildTree(data, parentPath = "") { if (typeof data !== 'object' || data === null) { return `${data}`; } @@ -594,19 +758,48 @@ function buildTree(data) { let html = '
'; for (const key in data) { const value = data[key]; + const currentPath = parentPath ? `${parentPath}.${key}` : key; + if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) { + // 📁 渲染資料夾 (加入編輯與操作按鈕) + const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`; + html += ` -
- - 📁 ${key} +
+ + 📁 ${key} + + + + ✏️ + + + + - ${buildTree(value)} + + +
+ ${buildTree(value, currentPath)} +
`; } else { + // 📄 渲染檔案 (移除獨立按鈕,改為埋入 data 屬性供父節點控制) + const safeValue = (value === null ? '' : value).toString().replace(/"/g, """); + html += ` -
- 📄 ${key}: ${value === null ? '' : value} +
+ 📄 ${key}: + + + ${value === null ? '' : value} +
`; } @@ -622,22 +815,29 @@ async function fetchFullConfig() { const connInfo = getGlobalConnectionInfo(); if (!connInfo) return; - loadingMsg.style.display = 'block'; - treeContainer.innerHTML = ''; + // 💡 1. 顯示標題旁邊的載入提示 (改為 inline-block 讓它排在同一行) + if (loadingMsg) loadingMsg.style.display = 'inline-block'; + + // 💡 2. 清空樹狀圖區塊,保持畫面乾淨 + if (treeContainer) { + treeContainer.innerHTML = ''; + } try { - const response = await fetch(`/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`); + const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`; + const response = await fetch(url); const result = await response.json(); if (result.status === 'success') { - treeContainer.innerHTML = buildTree(result.data); + if (treeContainer) treeContainer.innerHTML = buildTree(result.data); } else { - treeContainer.innerHTML = `
❌ 錯誤: ${result.message}
`; + if (treeContainer) treeContainer.innerHTML = `
❌ 錯誤: ${result.message}
`; } } catch (error) { - treeContainer.innerHTML = `
❌ 連線失敗: ${error.message}
`; + if (treeContainer) treeContainer.innerHTML = `
❌ 連線或解析失敗: ${error.message}
`; } finally { - loadingMsg.style.display = 'none'; + // 💡 3. 完成後隱藏載入提示 + if (loadingMsg) loadingMsg.style.display = 'none'; } }