feat: 完成 Tree 基礎編輯邏輯設計 & 父子層級 Reservation 防護機制

This commit is contained in:
swpa 2026-04-30 18:52:55 +08:00
parent 562625abb3
commit c9a98d26c2
4 changed files with 336 additions and 30 deletions

View File

@ -250,23 +250,22 @@
<!-- 表單 4完整設備配置樹狀圖 --> <!-- 表單 4完整設備配置樹狀圖 -->
<div id="form-full-config" class="task-form" style="display: none;"> <div id="form-full-config" class="task-form" style="display: none;">
<h3 style="margin-top: 0; font-size: 18px; color: #27ae60; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">🌳 完整設備配置樹狀圖</h3> <!-- 💡 將標題與載入提示放在同一行 (使用 flexbox) -->
<h3 style="display: flex; align-items: center; margin-bottom: 15px;">
<div class="control-group" style="background: #eafaf1; padding: 15px; border-radius: 6px; border: 1px solid #a9dfbf;"> 🌳 完整設備配置樹狀圖
<label style="font-weight: bold; color: #1e8449;">點擊下方按鈕,將抓取整台設備的完整配置並轉為樹狀結構:</label> <!-- 💡 隱藏的載入提示,放在標題右邊 -->
<br> <span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; margin-left: 15px; font-weight: normal;">
<button onclick="fetchFullConfig()" style="background-color: #27ae60; color: white; margin-top: 10px; padding: 8px 16px;">📥 載入完整配置</button>
<div id="loading-message" style="display: none; color: #d35400; margin-top: 15px; font-weight: bold;">
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候... ⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
</div> </span>
</div> </h3>
<!-- 💡 已經刪除了原本的綠色按鈕與說明文字 -->
<div id="tree-container" style="margin-top: 20px; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 600px; overflow-y: auto;"> <!-- 樹狀圖渲染區塊 -->
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方按鈕開始。</span> <div id="tree-container" style="background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px;">
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse 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") 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(query.router, prefix="/api/v1", tags=["Query"])
app.include_router(config.router, prefix="/api/v1", tags=["Config"]) app.include_router(config.router, prefix="/api/v1", tags=["Config"])
app.include_router(terminal.router, tags=["Terminal"]) app.include_router(terminal.router, tags=["Terminal"])
app.include_router(lock.router, prefix="/api/v1/locks")
# 根目錄路由:回傳前端 UI # 根目錄路由:回傳前端 UI
@app.get("/", response_class=HTMLResponse, tags=["UI"]) @app.get("/", response_class=HTMLResponse, tags=["UI"])

106
routers/lock.py Normal file
View File

@ -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}

View File

@ -10,6 +10,170 @@ let isConnected = false;
window.onload = initTerminal; window.onload = initTerminal;
window.onresize = () => { if (fitAddon) fitAddon.fit(); }; 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 = `
<input type="text" class="edit-input" data-path="${nodePath}" value="${origVal}"
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) {
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 = `
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${origVal}</span>
`;
});
}
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, "&#39;");
container.innerHTML = `
<input type="text" id="input-${elementId}" value="${safeValue}"
style="padding: 4px 8px; border: 2px solid #3498db; border-radius: 4px; font-family: Courier New, monospace; font-size: 14px; width: 250px; outline: none;">
<button onclick="previewCLI('${path}', '${elementId}')"
style="margin-left: 8px; background-color: #27ae60; color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 13px;">
預覽指令
</button>
<button onclick="cancelEdit('${path}', '${safeValue}', '${elementId}')"
style="margin-left: 4px; background-color: #e74c3c; color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 13px;">
取消
</button>
`;
}
function restoreViewModeUI(path, originalValue, elementId) {
const container = document.getElementById(elementId);
if (!container) return;
// 恢復成原本的文字與編輯按鈕
const safeValue = originalValue.replace(/'/g, "&#39;");
container.innerHTML = `
<span style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${originalValue}</span>
<span onclick="startEdit('${path}', '${safeValue}', '${elementId}')"
style="margin-left: 10px; cursor: pointer; font-size: 14px; opacity: 0.6;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="編輯此項目"></span>
`;
}
// 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 互動與頁籤切換 // 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) { if (typeof data !== 'object' || data === null) {
return `<span>${data}</span>`; return `<span>${data}</span>`;
} }
@ -594,19 +758,48 @@ function buildTree(data) {
let html = '<div style="margin-left: 20px;">'; let html = '<div style="margin-left: 20px;">';
for (const key in data) { for (const key in data) {
const value = data[key]; const value = data[key];
const currentPath = parentPath ? `${parentPath}.${key}` : key;
if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) { if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) {
// 📁 渲染資料夾 (加入編輯與操作按鈕)
const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
html += ` html += `
<details> <details id="details-${elementId}">
<summary style="cursor: pointer; font-weight: bold; color: #2c3e50; margin-top: 5px; padding: 2px 0;"> <summary style="cursor: pointer; font-weight: bold; color: #2c3e50; margin-top: 5px; padding: 2px 0; display: flex; align-items: center;">
📁 ${key} <span style="margin-right: 5px;">📁</span> ${key}
<!-- 資料夾專屬編輯按鈕 -->
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
style="margin-left: 10px; cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此區塊">
</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>
<button onclick="event.stopPropagation(); event.preventDefault(); cancelEditFolder('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;"> 取消</button>
</span>
</summary> </summary>
${buildTree(value)}
<!-- 💡 包裝子內容方便後續用 JS 抓取裡面的葉節點 -->
<div id="content-${elementId}">
${buildTree(value, currentPath)}
</div>
</details> </details>
`; `;
} else { } else {
// 📄 渲染檔案 (移除獨立按鈕,改為埋入 data 屬性供父節點控制)
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
html += ` html += `
<div style="padding: 2px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px;"> <div style="padding: 4px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px; display: flex; align-items: center;">
📄 <b>${key}</b>: <span style="color: #16a085;">${value === null ? '' : value}</span> <span style="margin-right: 5px;">📄</span> <b>${key}</b>:
<span class="leaf-container" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${value === null ? '' : value}</span>
</span>
</div> </div>
`; `;
} }
@ -622,22 +815,29 @@ async function fetchFullConfig() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return; if (!connInfo) return;
loadingMsg.style.display = 'block'; // 💡 1. 顯示標題旁邊的載入提示 (改為 inline-block 讓它排在同一行)
treeContainer.innerHTML = ''; if (loadingMsg) loadingMsg.style.display = 'inline-block';
// 💡 2. 清空樹狀圖區塊,保持畫面乾淨
if (treeContainer) {
treeContainer.innerHTML = '';
}
try { 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(); const result = await response.json();
if (result.status === 'success') { if (result.status === 'success') {
treeContainer.innerHTML = buildTree(result.data); if (treeContainer) treeContainer.innerHTML = buildTree(result.data);
} else { } else {
treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`; if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 錯誤: ${result.message}</div>`;
} }
} catch (error) { } catch (error) {
treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線失敗: ${error.message}</div>`; if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 連線或解析失敗: ${error.message}</div>`;
} finally { } finally {
loadingMsg.style.display = 'none'; // 💡 3. 完成後隱藏載入提示
if (loadingMsg) loadingMsg.style.display = 'none';
} }
} }