開發樹狀配置與系統設定頁面

This commit is contained in:
swpa 2026-04-30 16:27:18 +08:00
parent f931c0f397
commit 07859300a6
5 changed files with 516 additions and 75 deletions

View File

@ -8,7 +8,7 @@
<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>
<!-- 引入我們剛剛獨立出來的 CSS 樣式表 --> <!-- 引入獨立的 CSS 樣式表 -->
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
</head> </head>
<body> <body>
@ -32,6 +32,8 @@
<button class="tab-btn active" onclick="switchTab('cli-tab', this)">💻 True SSH Terminal</button> <button class="tab-btn active" onclick="switchTab('cli-tab', this)">💻 True SSH Terminal</button>
<button class="tab-btn" onclick="switchTab('query-tab', this)">🔍 CMTS 狀態查詢</button> <button class="tab-btn" onclick="switchTab('query-tab', this)">🔍 CMTS 狀態查詢</button>
<button class="tab-btn" onclick="switchTab('config-tab', this)">⚙️ CMTS 設備配置</button> <button class="tab-btn" onclick="switchTab('config-tab', this)">⚙️ CMTS 設備配置</button>
<!-- 💡 新增:系統設定頁籤 -->
<button class="tab-btn" onclick="switchTab('settings-tab', this); openSystemSettings();">⚙️ 系統設定</button>
</div> </div>
<!-- Tab 1: True SSH Terminal --> <!-- Tab 1: True SSH Terminal -->
@ -148,8 +150,8 @@
</div> </div>
</div> </div>
</div> </div>
</div> <!-- 💡 補上這個:關閉 query-tab 裡的 manager-container --> </div>
</div> <!-- 💡 補上這個:關閉 query-tab 本身 --> </div>
<!-- Tab 3: CMTS 設備配置 --> <!-- Tab 3: CMTS 設備配置 -->
<div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;"> <div id="config-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe;">
@ -157,17 +159,15 @@
<div class="control-group" style="background: #ffffff; padding: 15px 25px; 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: 15px 25px; 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>
<!-- 💡 移除了 onchange改由右側按鈕手動觸發 --> <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" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
<option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option> <option value="form-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
<!-- 未來可以在這裡新增 modulation-profile, rpd 等選項 --> <option value="form-full-config">🌳 完整設備配置樹狀圖</option>
</select> </select>
<!-- 💡 新增的手動觸發按鈕 -->
<button id="btn-load-task" onclick="startConfigTask()" style="background-color: #95a5a6; color: white; margin-left: 10px; cursor: not-allowed;" disabled>🚀 載入任務</button> <button id="btn-load-task" onclick="startConfigTask()" style="background-color: #95a5a6; color: white; margin-left: 10px; cursor: not-allowed;" disabled>🚀 載入任務</button>
</div> </div>
<!-- 💡 表單 3MAC Domain 配置精靈 --> <!-- 表單 3MAC Domain 配置精靈 -->
<div id="form-bonding-config" class="task-form"> <div id="form-bonding-config" class="task-form active">
<h3 style="margin-top: 0; font-size: 18px; color: #c0392b; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">⚠️ MAC Domain 狀態感知配置精靈</h3> <h3 style="margin-top: 0; font-size: 18px; color: #c0392b; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">⚠️ MAC Domain 狀態感知配置精靈</h3>
<div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;"> <div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;">
@ -180,7 +180,6 @@
</div> </div>
<div id="macDomainConfigArea" style="display: none; margin-top: 20px;"> <div id="macDomainConfigArea" style="display: none; margin-top: 20px;">
<!-- Common Settings --> <!-- Common Settings -->
<h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4> <h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4>
<div class="form-grid"> <div class="form-grid">
@ -240,7 +239,6 @@
<div id="cliPreviewContainer" style="display: none; background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 6px; margin-top: 30px; margin-bottom: 20px; font-family: monospace; white-space: pre-wrap;"></div> <div id="cliPreviewContainer" style="display: none; background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 6px; margin-top: 30px; margin-bottom: 20px; font-family: monospace; white-space: pre-wrap;"></div>
<div class="form-actions"> <div class="form-actions">
<!-- 💡 新增的復原按鈕 (使用灰色系,避免與主要動作搶視覺焦點) -->
<button id="btnRevert" onclick="revertFormChanges()" style="background-color: #7f8c8d; color: white; margin-right: 15px;"> <button id="btnRevert" onclick="revertFormChanges()" style="background-color: #7f8c8d; color: white; margin-right: 15px;">
🔄 復原重填 🔄 復原重填
</button> </button>
@ -249,10 +247,64 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 表單 4完整設備配置樹狀圖 -->
<div id="form-full-config" class="task-form" style="display: none;">
<h3 style="margin-top: 0; font-size: 18px; color: #27ae60; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">🌳 完整設備配置樹狀圖</h3>
<div class="control-group" style="background: #eafaf1; padding: 15px; border-radius: 6px; border: 1px solid #a9dfbf;">
<label style="font-weight: bold; color: #1e8449;">點擊下方按鈕,將抓取整台設備的完整配置並轉為樹狀結構:</label>
<br>
<button onclick="fetchFullConfig()" style="background-color: #27ae60; color: white; margin-top: 10px; padding: 8px 16px;">📥 載入完整配置</button>
<div id="loading-message" style="display: none; color: #d35400; margin-top: 15px; font-weight: bold;">
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
</div>
</div>
<div id="tree-container" style="margin-top: 20px; border: 1px solid #bdc3c7; padding: 15px; border-radius: 5px; background-color: #ffffff; overflow-x: auto; max-height: 600px; overflow-y: auto;">
<span style="color: #7f8c8d;">尚未載入資料。請點擊上方按鈕開始。</span>
</div>
</div>
</div> </div>
</div> </div>
<!-- 💡 Tab 4: 系統設定 (System Settings) -->
<div id="settings-tab" class="tab-content" style="overflow-y: auto; background-color: #fdfefe; text-align: left;">
<div class="manager-container" style="display: block; max-width: 100%;">
<div class="control-group" style="background: #ffffff; padding: 25px 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid #e2e8f0; text-align: left; display: block;">
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">
⚙️ 全域系統設定 (God Mode Filters)
</h3>
<div style="background: #f8f9fa; border-left: 4px solid #3498db; padding: 12px 15px; border-radius: 4px; margin-bottom: 20px;">
<p style="color: #2c3e50; font-size: 15px; margin: 0;">
請勾選您希望在「完整設備配置樹狀圖」中 <b>隱藏</b> 的設定項目。<br>
<span style="color: #7f8c8d; font-size: 14px;">💡 點擊下方按鈕載入設備實時配置,您可以深入展開並勾選任何層級的節點進行過濾。</span>
</p>
</div>
<button onclick="loadRealConfigForFilters()" style="background-color: #3498db; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; margin-bottom: 15px;">
📥 載入設備配置以設定過濾
</button>
<span id="filter-loading-msg" style="display: none; color: #e67e22; margin-left: 10px; font-weight: bold;">⏳ 正在抓取配置,請稍候...</span>
<!-- 樹狀 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>
<div style="margin-top: 20px; border-top: 1px solid #ecf0f1; padding-top: 20px;">
<button onclick="saveSystemSettings()" style="background-color: #27ae60; color: white; padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-size: 15px; font-weight: bold;">
💾 儲存並套用設定
</button>
<span id="settings-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">✅ 設定已成功儲存!</span>
</div>
</div>
</div>
</div>
<!-- 獨立的彈出式輸出視窗 (Modal) --> <!-- 獨立的彈出式輸出視窗 (Modal) -->
<div id="outputModal" class="modal-overlay" onclick="closeModal(event)"> <div id="outputModal" class="modal-overlay" onclick="closeModal(event)">
<div class="modal-container" onclick="event.stopPropagation()"> <div class="modal-container" onclick="event.stopPropagation()">
@ -269,7 +321,8 @@
</div> </div>
</div> </div>
<!-- 引入我們剛剛獨立出來的 JavaScript 邏輯 --> <!-- 引入獨立的 JavaScript 邏輯 -->
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>

View File

@ -1,8 +1,11 @@
# --- routers/config.py --- # --- routers/config.py ---
import re
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List
from netmiko import ConnectHandler from netmiko import ConnectHandler
from shared import CMTS_DEVICE, cmts_config_lock # 💡 確保從 shared 引入 deep_split_tree
from shared import CMTS_DEVICE, cmts_config_lock, parse_cli_to_tree, deep_split_tree, load_settings, save_settings
router = APIRouter() router = APIRouter()
@ -35,3 +38,121 @@ async def execute_cmts_config(req: ConfigRequest):
return {"status": "success", "data": output} return {"status": "success", "data": output}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"CMTS Config Error: {str(e)}") raise HTTPException(status_code=500, detail=f"CMTS Config Error: {str(e)}")
# ==========================================
# 💡 以下為 DS RF Port 動態樹狀查詢 API
# ==========================================
@router.get("/cmts-ds-rf-port-list")
async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
"""獲取設備上所有的 DS RF Port 清單"""
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
# 使用 include 只抓取關鍵字,並把等待時間延長至 60 秒
command = 'show running-config | include "cable ds-rf-port"'
raw_output = net_connect.send_command(command, read_timeout=60)
net_connect.disconnect()
# 使用正規表達式擷取 port 號碼 (例如 62:0/0)
pattern = r"cable ds-rf-port\s+([0-9:/]+)"
ports = re.findall(pattern, raw_output)
unique_ports = sorted(list(set(ports)))
return {
"status": "success",
"data": unique_ports,
"raw_output": raw_output
}
except Exception as e:
return {"status": "error", "message": str(e)}
@router.get("/cmts-ds-rf-port-config")
async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""):
"""獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON"""
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
command = f"show running-config interface cable ds-rf-port {target} | nomore"
raw_output = net_connect.send_command(command, read_timeout=60)
net_connect.disconnect()
# 1. 初步解析縮排
base_tree = parse_cli_to_tree(raw_output)
# 2. 💡 深層解析空白字元,建立完美樹狀結構
perfect_tree = deep_split_tree(base_tree)
return {
"status": "success",
"data": perfect_tree, # 回傳完美的樹狀結構
"raw_cli": raw_output.strip()
}
except Exception as e:
return {"status": "error", "message": str(e)}
@router.get("/cmts-full-config")
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False):
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
command = "show running-config | nomore"
raw_output = net_connect.send_command(command, read_timeout=60)
net_connect.disconnect()
base_tree = parse_cli_to_tree(raw_output)
perfect_tree = deep_split_tree(base_tree)
# ==========================================
# 💡 深度過濾攔截器:支援多層級路徑 (如 cable.mac-domain)
# ==========================================
if not skip_filter:
settings = load_settings()
hidden_keys = settings.get("hidden_keys", [])
for path in hidden_keys:
keys = path.split('.')
current = perfect_tree
# 走到倒數第二層
for k in keys[:-1]:
if isinstance(current, dict) and k in current:
current = current[k]
else:
current = None
break
# 刪除最後一層的目標節點
if isinstance(current, dict) and keys[-1] in current:
current.pop(keys[-1], None)
# ==========================================
return {"status": "success", "data": perfect_tree}
except Exception as e:
return {"status": "error", "message": str(e)}
# ==========================================
# 💡 系統設定 API (System Settings)
# ==========================================
class SettingsRequest(BaseModel):
hidden_keys: list[str]
@router.get("/settings/tree-filters")
async def get_tree_filters():
"""獲取目前的樹狀圖隱藏名單"""
settings = load_settings()
return {"status": "success", "data": settings["hidden_keys"]}
@router.post("/settings/tree-filters")
async def update_tree_filters(req: SettingsRequest):
"""更新樹狀圖隱藏名單並存檔"""
save_settings({"hidden_keys": req.hidden_keys})
return {"status": "success", "message": "系統設定已更新"}

112
shared.py
View File

@ -1,5 +1,7 @@
# --- shared.py --- # --- shared.py ---
import asyncio import asyncio
import json
import os
DEBUG_MODE = False DEBUG_MODE = False
@ -18,5 +20,115 @@ CMTS_DEVICE = {
'global_delay_factor': 2 'global_delay_factor': 2
} }
def parse_cli_to_tree(cli_text: str) -> dict:
"""
CMTS CLI 配置文字根據縮排轉換為巢狀 JSON (Dictionary)
"""
# 過濾掉空行與註解 (!)
lines = [line for line in cli_text.splitlines() if line.strip() and line.strip() != '!']
def parse_block(start_idx: int, base_indent: int):
block = {}
i = start_idx
while i < len(lines):
line = lines[i]
indent = len(line) - len(line.lstrip())
if indent < base_indent:
break
content = line.strip()
has_children = False
if i + 1 < len(lines):
next_indent = len(lines[i+1]) - len(lines[i+1].lstrip())
if next_indent > base_indent:
has_children = True
if has_children:
child_block, next_i = parse_block(i + 1, next_indent)
block[content] = child_block
i = next_i
else:
parts = content.split(maxsplit=1)
if len(parts) == 2:
block[parts[0]] = parts[1]
else:
block[parts[0]] = ""
i += 1
return block, i
tree, _ = parse_block(0, 0)
return tree
def deep_split_tree(data):
"""將平坦的 CLI 字典,依照空白字元拆分成深層巢狀結構 (具備型態衝突保護)"""
if not isinstance(data, dict):
return data
nested_tree = {}
for key, value in data.items():
# 1. 遞迴處理子節點
processed_value = deep_split_tree(value)
# 2. 將當前的 key 依照空白拆分
parts = str(key).strip().split()
if not parts:
continue
# 3. 依序建立深層結構
current = nested_tree
for i, part in enumerate(parts):
if i == len(parts) - 1:
# 抵達最後一個單字,準備塞入值
if part not in current:
current[part] = processed_value
else:
# 💡 衝突保護:如果節點已存在,根據型態進行安全合併
if isinstance(current[part], dict) and isinstance(processed_value, dict):
current[part].update(processed_value)
elif isinstance(current[part], dict) and not isinstance(processed_value, dict):
# 原本是資料夾,新來的是字串 -> 保留資料夾
pass
elif not isinstance(current[part], dict) and isinstance(processed_value, dict):
# 原本是字串,新來的是資料夾 -> 升級成資料夾
current[part] = processed_value
else:
# 兩者都是字串,直接覆蓋
current[part] = processed_value
else:
# 建立中間的階層
if part not in current:
current[part] = {}
elif not isinstance(current[part], dict):
# 💡 衝突保護:如果中間節點原本被當作字串,強制升級為字典(資料夾)以便容納子節點
current[part] = {}
current = current[part]
return nested_tree
# ==========================================
# 💡 系統設定管理 (System Settings Manager)
# ==========================================
SETTINGS_FILE = "system_settings.json"
def load_settings():
"""讀取系統設定,若檔案不存在則回傳預設的黑名單"""
if os.path.exists(SETTINGS_FILE):
try:
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
# 預設隱藏這些比較雜亂且不常修改的系統層級設定
return {"hidden_keys": ["logging", "privilege", "aaa", "certificate", "line"]}
def save_settings(settings_data):
"""將系統設定寫入 JSON 檔案"""
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(settings_data, f, indent=4, ensure_ascii=False)
# 全域非同步鎖:確保同一時間只有一人能執行設定寫入 # 全域非同步鎖:確保同一時間只有一人能執行設定寫入
cmts_config_lock = asyncio.Lock() cmts_config_lock = asyncio.Lock()

View File

@ -5,7 +5,7 @@
// ========================================== // ==========================================
let term, fitAddon, ws; let term, fitAddon, ws;
let currentMacDomainData = null; let currentMacDomainData = null;
let isConnected = false; // 💡 新增:用來追蹤目前的連線狀態 let isConnected = false;
window.onload = initTerminal; window.onload = initTerminal;
window.onresize = () => { if (fitAddon) fitAddon.fit(); }; window.onresize = () => { if (fitAddon) fitAddon.fit(); };
@ -22,55 +22,56 @@ function switchTab(tabId, element) {
if (element) element.classList.add('active'); if (element) element.classList.add('active');
if(tabId === 'cli-tab' && fitAddon) setTimeout(() => fitAddon.fit(), 50); if(tabId === 'cli-tab' && fitAddon) setTimeout(() => fitAddon.fit(), 50);
// 💡 已經移除自動觸發 switchConfigTask(),避免切換頁籤時覆蓋使用者資料
} }
// 💡 查詢頁籤專用的切換邏輯
function switchQueryTask() { function switchQueryTask() {
// 只隱藏 query-tab 裡面的表單
document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active')); document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active'));
const selectedTaskId = document.getElementById('queryTask').value; const selectedTaskId = document.getElementById('queryTask').value;
const targetForm = document.getElementById(selectedTaskId); const targetForm = document.getElementById(selectedTaskId);
if (targetForm) targetForm.classList.add('active'); if (targetForm) targetForm.classList.add('active');
} }
// 💡 配置頁籤專用的切換邏輯 (現在只負責切換表單顯示,不自動抓資料)
function switchConfigTask() { function switchConfigTask() {
// 只隱藏 config-tab 裡面的表單 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';
});
const selectedTaskId = document.getElementById('configTask').value; const selectedTaskId = document.getElementById('configTask').value;
const targetForm = document.getElementById(selectedTaskId); const targetForm = document.getElementById(selectedTaskId);
if (targetForm) targetForm.classList.add('active'); if (targetForm) {
targetForm.classList.add('active');
targetForm.style.display = 'block';
}
} }
// 💡 新增的函數:由「🚀 載入任務」按鈕手動觸發 // 💡 合併修復:統一處理「載入任務」的邏輯與防呆
function startConfigTask() { function startConfigTask() {
// 💡 新增防呆:雙重檢查是否已連線 // 防呆 1:檢查是否已連線
if (!isConnected) { if (!isConnected) {
alert("請先成功連線至 CMTS 設備後,再執行載入任務!"); alert("請先點擊畫面上方的「連線至 CMTS」成功後,再執行載入任務!");
return; return;
} }
// 1. 先確保對應的表單有顯示出來 // 先確保對應的表單有顯示出來
switchConfigTask(); switchConfigTask();
// 2. 根據選擇的任務,決定要向設備抓取什麼資料
const selectedTaskId = document.getElementById('configTask').value; const selectedTaskId = document.getElementById('configTask').value;
if (selectedTaskId === 'form-bonding-config') { if (selectedTaskId === 'form-bonding-config') {
// 如果使用者正在編輯到一半,給予警告提示 // 任務 AMAC Domain 配置精靈
if (currentMacDomainData && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) { if (currentMacDomainData && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) {
return; // 使用者取消,中斷執行 return;
} }
// 確定載入,重置畫面並向設備抓取 MAC Domain 清單
resetAllManagerData(); resetAllManagerData();
loadMacDomainList(); loadMacDomainList();
} else if (selectedTaskId === 'form-full-config') {
// 💡 修復:任務 B完整設備配置樹狀圖
// 當使用者點擊「載入任務」時,直接幫他觸發抓取樹狀圖的 API
fetchFullConfig();
} }
} }
// 💡 新增函數:向後端請求 MAC Domain 清單並動態生成下拉選單
async function loadMacDomainList() { async function loadMacDomainList() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return; if (!connInfo) return;
@ -78,7 +79,6 @@ async function loadMacDomainList() {
const mdSelect = document.getElementById('cfgMacDomain'); const mdSelect = document.getElementById('cfgMacDomain');
const statusSpan = document.getElementById('fetchStatus'); const statusSpan = document.getElementById('fetchStatus');
// 顯示載入中狀態
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>'; mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>';
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單..."; statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
statusSpan.style.color = "#f39c12"; statusSpan.style.color = "#f39c12";
@ -89,16 +89,13 @@ async function loadMacDomainList() {
const result = await response.json(); const result = await response.json();
if (result.status === 'success' && result.data.length > 0) { if (result.status === 'success' && result.data.length > 0) {
mdSelect.innerHTML = ''; // 清空預設選項 mdSelect.innerHTML = '';
// 將後端抓到的陣列一一塞入下拉選單
result.data.forEach(md => { result.data.forEach(md => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = md; option.value = md;
option.textContent = md; option.textContent = md;
mdSelect.appendChild(option); mdSelect.appendChild(option);
}); });
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。"; statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
statusSpan.style.color = "#27ae60"; statusSpan.style.color = "#27ae60";
} else { } else {
@ -130,12 +127,9 @@ function toggleQueryInputs(mode) {
} }
} }
// 💡 新增函數:重置所有 Manager 頁籤的暫存資料與畫面
function resetAllManagerData() { function resetAllManagerData() {
// 1. 清空記憶體中的設定資料
currentMacDomainData = null; currentMacDomainData = null;
// 2. 隱藏 MAC Domain 配置表單與 CLI 預覽區塊
const configArea = document.getElementById('macDomainConfigArea'); const configArea = document.getElementById('macDomainConfigArea');
if (configArea) configArea.style.display = 'none'; if (configArea) configArea.style.display = 'none';
@ -145,14 +139,12 @@ function resetAllManagerData() {
previewEl.style.display = 'none'; previewEl.style.display = 'none';
} }
// 3. 隱藏並禁用「正式套用設定」按鈕
const deployBtn = document.getElementById('btnDeployConfig'); const deployBtn = document.getElementById('btnDeployConfig');
if (deployBtn) { if (deployBtn) {
deployBtn.style.display = 'none'; deployBtn.style.display = 'none';
deployBtn.disabled = true; deployBtn.disabled = true;
} }
// 4. 重置 MAC Domain 下拉選單與狀態文字
const mdSelect = document.getElementById('cfgMacDomain'); const mdSelect = document.getElementById('cfgMacDomain');
if (mdSelect) mdSelect.innerHTML = '<option value="">請先點擊上方載入任務...</option>'; if (mdSelect) mdSelect.innerHTML = '<option value="">請先點擊上方載入任務...</option>';
@ -162,7 +154,6 @@ function resetAllManagerData() {
fetchStatus.style.color = "#7f8c8d"; fetchStatus.style.color = "#7f8c8d";
} }
// 5. (選擇性) 清空查詢表單的輸入框
document.getElementById('queryTargetCm').value = ''; document.getElementById('queryTargetCm').value = '';
document.getElementById('queryTargetRpd').value = ''; document.getElementById('queryTargetRpd').value = '';
} }
@ -211,7 +202,6 @@ function connectWebSocket() {
const connInfo = getGlobalConnectionInfo(); const connInfo = getGlobalConnectionInfo();
if (!connInfo) return; if (!connInfo) return;
// 💡 關鍵修改 1在發起新連線前強制清空舊設備的所有殘留資料
resetAllManagerData(); resetAllManagerData();
term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`); term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`);
@ -221,12 +211,12 @@ function connectWebSocket() {
ws = new WebSocket(wsUrl); ws = new WebSocket(wsUrl);
ws.onopen = () => { ws.onopen = () => {
isConnected = true; // 💡 標記為已連線 isConnected = true;
const btnLoadTask = document.getElementById('btn-load-task'); const btnLoadTask = document.getElementById('btn-load-task');
if (btnLoadTask) { if (btnLoadTask) {
btnLoadTask.disabled = false; // 解鎖按鈕功能 btnLoadTask.disabled = false;
btnLoadTask.style.backgroundColor = '#c0392b'; // 恢復原本的紅色 btnLoadTask.style.backgroundColor = '#c0392b';
btnLoadTask.style.cursor = 'pointer'; // 恢復正常滑鼠游標 btnLoadTask.style.cursor = 'pointer';
} }
document.getElementById('wsStatus').textContent = `狀態:已連線`; document.getElementById('wsStatus').textContent = `狀態:已連線`;
@ -244,12 +234,12 @@ function connectWebSocket() {
}; };
ws.onclose = () => { ws.onclose = () => {
isConnected = false; // 💡 標記為已斷線 isConnected = false;
const btnLoadTask = document.getElementById('btn-load-task'); const btnLoadTask = document.getElementById('btn-load-task');
if (btnLoadTask) { if (btnLoadTask) {
btnLoadTask.disabled = true; // 鎖定按鈕功能 btnLoadTask.disabled = true;
btnLoadTask.style.backgroundColor = '#95a5a6'; // 變回反灰色 btnLoadTask.style.backgroundColor = '#95a5a6';
btnLoadTask.style.cursor = 'not-allowed'; // 變回禁止游標 btnLoadTask.style.cursor = 'not-allowed';
} }
document.getElementById('wsStatus').textContent = '狀態:已斷線'; document.getElementById('wsStatus').textContent = '狀態:已斷線';
@ -261,7 +251,6 @@ function connectWebSocket() {
document.getElementById('cmtsPass').disabled = false; document.getElementById('cmtsPass').disabled = false;
term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n'); term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n');
// 💡 關鍵修改 2斷線時也自動重置畫面
resetAllManagerData(); resetAllManagerData();
}; };
} }
@ -387,7 +376,6 @@ async function fetchMacDomainConfig() {
function populateFormFromData() { function populateFormFromData() {
const d = currentMacDomainData; const d = currentMacDomainData;
// 💡 正名工程:對齊後端 common_settings
document.getElementById('g_ip_prov').value = d.common_settings['ip-provisioning-mode'] || 'dual-stack'; document.getElementById('g_ip_prov').value = d.common_settings['ip-provisioning-mode'] || 'dual-stack';
document.getElementById('g_diplexer').value = d.common_settings['diplexer-band-edge'] || 'enabled'; document.getElementById('g_diplexer').value = d.common_settings['diplexer-band-edge'] || 'enabled';
document.getElementById('g_bat31').value = d.common_settings['cm-battery-mode-31-support'] || 'disabled'; document.getElementById('g_bat31').value = d.common_settings['cm-battery-mode-31-support'] || 'disabled';
@ -396,7 +384,6 @@ function populateFormFromData() {
document.getElementById('g_ds_dyn').value = d.common_settings['ds-dynamic-bonding-group'] || 'disabled'; document.getElementById('g_ds_dyn').value = d.common_settings['ds-dynamic-bonding-group'] || 'disabled';
document.getElementById('g_us_dyn').value = d.common_settings['us-dynamic-bonding-group'] || 'disabled'; document.getElementById('g_us_dyn').value = d.common_settings['us-dynamic-bonding-group'] || 'disabled';
// 💡 正名工程:對齊後端 basic_channel_sets
document.getElementById('b_admin').value = d.basic_channel_sets['admin-state'] || 'down'; document.getElementById('b_admin').value = d.basic_channel_sets['admin-state'] || 'down';
document.getElementById('b_ds_pri').value = d.basic_channel_sets['ds-primary-set'] || ''; document.getElementById('b_ds_pri').value = d.basic_channel_sets['ds-primary-set'] || '';
document.getElementById('b_ds_non_pri').value = d.basic_channel_sets['ds-non-primary-set'] || ''; document.getElementById('b_ds_non_pri').value = d.basic_channel_sets['ds-non-primary-set'] || '';
@ -404,13 +391,11 @@ function populateFormFromData() {
document.getElementById('b_ds_ofdm').value = d.basic_channel_sets['ds-ofdm-set'] || ''; document.getElementById('b_ds_ofdm').value = d.basic_channel_sets['ds-ofdm-set'] || '';
document.getElementById('b_us_ofdma').value = d.basic_channel_sets['us-ofdma-set'] || ''; document.getElementById('b_us_ofdma').value = d.basic_channel_sets['us-ofdma-set'] || '';
// 💡 正名工程:對齊後端 static_ds_bonding_groups
const dsSelect = document.getElementById('select_ds_group'); const dsSelect = document.getElementById('select_ds_group');
dsSelect.innerHTML = '<option value="_new_">[ 新增 DS Group]</option>'; dsSelect.innerHTML = '<option value="_new_">[ 新增 DS Group]</option>';
Object.keys(d.static_ds_bonding_groups).forEach(grp => dsSelect.innerHTML += `<option value="${grp}">${grp}</option>`); Object.keys(d.static_ds_bonding_groups).forEach(grp => dsSelect.innerHTML += `<option value="${grp}">${grp}</option>`);
if (Object.keys(d.static_ds_bonding_groups).length > 0) dsSelect.value = Object.keys(d.static_ds_bonding_groups)[0]; if (Object.keys(d.static_ds_bonding_groups).length > 0) dsSelect.value = Object.keys(d.static_ds_bonding_groups)[0];
// 💡 正名工程:對齊後端 static_us_bonding_groups
const usSelect = document.getElementById('select_us_group'); const usSelect = document.getElementById('select_us_group');
usSelect.innerHTML = '<option value="_new_">[ 新增 US Group]</option>'; usSelect.innerHTML = '<option value="_new_">[ 新增 US Group]</option>';
Object.keys(d.static_us_bonding_groups).forEach(grp => usSelect.innerHTML += `<option value="${grp}">${grp}</option>`); Object.keys(d.static_us_bonding_groups).forEach(grp => usSelect.innerHTML += `<option value="${grp}">${grp}</option>`);
@ -421,20 +406,12 @@ function populateFormFromData() {
loadUsGroupData(); loadUsGroupData();
} }
// 💡 新增函數:放棄修改,還原回記憶體中的原始設備設定
function revertFormChanges() { function revertFormChanges() {
// 防呆:確保目前記憶體中有原始資料可以還原
if (!currentMacDomainData) return; if (!currentMacDomainData) return;
if (!confirm("確定要放棄目前的修改,還原回設備原始的設定值嗎?")) return;
// 加上確認對話框,避免使用者誤觸
if (!confirm("確定要放棄目前的修改,還原回設備原始的設定值嗎?")) {
return;
}
// 1. 重新利用記憶體中的原始 JSON 資料填入表單
populateFormFromData(); populateFormFromData();
// 2. 隱藏已經產生的 CLI 預覽與套用按鈕 (因為狀態已經改變)
const previewEl = document.getElementById('cliPreviewContainer'); const previewEl = document.getElementById('cliPreviewContainer');
if (previewEl) { if (previewEl) {
previewEl.textContent = ''; previewEl.textContent = '';
@ -447,12 +424,10 @@ function revertFormChanges() {
deployBtn.disabled = true; deployBtn.disabled = true;
} }
// 3. 給予使用者視覺回饋
const fetchStatus = document.getElementById('fetchStatus'); const fetchStatus = document.getElementById('fetchStatus');
fetchStatus.textContent = "🔄 已還原為原始設定!"; fetchStatus.textContent = "🔄 已還原為原始設定!";
fetchStatus.style.color = "#2980b9"; fetchStatus.style.color = "#2980b9";
// 3秒後把狀態文字改回綠色的讀取成功
setTimeout(() => { setTimeout(() => {
fetchStatus.textContent = "✅ 讀取成功!"; fetchStatus.textContent = "✅ 讀取成功!";
fetchStatus.style.color = "#27ae60"; fetchStatus.style.color = "#27ae60";
@ -477,7 +452,6 @@ function loadDsGroupData() {
document.getElementById('ds_g_fdx').value = ''; document.getElementById('ds_g_fdx').value = '';
} else { } else {
newGrpInput.style.display = 'none'; newGrpInput.style.display = 'none';
// 💡 正名工程:對齊後端 static_ds_bonding_groups
const data = currentMacDomainData.static_ds_bonding_groups[grp]; const data = currentMacDomainData.static_ds_bonding_groups[grp];
document.getElementById('ds_g_admin').value = data['admin-state'] || 'down'; document.getElementById('ds_g_admin').value = data['admin-state'] || 'down';
document.getElementById('ds_g_down').value = data['down-channel-set'] || ''; document.getElementById('ds_g_down').value = data['down-channel-set'] || '';
@ -497,7 +471,6 @@ function loadUsGroupData() {
document.getElementById('us_g_fdx').value = ''; document.getElementById('us_g_fdx').value = '';
} else { } else {
newGrpInput.style.display = 'none'; newGrpInput.style.display = 'none';
// 💡 正名工程:對齊後端 static_us_bonding_groups
const data = currentMacDomainData.static_us_bonding_groups[grp]; const data = currentMacDomainData.static_us_bonding_groups[grp];
document.getElementById('us_g_admin').value = data['admin-state'] || 'down'; document.getElementById('us_g_admin').value = data['admin-state'] || 'down';
document.getElementById('us_g_us').value = data['us-channel-set'] || ''; document.getElementById('us_g_us').value = data['us-channel-set'] || '';
@ -510,7 +483,6 @@ function generateMacDomainCLI() {
const md = document.getElementById('cfgMacDomain').value.trim(); const md = document.getElementById('cfgMacDomain').value.trim();
let cli = `! --- Harmonic MAC Domain Configuration SOP ---\n`; let cli = `! --- Harmonic MAC Domain Configuration SOP ---\n`;
// --- 區塊 1: MAC Domain Base ---
cli += `! 1. [區塊] MAC Domain 全域與 Base 設定\n`; cli += `! 1. [區塊] MAC Domain 全域與 Base 設定\n`;
cli += `cable mac-domain ${md} admin-state down\ncommit\n`; cli += `cable mac-domain ${md} admin-state down\ncommit\n`;
@ -533,7 +505,6 @@ function generateMacDomainCLI() {
}); });
cli += `cable mac-domain ${md} admin-state ${document.getElementById('b_admin').value}\ncommit\n`; cli += `cable mac-domain ${md} admin-state ${document.getElementById('b_admin').value}\ncommit\n`;
// --- 區塊 2: DS Bonding Group ---
if (document.getElementById('g_ds_dyn').value === 'disabled') { if (document.getElementById('g_ds_dyn').value === 'disabled') {
const isNewDs = document.getElementById('select_ds_group').value === '_new_'; const isNewDs = document.getElementById('select_ds_group').value === '_new_';
let dsName = isNewDs ? document.getElementById('input_new_ds_group').value.trim() : document.getElementById('select_ds_group').value; let dsName = isNewDs ? document.getElementById('input_new_ds_group').value.trim() : document.getElementById('select_ds_group').value;
@ -550,7 +521,6 @@ function generateMacDomainCLI() {
} }
} }
// --- 區塊 3: US Bonding Group ---
if (document.getElementById('g_us_dyn').value === 'disabled') { if (document.getElementById('g_us_dyn').value === 'disabled') {
const isNewUs = document.getElementById('select_us_group').value === '_new_'; const isNewUs = document.getElementById('select_us_group').value === '_new_';
let usName = isNewUs ? document.getElementById('input_new_us_group').value.trim() : document.getElementById('select_us_group').value; let usName = isNewUs ? document.getElementById('input_new_us_group').value.trim() : document.getElementById('select_us_group').value;
@ -614,7 +584,173 @@ async function executeBondingConfig() {
} }
// ========================================== // ==========================================
// 6. Modal 彈出視窗控制 // 💡 6. 完整設備配置樹狀圖邏輯 (從 HTML 移出)
// ==========================================
function buildTree(data) {
if (typeof data !== 'object' || data === null) {
return `<span>${data}</span>`;
}
let html = '<div style="margin-left: 20px;">';
for (const key in data) {
const value = data[key];
if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) {
html += `
<details>
<summary style="cursor: pointer; font-weight: bold; color: #2c3e50; margin-top: 5px; padding: 2px 0;">
📁 ${key}
</summary>
${buildTree(value)}
</details>
`;
} else {
html += `
<div style="padding: 2px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px;">
📄 <b>${key}</b>: <span style="color: #16a085;">${value === null ? '' : value}</span>
</div>
`;
}
}
html += '</div>';
return html;
}
async function fetchFullConfig() {
const loadingMsg = document.getElementById('loading-message');
const treeContainer = document.getElementById('tree-container');
const connInfo = getGlobalConnectionInfo();
if (!connInfo) return;
loadingMsg.style.display = 'block';
treeContainer.innerHTML = '';
try {
const response = await fetch(`/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`);
const result = await response.json();
if (result.status === 'success') {
treeContainer.innerHTML = buildTree(result.data);
} else {
treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`;
}
} catch (error) {
treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線失敗: ${error.message}</div>`;
} finally {
loadingMsg.style.display = 'none';
}
}
// ==========================================
// 7. 系統設定 (System Settings) 邏輯
// ==========================================
let currentHiddenKeys = [];
async function openSystemSettings() {
// 切換到此頁籤時,先默默讀取目前的隱藏名單
try {
const response = await fetch('/api/v1/settings/tree-filters');
const result = await response.json();
if (result.status === 'success') {
currentHiddenKeys = result.data;
}
} catch (error) {
console.error("讀取設定失敗:", error);
}
}
async function loadRealConfigForFilters() {
const connInfo = getGlobalConnectionInfo();
if (!connInfo) {
alert("請先在畫面上方輸入設備連線資訊!");
return;
}
const loadingMsg = document.getElementById('filter-loading-msg');
const container = document.getElementById('filter-checkboxes');
loadingMsg.style.display = 'inline-block';
container.style.display = 'none';
try {
// 💡 加上 skip_filter=true確保能抓到被隱藏的節點以便取消勾選
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true`;
const response = await fetch(url);
const result = await response.json();
if (result.status === 'success') {
container.style.display = 'block';
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys);
} else {
container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`;
}
} catch (error) {
container.style.display = 'block';
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線失敗: ${error.message}</div>`;
} finally {
loadingMsg.style.display = 'none';
}
}
function buildRealFilterTree(data, parentPath, hiddenKeys) {
if (typeof data !== 'object' || data === null) return '';
let html = `<div style="margin-left: ${parentPath ? '25px' : '0'};">`;
for (const key in data) {
const value = data[key];
// 💡 組合絕對路徑,例如 "cable.mac-domain"
const currentPath = parentPath ? `${parentPath}.${key}` : key;
const isChecked = hiddenKeys.includes(currentPath) ? "checked" : "";
if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) {
html += `
<details>
<summary style="cursor: pointer; font-weight: bold; color: #2c3e50; margin-top: 5px; padding: 4px 0; display: flex; align-items: center; list-style: none;">
<input type="checkbox" value="${currentPath}" class="filter-checkbox" style="margin-right: 8px; cursor: pointer; width: 16px; height: 16px; accent-color: #e74c3c;" ${isChecked}>
<span style="margin-right: 5px;">📁</span> ${key}
</summary>
${buildRealFilterTree(value, currentPath, hiddenKeys)}
</details>
`;
} else {
html += `
<div style="padding: 4px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px; display: flex; align-items: center;">
<input type="checkbox" value="${currentPath}" class="filter-checkbox" style="margin-right: 8px; cursor: pointer; width: 16px; height: 16px; accent-color: #e74c3c;" ${isChecked}>
<span style="margin-right: 5px;">📄</span> <b>${key}</b>: <span style="color: #16a085; margin-left: 5px;">${value === null ? '' : value}</span>
</div>
`;
}
}
html += '</div>';
return html;
}
async function saveSystemSettings() {
const checkboxes = document.querySelectorAll('.filter-checkbox:checked');
const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value);
try {
const response = await fetch('/api/v1/settings/tree-filters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden_keys: selectedHiddenKeys })
});
const result = await response.json();
if (result.status === 'success') {
const statusText = document.getElementById('settings-status');
statusText.style.display = 'inline-block';
currentHiddenKeys = selectedHiddenKeys; // 更新本地狀態
setTimeout(() => { statusText.style.display = 'none'; }, 3000);
}
} catch (error) {
console.error("儲存設定失敗:", error);
alert("儲存設定時發生錯誤!");
}
}
// ==========================================
// 8. Modal 彈出視窗控制
// ========================================== // ==========================================
function openModal(targetInfo) { function openModal(targetInfo) {
document.getElementById('outputModal').classList.add('active'); document.getElementById('outputModal').classList.add('active');
@ -626,4 +762,4 @@ function closeModal(event) {
if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return; if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return;
document.getElementById('outputModal').classList.remove('active'); document.getElementById('outputModal').classList.remove('active');
document.body.style.overflow = ''; document.body.style.overflow = '';
} }

19
system_settings.json Normal file
View File

@ -0,0 +1,19 @@
{
"hidden_keys": [
"alias",
"ssh",
"hostname",
"logging",
"no",
"ipdr",
"snmp-server",
"aaa",
"radius-server",
"privilege",
"cli",
"packetcable",
"system",
"network",
"clock"
]
}