feat: finalize JSON cache architecture and prepare for PostgreSQL migration

- 穩定並完善現有基於 JSON 檔案的 running/full 雙軌快取機制
- 確認 __metadata__ (版本與掃描時間) 的讀寫邏輯運作正常
- 確立下一階段 PostgreSQL + asyncpg 的升級藍圖與 Schema 設計
- 準備建立 feature/postgres-migration 分支進行資料庫無痛轉移
This commit is contained in:
swpa 2026-05-19 18:25:10 +08:00
parent 7b5337383b
commit 9585a4c958
12 changed files with 798 additions and 393 deletions

View File

@ -142,7 +142,8 @@ def parse_device_response(output: str) -> dict:
return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()}
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
# 🌟 1. 函數簽名加上 config_type 參數,預設為 "running"
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, config_type: str = "running"):
try:
total_paths = len(leaf_paths)
processed_count = 0
@ -188,6 +189,9 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
await read_until_quiet(timeout=1.5)
for path in batch:
# 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
await asyncio.sleep(0.01)
# --- 步驟 1發送 '?' 查詢 ---
command_to_send = f"{path} ?"
process.stdin.write(command_to_send)
@ -256,11 +260,20 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
# --- 步驟 3寫入快取檔 ---
try:
cache_file = "cmts_leaf_options_cache.json"
cache_data = {}
if os.path.exists(cache_file):
with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f)
# 🌟 2. 動態決定快取檔名 (加入 IP 隔離)
safe_host = host.replace(".", "_")
cache_file = f"{safe_host}_{config_type}_cache.json"
# ==========================================
# 🌟 優化 2-A將「讀取」舊快取檔丟到背景執行緒
# ==========================================
def read_json_from_file(filepath):
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
return {}
cache_data = await asyncio.to_thread(read_json_from_file, cache_file)
# 🌟 新增:寫入 Metadata (版本與掃描時間)
if "__metadata__" not in cache_data:
@ -277,9 +290,16 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
result_data[p]["updated_at"] = current_ts
cache_data[p] = result_data[p]
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(cache_data, f, indent=4, ensure_ascii=False)
print(f"💾 [Debug] 第 {batch_idx + 1}/{len(batches)} 批次已提早寫入快取檔!(版本: {cmts_version})")
# ==========================================
# 🌟 優化 2-B將「寫入」新快取檔丟到背景執行緒
# ==========================================
def write_json_to_file(filepath, data):
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
await asyncio.to_thread(write_json_to_file, cache_file, cache_data)
print(f"💾 [Debug] 第 {batch_idx + 1}/{len(batches)} 批次已非同步寫入快取檔!(版本: {cmts_version}, 檔案: {cache_file})")
except Exception as e:
print(f"⚠️ 提早寫入快取失敗: {e}")
@ -290,3 +310,4 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
except Exception as e:
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"

3
filters_full.json Normal file
View File

@ -0,0 +1,3 @@
{
"hidden_keys": []
}

3
filters_running.json Normal file
View File

@ -0,0 +1,3 @@
{
"hidden_keys": []
}

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Harmonic CMTS Manager</title>
<link rel="icon" type="image/png" href="/static/my_icon.png">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
@ -42,8 +43,8 @@
<div class="control-group">
<label><strong>快捷指令:</strong></label>
<select id="fixedCmd">
<option value="show cable modem">數據機狀態 (show cable modem)</option>
<option value="show cable rpd">RPD 狀態 (show cable rpd)</option>
<option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
<option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
</select>
<button onclick="injectCommand()" class="btn-modern btn-load">送出指令</button>
@ -161,8 +162,10 @@
<div class="control-group" style="background: #ffffff; padding: 10px 15px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">🛠️ 選擇配置任務:</label>
<select id="configTask" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
<!-- 🌟 拆分為兩個獨立的選項 -->
<option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option>
<option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option>
<option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
<option value="form-full-config">🌳 完整設備配置樹狀圖</option>
</select>
<button id="btn-load-task" onclick="startConfigTask()" class="btn-modern btn-load" style="margin-left: 10px;" disabled>🚀 載入任務</button>
</div>
@ -247,10 +250,13 @@
</div>
</div>
<!-- 表單 4完整設備配置樹狀圖 -->
<div id="form-full-config" class="task-form" style="display: none;">
<!-- 表單 4設備配置樹狀圖 (共用容器) -->
<!-- 🌟 修正 1將 ID 改為 form-tree-config -->
<div id="form-tree-config" class="task-form" style="display: none;">
<h3 style="display: flex; align-items: center; margin-top: 0; margin-bottom: 10px; font-size: 16px; color: #2c3e50;">
🌳 完整設備配置樹狀圖
<!-- 🌟 修正 2加上 span 與 ID用來動態切換文字 -->
<span id="tree-form-title">🌳 設備配置樹狀圖</span>
<!-- 🌟 掃描與清除按鈕群組 -->
<button id="btn-scan-visible" onclick="scanVisibleMissingOptions()" class="btn-modern btn-scan" disabled style="display: none; margin-left: 20px;">
@ -278,14 +284,13 @@
<!-- 🌟 左右分屏容器 -->
<div id="split-container" style="display: flex; align-items: flex-start; width: 100%; position: relative;">
<!-- 左側:樹狀圖 (預設滿版,顯示預覽時會被 JS 改為 50%) -->
<!-- <div id="tree-container" style="width: 100%; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; box-sizing: border-box; overflow-x: auto;">
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
</div> -->
<!-- 左側:樹狀圖 (雙容器實體隔離) -->
<div id="tree-container-running" class="tree-view-instance" style="width: 100%; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
<span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
</div>
<!-- 左側:樹狀圖 (預設滿版,顯示預覽時會被 JS 改為 50%) -->
<div id="tree-container" style="width: 100%; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
<div id="tree-container-full" class="tree-view-instance" style="display: none; width: 100%; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
<span style="color: #7f8c8d;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>
</div>
<!-- 🖱️ 拖曳分隔線 (預設隱藏) -->
@ -340,10 +345,19 @@
</p>
</div>
<button onclick="loadRealConfigForFilters()" style="background-color: #3498db; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; margin-bottom: 15px;">
<!-- 🌟 新增:過濾器模式切換下拉選單 -->
<div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
<label style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器:</label>
<select id="filter-mode-select" onchange="switchFilterMode()" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 14px; background-color: #fcf3f2; color: #c0392b;">
<option value="running">Running 配置過濾器</option>
<option value="full">Full 配置過濾器</option>
</select>
<button onclick="loadRealConfigForFilters()" style="background-color: #3498db; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
📥 載入設備配置以設定過濾
</button>
<span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span>
</div>
<!-- 樹狀 Checkbox 容器 -->
<div id="filter-checkboxes" style="margin: 15px 0; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 500px; overflow-y: auto; display: none;">

View File

@ -1,15 +1,47 @@
# --- routers/config.py ---
import re
import json
import os
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List
from netmiko import ConnectHandler
# 💡 確保從 shared 引入 deep_split_tree
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, load_settings, save_settings
# 🌟 移除了舊的 load_settings, save_settings改用專屬的雙軌機制
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree
from collections import defaultdict
router = APIRouter()
# ==========================================
# 🌟 雙軌過濾器檔案讀寫輔助函數
# ==========================================
def get_filter_file_path(config_type: str) -> str:
# 確保檔名安全,只允許 'running' 或 'full'
safe_type = "full" if config_type == "full" else "running"
return f"filters_{safe_type}.json"
def load_tree_filters(config_type: str) -> list:
file_path = get_filter_file_path(config_type)
if os.path.exists(file_path):
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get("hidden_keys", [])
except Exception as e:
print(f"讀取過濾器檔案失敗: {e}")
return []
return []
def save_tree_filters(config_type: str, hidden_keys: list):
file_path = get_filter_file_path(config_type)
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump({"hidden_keys": hidden_keys}, f, ensure_ascii=False, indent=4)
except Exception as e:
print(f"儲存過濾器檔案失敗: {e}")
# ==========================================
class ConfigRequest(BaseModel):
script: str
host: str
@ -24,7 +56,7 @@ class DiffItem(BaseModel):
class GenerateCliRequest(BaseModel):
diffs: List[DiffItem]
interfaces_with_admin_state: List[str] = [] # 💡 接收前端傳來的動態探測名單
interfaces_with_admin_state: List[str] = []
@router.post("/cmts-config")
async def execute_cmts_config(req: ConfigRequest):
@ -38,12 +70,10 @@ async def execute_cmts_config(req: ConfigRequest):
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
net_connect = ConnectHandler(**device)
# 🌟 1. 手動進入設定模式 (恢復您原本正確的寫法)
# 1. 手動進入設定模式
net_connect.send_command_timing("config")
# 🌟 2. 批次送出設定指令
# 加上 enter_config_mode=False 與 exit_config_mode=False
# 這樣 Netmiko 就不會自作主張去打 config terminal 了
# 2. 批次送出設定指令
output = net_connect.send_config_set(
commands,
read_timeout=60,
@ -52,14 +82,13 @@ async def execute_cmts_config(req: ConfigRequest):
exit_config_mode=False
)
# 🌟 3. 手動退出設定模式
# 3. 手動退出設定模式
net_connect.send_command_timing("exit")
net_connect.disconnect()
# 🌟 新增:檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字
# 檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字
lower_output = output.lower()
if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output:
# 回傳錯誤狀態,前端就會顯示紅色字體且「不會」自動關閉視窗
return {
"status": "error",
"message": "CMTS 拒絕了設定 (Commit Aborted)",
@ -76,21 +105,16 @@ async def generate_cli(req: GenerateCliRequest):
將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本
並自動處理 admin-state 的安全生命週期 ( down up)
"""
# 用來將同一個介面的變更群組化
interface_groups = defaultdict(list)
# 用來記錄該介面最終的 admin-state 應該是什麼
interface_admin_states = {}
for diff in req.diffs:
# 將路徑切分:例如 ['cable', 'ds-rf-port', '1:9/0', 'down-channel', '0', 'frequency']
parts = diff.path.split('::')
param_name = parts[-1]
interface_path = " ".join(parts[:-1])
# 🌟 新增這行:全域清除路徑中間可能夾帶的虛擬資料夾索引 (例如 [0], [1])
# 這樣 "logging evt 1024 [1] active" 就會還原成 "logging evt 1024 active"
# 全域清除路徑中間可能夾帶的虛擬資料夾索引
interface_path = re.sub(r"\s*\[\d+\]", "", interface_path)
if param_name == "admin-state":
@ -98,21 +122,17 @@ async def generate_cli(req: GenerateCliRequest):
else:
interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
# 開始組裝最終的 CLI 腳本
cli_lines = []
cli_lines.append("! --- Auto-Generated Configuration Script ---")
for interface, changes in interface_groups.items():
cli_lines.append(f"! Configuring: {interface}")
# 💡 核心邏輯:判斷這個特定區塊是否支援 admin-state
supports_admin_state = interface in req.interfaces_with_admin_state
# 1. 安全機制:如果支援,才強制將介面 admin-state down
if supports_admin_state:
cli_lines.append(f"{interface} admin-state down")
# 2. 寫入所有被修改的參數
for param, old_val, new_val in changes:
if re.match(r"^\[\d+\]$", param):
if new_val == "":
@ -128,16 +148,13 @@ async def generate_cli(req: GenerateCliRequest):
else:
cli_lines.append(f"{interface} {param} {new_val}")
# 3. 恢復狀態:如果支援,才恢復為 up 或使用者指定的值
if supports_admin_state:
final_state = interface_admin_states.get(interface, "up")
cli_lines.append(f"{interface} admin-state {final_state}")
# Harmonic 必須要有 commit 才會生效
cli_lines.append("commit")
cli_lines.append("!") # 空行分隔
cli_lines.append("!")
# 將獨立修改 admin-state (沒有修改其他參數) 的情況也補上
for interface, state in interface_admin_states.items():
if interface not in interface_groups:
cli_lines.append(f"! Configuring state only: {interface}")
@ -145,13 +162,11 @@ async def generate_cli(req: GenerateCliRequest):
cli_lines.append("commit")
cli_lines.append("!")
# 將陣列組合成字串回傳
final_script = "\n".join(cli_lines)
return {"status": "success", "data": final_script}
# ==========================================
# 💡 以下為 DS RF Port 動態樹狀查詢 API
# DS RF Port 動態樹狀查詢 API
# ==========================================
@router.get("/cmts-ds-rf-port-list")
@ -162,13 +177,10 @@ async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
# 使用 include 只抓取關鍵字,並把等待時間延長至 60 秒
command = 'show running-config | include "cable ds-rf-port"'
raw_output = net_connect.send_command(command, read_timeout=60)
raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
# 使用正規表達式擷取 port 號碼 (例如 62:0/0)
pattern = r"cable ds-rf-port\s+([0-9:/]+)"
ports = re.findall(pattern, raw_output)
unique_ports = sorted(list(set(ports)))
@ -191,17 +203,15 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
net_connect = ConnectHandler(**device)
command = f"show running-config interface cable ds-rf-port {target} | nomore"
raw_output = net_connect.send_command(command, read_timeout=60)
raw_output = str(net_connect.send_command(command, read_timeout=60))
net_connect.disconnect()
# 1. 初步解析縮排
base_tree = parse_cli_to_tree(raw_output)
# 2. 💡 深層解析空白字元,建立完美樹狀結構
perfect_tree = deep_split_tree(base_tree)
return {
"status": "success",
"data": perfect_tree, # 回傳完美的樹狀結構
"data": perfect_tree,
"raw_cli": raw_output.strip()
}
except Exception as e:
@ -209,26 +219,36 @@ async def get_ds_rf_port_config(target: str, host: str, username: str, password:
@router.get("/cmts-full-config")
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False):
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
if config_type == "full":
net_connect.send_command_timing("config")
command = "show full-configuration | nomore"
raw_output = str(net_connect.send_command(command, read_timeout=180))
net_connect.send_command_timing("exit")
else:
command = "show running-config | nomore"
raw_output = net_connect.send_command(command, read_timeout=60)
raw_output = str(net_connect.send_command(command, read_timeout=180))
net_connect.disconnect()
base_tree = parse_cli_to_tree(raw_output)
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
clean_output = clean_output.lstrip()
base_tree = parse_cli_to_tree(clean_output)
perfect_tree = deep_split_tree(base_tree)
# ==========================================
# 💡 深度過濾攔截器:支援多層級路徑 (如 cable.mac-domain)
# 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
# ==========================================
if not skip_filter:
settings = load_settings()
hidden_keys = settings.get("hidden_keys", [])
hidden_keys = load_tree_filters(config_type)
for path in hidden_keys:
keys = path.split('::')
@ -250,19 +270,22 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
return {"status": "error", "message": str(e)}
# ==========================================
# 💡 系統設定 API (System Settings)
# 🌟 系統設定 API (System Settings)
# ==========================================
class SettingsRequest(BaseModel):
hidden_keys: list[str]
config_type: str = "running" # 🌟 加入 config_type 參數
@router.get("/settings/tree-filters")
async def get_tree_filters():
async def get_tree_filters(config_type: str = "running"):
"""獲取目前的樹狀圖隱藏名單"""
settings = load_settings()
return {"status": "success", "data": settings["hidden_keys"]}
# 🌟 根據 config_type 讀取對應的 JSON
hidden_keys = load_tree_filters(config_type)
return {"status": "success", "data": hidden_keys}
@router.post("/settings/tree-filters")
async def update_tree_filters(req: SettingsRequest):
"""更新樹狀圖隱藏名單並存檔"""
save_settings({"hidden_keys": req.hidden_keys})
return {"status": "success", "message": "系統設定已更新"}
# 🌟 根據 config_type 寫入對應的 JSON
save_tree_filters(req.config_type, req.hidden_keys)
return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}

View File

@ -9,111 +9,132 @@ from cmts_scraper import sync_cmts_leaves_async
from shared import CMTS_DEVICE
router = APIRouter(tags=["Options"])
CACHE_FILE = "cmts_leaf_options_cache.json"
# 🌟 1. 動態獲取快取檔名 (加入 host 隔離)
def get_cache_file(host: str, config_type: str):
safe_host = host.replace(".", "_")
return f"{safe_host}_{config_type}_cache.json"
# ==========================================
# 🌟 全域廣播機制 (SSE)
# 🌟 2. 全域廣播機制 (SSE) - 升級為「IP + 模式」頻道分流
# ==========================================
IS_SCANNING = False
active_clients = set()
# 將狀態改為空字典,動態根據 host_configType 建立
SCAN_STATUS = {}
active_clients = {}
async def broadcast_message(message_dict: dict):
"""將訊息推播給所有連線中的前端"""
async def broadcast_message(message_dict: dict, host: str, config_type: str = "running"):
"""將訊息推播給特定頻道的連線前端"""
channel_key = f"{host}_{config_type}"
dead_clients = set()
# 🌟 關鍵修復 1SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
# SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
message_str = f"data: {json.dumps(message_dict)}\n\n"
for client_queue in active_clients:
for client_queue in active_clients.get(channel_key, set()):
try:
await client_queue.put(message_str)
except Exception:
dead_clients.add(client_queue)
for dead in dead_clients:
active_clients.discard(dead)
active_clients[channel_key].discard(dead)
@router.get("/cmts-leaf-options/stream")
async def sse_stream(request: Request):
"""前端一載入就會連上這個路由,持續監聽廣播"""
async def sse_stream(request: Request, host: str, config_type: str = "running"):
"""前端一載入就會連上這個路由,持續監聽特定頻道的廣播"""
channel_key = f"{host}_{config_type}"
client_queue = asyncio.Queue()
active_clients.add(client_queue)
if channel_key not in active_clients:
active_clients[channel_key] = set()
active_clients[channel_key].add(client_queue)
async def event_generator():
try:
while True:
# 🌟 每次迴圈先檢查前端是否已經斷線 (按了 F5)
if await request.is_disconnected():
break
try:
# 縮短 timeout 為 1 秒,讓檢查更靈敏
message = await asyncio.wait_for(client_queue.get(), timeout=1.0)
yield message
except asyncio.TimeoutError:
yield ": keepalive\n\n"
except asyncio.CancelledError:
# 🌟 關鍵修復:當 Uvicorn 觸發 reload 關閉伺服器時,會拋出此例外
# 我們捕捉它並默默退出,不讓伺服器卡住
pass
finally:
active_clients.discard(client_queue)
if channel_key in active_clients:
active_clients[channel_key].discard(client_queue)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/scan-status")
async def get_scan_status():
global IS_SCANNING
return {"is_scanning": IS_SCANNING}
async def get_scan_status(host: str, config_type: str = "running"):
channel_key = f"{host}_{config_type}"
return {"is_scanning": SCAN_STATUS.get(channel_key, False)}
# ==========================================
# 🌟 快取讀取與背景掃描任務
# 🌟 3. 快取讀取與背景掃描任務
# ==========================================
class SyncOptionsRequest(BaseModel):
host: str # 🌟 新增 host 參數
username: str = "" # 🌟 新增 username 參數
password: str = "" # 🌟 新增 password 參數
leaf_paths: List[str]
config_type: str = "running"
@router.get("/cmts-leaf-options")
async def get_leaf_options():
if not os.path.exists(CACHE_FILE): return {}
with open(CACHE_FILE, "r", encoding="utf-8") as f:
async def get_leaf_options(host: str, config_type: str = "running"):
cache_file = get_cache_file(host, config_type)
if not os.path.exists(cache_file): return {}
# 🌟 優化 3將讀取動作丟到背景執行緒
def read_json_from_file(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
async def run_scan_task(paths: List[str]):
"""在背景執行的爬蟲任務,並將進度廣播出去"""
global IS_SCANNING
try:
await broadcast_message({"event": "scan_start"})
return await asyncio.to_thread(read_json_from_file, cache_file)
# 呼叫爬蟲 (帶入設備連線資訊)
async def run_scan_task(host: str, username: str, password: str, paths: List[str], config_type: str):
"""在背景執行的爬蟲任務,並將進度廣播出去"""
global SCAN_STATUS
channel_key = f"{host}_{config_type}"
try:
await broadcast_message({"event": "scan_start"}, host, config_type)
# 呼叫爬蟲 (帶入設備連線資訊與 config_type)
async for chunk in sync_cmts_leaves_async(
host=CMTS_DEVICE.get("host"),
username=CMTS_DEVICE.get("username"),
password=CMTS_DEVICE.get("password"),
leaf_paths=paths
host=host,
username=username,
password=password,
leaf_paths=paths,
config_type=config_type
):
if isinstance(chunk, str):
await broadcast_message(json.loads(chunk))
await broadcast_message(json.loads(chunk), host, config_type)
else:
await broadcast_message(chunk)
await broadcast_message(chunk, host, config_type)
await broadcast_message({"event": "done"})
await broadcast_message({"event": "done"}, host, config_type)
except Exception as e:
await broadcast_message({"event": "error", "message": str(e)})
await broadcast_message({"event": "error", "message": str(e)}, host, config_type)
finally:
IS_SCANNING = False
# 解除特定頻道的鎖定
SCAN_STATUS[channel_key] = False
@router.post("/cmts-leaf-options/sync")
async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks):
"""前端點擊掃描時,只負責觸發背景任務並立即回傳 JSON"""
global IS_SCANNING
global SCAN_STATUS
if not request.leaf_paths:
raise HTTPException(status_code=400)
if IS_SCANNING:
return {"status": "busy", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"}
channel_key = f"{request.host}_{request.config_type}"
IS_SCANNING = True
if SCAN_STATUS.get(channel_key, False):
return {"status": "busy", "message": f"⚠️ {request.config_type} 模式的掃描任務正在執行中,請勿重複點擊!"}
SCAN_STATUS[channel_key] = True
# 保留您原本完美的路徑清理邏輯
cmts_query_paths = []
for p in request.leaf_paths:
clean_p = p.replace("::", " ")
@ -122,21 +143,27 @@ async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: Backg
cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
# 將任務丟入背景執行,並立刻回傳 JSON 給前端
background_tasks.add_task(run_scan_task, cmts_query_paths)
# 決定帳密 (優先使用 request 傳來的,否則 fallback 到 shared.CMTS_DEVICE)
# 🌟 加上 or "" 確保最終結果絕對是字串,消除 Pylance 警告
user = request.username or CMTS_DEVICE.get("username") or ""
pwd = request.password or CMTS_DEVICE.get("password") or ""
background_tasks.add_task(run_scan_task, request.host, user, pwd, cmts_query_paths, request.config_type)
return {"status": "started"}
# ==========================================
# 🌟 清除快取 API (對應 api.js 的 POST 請求)
# 🌟 4. 清除快取 API
# ==========================================
@router.post("/clear_cache")
async def clear_specific_cache(paths_to_clear: list = Body(...)):
async def clear_specific_cache(host: str, config_type: str = "running", paths_to_clear: list = Body(...)):
cleared_count = 0
if not os.path.exists(CACHE_FILE):
cache_file = get_cache_file(host, config_type)
if not os.path.exists(cache_file):
return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
try:
with open(CACHE_FILE, "r", encoding="utf-8") as f:
with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f)
for path in paths_to_clear:
@ -145,7 +172,7 @@ async def clear_specific_cache(paths_to_clear: list = Body(...)):
cleared_count += 1
if cleared_count > 0:
with open(CACHE_FILE, "w", encoding="utf-8") as f:
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=4)
return {"status": "success", "cleared_count": cleared_count}

View File

@ -1,48 +1,49 @@
# --- routers/lock.py ---
import time
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import Dict, Optional
# 🌟 只定義自己的子路徑
router = APIRouter(prefix="/locks", tags=["Lock Management"])
# ==========================================
# 💡 全域鎖定表 (In-Memory Lock Table)
# 結構: { "path": {"user_id": "...", "username": "...", "expires_at": 1234567890.123} }
# 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
# ==========================================
ACTIVE_LOCKS: Dict[str, dict] = {}
LOCK_TIMEOUT = 120
# 鎖定超時時間 (秒):前端需在此時間內發送 Heartbeat否則自動釋放
LOCK_TIMEOUT = 30
# 🌟 新增 host 欄位
class LockAcquireReq(BaseModel):
host: str
path: str
user_id: str # 前端產生的 UUID (Session ID)
username: str # 登入的帳號 (用於 UI 提示,例如 "admin")
user_id: str
username: str
class LockActionReq(BaseModel):
host: str
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]
expired_keys = [k for k, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
for k in expired_keys:
del ACTIVE_LOCKS[k]
def is_path_locked_by_others(target_path: str, user_id: str) -> tuple[Optional[dict], str]:
"""
檢查路徑是否被其他人鎖定並回傳 (衝突的鎖定資訊, 具體錯誤訊息)
"""
def is_path_locked_by_others(target_host: str, target_path: str, user_id: str) -> tuple[Optional[dict], str]:
clean_expired_locks()
prefix = f"{target_host}@@"
for locked_path, lock_info in ACTIVE_LOCKS.items():
for locked_key, lock_info in ACTIVE_LOCKS.items():
# 🌟 只檢查同一台設備 (IP) 的鎖定
if not locked_key.startswith(prefix):
continue
locked_path = locked_key.split("@@", 1)[1]
if lock_info["user_id"] == user_id:
continue # 自己鎖定的不算衝突
continue
# 💡 拆分判斷,回傳更精準的錯誤訊息
if target_path == locked_path:
return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中"
@ -54,24 +55,15 @@ def is_path_locked_by_others(target_path: str, user_id: str) -> tuple[Optional[d
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)
conflict_lock, error_msg = is_path_locked_by_others(req.host, req.path, req.user_id)
if conflict_lock:
raise HTTPException(
status_code=409,
detail=error_msg # 💡 將精準的錯誤訊息傳給前端
)
raise HTTPException(status_code=409, detail=error_msg)
# 寫入或更新鎖定狀態
ACTIVE_LOCKS[req.path] = {
lock_key = f"{req.host}@@{req.path}"
ACTIVE_LOCKS[lock_key] = {
"user_id": req.user_id,
"username": req.username,
"expires_at": time.time() + LOCK_TIMEOUT
@ -80,28 +72,33 @@ async def acquire_lock(req: LockAcquireReq):
@router.post("/heartbeat")
async def heartbeat_lock(req: LockActionReq):
"""延長鎖定時間 (心跳偵測)"""
clean_expired_locks()
lock_key = f"{req.host}@@{req.path}"
if req.path not in ACTIVE_LOCKS:
if lock_key not in ACTIVE_LOCKS:
raise HTTPException(status_code=404, detail="鎖定已失效,請重新獲取")
if ACTIVE_LOCKS[req.path]["user_id"] != req.user_id:
if ACTIVE_LOCKS[lock_key]["user_id"] != req.user_id:
raise HTTPException(status_code=403, detail="無權更新他人的鎖定")
# 延長鎖定時間
ACTIVE_LOCKS[req.path]["expires_at"] = time.time() + LOCK_TIMEOUT
ACTIVE_LOCKS[lock_key]["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]
lock_key = f"{req.host}@@{req.path}"
if lock_key in ACTIVE_LOCKS and ACTIVE_LOCKS[lock_key]["user_id"] == req.user_id:
del ACTIVE_LOCKS[lock_key]
return {"status": "success", "message": "鎖定已釋放"}
@router.get("/status")
async def get_lock_status():
"""獲取目前所有被鎖定的路徑 (供前端 UI 標示 '編輯中' 狀態)"""
async def get_lock_status(host: str = Query(None)):
"""獲取特定設備的鎖定狀態"""
clean_expired_locks()
return {"status": "success", "data": ACTIVE_LOCKS}
if host:
prefix = f"{host}@@"
# 🌟 貼心設計:拔除 host@@ 前綴,讓前端收到的依然是乾淨的 pathUI 不用改!
filtered_locks = {k.split("@@", 1)[1]: v for k, v in ACTIVE_LOCKS.items() if k.startswith(prefix)}
return {"status": "success", "data": filtered_locks}
return {"status": "success", "data": {}}

View File

@ -3,56 +3,69 @@
// ==========================================
// 1. 鎖定管理 (Lock Management)
// ==========================================
export async function apiAcquireLock(path, userId, username) {
const response = await fetch('/api/v1/locks/acquire', {
export async function apiAcquireLock(path, userId, username, host) {
const res = await fetch('/api/v1/locks/acquire', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId, username })
body: JSON.stringify({ path, user_id: userId, username, host })
});
return { status: response.status, data: await response.json() };
const data = await res.json();
return { status: res.status, data };
}
export async function apiReleaseLock(path, userId) {
export async function apiReleaseLock(path, userId, host) {
return fetch('/api/v1/locks/release', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId })
body: JSON.stringify({ path, user_id: userId, host })
});
}
export async function apiSendHeartbeat(path, userId) {
export async function apiSendHeartbeat(path, userId, host) {
return fetch('/api/v1/locks/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, user_id: userId })
body: JSON.stringify({ path, user_id: userId, host })
});
}
export async function apiGetLockStatus(host) {
// 帶入 host 參數,實現 IP 隔離查詢
const url = host ? `/api/v1/locks/status?host=${encodeURIComponent(host)}` : '/api/v1/locks/status';
const response = await fetch(url);
return response.json();
}
// ==========================================
// 2. 樹狀圖與選項快取 (Tree & Options)
// ==========================================
export async function apiGetFullConfig(host, username, password, skipFilter = false) {
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
export async function apiGetFullConfig(host, username, password, skipFilter = false, configType = 'running') {
// 🌟 將 config_type 加入 URL 參數中
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&config_type=${configType}`;
if (skipFilter) url += '&skip_filter=true';
const response = await fetch(url);
return response.json();
}
export async function apiGetLeafOptions() {
const response = await fetch('/api/v1/cmts-leaf-options');
// 🌟 1. 取得選項快取 (加上 configType 查詢參數)
export async function apiGetLeafOptions(host, configType = 'running') {
const response = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(host)}&config_type=${configType}`);
return response.json();
}
export async function apiSyncLeafOptions(paths) {
// 🌟 2. 同步選項快取 (將 config_type 塞入 Body)
export async function apiSyncLeafOptions(host, paths, configType = 'running') {
return fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: paths })
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
});
}
export async function apiClearCache(paths) {
const response = await fetch('/api/v1/clear_cache', {
// 🌟 3. 清除快取 (加上 configType 查詢參數)
export async function apiClearCache(host, paths, configType = 'running') {
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(paths)
@ -67,13 +80,12 @@ export async function apiGetCmtsVersion(host, username, password) {
return response.json();
}
export async function apiGetScanStatus() {
export async function apiGetScanStatus(host, configType = 'running') {
try {
const response = await fetch('/api/v1/scan-status');
const response = await fetch(`/api/v1/scan-status?host=${encodeURIComponent(host)}&config_type=${configType}`);
const data = await response.json();
return data.is_scanning;
} catch (e) {
console.warn("無法取得掃描狀態,預設為未掃描", e);
return false;
}
}
@ -102,16 +114,18 @@ export async function apiExecuteConfig(script, host, username, password) {
// ==========================================
// 4. 系統設定與過濾器 (System Settings)
// ==========================================
export async function apiGetTreeFilters() {
const response = await fetch('/api/v1/settings/tree-filters');
// 🌟 加上 configType 參數
export async function apiGetTreeFilters(configType = 'running') {
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`);
return response.json();
}
export async function apiSaveTreeFilters(hiddenKeys) {
const response = await fetch('/api/v1/settings/tree-filters', {
// 🌟 加上 configType 參數
export async function apiSaveTreeFilters(hiddenKeys, configType = 'running') {
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden_keys: hiddenKeys })
body: JSON.stringify({ hidden_keys: hiddenKeys, config_type: configType })
});
return response.json();
}
@ -140,11 +154,12 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
// ==========================================
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新)
// ==========================================
export async function apiSyncLeafOptionsStream(paths, onProgress) {
// 🌟 4. 串流掃描 API (將 config_type 塞入 Body)
export async function apiSyncLeafOptionsStream(host, paths, onProgress, configType = 'running') {
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: paths })
body: JSON.stringify({ host, leaf_paths: paths, config_type: configType })
});
if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");

View File

@ -12,7 +12,7 @@ import {
apiGetFullConfig, apiClearCache, apiExecuteQuery,
apiGetTreeFilters, apiSaveTreeFilters,
apiSyncLeafOptionsStream, apiGetCmtsVersion,
apiGetScanStatus
apiGetScanStatus, apiGetLockStatus
} from './api.js';
// 3. 共用工具模組 (Utils)
@ -25,7 +25,7 @@ import { buildTree, buildRealFilterTree, expandAll, collapseAll } from './tree-u
import {
releaseAllLocks, startEditFolder, startEditLeaf,
cancelEditFolder, cancelEditLeaf, previewFolderCLI, previewLeafCLI,
hideSideCLI, executeSideCLI, previewNodeCLI
hideSideCLI, executeSideCLI, previewNodeCLI, SESSION_USER_ID
} from './edit-mode.js';
// 6. MAC Domain 配置模組 (MAC Domain)
@ -44,11 +44,12 @@ let idleTimer;
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
let globalSSE = null;
// 初始化全域 SSE 監聽器
function initGlobalSSE() {
if (globalSSE) return;
globalSSE = new EventSource('/api/v1/cmts-leaf-options/stream');
// 初始化全域 SSE 監聽器,加上 mode 參數,並在連線前關閉舊頻道
function initGlobalSSE(mode = 'running') {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return; // 🌟 確保有連線才建立 SSE
if (globalSSE) globalSSE.close();
globalSSE = new EventSource(`/api/v1/cmts-leaf-options/stream?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
globalSSE.onmessage = (event) => {
const data = JSON.parse(event.data);
@ -101,11 +102,55 @@ window.addEventListener('beforeunload', () => {
}
});
let lockPollingTimer = null;
function startLockStatusPolling() {
if (lockPollingTimer) clearInterval(lockPollingTimer);
lockPollingTimer = setInterval(async () => {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
try {
const result = await apiGetLockStatus(connInfo.host);
if (result.status === 'success') {
const activeLocks = result.data;
document.querySelectorAll('.edit-btn').forEach(btn => {
const path = btn.getAttribute('data-path');
if (activeLocks[path] && activeLocks[path].user_id !== SESSION_USER_ID) {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.2';
btn.title = `🔒 此區塊正由 [${activeLocks[path].username}] 編輯中`;
btn.innerText = "🔒";
} else {
if (btn.innerText === "🔒") {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '0.3';
btn.title = "鎖定並編輯此項目";
btn.innerText = "✏️";
}
}
});
}
} catch (error) {
console.warn("獲取鎖定狀態失敗:", error);
}
}, 15000);
}
// 頁面載入與縮放初始化
window.onload = () => {
initTerminal();
resetIdleTimer(); // 頁面載入時啟動閒置計時器
initGlobalSSE(); // 啟動全域廣播監聽
startLockStatusPolling(); // 啟動鎖定狀態輪詢
// 🌟 新增:網頁載入時,自動觸發一次「選擇配置任務」的切換,確保畫面與選單同步
const configTaskSelect = document.getElementById('configTask');
if (configTaskSelect) {
configTaskSelect.dispatchEvent(new Event('change'));
}
};
window.onresize = () => { if (typeof fitAddon !== 'undefined' && fitAddon) fitAddon.fit(); };
@ -115,9 +160,18 @@ function resetIdleTimer() {
idleTimer = setTimeout(releaseAllLocks, IDLE_TIMEOUT);
}
window.addEventListener('mousemove', resetIdleTimer);
window.addEventListener('keydown', resetIdleTimer);
window.addEventListener('click', resetIdleTimer);
// 🌟 效能急救:節流閥 (Throttle) 機制
let throttleTimer = false;
function throttledReset() {
if (!throttleTimer) {
resetIdleTimer();
throttleTimer = true;
setTimeout(() => { throttleTimer = false; }, 2000); // 每 2 秒最多只觸發一次重設
}
}
window.addEventListener('mousemove', throttledReset);
window.addEventListener('keydown', throttledReset);
window.addEventListener('click', throttledReset);
// ============================================================================
// 🌟 2. 頁籤切換與 UI 狀態控制 (Tabs & UI State)
@ -143,17 +197,57 @@ function switchQueryTask() {
if (targetForm) targetForm.classList.add('active');
}
// 🌟 新增一個變數來記錄上一次的樹狀圖模式
let lastTreeMode = null;
// 切換「設備配置」內的子任務表單
function switchConfigTask() {
document.querySelectorAll('#config-tab .task-form').forEach(form => {
form.classList.remove('active');
form.style.display = 'none';
});
const selectedTaskId = document.getElementById('configTask').value;
const targetForm = document.getElementById(selectedTaskId);
let targetFormId = selectedTaskId;
if (selectedTaskId === 'form-running-config' || selectedTaskId === 'form-full-config') {
targetFormId = 'form-tree-config';
}
const targetForm = document.getElementById(targetFormId);
if (targetForm) {
targetForm.classList.add('active');
targetForm.style.display = 'block';
const titleSpan = document.getElementById('tree-form-title');
const currentMode = selectedTaskId === 'form-full-config' ? 'full' : 'running';
if (titleSpan) {
titleSpan.textContent = currentMode === 'running'
? '🌳 設備配置樹狀圖 (running-config)'
: '🌳 完整設備配置樹狀圖 (full configuration)';
}
if (targetFormId === 'form-tree-config') {
// 🌟 魔法所在:純視覺切換,不銷毀資料!
const runningContainer = document.getElementById('tree-container-running');
const fullContainer = document.getElementById('tree-container-full');
if (runningContainer) runningContainer.style.display = currentMode === 'running' ? 'block' : 'none';
if (fullContainer) fullContainer.style.display = currentMode === 'full' ? 'block' : 'none';
if (lastTreeMode !== currentMode) {
const statusEl = document.getElementById('scan-status');
if (statusEl) statusEl.textContent = '';
// 🌟 新增:如果使用者在載入途中切換模式,強制把沙漏文字隱藏!
const loadingMsg = document.getElementById('loading-message');
if (loadingMsg) loadingMsg.style.display = 'none';
lastTreeMode = currentMode;
}
}
initGlobalSSE(currentMode);
}
}
@ -170,8 +264,13 @@ function startConfigTask() {
if (hasMacDomainData() && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) return;
resetAllManagerData();
loadMacDomainList();
} else if (selectedTaskId === 'form-full-config') {
fetchFullConfig();
}
// 🌟 修正:正確的括號閉合,並根據下拉選單傳遞 configType
else if (selectedTaskId === 'form-running-config') {
fetchFullConfig('running');
}
else if (selectedTaskId === 'form-full-config') {
fetchFullConfig('full'); // 🌟 補上 full 參數
}
}
@ -260,10 +359,11 @@ async function executeQuery(mode) {
}
}
// 載入完整設備配置並生成樹狀圖
async function fetchFullConfig() {
// 載入完整設備配置並生成樹狀圖,預設為 running
async function fetchFullConfig(configType = 'running') {
const loadingMsg = document.getElementById('loading-message');
const treeContainer = document.getElementById('tree-container');
// 🌟 動態抓取當前模式的專屬容器
const treeContainer = document.getElementById(`tree-container-${configType}`);
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
@ -279,7 +379,9 @@ async function fetchFullConfig() {
const versionRes = await apiGetCmtsVersion(connInfo.host, connInfo.user, connInfo.pass);
if (versionRes.status === 'success') {
const currentVersion = versionRes.version;
const optRes = await fetch('/api/v1/cmts-leaf-options');
// 🌟 修正 1讀取選項快取時必須加上 config_type 參數
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
const optData = await optRes.json();
const cachedVersion = optData.__metadata__?.cmts_version;
@ -287,7 +389,10 @@ async function fetchFullConfig() {
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
if (doClear) {
const allKeys = Object.keys(optData);
await apiClearCache(allKeys);
// 🌟 修正 2清空快取時必須傳遞 configType 告訴後端清哪一個
await apiClearCache(connInfo.host, allKeys, configType);
alert("✅ 舊版快取已清空!系統載入配置後,將自動為您重新掃描選項。");
needAutoScan = true; // 🌟 觸發自動掃描旗標
}
@ -298,27 +403,42 @@ async function fetchFullConfig() {
}
// --- 2. 載入完整配置 ---
const result = await apiGetFullConfig(connInfo.host, connInfo.user, connInfo.pass);
const result = await apiGetFullConfig(connInfo.host, connInfo.user, connInfo.pass, false, configType);
// 🌟 新增:將原始資料存入全域變數,供全域掃描使用
window.currentTreeData = result.data;
// 🌟 新增防呆攔截:檢查使用者是否在等待期間切換了下拉選單
const currentSelectedTask = document.getElementById('configTask').value;
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
if (currentSelectedTask !== expectedTask) {
console.warn(`[防呆攔截] 正在載入 ${configType},但使用者已切換至 ${currentSelectedTask},捨棄本次結果以防畫面錯亂。`);
return; // 🌟 發現模式不符,直接中斷,不要把舊樹狀圖貼上去!
}
if (result.status === 'success') {
if (treeContainer) {
treeContainer.innerHTML = buildTree(result.data);
// 🌟 傳入 configType讓 ID 加上前綴
treeContainer.innerHTML = buildTree(result.data, '', false, configType);
// 🌟 修正:樹狀圖一畫完,立刻隱藏載入訊息,讓使用者感覺「秒開」
if (loadingMsg) loadingMsg.style.display = 'none';
// 🌟 3. 檢查後端是否已經有人在掃描
const isSomeoneScanning = await apiGetScanStatus();
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
if (isSomeoneScanning) {
// B 使用者情境:有人在掃描了,反灰按鈕並提示
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
lockScanButton(60000); // 使用您現有的鎖定 UI 函數
lockScanButton(60000);
} else {
// A 使用者情境:沒人在掃描,正常顯示按鈕
enableGlobalScanButton();
// 如果剛清空快取,自動啟動掃描 (改為全域掃描)
// 如果剛清空快取,自動啟動掃描
if (needAutoScan) {
setTimeout(() => {
scanGlobalMissingOptions();
}, 500); // 給予 0.5 秒 UI 渲染緩衝
// 🌟 修正 3全域掃描也必須知道現在要掃哪一棵樹
scanGlobalMissingOptions(configType);
}, 500);
}
}
}
@ -336,10 +456,15 @@ async function fetchFullConfig() {
// 🌟 4. 選項快取與背景掃描 (Cache & Background Scanning)
// ============================================================================
// 為現有的 input 加上下拉選項 (不破壞原有結構)
async function enhanceInputWithOptions(path, elementId) {
// 為現有的 input 加上下拉選項 (不破壞原有結構),加上 mode 參數
async function enhanceInputWithOptions(path, elementId, mode = 'running') {
// 🌟 1. 取得當前連線的 IP
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return;
try {
const optRes = await fetch('/api/v1/cmts-leaf-options');
// 🌟 2. GET 請求:網址加上 host 參數
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
const optData = await optRes.json();
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
@ -362,10 +487,11 @@ async function enhanceInputWithOptions(path, elementId) {
dataList.innerHTML = leafCache.options.map(opt => `<option value="${opt}">`).join('');
}
} else {
// 🌟 3. POST 請求:把 host 塞進 body 裡面
fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: [cacheKey] })
body: JSON.stringify({ host: connInfo.host, leaf_paths: [cacheKey], config_type: mode })
});
}
} catch (e) {
@ -374,40 +500,77 @@ async function enhanceInputWithOptions(path, elementId) {
}
// ============================================================================
// 🌟 核心功能:執行掃描 (廣播升級版)
// 🌟 核心功能:執行掃描 (廣播升級版),加上 mode 參數
// ============================================================================
async function performScan(isGlobal) {
const isSomeoneScanning = await apiGetScanStatus();
// 🌟 新增:遞迴提取所有快取 Key 的輔助函數 (不依賴網頁 DOM)
function extractAllCacheKeys(node, path = '') {
let keys = [];
for (const [key, value] of Object.entries(node)) {
const currentPath = path ? `${path}::${key}` : key;
if (typeof value === 'object' && value !== null) {
keys = keys.concat(extractAllCacheKeys(value, currentPath));
} else {
const origVal = (value === null ? '' : value).toString();
let cacheKey = currentPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (currentPath.endsWith('::no')) {
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
}
keys.push(cacheKey);
}
}
return keys;
}
async function performScan(isGlobal, mode = 'running') {
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
if (isSomeoneScanning) {
alert("目前已有其他使用者正在執行掃描,請稍候共享更新結果!");
return;
}
const treeContainer = document.getElementById('tree-container');
const treeContainer = document.getElementById(`tree-container-${mode}`);
if (!treeContainer) return;
const statusEl = document.getElementById('scan-status');
try {
const optRes = await fetch('/api/v1/cmts-leaf-options');
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
const optData = await optRes.json();
const allContainers = treeContainer.querySelectorAll('.leaf-container');
const pathsToSync = [];
if (isGlobal) {
// 🌟 全域掃描:直接從原始 JSON 資料遞迴提取,不依賴 DOM
if (!window.currentTreeData) return alert("請先載入設備配置!");
const allKeys = extractAllCacheKeys(window.currentTreeData);
allKeys.forEach(cacheKey => {
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
pathsToSync.push(cacheKey);
}
});
} else {
// 🌟 局部掃描:維持原樣,只抓畫面上看得到的 (已展開的)
const allContainers = treeContainer.querySelectorAll('.leaf-container');
allContainers.forEach(container => {
const isHiddenByFolder = container.closest('details:not([open])') !== null;
if (isGlobal || !isHiddenByFolder) {
if (!isHiddenByFolder) {
const path = container.getAttribute('data-path');
if (path) {
const cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
const leafCache = optData[cacheKey];
if (!leafCache) {
if (!pathsToSync.includes(cacheKey)) pathsToSync.push(cacheKey);
const origVal = container.getAttribute('data-original') || '';
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
if (!optData[cacheKey] && !pathsToSync.includes(cacheKey)) {
pathsToSync.push(cacheKey);
}
}
}
});
}
if (pathsToSync.length === 0) {
statusEl.textContent = isGlobal ? "✅ 全域所有欄位都已有最新選項,無需掃描!" : "✅ 局部展開的欄位都已有最新選項,無需掃描!";
@ -416,11 +579,10 @@ async function performScan(isGlobal) {
return;
}
// 直接發送 POST 請求觸發背景任務UI 變化交給 SSE 處理
const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ leaf_paths: pathsToSync })
body: JSON.stringify({ host: connInfo.host, leaf_paths: pathsToSync, config_type: mode })
});
const result = await response.json();
@ -436,10 +598,15 @@ async function performScan(isGlobal) {
}
// ============================================================================
// 🌟 核心功能:清除快取 (廣播升級版)
// 🌟 核心功能:清除快取 (廣播升級版),加上 mode 參數
// ============================================================================
async function performClear(isGlobal) {
const isSomeoneScanning = await apiGetScanStatus();
async function performClear(isGlobal, mode = 'running') {
// 🌟 1. 取得當前連線的 IP
const connInfo = getGlobalConnectionInfo();
if (!connInfo || !connInfo.host) return alert("請先連線至設備!");
// 🌟 2. 檢查掃描狀態時,傳入 host
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, mode);
if (isSomeoneScanning) {
alert("⚠️ 系統正在進行全域選項掃描更新中!\n為避免資料衝突請稍候掃描完成再執行清除動作。");
return;
@ -449,7 +616,8 @@ async function performClear(isGlobal) {
if (isGlobal) {
try {
const optRes = await fetch('/api/v1/cmts-leaf-options');
// 🌟 3. GET 請求:網址加上 host 參數
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${mode}`);
const optData = await optRes.json();
pathsToClear = Object.keys(optData).filter(k => k !== '__metadata__');
} catch (e) {
@ -457,7 +625,8 @@ async function performClear(isGlobal) {
return alert("❌ 無法取得全域快取清單。");
}
} else {
const elements = document.querySelectorAll('.leaf-container');
const treeContainer = document.getElementById(`tree-container-${mode}`);
const elements = treeContainer ? treeContainer.querySelectorAll('.leaf-container') : [];
elements.forEach(el => {
const isHiddenByFolder = el.closest('details:not([open])') !== null;
if (!isHiddenByFolder) {
@ -479,7 +648,8 @@ async function performClear(isGlobal) {
if (!confirm(msg)) return;
try {
const result = await apiClearCache(pathsToClear);
// 🌟 4. 呼叫清除 API 時,傳入 host
const result = await apiClearCache(connInfo.host, pathsToClear, mode);
const statusEl = document.getElementById('scan-status');
if (statusEl) {
statusEl.textContent = `✅ 成功清除 ${result.cleared_count} 筆快取!準備重新掃描...`;
@ -487,7 +657,7 @@ async function performClear(isGlobal) {
}
// 清除完畢後,自動呼叫簡化版的 performScan
await performScan(isGlobal);
await performScan(isGlobal, mode);
} catch (error) {
console.error("清除快取失敗:", error);
@ -496,11 +666,22 @@ async function performClear(isGlobal) {
}
// 綁定給 HTML 呼叫的 4 個獨立函數
function scanVisibleMissingOptions() { performScan(false); }
function scanGlobalMissingOptions() { performScan(true); }
function clearVisibleCache() { performClear(false); }
function clearGlobalCache() { performClear(true); }
function scanVisibleMissingOptions() {
performScan(false);
}
function scanGlobalMissingOptions() {
// 🌟 動態抓取當前選擇的模式,傳給 performScan
const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
performScan(true, mode);
}
function clearVisibleCache() {
performClear(false);
}
function clearGlobalCache() {
// 🌟 動態抓取當前選擇的模式,傳給 performClear
const mode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
performClear(true, mode);
}
// ============================================================================
// 🌟 5. 系統設定與過濾器 (System Settings & Filters)
@ -508,10 +689,21 @@ function clearGlobalCache() { performClear(true); }
let currentHiddenKeys = [];
// 🌟 新增:切換過濾器模式時觸發
async function switchFilterMode() {
const container = document.getElementById('filter-checkboxes');
if (container) container.style.display = 'none'; // 先隱藏樹狀圖,強迫使用者重新載入
await openSystemSettings();
}
// 開啟系統設定並讀取目前的過濾器清單
async function openSystemSettings() {
// 🌟 動態抓取過濾器模式
const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running';
try {
const result = await apiGetTreeFilters();
const result = await apiGetTreeFilters(mode);
if (result.status === 'success') currentHiddenKeys = result.data;
} catch (error) {
console.error("讀取設定失敗:", error);
@ -523,6 +715,10 @@ async function loadRealConfigForFilters() {
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return alert("請先在畫面上方輸入設備連線資訊!");
// 🌟 動態抓取過濾器模式
const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running';
const loadingMsg = document.getElementById('filter-loading-msg');
const container = document.getElementById('filter-checkboxes');
@ -530,13 +726,15 @@ async function loadRealConfigForFilters() {
container.style.display = 'none';
try {
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true`;
// 🌟 根據模式載入對應的配置檔 (Running 或 Full)
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true&config_type=${mode}`;
const response = await fetch(url);
const result = await response.json();
if (result.status === 'success') {
container.style.display = 'block';
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys);
// 🌟 傳遞 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>`;
@ -576,11 +774,15 @@ function updateParentCheckboxState(element) {
// 儲存系統設定 (過濾器)
async function saveSystemSettings() {
// 🌟 動態抓取過濾器模式
const modeSelect = document.getElementById('filter-mode-select');
const mode = modeSelect ? modeSelect.value : 'running';
const checkboxes = document.querySelectorAll('.filter-checkbox:checked');
const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value);
try {
const result = await apiSaveTreeFilters(selectedHiddenKeys);
const result = await apiSaveTreeFilters(selectedHiddenKeys, mode);
if (result.status === 'success') {
const statusText = document.getElementById('settings-status');
statusText.style.display = 'inline-block';
@ -685,7 +887,8 @@ function checkScanButtonState() {
function lockScanButton(durationMs) {
setAdminButtonsState(true, "⏳ 背景掃描執行中...");
setTimeout(() => {
const treeContainer = document.getElementById('tree-container');
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
const treeContainer = document.getElementById(`tree-container-${currentMode}`);
if (treeContainer && treeContainer.innerHTML.includes('leaf-container')) {
enableGlobalScanButton();
} else {
@ -740,6 +943,7 @@ window.scanGlobalMissingOptions = scanGlobalMissingOptions;
window.clearVisibleCache = clearVisibleCache;
window.clearGlobalCache = clearGlobalCache;
window.previewNodeCLI = previewNodeCLI;
window.switchFilterMode = switchFilterMode; // 🌟 新增綁定
// --- 樹狀圖展開與編輯操作 ---
window.expandAll = expandAll;

View File

@ -21,7 +21,7 @@ function escapeHTML(str) {
// ==========================================
// 1. 全域狀態與鎖定管理 (Lock Management)
// ==========================================
const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
const ACTIVE_HEARTBEATS = {};
// 💡 追蹤當前編輯狀態
@ -32,13 +32,15 @@ export let currentEditType = null;
export let currentEditDiffs = []; // 🌟 新增:用來記錄這次修改了哪些路徑
export async function releaseAllLocks() {
const pathsToRelease = Object.keys(ACTIVE_HEARTBEATS);
if (pathsToRelease.length === 0) return;
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
if (keysToRelease.length === 0) return;
const releasePromises = pathsToRelease.map(path => {
clearInterval(ACTIVE_HEARTBEATS[path]);
delete ACTIVE_HEARTBEATS[path];
return apiReleaseLock(path, SESSION_USER_ID)
const releasePromises = keysToRelease.map(key => {
// 🌟 解析出 host 與 path
const [host, path] = key.split('@@');
clearInterval(ACTIVE_HEARTBEATS[key]);
delete ACTIVE_HEARTBEATS[key];
return apiReleaseLock(path, SESSION_USER_ID, host)
.catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
});
@ -47,14 +49,17 @@ export async function releaseAllLocks() {
location.reload();
}
async function sendHeartbeat(path) {
// 🌟 加入 host 與 lockKey 參數
async function sendHeartbeat(path, host, intervalId, lockKey) {
try {
const response = await apiSendHeartbeat(path, SESSION_USER_ID);
const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
if (!response.ok) {
console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`);
if (response.status === 404) {
clearInterval(ACTIVE_HEARTBEATS[path]);
delete ACTIVE_HEARTBEATS[path];
clearInterval(intervalId);
if (ACTIVE_HEARTBEATS[lockKey] === intervalId) {
delete ACTIVE_HEARTBEATS[lockKey];
}
}
}
} catch (error) {
@ -65,47 +70,67 @@ async function sendHeartbeat(path) {
// ==========================================
// 2. 編輯模式啟動與取消 (Folder & Leaf)
// ==========================================
export async function startEditFolder(path, elementId) {
const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : ''; // 🌟 取得設備 IP
const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username);
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
if (result.data.status === 'success') {
ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000);
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
let intervalId;
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId;
document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
document.getElementById(`details-${elementId}`).open = true;
// 🌟 修正:加入安全檢查,避免找不到元素時發生 null 錯誤
const editBtn = document.getElementById(`edit-btn-${elementId}`);
if (editBtn) editBtn.style.display = 'none';
const actionBtn = document.getElementById(`actions-${elementId}`);
if (actionBtn) actionBtn.style.display = 'inline-block';
const detailsEl = document.getElementById(`details-${elementId}`);
if (detailsEl) detailsEl.open = true;
const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) {
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
return; // 找不到內容容器就提早結束,避免後續 querySelectorAll 報錯
}
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
let optData = {};
try { optData = await apiGetLeafOptions(); } catch (e) { console.warn("無法取得選項快取", e); }
// 🌟 修改 1傳入 host 給 apiGetLeafOptions
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
const pathsToSync = [];
leafContainers.forEach(container => {
// ==========================================
// 🌟 效能急救:分批渲染 (Chunking) 避免卡死主執行緒
// ==========================================
const containersArray = Array.from(leafContainers);
const CHUNK_SIZE = 50; // 每次處理 50 個,確保畫面不結凍
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
chunk.forEach(container => {
const origVal = container.getAttribute('data-original');
const rawPath = container.getAttribute('data-path');
// ✅ 修改這裡:新增 safeOrigVal
const safeOrigVal = escapeHTML(origVal);
let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (rawPath.endsWith('::no')) {
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
}
if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
let leafCache = optData[cacheKey];
if (!leafCache) {
pathsToSync.push(cacheKey);
}
if (!leafCache) pathsToSync.push(cacheKey);
let inputHtml = "";
let hintAttr = "";
@ -123,23 +148,23 @@ export async function startEditFolder(path, elementId) {
inputHtml = `<select class="edit-input" data-path="${rawPath}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
options.forEach(opt => {
// ✅ 修改這裡:新增 safeOpt並替換 value 與顯示文字
const safeOpt = escapeHTML(opt);
const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
});
inputHtml += `</select>${hintIcon}`;
} else {
// ✅ 修改這裡:把 value="${origVal}" 改成 value="${safeOrigVal}"
inputHtml = `<input type="text" class="edit-input" data-path="${rawPath}" value="${safeOrigVal}" ${hintAttr} 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;">${hintIcon}`;
}
container.innerHTML = inputHtml;
});
if (pathsToSync.length > 0) {
console.log(`🚀 將 ${pathsToSync.length} 個欄位送入背景排程抓取...`);
apiSyncLeafOptions(pathsToSync);
// 🌟 關鍵魔法:每處理完 50 個,就暫停 0 毫秒,讓瀏覽器有空檔去畫畫面或回應滑鼠
await new Promise(resolve => setTimeout(resolve, 0));
}
// 🌟 傳入 host 給 apiSyncLeafOptions
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode);
}
} catch (error) {
alert("❌ 無法連線到鎖定伺服器:" + error.message);
@ -149,47 +174,50 @@ export async function startEditFolder(path, elementId) {
export async function startEditLeaf(path, elementId) {
const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin';
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
try {
const result = await apiAcquireLock(path, SESSION_USER_ID, username);
const result = await apiAcquireLock(path, SESSION_USER_ID, username, host);
if (result.status === 409) return alert(`⚠️ ${result.data.detail}`);
if (result.data.status === 'success') {
ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000);
if (ACTIVE_HEARTBEATS[lockKey]) clearInterval(ACTIVE_HEARTBEATS[lockKey]);
let intervalId;
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
ACTIVE_HEARTBEATS[lockKey] = intervalId;
document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original');
// ✅ 修改這裡:新增 safeOrigVal
const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
try {
let optData = await apiGetLeafOptions();
// 🌟 修改 1傳入 host
let optData = await apiGetLeafOptions(host, currentMode);
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (path.endsWith('::no')) {
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
}
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
let leafCache = optData[cacheKey];
if (!leafCache) {
apiSyncLeafOptions([cacheKey]);
// 🌟 修改 2傳入 host
apiSyncLeafOptions(host, [cacheKey], currentMode);
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions();
// 🌟 修改 3傳入 host
optData = await apiGetLeafOptions(host, currentMode);
leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) {
found = true; break;
}
}
if (!found) console.warn("⏳ 等待選項超時,退回純文字模式");
}
let inputHtml = "";
@ -208,20 +236,16 @@ export async function startEditLeaf(path, elementId) {
inputHtml = `<select class="edit-input" data-path="${path}" ${hintAttr} style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; height: 26px; outline: none; margin-left: 5px; background-color: white; cursor: pointer;">`;
options.forEach(opt => {
// ✅ 修改這裡:新增 safeOpt
const safeOpt = escapeHTML(opt);
const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
});
inputHtml += `</select>${hintIcon}`;
} else {
// ✅ 修改這裡:使用 safeOrigVal
inputHtml = `<input type="text" class="edit-input" data-path="${path}" value="${safeOrigVal}" ${hintAttr} 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;">${hintIcon}`;
}
container.innerHTML = inputHtml;
} catch (e) {
console.warn("選項載入失敗", e);
// ✅ 修改這裡catch 區塊也要使用 safeOrigVal
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;">`;
}
}
@ -231,11 +255,15 @@ export async function startEditLeaf(path, elementId) {
}
export async function cancelEditFolder(path, elementId) {
if (ACTIVE_HEARTBEATS[path]) {
clearInterval(ACTIVE_HEARTBEATS[path]);
delete ACTIVE_HEARTBEATS[path];
const connInfo = getGlobalConnectionInfo();
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
if (ACTIVE_HEARTBEATS[lockKey]) {
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
delete ACTIVE_HEARTBEATS[lockKey];
}
try { await apiReleaseLock(path, SESSION_USER_ID); } catch (error) {}
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';
@ -244,25 +272,27 @@ export async function cancelEditFolder(path, elementId) {
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
leafContainers.forEach(container => {
const origVal = container.getAttribute('data-original');
// ✅ 修改這裡:還原時也要跳脫
const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
});
}
export async function cancelEditLeaf(path, elementId) {
if (ACTIVE_HEARTBEATS[path]) {
clearInterval(ACTIVE_HEARTBEATS[path]);
delete ACTIVE_HEARTBEATS[path];
const connInfo = getGlobalConnectionInfo();
const host = connInfo ? connInfo.host : '';
const lockKey = `${host}@@${path}`;
if (ACTIVE_HEARTBEATS[lockKey]) {
clearInterval(ACTIVE_HEARTBEATS[lockKey]);
delete ACTIVE_HEARTBEATS[lockKey];
}
try { await apiReleaseLock(path, SESSION_USER_ID); } catch (error) {}
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';
const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original');
// ✅ 修改這裡:還原時也要跳脫
const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`;
}
@ -271,7 +301,12 @@ export async function cancelEditLeaf(path, elementId) {
// 3. CLI 生成與預覽 (CLI Generation)
// ==========================================
function getInterfacesWithAdminState() {
const nodes = document.querySelectorAll('.leaf-container');
// 🌟 限制只從當前顯示的樹狀圖中抓取狀態
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
if (!activeContainer) return [];
const nodes = activeContainer.querySelectorAll('.leaf-container');
const interfaces = new Set();
nodes.forEach(node => {
const path = node.getAttribute('data-path');
@ -355,7 +390,8 @@ export async function previewLeafCLI(path, elementId) {
}
function showCliPreviewModal(cliScript) {
const leftPane = document.getElementById('tree-container');
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance');
const resizer = document.getElementById('drag-resizer');
const rightPane = document.getElementById('side-cli-preview');
@ -369,7 +405,8 @@ function showCliPreviewModal(cliScript) {
document.getElementById('side-cli-textarea').value = cliScript;
leftPane.style.width = 'calc(50% - 11px)';
// 🌟 修改:遍歷所有樹狀圖容器進行縮放
leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)');
rightPane.style.width = 'calc(50% - 11px)';
resizer.style.display = 'flex';
rightPane.style.display = 'block';
@ -384,7 +421,8 @@ function showCliPreviewModal(cliScript) {
// 4. 側邊欄與執行邏輯
// ==========================================
export async function hideSideCLI() {
document.getElementById('tree-container').style.width = '100%';
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
document.querySelectorAll('.tree-view-instance').forEach(pane => pane.style.width = '100%');
document.getElementById('drag-resizer').style.display = 'none';
document.getElementById('side-cli-preview').style.display = 'none';
@ -457,17 +495,13 @@ async function executeGeneratedCLI(script) {
try {
const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass);
// 注意:這裡移除了原本直接顯示 btn-side-close 的程式碼,改到後續判斷中顯示
if (result.status === 'success') {
currentEditIsSuccess = true;
// 🌟 1. 顯示寫入成功,並提示正在同步
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在從設備同步最新狀態...';
document.getElementById('side-pane-title').style.color = '#f39c12';
outputEl.innerHTML = `<span style="color: #ecf0f1;">${result.data}\n\n[系統] 正在背景重新抓取變更欄位的最新選項,請稍候...</span>`;
// 🌟 2. 萃取出需要重新抓取的路徑 (精準打擊)
const pathsToSync = currentEditDiffs.map(d => {
let cacheKey = d.path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (d.path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + d.old_val;
@ -475,21 +509,23 @@ async function executeGeneratedCLI(script) {
});
const uniquePaths = [...new Set(pathsToSync)];
// 🌟 3. 呼叫後端爬蟲,去 CMTS 把這幾個欄位的最新狀態拉下來更新 Cache
try {
await apiSyncLeafOptionsStream(uniquePaths, (data) => {
// 🌟 修改:取得當前模式,並將 connInfo.host 與 currentMode 傳給 apiSyncLeafOptionsStream
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
await apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
if (data.event === 'done') {
document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成';
document.getElementById('side-pane-title').style.color = '#2ecc71';
document.getElementById('btn-side-close').style.display = 'inline-block'; // 同步完成才顯示關閉按鈕
document.getElementById('btn-side-close').style.display = 'inline-block';
outputEl.innerHTML += `<br><br><span style="color: #2ecc71;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
} else if (data.event === 'error') {
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功 (但同步快取失敗)';
document.getElementById('side-pane-title').style.color = '#e74c3c';
document.getElementById('btn-side-close').style.display = 'inline-block'; // 失敗也顯示關閉按鈕
document.getElementById('btn-side-close').style.display = 'inline-block';
outputEl.innerHTML += `<br><br><span style="color: #e74c3c;">[系統] 同步失敗: ${data.message}</span>`;
}
});
}, currentMode);
} catch (syncErr) {
document.getElementById('btn-side-close').style.display = 'inline-block';
}
@ -515,7 +551,8 @@ async function executeGeneratedCLI(script) {
// ==========================================
function initResizer() {
const resizer = document.getElementById('drag-resizer');
const leftPane = document.getElementById('tree-container');
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance');
const rightPane = document.getElementById('side-cli-preview');
const container = document.getElementById('split-container');
let isResizing = false;
@ -537,7 +574,8 @@ function initResizer() {
const leftPercentage = (newLeftWidth / containerRect.width) * 100;
const rightPercentage = 100 - leftPercentage;
leftPane.style.width = `calc(${leftPercentage}% - 11px)`;
// 🌟 修改:遍歷所有樹狀圖容器進行縮放
leftPanes.forEach(pane => pane.style.width = `calc(${leftPercentage}% - 11px)`);
rightPane.style.width = `calc(${rightPercentage}% - 11px)`;
});
@ -632,7 +670,8 @@ export function previewNodeCLI(btnElement, rootPath) {
cliOutput += `exit\n`;
// 3. 呼叫現有的樹狀圖側邊欄 (Side Pane) 顯示
const leftPane = document.getElementById('tree-container');
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance');
const resizer = document.getElementById('drag-resizer');
const rightPane = document.getElementById('side-cli-preview');
@ -660,7 +699,8 @@ export function previewNodeCLI(btnElement, rootPath) {
document.getElementById('side-execution-result').style.display = 'none';
// 展開側邊欄
leftPane.style.width = 'calc(50% - 11px)';
// 🌟 修改:遍歷所有樹狀圖容器進行縮放
leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)');
rightPane.style.width = 'calc(50% - 11px)';
if (resizer) resizer.style.display = 'flex';
rightPane.style.display = 'block';
@ -671,3 +711,5 @@ export function previewNodeCLI(btnElement, rootPath) {
window.isResizerInitialized = true;
}
}
// --- EOF (檔案結束) ---

BIN
static/my_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 B

View File

@ -1,14 +1,57 @@
// --- static/tree-ui.js ---
// ==========================================
// 🌟 輔助函數:全部展開與全部收合
// 🌟 效能終極優化:全域快取與延遲渲染 (Lazy Rendering)
// ==========================================
export const folderDataCache = {};
export const filterFolderCache = {};
// 魔法函數:當資料夾被點擊展開時,才將 HTML 渲染進去
window.lazyLoadFolder = function(elementId, isFilter = false) {
const detailsEl = document.getElementById(`details-${elementId}`);
// 如果已經載入過,就不重複渲染
if (!detailsEl || detailsEl.dataset.loaded === 'true') return;
// 從 dataset 讀取當初存下來的屬性
const currentPath = detailsEl.dataset.path;
const isCommandGroup = detailsEl.dataset.isGroup === 'true';
const mode = detailsEl.dataset.mode;
const contentDiv = document.getElementById(isFilter ? `filter-content-${elementId}` : `content-${elementId}`);
const cache = isFilter ? filterFolderCache[elementId] : folderDataCache[elementId];
if (contentDiv && cache) {
if (isFilter) {
contentDiv.innerHTML = buildRealFilterTree(cache.data, currentPath, cache.hiddenKeys, isCommandGroup, mode);
} else {
contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode);
}
detailsEl.dataset.loaded = 'true'; // 標記為已載入
}
};
// ==========================================
// 🌟 輔助函數:全部展開與全部收合 (支援延遲渲染)
// ==========================================
export function expandAll(elementId) {
const details = document.getElementById(`details-${elementId}`);
if (details) {
details.open = true; // 展開自己
const subDetails = details.querySelectorAll('details');
subDetails.forEach(d => d.open = true); // 展開所有子項目
// 如果還沒載入,先強制載入它
if (!details.dataset.loaded) {
const isFilter = details.id.includes('filter-');
window.lazyLoadFolder(elementId, isFilter);
}
details.open = true;
// 找出剛剛渲染出來的第一層子資料夾,遞迴展開
const contentDiv = details.querySelector(':scope > div');
if (contentDiv) {
const childDetails = contentDiv.querySelectorAll(':scope > details');
childDetails.forEach(d => {
const subId = d.id.replace('details-', '');
expandAll(subId); // 遞迴展開
});
}
}
}
@ -16,15 +59,15 @@ export function collapseAll(elementId) {
const details = document.getElementById(`details-${elementId}`);
if (details) {
const subDetails = details.querySelectorAll('details');
subDetails.forEach(d => d.open = false); // 收合所有子項目
details.open = false; // 收合自己
subDetails.forEach(d => d.open = false);
details.open = false;
}
}
// ==========================================
// 🌟 核心函數:生成完整設備配置樹狀圖
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
// ==========================================
export function buildTree(node, path = '', isParentCommandGroup = false) {
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
let html = '';
if (!node || typeof node !== 'object') return html;
@ -35,9 +78,12 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
if (typeof value === 'object' && value !== null) {
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
const elementId = `${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
const folderSuffix = isCommandGroup ? '<span style="color: #95a5a6; font-size: 0.85em; font-weight: normal; margin-left: 5px;">(指令群組)</span>' : '';
// 🌟 將資料存入快取,供延遲渲染使用
folderDataCache[elementId] = value;
const svgFolderClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const svgFolderOpen = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#f1c40f"><path d="M19 8H8.99C8.04 8 7.19 8.59 6.81 9.46L2.81 18.55C2.62 18.98 2.94 19.5 3.41 19.5H16.99C17.94 19.5 18.79 18.91 19.17 18.04L23.17 8.95C23.36 8.52 23.04 8 22.57 8H19zM4 4c-1.1 0-2 .9-2 2v10.59l3.09-7.04C5.58 8.36 6.27 8 7.01 8H19v-2c0-1.1-.9-2-2-2h-7l-2-2H4c-1.1 0-2 .9-2 2v12z"/></svg>`;
const svgGroupClosed = `<svg width="18" height="18" viewBox="0 0 24 24" fill="#95a5a6"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`;
@ -64,9 +110,11 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
</span>
`;
// 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder
html += `
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';">
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
<summary class="tree-folder-header"
title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'"
@ -89,7 +137,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
onmouseout="this.style.transform='scale(1)'">
🔍
</span>
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組">
@ -101,15 +149,16 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
</div>
</summary>
<div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;">
${buildTree(value, currentPath, isCommandGroup)}
<!-- 🌟 延遲渲染這裡一開始是空的展開時才會填入 -->
</div>
</details>
`;
} else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");
const elementId = `leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
const elementId = `${mode}-leaf-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
if (isNoCommand) {
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
@ -167,7 +216,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
onmouseout="this.style.transform='scale(1)'">
🔍
</span>
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
<span id="edit-btn-${elementId}" class="edit-btn" data-path="${currentPath}" onclick="event.stopPropagation(); event.preventDefault(); startEditLeaf('${currentPath}', '${elementId}')"
style="cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此項目">
@ -185,9 +234,9 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
}
// ==========================================
// 🌟 核心函數:生成系統設定的過濾樹狀圖
// 🌟 核心函數:生成系統設定的過濾樹狀圖 (Lazy 版)
// ==========================================
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false) {
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
if (typeof data !== 'object' || data === null) return '';
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
@ -212,7 +261,10 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
if (isFolder) {
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
const isCommandGroup = isParentCommandGroup || !hasNestedObject;
const elementId = `filter-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
const elementId = `filter-${mode}-folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
// 🌟 存入過濾器專用快取
filterFolderCache[elementId] = { data: value, hiddenKeys: hiddenKeys };
const iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
@ -236,8 +288,11 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
</span>
`;
// 🌟 加入 lazyLoadFolder 觸發
html += `
<details id="details-${elementId}" class="tree-folder" ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none';">
<details id="details-${elementId}" class="tree-folder"
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
<summary class="tree-node-header" title="${cliCommand}" style="font-weight: bold; color: #2c3e50; margin-top: 4px; list-style: none; display: flex; align-items: center; cursor: pointer; outline: none; padding: 4px 0;">
<style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
${checkboxHtml}
@ -248,12 +303,13 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
<span>${key}${folderSuffix}</span>
${expandCollapseBtns}
</summary>
<div class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
${buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup)}
<div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
<!-- 🌟 延遲渲染 -->
</div>
</details>
`;
} else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key);
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");