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 78b7493bd4
commit 289204780a
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()} 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: try:
total_paths = len(leaf_paths) total_paths = len(leaf_paths)
processed_count = 0 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) await read_until_quiet(timeout=1.5)
for path in batch: for path in batch:
# 🌟 優化 1強迫讓出事件迴圈控制權讓 FastAPI 去處理其他使用者的請求
await asyncio.sleep(0.01)
# --- 步驟 1發送 '?' 查詢 --- # --- 步驟 1發送 '?' 查詢 ---
command_to_send = f"{path} ?" command_to_send = f"{path} ?"
process.stdin.write(command_to_send) process.stdin.write(command_to_send)
@ -254,13 +258,22 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
finally: finally:
if conn: conn.close() if conn: conn.close()
# --- 步驟 3寫入快取檔 --- # --- 步驟 3寫入快取檔 ---
try: try:
cache_file = "cmts_leaf_options_cache.json" # 🌟 2. 動態決定快取檔名 (加入 IP 隔離)
cache_data = {} safe_host = host.replace(".", "_")
if os.path.exists(cache_file): cache_file = f"{safe_host}_{config_type}_cache.json"
with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f) # ==========================================
# 🌟 優化 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 (版本與掃描時間) # 🌟 新增:寫入 Metadata (版本與掃描時間)
if "__metadata__" not in cache_data: 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 result_data[p]["updated_at"] = current_ts
cache_data[p] = result_data[p] 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) # 🌟 優化 2-B將「寫入」新快取檔丟到背景執行緒
print(f"💾 [Debug] 第 {batch_idx + 1}/{len(batches)} 批次已提早寫入快取檔!(版本: {cmts_version})") # ==========================================
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: except Exception as e:
print(f"⚠️ 提早寫入快取失敗: {e}") print(f"⚠️ 提早寫入快取失敗: {e}")
@ -290,3 +310,4 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
except Exception as e: except Exception as e:
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n" 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 charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Harmonic CMTS Manager</title> <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" /> <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@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> <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"> <div class="control-group">
<label><strong>快捷指令:</strong></label> <label><strong>快捷指令:</strong></label>
<select id="fixedCmd"> <select id="fixedCmd">
<option value="show cable modem">數據機狀態 (show cable modem)</option> <option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
<option value="show cable rpd">RPD 狀態 (show cable rpd)</option> <option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option> <option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
</select> </select>
<button onclick="injectCommand()" class="btn-modern btn-load">送出指令</button> <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;"> <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> <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;"> <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-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
<option value="form-full-config">🌳 完整設備配置樹狀圖</option>
</select> </select>
<button id="btn-load-task" onclick="startConfigTask()" class="btn-modern btn-load" style="margin-left: 10px;" disabled>🚀 載入任務</button> <button id="btn-load-task" onclick="startConfigTask()" class="btn-modern btn-load" style="margin-left: 10px;" disabled>🚀 載入任務</button>
</div> </div>
@ -247,10 +250,13 @@
</div> </div>
</div> </div>
<!-- 表單 4完整設備配置樹狀圖 --> <!-- 表單 4設備配置樹狀圖 (共用容器) -->
<div id="form-full-config" class="task-form" style="display: none;"> <!-- 🌟 修正 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;"> <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;"> <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;"> <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;"> <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;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span> <span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
</div> --> </div>
<!-- 左側:樹狀圖 (預設滿版,顯示預覽時會被 JS 改為 50%) --> <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;">
<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;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方「載入任務」按鈕開始。</span>
</div> </div>
<!-- 🖱️ 拖曳分隔線 (預設隱藏) --> <!-- 🖱️ 拖曳分隔線 (預設隱藏) -->
@ -340,10 +345,19 @@
</p> </p>
</div> </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;">
</button> <label style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器:</label>
<span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span> <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 容器 --> <!-- 樹狀 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;"> <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 --- # --- routers/config.py ---
import re import re
import json
import os
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List
from netmiko import ConnectHandler from netmiko import ConnectHandler
# 💡 確保從 shared 引入 deep_split_tree # 🌟 移除了舊的 load_settings, save_settings改用專屬的雙軌機制
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, load_settings, save_settings from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree
from collections import defaultdict from collections import defaultdict
router = APIRouter() 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): class ConfigRequest(BaseModel):
script: str script: str
host: str host: str
@ -24,7 +56,7 @@ class DiffItem(BaseModel):
class GenerateCliRequest(BaseModel): class GenerateCliRequest(BaseModel):
diffs: List[DiffItem] diffs: List[DiffItem]
interfaces_with_admin_state: List[str] = [] # 💡 接收前端傳來的動態探測名單 interfaces_with_admin_state: List[str] = []
@router.post("/cmts-config") @router.post("/cmts-config")
async def execute_cmts_config(req: ConfigRequest): 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('!')] commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
# 🌟 1. 手動進入設定模式 (恢復您原本正確的寫法) # 1. 手動進入設定模式
net_connect.send_command_timing("config") net_connect.send_command_timing("config")
# 🌟 2. 批次送出設定指令 # 2. 批次送出設定指令
# 加上 enter_config_mode=False 與 exit_config_mode=False
# 這樣 Netmiko 就不會自作主張去打 config terminal 了
output = net_connect.send_config_set( output = net_connect.send_config_set(
commands, commands,
read_timeout=60, read_timeout=60,
@ -52,14 +82,13 @@ async def execute_cmts_config(req: ConfigRequest):
exit_config_mode=False exit_config_mode=False
) )
# 🌟 3. 手動退出設定模式 # 3. 手動退出設定模式
net_connect.send_command_timing("exit") net_connect.send_command_timing("exit")
net_connect.disconnect() net_connect.disconnect()
# 🌟 新增:檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字 # 檢查設備回傳的文字中是否包含拒絕或錯誤的關鍵字
lower_output = output.lower() lower_output = output.lower()
if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output: if "aborted" in lower_output or "error" in lower_output or "invalid" in lower_output:
# 回傳錯誤狀態,前端就會顯示紅色字體且「不會」自動關閉視窗
return { return {
"status": "error", "status": "error",
"message": "CMTS 拒絕了設定 (Commit Aborted)", "message": "CMTS 拒絕了設定 (Commit Aborted)",
@ -76,21 +105,16 @@ async def generate_cli(req: GenerateCliRequest):
將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本 將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本
並自動處理 admin-state 的安全生命週期 ( down up) 並自動處理 admin-state 的安全生命週期 ( down up)
""" """
# 用來將同一個介面的變更群組化
interface_groups = defaultdict(list) interface_groups = defaultdict(list)
# 用來記錄該介面最終的 admin-state 應該是什麼
interface_admin_states = {} interface_admin_states = {}
for diff in req.diffs: for diff in req.diffs:
# 將路徑切分:例如 ['cable', 'ds-rf-port', '1:9/0', 'down-channel', '0', 'frequency']
parts = diff.path.split('::') parts = diff.path.split('::')
param_name = parts[-1] param_name = parts[-1]
interface_path = " ".join(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) interface_path = re.sub(r"\s*\[\d+\]", "", interface_path)
if param_name == "admin-state": if param_name == "admin-state":
@ -98,21 +122,17 @@ async def generate_cli(req: GenerateCliRequest):
else: else:
interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val)) interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
# 開始組裝最終的 CLI 腳本
cli_lines = [] cli_lines = []
cli_lines.append("! --- Auto-Generated Configuration Script ---") cli_lines.append("! --- Auto-Generated Configuration Script ---")
for interface, changes in interface_groups.items(): for interface, changes in interface_groups.items():
cli_lines.append(f"! Configuring: {interface}") cli_lines.append(f"! Configuring: {interface}")
# 💡 核心邏輯:判斷這個特定區塊是否支援 admin-state
supports_admin_state = interface in req.interfaces_with_admin_state supports_admin_state = interface in req.interfaces_with_admin_state
# 1. 安全機制:如果支援,才強制將介面 admin-state down
if supports_admin_state: if supports_admin_state:
cli_lines.append(f"{interface} admin-state down") cli_lines.append(f"{interface} admin-state down")
# 2. 寫入所有被修改的參數
for param, old_val, new_val in changes: for param, old_val, new_val in changes:
if re.match(r"^\[\d+\]$", param): if re.match(r"^\[\d+\]$", param):
if new_val == "": if new_val == "":
@ -128,16 +148,13 @@ async def generate_cli(req: GenerateCliRequest):
else: else:
cli_lines.append(f"{interface} {param} {new_val}") cli_lines.append(f"{interface} {param} {new_val}")
# 3. 恢復狀態:如果支援,才恢復為 up 或使用者指定的值
if supports_admin_state: if supports_admin_state:
final_state = interface_admin_states.get(interface, "up") final_state = interface_admin_states.get(interface, "up")
cli_lines.append(f"{interface} admin-state {final_state}") cli_lines.append(f"{interface} admin-state {final_state}")
# Harmonic 必須要有 commit 才會生效
cli_lines.append("commit") cli_lines.append("commit")
cli_lines.append("!") # 空行分隔 cli_lines.append("!")
# 將獨立修改 admin-state (沒有修改其他參數) 的情況也補上
for interface, state in interface_admin_states.items(): for interface, state in interface_admin_states.items():
if interface not in interface_groups: if interface not in interface_groups:
cli_lines.append(f"! Configuring state only: {interface}") 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("commit")
cli_lines.append("!") cli_lines.append("!")
# 將陣列組合成字串回傳
final_script = "\n".join(cli_lines) final_script = "\n".join(cli_lines)
return {"status": "success", "data": final_script} return {"status": "success", "data": final_script}
# ========================================== # ==========================================
# 💡 以下為 DS RF Port 動態樹狀查詢 API # DS RF Port 動態樹狀查詢 API
# ========================================== # ==========================================
@router.get("/cmts-ds-rf-port-list") @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}) device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
# 使用 include 只抓取關鍵字,並把等待時間延長至 60 秒
command = 'show running-config | include "cable ds-rf-port"' 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() net_connect.disconnect()
# 使用正規表達式擷取 port 號碼 (例如 62:0/0)
pattern = r"cable ds-rf-port\s+([0-9:/]+)" pattern = r"cable ds-rf-port\s+([0-9:/]+)"
ports = re.findall(pattern, raw_output) ports = re.findall(pattern, raw_output)
unique_ports = sorted(list(set(ports))) 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) net_connect = ConnectHandler(**device)
command = f"show running-config interface cable ds-rf-port {target} | nomore" 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() net_connect.disconnect()
# 1. 初步解析縮排
base_tree = parse_cli_to_tree(raw_output) base_tree = parse_cli_to_tree(raw_output)
# 2. 💡 深層解析空白字元,建立完美樹狀結構
perfect_tree = deep_split_tree(base_tree) perfect_tree = deep_split_tree(base_tree)
return { return {
"status": "success", "status": "success",
"data": perfect_tree, # 回傳完美的樹狀結構 "data": perfect_tree,
"raw_cli": raw_output.strip() "raw_cli": raw_output.strip()
} }
except Exception as e: 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") @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""" """獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
try: try:
device = CMTS_DEVICE.copy() device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password}) device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device) net_connect = ConnectHandler(**device)
command = "show running-config | nomore"
raw_output = net_connect.send_command(command, read_timeout=60) 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 = str(net_connect.send_command(command, read_timeout=180))
net_connect.disconnect() 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) perfect_tree = deep_split_tree(base_tree)
# ========================================== # ==========================================
# 💡 深度過濾攔截器:支援多層級路徑 (如 cable.mac-domain) # 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
# ========================================== # ==========================================
if not skip_filter: if not skip_filter:
settings = load_settings() hidden_keys = load_tree_filters(config_type)
hidden_keys = settings.get("hidden_keys", [])
for path in hidden_keys: for path in hidden_keys:
keys = path.split('::') 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)} return {"status": "error", "message": str(e)}
# ========================================== # ==========================================
# 💡 系統設定 API (System Settings) # 🌟 系統設定 API (System Settings)
# ========================================== # ==========================================
class SettingsRequest(BaseModel): class SettingsRequest(BaseModel):
hidden_keys: list[str] hidden_keys: list[str]
config_type: str = "running" # 🌟 加入 config_type 參數
@router.get("/settings/tree-filters") @router.get("/settings/tree-filters")
async def get_tree_filters(): async def get_tree_filters(config_type: str = "running"):
"""獲取目前的樹狀圖隱藏名單""" """獲取目前的樹狀圖隱藏名單"""
settings = load_settings() # 🌟 根據 config_type 讀取對應的 JSON
return {"status": "success", "data": settings["hidden_keys"]} hidden_keys = load_tree_filters(config_type)
return {"status": "success", "data": hidden_keys}
@router.post("/settings/tree-filters") @router.post("/settings/tree-filters")
async def update_tree_filters(req: SettingsRequest): async def update_tree_filters(req: SettingsRequest):
"""更新樹狀圖隱藏名單並存檔""" """更新樹狀圖隱藏名單並存檔"""
save_settings({"hidden_keys": req.hidden_keys}) # 🌟 根據 config_type 寫入對應的 JSON
return {"status": "success", "message": "系統設定已更新"} 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 from shared import CMTS_DEVICE
router = APIRouter(tags=["Options"]) 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 # 將狀態改為空字典,動態根據 host_configType 建立
active_clients = set() 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() dead_clients = set()
# 🌟 關鍵修復 1SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾 # SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
message_str = f"data: {json.dumps(message_dict)}\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: try:
await client_queue.put(message_str) await client_queue.put(message_str)
except Exception: except Exception:
dead_clients.add(client_queue) dead_clients.add(client_queue)
for dead in dead_clients: for dead in dead_clients:
active_clients.discard(dead) active_clients[channel_key].discard(dead)
@router.get("/cmts-leaf-options/stream") @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() 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(): async def event_generator():
try: try:
while True: while True:
# 🌟 每次迴圈先檢查前端是否已經斷線 (按了 F5)
if await request.is_disconnected(): if await request.is_disconnected():
break break
try: try:
# 縮短 timeout 為 1 秒,讓檢查更靈敏
message = await asyncio.wait_for(client_queue.get(), timeout=1.0) message = await asyncio.wait_for(client_queue.get(), timeout=1.0)
yield message yield message
except asyncio.TimeoutError: except asyncio.TimeoutError:
yield ": keepalive\n\n" yield ": keepalive\n\n"
except asyncio.CancelledError: except asyncio.CancelledError:
# 🌟 關鍵修復:當 Uvicorn 觸發 reload 關閉伺服器時,會拋出此例外
# 我們捕捉它並默默退出,不讓伺服器卡住
pass pass
finally: 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") return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/scan-status") @router.get("/scan-status")
async def get_scan_status(): async def get_scan_status(host: str, config_type: str = "running"):
global IS_SCANNING channel_key = f"{host}_{config_type}"
return {"is_scanning": IS_SCANNING} return {"is_scanning": SCAN_STATUS.get(channel_key, False)}
# ========================================== # ==========================================
# 🌟 快取讀取與背景掃描任務 # 🌟 3. 快取讀取與背景掃描任務
# ========================================== # ==========================================
class SyncOptionsRequest(BaseModel): class SyncOptionsRequest(BaseModel):
host: str # 🌟 新增 host 參數
username: str = "" # 🌟 新增 username 參數
password: str = "" # 🌟 新增 password 參數
leaf_paths: List[str] leaf_paths: List[str]
config_type: str = "running"
@router.get("/cmts-leaf-options") @router.get("/cmts-leaf-options")
async def get_leaf_options(): async def get_leaf_options(host: str, config_type: str = "running"):
if not os.path.exists(CACHE_FILE): return {} cache_file = get_cache_file(host, config_type)
with open(CACHE_FILE, "r", encoding="utf-8") as f: if not os.path.exists(cache_file): return {}
return json.load(f)
async def run_scan_task(paths: List[str]): # 🌟 優化 3將讀取動作丟到背景執行緒
def read_json_from_file(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
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 IS_SCANNING global SCAN_STATUS
channel_key = f"{host}_{config_type}"
try: try:
await broadcast_message({"event": "scan_start"}) await broadcast_message({"event": "scan_start"}, host, config_type)
# 呼叫爬蟲 (帶入設備連線資訊) # 呼叫爬蟲 (帶入設備連線資訊與 config_type)
async for chunk in sync_cmts_leaves_async( async for chunk in sync_cmts_leaves_async(
host=CMTS_DEVICE.get("host"), host=host,
username=CMTS_DEVICE.get("username"), username=username,
password=CMTS_DEVICE.get("password"), password=password,
leaf_paths=paths leaf_paths=paths,
config_type=config_type
): ):
if isinstance(chunk, str): if isinstance(chunk, str):
await broadcast_message(json.loads(chunk)) await broadcast_message(json.loads(chunk), host, config_type)
else: 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: except Exception as e:
await broadcast_message({"event": "error", "message": str(e)}) await broadcast_message({"event": "error", "message": str(e)}, host, config_type)
finally: finally:
IS_SCANNING = False # 解除特定頻道的鎖定
SCAN_STATUS[channel_key] = False
@router.post("/cmts-leaf-options/sync") @router.post("/cmts-leaf-options/sync")
async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks): async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks):
"""前端點擊掃描時,只負責觸發背景任務並立即回傳 JSON""" global SCAN_STATUS
global IS_SCANNING
if not request.leaf_paths: if not request.leaf_paths:
raise HTTPException(status_code=400) raise HTTPException(status_code=400)
if IS_SCANNING: channel_key = f"{request.host}_{request.config_type}"
return {"status": "busy", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"}
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 = [] cmts_query_paths = []
for p in request.leaf_paths: for p in request.leaf_paths:
clean_p = p.replace("::", " ") 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)) cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
# 將任務丟入背景執行,並立刻回傳 JSON 給前端 # 決定帳密 (優先使用 request 傳來的,否則 fallback 到 shared.CMTS_DEVICE)
background_tasks.add_task(run_scan_task, cmts_query_paths) # 🌟 加上 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"} return {"status": "started"}
# ========================================== # ==========================================
# 🌟 清除快取 API (對應 api.js 的 POST 請求) # 🌟 4. 清除快取 API
# ========================================== # ==========================================
@router.post("/clear_cache") @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 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": "快取檔案不存在"} return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
try: 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) cache_data = json.load(f)
for path in paths_to_clear: for path in paths_to_clear:
@ -145,7 +172,7 @@ async def clear_specific_cache(paths_to_clear: list = Body(...)):
cleared_count += 1 cleared_count += 1
if cleared_count > 0: 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) json.dump(cache_data, f, ensure_ascii=False, indent=4)
return {"status": "success", "cleared_count": cleared_count} return {"status": "success", "cleared_count": cleared_count}

View File

@ -1,48 +1,49 @@
# --- routers/lock.py --- # --- routers/lock.py ---
import time import time
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Dict, Optional from typing import Dict, Optional
# 🌟 只定義自己的子路徑
router = APIRouter(prefix="/locks", tags=["Lock Management"]) router = APIRouter(prefix="/locks", tags=["Lock Management"])
# ========================================== # ==========================================
# 💡 全域鎖定表 (In-Memory Lock Table) # 💡 全域鎖定表 (In-Memory Lock Table)
# 結構: { "path": {"user_id": "...", "username": "...", "expires_at": 1234567890.123} } # 🌟 結構升級: { "host@@path": {"user_id": "...", "username": "...", "expires_at": ...} }
# ========================================== # ==========================================
ACTIVE_LOCKS: Dict[str, dict] = {} ACTIVE_LOCKS: Dict[str, dict] = {}
LOCK_TIMEOUT = 120
# 鎖定超時時間 (秒):前端需在此時間內發送 Heartbeat否則自動釋放 # 🌟 新增 host 欄位
LOCK_TIMEOUT = 30
class LockAcquireReq(BaseModel): class LockAcquireReq(BaseModel):
host: str
path: str path: str
user_id: str # 前端產生的 UUID (Session ID) user_id: str
username: str # 登入的帳號 (用於 UI 提示,例如 "admin") username: str
class LockActionReq(BaseModel): class LockActionReq(BaseModel):
host: str
path: str path: str
user_id: str user_id: str
def clean_expired_locks(): def clean_expired_locks():
"""清除已經超時的鎖 (被動式清理)"""
current_time = time.time() current_time = time.time()
expired_paths = [path for path, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time] expired_keys = [k for k, lock in ACTIVE_LOCKS.items() if lock["expires_at"] < current_time]
for path in expired_paths: for k in expired_keys:
del ACTIVE_LOCKS[path] 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() 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: if lock_info["user_id"] == user_id:
continue # 自己鎖定的不算衝突 continue
# 💡 拆分判斷,回傳更精準的錯誤訊息
if target_path == locked_path: if target_path == locked_path:
return lock_info, f"此區塊正由 [{lock_info['username']}] 編輯中" 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, "" return None, ""
# ==========================================
# 💡 API Endpoints
# ==========================================
@router.post("/acquire") @router.post("/acquire")
async def acquire_lock(req: LockAcquireReq): async def acquire_lock(req: LockAcquireReq):
"""請求鎖定特定路徑""" conflict_lock, error_msg = is_path_locked_by_others(req.host, req.path, req.user_id)
# 接收回傳的鎖定資訊與具體訊息
conflict_lock, error_msg = is_path_locked_by_others(req.path, req.user_id)
if conflict_lock: if conflict_lock:
raise HTTPException( raise HTTPException(status_code=409, detail=error_msg)
status_code=409,
detail=error_msg # 💡 將精準的錯誤訊息傳給前端
)
# 寫入或更新鎖定狀態 lock_key = f"{req.host}@@{req.path}"
ACTIVE_LOCKS[req.path] = { ACTIVE_LOCKS[lock_key] = {
"user_id": req.user_id, "user_id": req.user_id,
"username": req.username, "username": req.username,
"expires_at": time.time() + LOCK_TIMEOUT "expires_at": time.time() + LOCK_TIMEOUT
@ -80,28 +72,33 @@ async def acquire_lock(req: LockAcquireReq):
@router.post("/heartbeat") @router.post("/heartbeat")
async def heartbeat_lock(req: LockActionReq): async def heartbeat_lock(req: LockActionReq):
"""延長鎖定時間 (心跳偵測)"""
clean_expired_locks() 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="鎖定已失效,請重新獲取") 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="無權更新他人的鎖定") raise HTTPException(status_code=403, detail="無權更新他人的鎖定")
# 延長鎖定時間 ACTIVE_LOCKS[lock_key]["expires_at"] = time.time() + LOCK_TIMEOUT
ACTIVE_LOCKS[req.path]["expires_at"] = time.time() + LOCK_TIMEOUT
return {"status": "success", "message": "心跳更新成功"} return {"status": "success", "message": "心跳更新成功"}
@router.post("/release") @router.post("/release")
async def release_lock(req: LockActionReq): async def release_lock(req: LockActionReq):
"""主動釋放鎖定""" lock_key = f"{req.host}@@{req.path}"
if req.path in ACTIVE_LOCKS and ACTIVE_LOCKS[req.path]["user_id"] == req.user_id: if lock_key in ACTIVE_LOCKS and ACTIVE_LOCKS[lock_key]["user_id"] == req.user_id:
del ACTIVE_LOCKS[req.path] del ACTIVE_LOCKS[lock_key]
return {"status": "success", "message": "鎖定已釋放"} return {"status": "success", "message": "鎖定已釋放"}
@router.get("/status") @router.get("/status")
async def get_lock_status(): async def get_lock_status(host: str = Query(None)):
"""獲取目前所有被鎖定的路徑 (供前端 UI 標示 '編輯中' 狀態)""" """獲取特定設備的鎖定狀態"""
clean_expired_locks() 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) // 1. 鎖定管理 (Lock Management)
// ========================================== // ==========================================
export async function apiAcquireLock(path, userId, username) { export async function apiAcquireLock(path, userId, username, host) {
const response = await fetch('/api/v1/locks/acquire', { const res = await fetch('/api/v1/locks/acquire', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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', { return fetch('/api/v1/locks/release', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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', { return fetch('/api/v1/locks/heartbeat', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) // 2. 樹狀圖與選項快取 (Tree & Options)
// ========================================== // ==========================================
export async function apiGetFullConfig(host, username, password, skipFilter = false) { export async function apiGetFullConfig(host, username, password, skipFilter = false, configType = 'running') {
let url = `/api/v1/cmts-full-config?host=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`; // 🌟 將 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'; if (skipFilter) url += '&skip_filter=true';
const response = await fetch(url); const response = await fetch(url);
return response.json(); return response.json();
} }
export async function apiGetLeafOptions() { // 🌟 1. 取得選項快取 (加上 configType 查詢參數)
const response = await fetch('/api/v1/cmts-leaf-options'); 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(); 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', { return fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { // 🌟 3. 清除快取 (加上 configType 查詢參數)
const response = await fetch('/api/v1/clear_cache', { export async function apiClearCache(host, paths, configType = 'running') {
const response = await fetch(`/api/v1/clear_cache?host=${encodeURIComponent(host)}&config_type=${configType}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(paths) body: JSON.stringify(paths)
@ -67,13 +80,12 @@ export async function apiGetCmtsVersion(host, username, password) {
return response.json(); return response.json();
} }
export async function apiGetScanStatus() { export async function apiGetScanStatus(host, configType = 'running') {
try { 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(); const data = await response.json();
return data.is_scanning; return data.is_scanning;
} catch (e) { } catch (e) {
console.warn("無法取得掃描狀態,預設為未掃描", e);
return false; return false;
} }
} }
@ -102,16 +114,18 @@ export async function apiExecuteConfig(script, host, username, password) {
// ========================================== // ==========================================
// 4. 系統設定與過濾器 (System Settings) // 4. 系統設定與過濾器 (System Settings)
// ========================================== // ==========================================
export async function apiGetTreeFilters() { // 🌟 加上 configType 參數
const response = await fetch('/api/v1/settings/tree-filters'); export async function apiGetTreeFilters(configType = 'running') {
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`);
return response.json(); return response.json();
} }
export async function apiSaveTreeFilters(hiddenKeys) { // 🌟 加上 configType 參數
const response = await fetch('/api/v1/settings/tree-filters', { export async function apiSaveTreeFilters(hiddenKeys, configType = 'running') {
const response = await fetch(`/api/v1/settings/tree-filters?config_type=${configType}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden_keys: hiddenKeys }) body: JSON.stringify({ hidden_keys: hiddenKeys, config_type: configType })
}); });
return response.json(); return response.json();
} }
@ -140,11 +154,12 @@ export async function apiExecuteQuery(queryType, target, host, username, passwor
// ========================================== // ==========================================
// 6. 專門處理串流的 API 呼叫函數 (UI 可以即時更新) // 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', { const response = await fetch('/api/v1/cmts-leaf-options/sync', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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"); if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");

View File

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

View File

@ -21,7 +21,7 @@ function escapeHTML(str) {
// ========================================== // ==========================================
// 1. 全域狀態與鎖定管理 (Lock Management) // 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 = {}; const ACTIVE_HEARTBEATS = {};
// 💡 追蹤當前編輯狀態 // 💡 追蹤當前編輯狀態
@ -32,13 +32,15 @@ export let currentEditType = null;
export let currentEditDiffs = []; // 🌟 新增:用來記錄這次修改了哪些路徑 export let currentEditDiffs = []; // 🌟 新增:用來記錄這次修改了哪些路徑
export async function releaseAllLocks() { export async function releaseAllLocks() {
const pathsToRelease = Object.keys(ACTIVE_HEARTBEATS); const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
if (pathsToRelease.length === 0) return; if (keysToRelease.length === 0) return;
const releasePromises = pathsToRelease.map(path => { const releasePromises = keysToRelease.map(key => {
clearInterval(ACTIVE_HEARTBEATS[path]); // 🌟 解析出 host 與 path
delete ACTIVE_HEARTBEATS[path]; const [host, path] = key.split('@@');
return apiReleaseLock(path, SESSION_USER_ID) clearInterval(ACTIVE_HEARTBEATS[key]);
delete ACTIVE_HEARTBEATS[key];
return apiReleaseLock(path, SESSION_USER_ID, host)
.catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err)); .catch(err => console.error(`釋放鎖定 ${path} 失敗:`, err));
}); });
@ -47,14 +49,17 @@ export async function releaseAllLocks() {
location.reload(); location.reload();
} }
async function sendHeartbeat(path) { // 🌟 加入 host 與 lockKey 參數
async function sendHeartbeat(path, host, intervalId, lockKey) {
try { try {
const response = await apiSendHeartbeat(path, SESSION_USER_ID); const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
if (!response.ok) { if (!response.ok) {
console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`); console.warn(`心跳發送失敗 (${path}): 伺服器回傳 ${response.status}`);
if (response.status === 404) { if (response.status === 404) {
clearInterval(ACTIVE_HEARTBEATS[path]); clearInterval(intervalId);
delete ACTIVE_HEARTBEATS[path]; if (ACTIVE_HEARTBEATS[lockKey] === intervalId) {
delete ACTIVE_HEARTBEATS[lockKey];
}
} }
} }
} catch (error) { } catch (error) {
@ -65,81 +70,101 @@ async function sendHeartbeat(path) {
// ========================================== // ==========================================
// 2. 編輯模式啟動與取消 (Folder & Leaf) // 2. 編輯模式啟動與取消 (Folder & Leaf)
// ========================================== // ==========================================
export async function startEditFolder(path, elementId) { export async function startEditFolder(path, elementId) {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin'; 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 { 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.status === 409) return alert(`⚠️ ${result.data.detail}`);
if (result.data.status === 'success') { 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'; // 🌟 修正:加入安全檢查,避免找不到元素時發生 null 錯誤
document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; const editBtn = document.getElementById(`edit-btn-${elementId}`);
document.getElementById(`details-${elementId}`).open = true; 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}`); const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) {
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
return; // 找不到內容容器就提早結束,避免後續 querySelectorAll 報錯
}
const leafContainers = contentDiv.querySelectorAll('.leaf-container'); const leafContainers = contentDiv.querySelectorAll('.leaf-container');
let optData = {}; let optData = {};
try { optData = await apiGetLeafOptions(); } catch (e) { console.warn("無法取得選項快取", e); } // 🌟 修改 1傳入 host 給 apiGetLeafOptions
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
const pathsToSync = []; const pathsToSync = [];
leafContainers.forEach(container => { // ==========================================
const origVal = container.getAttribute('data-original'); // 🌟 效能急救:分批渲染 (Chunking) 避免卡死主執行緒
const rawPath = container.getAttribute('data-path'); // ==========================================
const containersArray = Array.from(leafContainers);
const CHUNK_SIZE = 50; // 每次處理 50 個,確保畫面不結凍
// ✅ 修改這裡:新增 safeOrigVal for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
const safeOrigVal = escapeHTML(origVal); const chunk = containersArray.slice(i, i + CHUNK_SIZE);
let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); chunk.forEach(container => {
const origVal = container.getAttribute('data-original');
const rawPath = container.getAttribute('data-path');
const safeOrigVal = escapeHTML(origVal);
if (rawPath.endsWith('::no')) { let cacheKey = rawPath.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal; if (rawPath.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
} let leafCache = optData[cacheKey];
let leafCache = optData[cacheKey]; if (!leafCache) pathsToSync.push(cacheKey);
if (!leafCache) { let inputHtml = "";
pathsToSync.push(cacheKey); let hintAttr = "";
} let hintIcon = "";
let inputHtml = ""; if (leafCache && leafCache.hint) {
let hintAttr = ""; const safeHint = leafCache.hint.replace(/"/g, '&quot;');
let hintIcon = ""; hintAttr = `title="${safeHint}"`;
hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
}
if (leafCache && leafCache.hint) { if (leafCache && leafCache.options && leafCache.options.length > 0) {
const safeHint = leafCache.hint.replace(/"/g, '&quot;'); let options = leafCache.options;
hintAttr = `title="${safeHint}"`; if (origVal && !options.includes(origVal)) options = [origVal, ...options];
hintIcon = `<span style="cursor: help; margin-left: 6px; color: #e67e22; font-size: 14px;" title="${safeHint}">❓</span>`;
}
if (leafCache && leafCache.options && leafCache.options.length > 0) { 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;">`;
let options = leafCache.options; options.forEach(opt => {
if (origVal && !options.includes(origVal)) options = [origVal, ...options]; const safeOpt = escapeHTML(opt);
const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
});
inputHtml += `</select>${hintIcon}`;
} else {
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;
});
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;">`; // 🌟 關鍵魔法:每處理完 50 個,就暫停 0 毫秒,讓瀏覽器有空檔去畫畫面或回應滑鼠
options.forEach(opt => { await new Promise(resolve => setTimeout(resolve, 0));
// ✅ 修改這裡:新增 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);
} }
// 🌟 傳入 host 給 apiSyncLeafOptions
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode);
} }
} catch (error) { } catch (error) {
alert("❌ 無法連線到鎖定伺服器:" + error.message); alert("❌ 無法連線到鎖定伺服器:" + error.message);
@ -149,47 +174,50 @@ export async function startEditFolder(path, elementId) {
export async function startEditLeaf(path, elementId) { export async function startEditLeaf(path, elementId) {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
const username = connInfo ? connInfo.user : 'admin'; 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 { 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.status === 409) return alert(`⚠️ ${result.data.detail}`);
if (result.data.status === 'success') { 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(`edit-btn-${elementId}`).style.display = 'none';
document.getElementById(`actions-${elementId}`).style.display = 'inline-block'; document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
const container = document.getElementById(`container-${elementId}`); const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
// ✅ 修改這裡:新增 safeOrigVal
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`; container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
try { try {
let optData = await apiGetLeafOptions(); // 🌟 修改 1傳入 host
let optData = await apiGetLeafOptions(host, currentMode);
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); 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]; let leafCache = optData[cacheKey];
if (!leafCache) { if (!leafCache) {
apiSyncLeafOptions([cacheKey]); // 🌟 修改 2傳入 host
apiSyncLeafOptions(host, [cacheKey], currentMode);
let found = false; let found = false;
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions(); // 🌟 修改 3傳入 host
optData = await apiGetLeafOptions(host, currentMode);
leafCache = optData[cacheKey]; leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) { if (leafCache && leafCache.options && leafCache.options.length > 0) {
found = true; break; found = true; break;
} }
} }
if (!found) console.warn("⏳ 等待選項超時,退回純文字模式");
} }
let inputHtml = ""; 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;">`; 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 => { options.forEach(opt => {
// ✅ 修改這裡:新增 safeOpt
const safeOpt = escapeHTML(opt); const safeOpt = escapeHTML(opt);
const isSelected = (opt === origVal) ? 'selected' : ''; const isSelected = (opt === origVal) ? 'selected' : '';
inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`; inputHtml += `<option value="${safeOpt}" ${isSelected}>${safeOpt}</option>`;
}); });
inputHtml += `</select>${hintIcon}`; inputHtml += `</select>${hintIcon}`;
} else { } 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}`; 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; container.innerHTML = inputHtml;
} catch (e) { } 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;">`; 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) { export async function cancelEditFolder(path, elementId) {
if (ACTIVE_HEARTBEATS[path]) { const connInfo = getGlobalConnectionInfo();
clearInterval(ACTIVE_HEARTBEATS[path]); const host = connInfo ? connInfo.host : '';
delete ACTIVE_HEARTBEATS[path]; 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(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'none';
@ -244,25 +272,27 @@ export async function cancelEditFolder(path, elementId) {
const leafContainers = contentDiv.querySelectorAll('.leaf-container'); const leafContainers = contentDiv.querySelectorAll('.leaf-container');
leafContainers.forEach(container => { leafContainers.forEach(container => {
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
// ✅ 修改這裡:還原時也要跳脫
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`; 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) { export async function cancelEditLeaf(path, elementId) {
if (ACTIVE_HEARTBEATS[path]) { const connInfo = getGlobalConnectionInfo();
clearInterval(ACTIVE_HEARTBEATS[path]); const host = connInfo ? connInfo.host : '';
delete ACTIVE_HEARTBEATS[path]; 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(`edit-btn-${elementId}`).style.display = 'inline-block';
document.getElementById(`actions-${elementId}`).style.display = 'none'; document.getElementById(`actions-${elementId}`).style.display = 'none';
const container = document.getElementById(`container-${elementId}`); const container = document.getElementById(`container-${elementId}`);
const origVal = container.getAttribute('data-original'); const origVal = container.getAttribute('data-original');
// ✅ 修改這裡:還原時也要跳脫
const safeOrigVal = escapeHTML(origVal); const safeOrigVal = escapeHTML(origVal);
container.innerHTML = `<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${safeOrigVal}</span>`; 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) // 3. CLI 生成與預覽 (CLI Generation)
// ========================================== // ==========================================
function getInterfacesWithAdminState() { 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(); const interfaces = new Set();
nodes.forEach(node => { nodes.forEach(node => {
const path = node.getAttribute('data-path'); const path = node.getAttribute('data-path');
@ -355,7 +390,8 @@ export async function previewLeafCLI(path, elementId) {
} }
function showCliPreviewModal(cliScript) { function showCliPreviewModal(cliScript) {
const leftPane = document.getElementById('tree-container'); // 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance');
const resizer = document.getElementById('drag-resizer'); const resizer = document.getElementById('drag-resizer');
const rightPane = document.getElementById('side-cli-preview'); const rightPane = document.getElementById('side-cli-preview');
@ -369,7 +405,8 @@ function showCliPreviewModal(cliScript) {
document.getElementById('side-cli-textarea').value = 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)'; rightPane.style.width = 'calc(50% - 11px)';
resizer.style.display = 'flex'; resizer.style.display = 'flex';
rightPane.style.display = 'block'; rightPane.style.display = 'block';
@ -384,7 +421,8 @@ function showCliPreviewModal(cliScript) {
// 4. 側邊欄與執行邏輯 // 4. 側邊欄與執行邏輯
// ========================================== // ==========================================
export async function hideSideCLI() { 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('drag-resizer').style.display = 'none';
document.getElementById('side-cli-preview').style.display = 'none'; document.getElementById('side-cli-preview').style.display = 'none';
@ -457,17 +495,13 @@ async function executeGeneratedCLI(script) {
try { try {
const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass); const result = await apiExecuteConfig(script, connInfo.host, connInfo.user, connInfo.pass);
// 注意:這裡移除了原本直接顯示 btn-side-close 的程式碼,改到後續判斷中顯示
if (result.status === 'success') { if (result.status === 'success') {
currentEditIsSuccess = true; currentEditIsSuccess = true;
// 🌟 1. 顯示寫入成功,並提示正在同步
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在從設備同步最新狀態...'; document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功!正在從設備同步最新狀態...';
document.getElementById('side-pane-title').style.color = '#f39c12'; document.getElementById('side-pane-title').style.color = '#f39c12';
outputEl.innerHTML = `<span style="color: #ecf0f1;">${result.data}\n\n[系統] 正在背景重新抓取變更欄位的最新選項,請稍候...</span>`; outputEl.innerHTML = `<span style="color: #ecf0f1;">${result.data}\n\n[系統] 正在背景重新抓取變更欄位的最新選項,請稍候...</span>`;
// 🌟 2. 萃取出需要重新抓取的路徑 (精準打擊)
const pathsToSync = currentEditDiffs.map(d => { const pathsToSync = currentEditDiffs.map(d => {
let cacheKey = d.path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, ''); let cacheKey = d.path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
if (d.path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + d.old_val; 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)]; const uniquePaths = [...new Set(pathsToSync)];
// 🌟 3. 呼叫後端爬蟲,去 CMTS 把這幾個欄位的最新狀態拉下來更新 Cache
try { 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') { if (data.event === 'done') {
document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成'; document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成';
document.getElementById('side-pane-title').style.color = '#2ecc71'; 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>`; outputEl.innerHTML += `<br><br><span style="color: #2ecc71;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
} else if (data.event === 'error') { } else if (data.event === 'error') {
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功 (但同步快取失敗)'; document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功 (但同步快取失敗)';
document.getElementById('side-pane-title').style.color = '#e74c3c'; 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>`; outputEl.innerHTML += `<br><br><span style="color: #e74c3c;">[系統] 同步失敗: ${data.message}</span>`;
} }
}); }, currentMode);
} catch (syncErr) { } catch (syncErr) {
document.getElementById('btn-side-close').style.display = 'inline-block'; document.getElementById('btn-side-close').style.display = 'inline-block';
} }
@ -515,7 +551,8 @@ async function executeGeneratedCLI(script) {
// ========================================== // ==========================================
function initResizer() { function initResizer() {
const resizer = document.getElementById('drag-resizer'); 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 rightPane = document.getElementById('side-cli-preview');
const container = document.getElementById('split-container'); const container = document.getElementById('split-container');
let isResizing = false; let isResizing = false;
@ -537,7 +574,8 @@ function initResizer() {
const leftPercentage = (newLeftWidth / containerRect.width) * 100; const leftPercentage = (newLeftWidth / containerRect.width) * 100;
const rightPercentage = 100 - leftPercentage; 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)`; rightPane.style.width = `calc(${rightPercentage}% - 11px)`;
}); });
@ -632,7 +670,8 @@ export function previewNodeCLI(btnElement, rootPath) {
cliOutput += `exit\n`; cliOutput += `exit\n`;
// 3. 呼叫現有的樹狀圖側邊欄 (Side Pane) 顯示 // 3. 呼叫現有的樹狀圖側邊欄 (Side Pane) 顯示
const leftPane = document.getElementById('tree-container'); // 🌟 修改:改用 class 統一控制所有樹狀圖容器
const leftPanes = document.querySelectorAll('.tree-view-instance');
const resizer = document.getElementById('drag-resizer'); const resizer = document.getElementById('drag-resizer');
const rightPane = document.getElementById('side-cli-preview'); const rightPane = document.getElementById('side-cli-preview');
@ -660,7 +699,8 @@ export function previewNodeCLI(btnElement, rootPath) {
document.getElementById('side-execution-result').style.display = 'none'; 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)'; rightPane.style.width = 'calc(50% - 11px)';
if (resizer) resizer.style.display = 'flex'; if (resizer) resizer.style.display = 'flex';
rightPane.style.display = 'block'; rightPane.style.display = 'block';
@ -671,3 +711,5 @@ export function previewNodeCLI(btnElement, rootPath) {
window.isResizerInitialized = true; 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 --- // --- 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) { export function expandAll(elementId) {
const details = document.getElementById(`details-${elementId}`); const details = document.getElementById(`details-${elementId}`);
if (details) { if (details) {
details.open = true; // 展開自己 // 如果還沒載入,先強制載入它
const subDetails = details.querySelectorAll('details'); if (!details.dataset.loaded) {
subDetails.forEach(d => d.open = true); // 展開所有子項目 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}`); const details = document.getElementById(`details-${elementId}`);
if (details) { if (details) {
const subDetails = details.querySelectorAll('details'); const subDetails = details.querySelectorAll('details');
subDetails.forEach(d => d.open = false); // 收合所有子項目 subDetails.forEach(d => d.open = false);
details.open = false; // 收合自己 details.open = false;
} }
} }
// ========================================== // ==========================================
// 🌟 核心函數:生成完整設備配置樹狀圖 // 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
// ========================================== // ==========================================
export function buildTree(node, path = '', isParentCommandGroup = false) { export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
let html = ''; let html = '';
if (!node || typeof node !== 'object') return 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) { if (typeof value === 'object' && value !== null) {
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null); const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
const isCommandGroup = isParentCommandGroup || !hasNestedObject; 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>' : ''; 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 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 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>`; 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> </span>
`; `;
// 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder
html += ` html += `
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;" <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" <summary class="tree-folder-header"
title="${cliCommand}" title="${cliCommand}"
onmouseover="this.style.backgroundColor='#f1f2f6'" onmouseover="this.style.backgroundColor='#f1f2f6'"
@ -89,7 +137,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
onmouseout="this.style.transform='scale(1)'"> onmouseout="this.style.transform='scale(1)'">
🔍 🔍
</span> </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;" 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="鎖定並編輯此群組"> onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此群組">
@ -101,15 +149,16 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
</div> </div>
</summary> </summary>
<div id="content-${elementId}" class="tree-folder-content" style="margin-left: 12px; border-left: 1px dashed #bdc3c7; padding-left: 12px;"> <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> </div>
</details> </details>
`; `;
} else { } else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
const isNoCommand = (key === 'no'); const isNoCommand = (key === 'no');
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); 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) { if (isNoCommand) {
let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim(); let baseCmd = cliCommand.replace(/(^|\s)no$/, '').trim();
@ -167,7 +216,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false) {
onmouseout="this.style.transform='scale(1)'"> onmouseout="this.style.transform='scale(1)'">
🔍 🔍
</span> </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;" 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="鎖定並編輯此項目"> 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 ''; if (typeof data !== 'object' || data === null) return '';
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`; let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
@ -212,7 +261,10 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
if (isFolder) { if (isFolder) {
const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null); const hasNestedObject = Object.values(value).some(v => typeof v === 'object' && v !== null);
const isCommandGroup = isParentCommandGroup || !hasNestedObject; 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 iconClosed = isCommandGroup ? svgGroupClosed : svgFolderClosed;
const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen; const iconOpen = isCommandGroup ? svgGroupOpen : svgFolderOpen;
@ -236,8 +288,11 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
</span> </span>
`; `;
// 🌟 加入 lazyLoadFolder 觸發
html += ` 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;"> <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> <style>#details-${elementId} > summary::-webkit-details-marker { display: none; }</style>
${checkboxHtml} ${checkboxHtml}
@ -248,12 +303,13 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
<span>${key}${folderSuffix}</span> <span>${key}${folderSuffix}</span>
${expandCollapseBtns} ${expandCollapseBtns}
</summary> </summary>
<div class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;"> <div id="filter-content-${elementId}" class="filter-children-container" style="border-left: 1px dashed #bdc3c7; margin-left: 7px; padding-left: 16px;">
${buildRealFilterTree(value, currentPath, hiddenKeys, isCommandGroup)} <!-- 🌟 延遲渲染 -->
</div> </div>
</details> </details>
`; `;
} else { } else {
// 葉節點邏輯保持不變
const isVirtualIndex = /^\[\d+\]$/.test(key); const isVirtualIndex = /^\[\d+\]$/.test(key);
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`; let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;"); const safeValue = (value === null ? '' : value).toString().replace(/"/g, "&quot;");