完成Tree基礎編輯邏輯設計&父子層級Reservation防護機制
This commit is contained in:
parent
07859300a6
commit
09a0f3699a
27
index.html
27
index.html
|
|
@ -250,23 +250,22 @@
|
|||
|
||||
<!-- 表單 4:完整設備配置樹狀圖 -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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;">
|
||||
<!-- 💡 將標題與載入提示放在同一行 (使用 flexbox) -->
|
||||
<h3 style="display: flex; align-items: center; margin-bottom: 15px;">
|
||||
🌳 完整設備配置樹狀圖
|
||||
<!-- 💡 隱藏的載入提示,放在標題右邊 -->
|
||||
<span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; margin-left: 15px; font-weight: normal;">
|
||||
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</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>
|
||||
|
||||
|
|
|
|||
3
main.py
3
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"])
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
230
static/app.js
230
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 = `
|
||||
<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, "'");
|
||||
|
||||
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, "'");
|
||||
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 互動與頁籤切換
|
||||
// ==========================================
|
||||
|
|
@ -584,9 +748,9 @@ async function executeBondingConfig() {
|
|||
}
|
||||
|
||||
// ==========================================
|
||||
// 💡 6. 完整設備配置樹狀圖邏輯 (從 HTML 移出)
|
||||
// 💡 6. 完整設備配置樹狀圖邏輯 (支援資料夾層級鎖定)
|
||||
// ==========================================
|
||||
function buildTree(data) {
|
||||
function buildTree(data, parentPath = "") {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return `<span>${data}</span>`;
|
||||
}
|
||||
|
|
@ -594,19 +758,48 @@ function buildTree(data) {
|
|||
let html = '<div style="margin-left: 20px;">';
|
||||
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 += `
|
||||
<details>
|
||||
<summary style="cursor: pointer; font-weight: bold; color: #2c3e50; margin-top: 5px; padding: 2px 0;">
|
||||
📁 ${key}
|
||||
<details id="details-${elementId}">
|
||||
<summary style="cursor: pointer; font-weight: bold; color: #2c3e50; margin-top: 5px; padding: 2px 0; display: flex; align-items: center;">
|
||||
<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>
|
||||
${buildTree(value)}
|
||||
|
||||
<!-- 💡 包裝子內容,方便後續用 JS 抓取裡面的葉節點 -->
|
||||
<div id="content-${elementId}">
|
||||
${buildTree(value, currentPath)}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
} else {
|
||||
// 📄 渲染檔案 (移除獨立按鈕,改為埋入 data 屬性供父節點控制)
|
||||
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||
|
||||
html += `
|
||||
<div style="padding: 2px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px;">
|
||||
📄 <b>${key}</b>: <span style="color: #16a085;">${value === null ? '' : value}</span>
|
||||
<div style="padding: 4px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px; display: flex; align-items: center;">
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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 = `<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) {
|
||||
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 {
|
||||
loadingMsg.style.display = 'none';
|
||||
// 💡 3. 完成後隱藏載入提示
|
||||
if (loadingMsg) loadingMsg.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue