開發樹狀配置與系統設定頁面
This commit is contained in:
parent
f931c0f397
commit
07859300a6
77
index.html
77
index.html
|
|
@ -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-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
|
||||
<!-- 引入我們剛剛獨立出來的 CSS 樣式表 -->
|
||||
<!-- 引入獨立的 CSS 樣式表 -->
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -32,6 +32,8 @@
|
|||
<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('config-tab', this)">⚙️ CMTS 設備配置</button>
|
||||
<!-- 💡 新增:系統設定頁籤 -->
|
||||
<button class="tab-btn" onclick="switchTab('settings-tab', this); openSystemSettings();">⚙️ 系統設定</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: True SSH Terminal -->
|
||||
|
|
@ -148,8 +150,8 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- 💡 補上這個:關閉 query-tab 裡的 manager-container -->
|
||||
</div> <!-- 💡 補上這個:關閉 query-tab 本身 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: CMTS 設備配置 -->
|
||||
<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;">
|
||||
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">🛠️ 選擇配置任務:</label>
|
||||
<!-- 💡 移除了 onchange,改由右側按鈕手動觸發 -->
|
||||
<select id="configTask" 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-bonding-config">⚠️ MAC Domain 狀態感知配置精靈</option>
|
||||
<!-- 未來可以在這裡新增 modulation-profile, rpd 等選項 -->
|
||||
<option value="form-full-config">🌳 完整設備配置樹狀圖</option>
|
||||
</select>
|
||||
<!-- 💡 新增的手動觸發按鈕 -->
|
||||
<button id="btn-load-task" onclick="startConfigTask()" style="background-color: #95a5a6; color: white; margin-left: 10px; cursor: not-allowed;" disabled>🚀 載入任務</button>
|
||||
</div>
|
||||
|
||||
<!-- 💡 表單 3:MAC Domain 配置精靈 -->
|
||||
<div id="form-bonding-config" class="task-form">
|
||||
<!-- 表單 3:MAC Domain 配置精靈 -->
|
||||
<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>
|
||||
|
||||
<div class="control-group" style="background: #fdf2e9; padding: 15px; border-radius: 6px; border: 1px solid #fadbd8;">
|
||||
|
|
@ -180,7 +180,6 @@
|
|||
</div>
|
||||
|
||||
<div id="macDomainConfigArea" style="display: none; margin-top: 20px;">
|
||||
|
||||
<!-- Common Settings -->
|
||||
<h4 style="color: #2980b9; border-left: 4px solid #2980b9; padding-left: 8px;">Common Settings</h4>
|
||||
<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 class="form-actions">
|
||||
<!-- 💡 新增的復原按鈕 (使用灰色系,避免與主要動作搶視覺焦點) -->
|
||||
<button id="btnRevert" onclick="revertFormChanges()" style="background-color: #7f8c8d; color: white; margin-right: 15px;">
|
||||
🔄 復原重填
|
||||
</button>
|
||||
|
|
@ -250,6 +248,60 @@
|
|||
</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>
|
||||
|
||||
<!-- 💡 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>
|
||||
|
||||
|
|
@ -269,7 +321,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 引入我們剛剛獨立出來的 JavaScript 邏輯 -->
|
||||
<!-- 引入獨立的 JavaScript 邏輯 -->
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
# --- routers/config.py ---
|
||||
import re
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
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()
|
||||
|
||||
|
|
@ -35,3 +38,121 @@ async def execute_cmts_config(req: ConfigRequest):
|
|||
return {"status": "success", "data": output}
|
||||
except Exception as 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
112
shared.py
|
|
@ -1,5 +1,7 @@
|
|||
# --- shared.py ---
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
DEBUG_MODE = False
|
||||
|
||||
|
|
@ -18,5 +20,115 @@ CMTS_DEVICE = {
|
|||
'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()
|
||||
|
|
|
|||
258
static/app.js
258
static/app.js
|
|
@ -5,7 +5,7 @@
|
|||
// ==========================================
|
||||
let term, fitAddon, ws;
|
||||
let currentMacDomainData = null;
|
||||
let isConnected = false; // 💡 新增:用來追蹤目前的連線狀態
|
||||
let isConnected = false;
|
||||
|
||||
window.onload = initTerminal;
|
||||
window.onresize = () => { if (fitAddon) fitAddon.fit(); };
|
||||
|
|
@ -22,55 +22,56 @@ function switchTab(tabId, element) {
|
|||
if (element) element.classList.add('active');
|
||||
|
||||
if(tabId === 'cli-tab' && fitAddon) setTimeout(() => fitAddon.fit(), 50);
|
||||
|
||||
// 💡 已經移除自動觸發 switchConfigTask(),避免切換頁籤時覆蓋使用者資料
|
||||
}
|
||||
|
||||
// 💡 查詢頁籤專用的切換邏輯
|
||||
function switchQueryTask() {
|
||||
// 只隱藏 query-tab 裡面的表單
|
||||
document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active'));
|
||||
const selectedTaskId = document.getElementById('queryTask').value;
|
||||
const targetForm = document.getElementById(selectedTaskId);
|
||||
if (targetForm) targetForm.classList.add('active');
|
||||
}
|
||||
|
||||
// 💡 配置頁籤專用的切換邏輯 (現在只負責切換表單顯示,不自動抓資料)
|
||||
function switchConfigTask() {
|
||||
// 只隱藏 config-tab 裡面的表單
|
||||
document.querySelectorAll('#config-tab .task-form').forEach(form => form.classList.remove('active'));
|
||||
document.querySelectorAll('#config-tab .task-form').forEach(form => {
|
||||
form.classList.remove('active');
|
||||
form.style.display = 'none';
|
||||
});
|
||||
const selectedTaskId = document.getElementById('configTask').value;
|
||||
const targetForm = document.getElementById(selectedTaskId);
|
||||
if (targetForm) targetForm.classList.add('active');
|
||||
if (targetForm) {
|
||||
targetForm.classList.add('active');
|
||||
targetForm.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 💡 新增的函數:由「🚀 載入任務」按鈕手動觸發
|
||||
// 💡 合併修復:統一處理「載入任務」的邏輯與防呆
|
||||
function startConfigTask() {
|
||||
// 💡 新增防呆:雙重檢查是否已連線
|
||||
// 防呆 1:檢查是否已連線
|
||||
if (!isConnected) {
|
||||
alert("請先成功連線至 CMTS 設備後,再執行載入任務!");
|
||||
alert("請先點擊畫面上方的「連線至 CMTS」成功後,再執行載入任務!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 先確保對應的表單有顯示出來
|
||||
// 先確保對應的表單有顯示出來
|
||||
switchConfigTask();
|
||||
|
||||
// 2. 根據選擇的任務,決定要向設備抓取什麼資料
|
||||
const selectedTaskId = document.getElementById('configTask').value;
|
||||
|
||||
if (selectedTaskId === 'form-bonding-config') {
|
||||
// 如果使用者正在編輯到一半,給予警告提示
|
||||
// 任務 A:MAC Domain 配置精靈
|
||||
if (currentMacDomainData && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) {
|
||||
return; // 使用者取消,中斷執行
|
||||
return;
|
||||
}
|
||||
|
||||
// 確定載入,重置畫面並向設備抓取 MAC Domain 清單
|
||||
resetAllManagerData();
|
||||
loadMacDomainList();
|
||||
|
||||
} else if (selectedTaskId === 'form-full-config') {
|
||||
// 💡 修復:任務 B:完整設備配置樹狀圖
|
||||
// 當使用者點擊「載入任務」時,直接幫他觸發抓取樹狀圖的 API
|
||||
fetchFullConfig();
|
||||
}
|
||||
}
|
||||
|
||||
// 💡 新增函數:向後端請求 MAC Domain 清單並動態生成下拉選單
|
||||
async function loadMacDomainList() {
|
||||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo) return;
|
||||
|
|
@ -78,7 +79,6 @@ async function loadMacDomainList() {
|
|||
const mdSelect = document.getElementById('cfgMacDomain');
|
||||
const statusSpan = document.getElementById('fetchStatus');
|
||||
|
||||
// 顯示載入中狀態
|
||||
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>';
|
||||
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
|
||||
statusSpan.style.color = "#f39c12";
|
||||
|
|
@ -89,16 +89,13 @@ async function loadMacDomainList() {
|
|||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success' && result.data.length > 0) {
|
||||
mdSelect.innerHTML = ''; // 清空預設選項
|
||||
|
||||
// 將後端抓到的陣列一一塞入下拉選單
|
||||
mdSelect.innerHTML = '';
|
||||
result.data.forEach(md => {
|
||||
const option = document.createElement('option');
|
||||
option.value = md;
|
||||
option.textContent = md;
|
||||
mdSelect.appendChild(option);
|
||||
});
|
||||
|
||||
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
|
||||
statusSpan.style.color = "#27ae60";
|
||||
} else {
|
||||
|
|
@ -130,12 +127,9 @@ function toggleQueryInputs(mode) {
|
|||
}
|
||||
}
|
||||
|
||||
// 💡 新增函數:重置所有 Manager 頁籤的暫存資料與畫面
|
||||
function resetAllManagerData() {
|
||||
// 1. 清空記憶體中的設定資料
|
||||
currentMacDomainData = null;
|
||||
|
||||
// 2. 隱藏 MAC Domain 配置表單與 CLI 預覽區塊
|
||||
const configArea = document.getElementById('macDomainConfigArea');
|
||||
if (configArea) configArea.style.display = 'none';
|
||||
|
||||
|
|
@ -145,14 +139,12 @@ function resetAllManagerData() {
|
|||
previewEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// 3. 隱藏並禁用「正式套用設定」按鈕
|
||||
const deployBtn = document.getElementById('btnDeployConfig');
|
||||
if (deployBtn) {
|
||||
deployBtn.style.display = 'none';
|
||||
deployBtn.disabled = true;
|
||||
}
|
||||
|
||||
// 4. 重置 MAC Domain 下拉選單與狀態文字
|
||||
const mdSelect = document.getElementById('cfgMacDomain');
|
||||
if (mdSelect) mdSelect.innerHTML = '<option value="">請先點擊上方載入任務...</option>';
|
||||
|
||||
|
|
@ -162,7 +154,6 @@ function resetAllManagerData() {
|
|||
fetchStatus.style.color = "#7f8c8d";
|
||||
}
|
||||
|
||||
// 5. (選擇性) 清空查詢表單的輸入框
|
||||
document.getElementById('queryTargetCm').value = '';
|
||||
document.getElementById('queryTargetRpd').value = '';
|
||||
}
|
||||
|
|
@ -211,7 +202,6 @@ function connectWebSocket() {
|
|||
const connInfo = getGlobalConnectionInfo();
|
||||
if (!connInfo) return;
|
||||
|
||||
// 💡 關鍵修改 1:在發起新連線前,強制清空舊設備的所有殘留資料
|
||||
resetAllManagerData();
|
||||
|
||||
term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`);
|
||||
|
|
@ -221,12 +211,12 @@ function connectWebSocket() {
|
|||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
isConnected = true; // 💡 標記為已連線
|
||||
isConnected = true;
|
||||
const btnLoadTask = document.getElementById('btn-load-task');
|
||||
if (btnLoadTask) {
|
||||
btnLoadTask.disabled = false; // 解鎖按鈕功能
|
||||
btnLoadTask.style.backgroundColor = '#c0392b'; // 恢復原本的紅色
|
||||
btnLoadTask.style.cursor = 'pointer'; // 恢復正常滑鼠游標
|
||||
btnLoadTask.disabled = false;
|
||||
btnLoadTask.style.backgroundColor = '#c0392b';
|
||||
btnLoadTask.style.cursor = 'pointer';
|
||||
}
|
||||
|
||||
document.getElementById('wsStatus').textContent = `狀態:已連線`;
|
||||
|
|
@ -244,12 +234,12 @@ function connectWebSocket() {
|
|||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
isConnected = false; // 💡 標記為已斷線
|
||||
isConnected = false;
|
||||
const btnLoadTask = document.getElementById('btn-load-task');
|
||||
if (btnLoadTask) {
|
||||
btnLoadTask.disabled = true; // 鎖定按鈕功能
|
||||
btnLoadTask.style.backgroundColor = '#95a5a6'; // 變回反灰色
|
||||
btnLoadTask.style.cursor = 'not-allowed'; // 變回禁止游標
|
||||
btnLoadTask.disabled = true;
|
||||
btnLoadTask.style.backgroundColor = '#95a5a6';
|
||||
btnLoadTask.style.cursor = 'not-allowed';
|
||||
}
|
||||
|
||||
document.getElementById('wsStatus').textContent = '狀態:已斷線';
|
||||
|
|
@ -261,7 +251,6 @@ function connectWebSocket() {
|
|||
document.getElementById('cmtsPass').disabled = false;
|
||||
term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n');
|
||||
|
||||
// 💡 關鍵修改 2:斷線時也自動重置畫面
|
||||
resetAllManagerData();
|
||||
};
|
||||
}
|
||||
|
|
@ -387,7 +376,6 @@ async function fetchMacDomainConfig() {
|
|||
function populateFormFromData() {
|
||||
const d = currentMacDomainData;
|
||||
|
||||
// 💡 正名工程:對齊後端 common_settings
|
||||
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_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_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_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'] || '';
|
||||
|
|
@ -404,13 +391,11 @@ function populateFormFromData() {
|
|||
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'] || '';
|
||||
|
||||
// 💡 正名工程:對齊後端 static_ds_bonding_groups
|
||||
const dsSelect = document.getElementById('select_ds_group');
|
||||
dsSelect.innerHTML = '<option value="_new_">[➕ 新增 DS Group]</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];
|
||||
|
||||
// 💡 正名工程:對齊後端 static_us_bonding_groups
|
||||
const usSelect = document.getElementById('select_us_group');
|
||||
usSelect.innerHTML = '<option value="_new_">[➕ 新增 US Group]</option>';
|
||||
Object.keys(d.static_us_bonding_groups).forEach(grp => usSelect.innerHTML += `<option value="${grp}">${grp}</option>`);
|
||||
|
|
@ -421,20 +406,12 @@ function populateFormFromData() {
|
|||
loadUsGroupData();
|
||||
}
|
||||
|
||||
// 💡 新增函數:放棄修改,還原回記憶體中的原始設備設定
|
||||
function revertFormChanges() {
|
||||
// 防呆:確保目前記憶體中有原始資料可以還原
|
||||
if (!currentMacDomainData) return;
|
||||
if (!confirm("確定要放棄目前的修改,還原回設備原始的設定值嗎?")) return;
|
||||
|
||||
// 加上確認對話框,避免使用者誤觸
|
||||
if (!confirm("確定要放棄目前的修改,還原回設備原始的設定值嗎?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 重新利用記憶體中的原始 JSON 資料填入表單
|
||||
populateFormFromData();
|
||||
|
||||
// 2. 隱藏已經產生的 CLI 預覽與套用按鈕 (因為狀態已經改變)
|
||||
const previewEl = document.getElementById('cliPreviewContainer');
|
||||
if (previewEl) {
|
||||
previewEl.textContent = '';
|
||||
|
|
@ -447,12 +424,10 @@ function revertFormChanges() {
|
|||
deployBtn.disabled = true;
|
||||
}
|
||||
|
||||
// 3. 給予使用者視覺回饋
|
||||
const fetchStatus = document.getElementById('fetchStatus');
|
||||
fetchStatus.textContent = "🔄 已還原為原始設定!";
|
||||
fetchStatus.style.color = "#2980b9";
|
||||
|
||||
// 3秒後把狀態文字改回綠色的讀取成功
|
||||
setTimeout(() => {
|
||||
fetchStatus.textContent = "✅ 讀取成功!";
|
||||
fetchStatus.style.color = "#27ae60";
|
||||
|
|
@ -477,7 +452,6 @@ function loadDsGroupData() {
|
|||
document.getElementById('ds_g_fdx').value = '';
|
||||
} else {
|
||||
newGrpInput.style.display = 'none';
|
||||
// 💡 正名工程:對齊後端 static_ds_bonding_groups
|
||||
const data = currentMacDomainData.static_ds_bonding_groups[grp];
|
||||
document.getElementById('ds_g_admin').value = data['admin-state'] || 'down';
|
||||
document.getElementById('ds_g_down').value = data['down-channel-set'] || '';
|
||||
|
|
@ -497,7 +471,6 @@ function loadUsGroupData() {
|
|||
document.getElementById('us_g_fdx').value = '';
|
||||
} else {
|
||||
newGrpInput.style.display = 'none';
|
||||
// 💡 正名工程:對齊後端 static_us_bonding_groups
|
||||
const data = currentMacDomainData.static_us_bonding_groups[grp];
|
||||
document.getElementById('us_g_admin').value = data['admin-state'] || 'down';
|
||||
document.getElementById('us_g_us').value = data['us-channel-set'] || '';
|
||||
|
|
@ -510,7 +483,6 @@ function generateMacDomainCLI() {
|
|||
const md = document.getElementById('cfgMacDomain').value.trim();
|
||||
let cli = `! --- Harmonic MAC Domain Configuration SOP ---\n`;
|
||||
|
||||
// --- 區塊 1: MAC Domain Base ---
|
||||
cli += `! 1. [區塊] MAC Domain 全域與 Base 設定\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`;
|
||||
|
||||
// --- 區塊 2: DS Bonding Group ---
|
||||
if (document.getElementById('g_ds_dyn').value === 'disabled') {
|
||||
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;
|
||||
|
|
@ -550,7 +521,6 @@ function generateMacDomainCLI() {
|
|||
}
|
||||
}
|
||||
|
||||
// --- 區塊 3: US Bonding Group ---
|
||||
if (document.getElementById('g_us_dyn').value === 'disabled') {
|
||||
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;
|
||||
|
|
@ -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) {
|
||||
document.getElementById('outputModal').classList.add('active');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"hidden_keys": [
|
||||
"alias",
|
||||
"ssh",
|
||||
"hostname",
|
||||
"logging",
|
||||
"no",
|
||||
"ipdr",
|
||||
"snmp-server",
|
||||
"aaa",
|
||||
"radius-server",
|
||||
"privilege",
|
||||
"cli",
|
||||
"packetcable",
|
||||
"system",
|
||||
"network",
|
||||
"clock"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue