fix(backup): 設備終端機分頁機制(Pagination)問題處理
- 根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁 refactor(backup): 將 CMTS 的 CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法) - 階段 0: 預處理 (清理雜訊,移除危險的自動黏合) - 階段 2: 邏輯群組化與字典合併 (保留嚴格型別防護) refactor(ui): 獲取整台 CMTS 的完整配置 (串流進度回報版) - 前端串流接收器 (Streaming UI) fix(ui): 修復 SSH 終端機無底洞溢出與頁籤底部左右捲軸錯位問題 - 於 .xterm 新增 height: 100% 與 box-sizing: border-box,確保終端機畫布完美貼合黑底外框,並將捲動權限成功交還給終端機內部。
This commit is contained in:
parent
cf4b6f2e3b
commit
fb1c091be9
|
|
@ -363,15 +363,15 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
|
||||||
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
|
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
|
||||||
await read_until_quiet(timeout=1.0)
|
await read_until_quiet(timeout=1.0)
|
||||||
|
|
||||||
# 關鍵修改:根據 config_type 切換模式與指令
|
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
|
||||||
if config_type == "full":
|
if config_type == "full":
|
||||||
# 1. 先進入 config 模式
|
# 1. 先進入 config 模式
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)#
|
await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)#
|
||||||
|
|
||||||
# 2. 下達 full-configuration 指令
|
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
|
||||||
cmd = "show full-configuration"
|
cmd = "show full-configuration | nomore"
|
||||||
process.stdin.write(f"{cmd}\n")
|
process.stdin.write(f"{cmd}\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
raw_output = await read_until_quiet(timeout=3.0)
|
raw_output = await read_until_quiet(timeout=3.0)
|
||||||
|
|
@ -381,8 +381,8 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# running-config 在一般模式即可下達
|
# running-config 在一般模式即可下達 (🌟 加上 | nomore)
|
||||||
cmd = "show running-config"
|
cmd = "show running-config | nomore"
|
||||||
process.stdin.write(f"{cmd}\n")
|
process.stdin.write(f"{cmd}\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
raw_output = await read_until_quiet(timeout=3.0)
|
raw_output = await read_until_quiet(timeout=3.0)
|
||||||
|
|
|
||||||
80
index.html
80
index.html
|
|
@ -253,81 +253,67 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 表單 4:設備配置樹狀圖 (共用容器) -->
|
<!-- 表單 4:設備配置樹狀圖 (共用容器) -->
|
||||||
<!-- 🌟 修正 1:將 ID 改為 form-tree-config -->
|
|
||||||
<div id="form-tree-config" class="task-form" style="display: none;">
|
<div id="form-tree-config" class="task-form" style="display: none;">
|
||||||
<!-- 🌟 統一規則:A 方案左右分離佈局 (已補上 width: 100% 確保撐開) -->
|
<!-- 頂部:標題與按鈕區塊 (維持原樣) -->
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 15px; width: 100%;">
|
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 15px; width: 100%;">
|
||||||
|
|
||||||
<!-- 左側:標題與狀態提示 -->
|
|
||||||
<div style="display: flex; align-items: center; gap: 15px;">
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
<h3 style="margin: 0; font-size: 16px; color: #2c3e50;">
|
<h3 style="margin: 0; font-size: 16px; color: #2c3e50;">
|
||||||
<span id="tree-form-title">🌳 設備配置樹狀圖</span>
|
<span id="tree-form-title">🌲 設備配置樹狀圖</span>
|
||||||
</h3>
|
</h3>
|
||||||
<span id="scan-status" style="color: #7f8c8d; font-size: 14px; font-weight: normal;"></span>
|
<span id="scan-status" style="color: #7f8c8d; font-size: 14px; font-weight: normal;"></span>
|
||||||
<span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; font-weight: normal;">
|
<span id="loading-message" style="display: none; color: #f39c12; font-size: 14px; font-weight: normal;">
|
||||||
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
|
⏳ 正在從設備抓取完整配置,可能需要 30~60 秒,請耐心稍候...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右側:馬卡龍色系按鈕群組 (God-mode 觸發時才會顯示) -->
|
|
||||||
<div style="display: flex; gap: 10px;">
|
<div style="display: flex; gap: 10px;">
|
||||||
<button id="btn-scan-visible" onclick="scanVisibleMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">
|
<button id="btn-scan-visible" onclick="scanVisibleMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描局部缺失</button>
|
||||||
掃描局部缺失
|
<button id="btn-clear-visible" onclick="clearVisibleCache()" class="btn-modern btn-clear" style="display: none;">清除局部快取</button>
|
||||||
</button>
|
<button id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">掃描全域缺失</button>
|
||||||
<button id="btn-clear-visible" onclick="clearVisibleCache()" class="btn-modern btn-clear" style="display: none;">
|
<button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none;">清除全域快取</button>
|
||||||
清除局部快取
|
|
||||||
</button>
|
|
||||||
<button id="btn-scan-global" onclick="scanGlobalMissingOptions()" class="btn-modern btn-scan" disabled style="display: none;">
|
|
||||||
掃描全域缺失
|
|
||||||
</button>
|
|
||||||
<button id="btn-clear-global" onclick="clearGlobalCache()" class="btn-modern btn-clear" style="display: none;">
|
|
||||||
清除全域快取
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 🌟 左右分屏容器 -->
|
<!-- 🌟 左右分屏容器 (關鍵修改:align-items: stretch 讓左右強制等高) -->
|
||||||
<div id="split-container" style="display: flex; align-items: flex-start; width: 100%; position: relative;">
|
<div id="split-container" style="display: flex; align-items: stretch; width: 100%; position: relative; gap: 10px;">
|
||||||
|
|
||||||
<!-- 左側:樹狀圖 (雙容器實體隔離) -->
|
<!-- 左側:樹狀圖 (保留 max-height: 600px,超過才出捲軸) -->
|
||||||
<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;">
|
<div id="tree-container-running" class="tree-view-instance" style="flex: 1; min-width: 400px; background-color: #ffffff; padding: 15px; border-radius: 5px; border: 1px solid #e0e0e0; min-height: 100px; max-height: 600px; box-sizing: border-box; overflow-x: auto; overflow-y: auto;">
|
||||||
<span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
|
<span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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-full" class="tree-view-instance" style="display: none; flex: 1; min-width: 400px; 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;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 🖱️ 拖曳分隔線 (預設隱藏) -->
|
<!-- 🖱️ 拖曳分隔線 (拔除寫死的 height: 400px) -->
|
||||||
<div id="drag-resizer" style="display: none; width: 12px; cursor: col-resize; margin: 0 5px; flex-shrink: 0; align-items: center; justify-content: center; position: sticky; top: 20px; height: 400px; z-index: 10;" title="左右拖曳調整寬度">
|
<div id="drag-resizer" style="display: none; width: 12px; cursor: col-resize; flex-shrink: 0; align-items: center; justify-content: center; z-index: 10;" title="左右拖曳調整寬度">
|
||||||
<div style="width: 4px; height: 40px; background-color: #bdc3c7; border-radius: 2px;"></div>
|
<div style="width: 4px; height: 40px; background-color: #bdc3c7; border-radius: 2px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右側:CLI 預覽與執行結果 (預設隱藏,顯示時佔 50%) -->
|
<!-- 右側:CLI 預覽與執行結果外框 -->
|
||||||
<div id="side-cli-preview" style="width: 50%; position: sticky; top: 20px; background: #2c3e50; padding: 12px 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: none; border: 1px solid #34495e; box-sizing: border-box; flex-shrink: 0;">
|
<div id="side-cli-preview" style="flex: 1; min-width: 300px; max-width: 50%; display: none; box-sizing: border-box;">
|
||||||
|
|
||||||
<!-- 頂部標題列 -->
|
<!-- 🌟 內部黑底容器 (使用 flex column 與 height: 100% 完美填滿外框) -->
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
<div style="display: flex; flex-direction: column; height: 100%; background: #1e1e1e; padding: 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); border: 1px solid #34495e; box-sizing: border-box;">
|
||||||
<h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
|
|
||||||
|
|
||||||
<div style="display: flex; align-items: center; gap: 10px;">
|
<!-- 頂部標題列 -->
|
||||||
<!-- 預覽模式按鈕 -->
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 1px solid #34495e; padding-bottom: 10px; flex-shrink: 0;">
|
||||||
<button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button>
|
<h4 id="side-pane-title" style="color: #f1c40f; margin: 0; font-size: 15px;">⚠️ 即將寫入的指令</h4>
|
||||||
<button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<button id="btn-side-cancel" onclick="hideSideCLI()" class="btn-modern btn-disconnect" style="padding: 5px 12px; font-size: 13px;">取消</button>
|
||||||
<!-- 執行結果模式按鈕 (預設隱藏) -->
|
<button id="btn-side-confirm" onclick="executeSideCLI()" class="btn-modern btn-save" style="padding: 5px 12px; font-size: 13px;">🚀 確認寫入</button>
|
||||||
<button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
|
<button id="btn-side-close" onclick="hideSideCLI()" class="btn-modern btn-load" style="padding: 5px 12px; font-size: 13px; display: none;">✅ 完成並關閉</button>
|
||||||
|
<div style="width: 1px; height: 20px; background-color: #7f8c8d; margin: 0 5px;"></div>
|
||||||
<div style="width: 1px; height: 20px; background-color: #7f8c8d; margin: 0 5px;"></div>
|
<span onclick="hideSideCLI()" style="color: #bdc3c7; font-size: 26px; cursor: pointer; line-height: 1;" title="關閉面板">×</span>
|
||||||
<span onclick="hideSideCLI()" style="color: #bdc3c7; font-size: 26px; cursor: pointer; line-height: 1;" title="關閉面板">×</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 模式一:指令預覽框 (🌟 拔除 height: 400px,改用 flex: 1 自動填滿) -->
|
||||||
|
<textarea id="side-cli-textarea" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; outline: none; box-sizing: border-box; white-space: pre; overflow-x: auto; overflow-y: auto; margin: 0; display: block; resize: none; min-height: 150px;"></textarea>
|
||||||
|
|
||||||
|
<!-- 模式二:執行結果框 (🌟 拔除 height: 400px,改用 flex: 1 自動填滿) -->
|
||||||
|
<div id="side-execution-result" style="flex: 1; width: 100%; background: transparent; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 0; border: none; box-sizing: border-box; overflow-x: auto; overflow-y: auto; margin: 0; display: none; white-space: pre; min-height: 150px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模式一:指令預覽框 (可編輯) -->
|
|
||||||
<textarea id="side-cli-textarea" style="width: 100%; height: 400px; background: #1e1e1e; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 12px 15px; border: 1px solid #7f8c8d; border-radius: 4px; box-sizing: border-box; white-space: pre; overflow-wrap: normal; overflow-x: scroll; margin-bottom: 0; display: block;"></textarea>
|
|
||||||
|
|
||||||
<!-- 模式二:執行結果框 (唯讀,升級字體與行高) -->
|
|
||||||
<div id="side-execution-result" style="width: 100%; height: 400px; background: #1e1e1e; color: #ecf0f1; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; line-height: 1.5; padding: 12px 15px; border: 1px solid #7f8c8d; border-radius: 4px; box-sizing: border-box; overflow-y: auto; margin-bottom: 0; display: none; white-space: pre-wrap; word-wrap: break-word;"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,77 @@ async def get_full_config(host: str, username: str, password: str = "", skip_fil
|
||||||
return {"status": "success", "data": perfect_tree}
|
return {"status": "success", "data": perfect_tree}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
@router.get("/cmts-full-config/stream")
|
||||||
|
async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
|
||||||
|
"""獲取整台 CMTS 的完整配置 (串流進度回報版)"""
|
||||||
|
|
||||||
|
async def config_streamer():
|
||||||
|
try:
|
||||||
|
# 1. 廣播:正在連線
|
||||||
|
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n"
|
||||||
|
await asyncio.sleep(0.1) # 讓前端有時間渲染
|
||||||
|
|
||||||
|
device = CMTS_DEVICE.copy()
|
||||||
|
device.update({'host': host, 'username': username, 'password': password})
|
||||||
|
|
||||||
|
# 使用 asyncio.to_thread 將阻塞的 netmiko 連線放到背景執行,避免卡住其他非同步任務
|
||||||
|
net_connect = await asyncio.to_thread(ConnectHandler, **device)
|
||||||
|
|
||||||
|
# 2. 廣播:正在下載
|
||||||
|
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
|
||||||
|
|
||||||
|
if config_type == "full":
|
||||||
|
await asyncio.to_thread(net_connect.send_command_timing, "config")
|
||||||
|
command = "show full-configuration | nomore"
|
||||||
|
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
|
||||||
|
await asyncio.to_thread(net_connect.send_command_timing, "exit")
|
||||||
|
else:
|
||||||
|
command = "show running-config | nomore"
|
||||||
|
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
|
||||||
|
|
||||||
|
await asyncio.to_thread(net_connect.disconnect)
|
||||||
|
|
||||||
|
# 3. 廣播:正在解析
|
||||||
|
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 🌟 深度過濾攔截器 (維持原樣)
|
||||||
|
# ==========================================
|
||||||
|
if not skip_filter:
|
||||||
|
yield json.dumps({"status": "progress", "message": "🔍 正在套用系統過濾器規則..."}) + "\n"
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
hidden_keys = await load_tree_filters(config_type)
|
||||||
|
|
||||||
|
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)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# 4. 廣播:完成並傳送最終資料
|
||||||
|
yield json.dumps({"status": "success", "data": perfect_tree}) + "\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n"
|
||||||
|
|
||||||
|
# 回傳 NDJSON 串流格式
|
||||||
|
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 🌟 系統設定 API (System Settings)
|
# 🌟 系統設定 API (System Settings)
|
||||||
|
|
|
||||||
60
shared.py
60
shared.py
|
|
@ -28,44 +28,28 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
將 CMTS 的 CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法)
|
將 CMTS 的 CLI 配置文字轉換為完美的邏輯巢狀 JSON (兩階段解析法)
|
||||||
"""
|
"""
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 🌟 階段 0:預處理 (修復終端機 200 字元自動折行問題)
|
# 🌟 階段 0:預處理 (清理雜訊,移除危險的自動黏合)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
cli_text = cli_text.replace('\r\n', '\n').replace('\r', '\n')
|
||||||
|
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
||||||
|
|
||||||
raw_lines = cli_text.split('\n')
|
raw_lines = cli_text.split('\n')
|
||||||
fixed_lines = []
|
fixed_lines = []
|
||||||
|
|
||||||
# 定義常見的 Root 指令關鍵字,避免把正常的指令誤判為斷行
|
|
||||||
root_keywords = (
|
|
||||||
'alias', 'ssh', 'hostname', 'logging', 'cable', 'ipdr',
|
|
||||||
'snmp-server', 'aaa', 'radius-server', 'privilege', 'cli', 'packetcable',
|
|
||||||
'system', 'network', 'clock', 'no'
|
|
||||||
)
|
|
||||||
|
|
||||||
for line in raw_lines:
|
for line in raw_lines:
|
||||||
line = line.replace('\r', '')
|
line = ansi_escape.sub('', line)
|
||||||
|
line = re.sub(r'[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]', '', line)
|
||||||
|
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 💡 判斷是否為被截斷的行:
|
# 💡 乾淨俐落:直接加入陣列,不再做危險的字串長度黏合判斷
|
||||||
# 1. 前一行長度非常長 (大於 190 字元,根據您的截圖,極限是 199)
|
|
||||||
# 2. 當前行「沒有縮排」(終端機自動折行會把字元推到最左邊第 0 格)
|
|
||||||
# 3. 當前行不是註解 '!'
|
|
||||||
if fixed_lines and len(fixed_lines[-1]) > 190 and not line.startswith(' ') and not line.startswith('!'):
|
|
||||||
# 取得當前行的第一個單字
|
|
||||||
first_word = line.strip().split()[0] if line.strip() else ""
|
|
||||||
|
|
||||||
# 確保它不是一個正常的 Root 指令
|
|
||||||
if first_word not in root_keywords:
|
|
||||||
# ✅ 觸發黏合邏輯:將這行直接接在上一行後面
|
|
||||||
fixed_lines[-1] = fixed_lines[-1] + line
|
|
||||||
continue
|
|
||||||
|
|
||||||
fixed_lines.append(line)
|
fixed_lines.append(line)
|
||||||
|
|
||||||
# 統一去除右側多餘空白,準備進入階段 1
|
|
||||||
lines = [line.rstrip() for line in fixed_lines if line.strip()]
|
lines = [line.rstrip() for line in fixed_lines if line.strip()]
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 階段 1:建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
|
# 🌟 階段 1:建立實體樹 (嚴格比對縮排與 '!' 的對應關係)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
def build_raw_tree(start_idx, current_indent):
|
def build_raw_tree(start_idx, current_indent):
|
||||||
nodes = []
|
nodes = []
|
||||||
|
|
@ -75,17 +59,13 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
indent = len(line) - len(line.lstrip())
|
indent = len(line) - len(line.lstrip())
|
||||||
|
|
||||||
# 🌟 嚴格判斷 1:遇到「真實指令」縮排退回,才代表區塊結束
|
|
||||||
# (加上 stripped != '!',防止設備偶發的「0 縮排 !」導致區塊被誤殺)
|
|
||||||
if indent < current_indent and stripped != '!':
|
if indent < current_indent and stripped != '!':
|
||||||
return nodes, i
|
return nodes, i
|
||||||
|
|
||||||
# 🌟 嚴格判斷 2:處理 ! (無論縮排多少,都當純分隔符消耗掉)
|
|
||||||
if stripped == '!':
|
if stripped == '!':
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 🌟 探測是否有子節點 (強化版:往下尋找第一個「非 !」的真實指令來判斷)
|
|
||||||
has_children = False
|
has_children = False
|
||||||
next_i = i + 1
|
next_i = i + 1
|
||||||
while next_i < len(lines) and lines[next_i].strip() == '!':
|
while next_i < len(lines) and lines[next_i].strip() == '!':
|
||||||
|
|
@ -109,16 +89,14 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
raw_tree, _ = build_raw_tree(0, 0)
|
raw_tree, _ = build_raw_tree(0, 0)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 階段 2:邏輯群組化與字典合併
|
# 🌟 階段 2:邏輯群組化與字典合併 (保留嚴格型別防護)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
def deep_merge(dict1, dict2):
|
def deep_merge(dict1, dict2):
|
||||||
"""深度合併兩個字典 (解決同名資料夾與指令群組的融合)"""
|
|
||||||
for k, v in dict2.items():
|
for k, v in dict2.items():
|
||||||
if k in dict1:
|
if k in dict1:
|
||||||
if isinstance(dict1[k], dict) and isinstance(v, dict):
|
if isinstance(dict1[k], dict) and isinstance(v, dict):
|
||||||
deep_merge(dict1[k], v)
|
deep_merge(dict1[k], v)
|
||||||
else:
|
else:
|
||||||
# 發生碰撞時的安全機制
|
|
||||||
idx = 1
|
idx = 1
|
||||||
while f"{k} [{idx}]" in dict1:
|
while f"{k} [{idx}]" in dict1:
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
@ -127,7 +105,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
dict1[k] = v
|
dict1[k] = v
|
||||||
|
|
||||||
def nest_folder_key(key_string, value_dict, target_dict):
|
def nest_folder_key(key_string, value_dict, target_dict):
|
||||||
"""將 'cable mac-domain 13' 拆解為巢狀字典"""
|
|
||||||
parts = key_string.split()
|
parts = key_string.split()
|
||||||
current = target_dict
|
current = target_dict
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
|
|
@ -138,14 +115,20 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
if isinstance(current[part], dict) and isinstance(value_dict, dict):
|
if isinstance(current[part], dict) and isinstance(value_dict, dict):
|
||||||
deep_merge(current[part], value_dict)
|
deep_merge(current[part], value_dict)
|
||||||
else:
|
else:
|
||||||
current[f"{part} (資料夾)"] = value_dict
|
idx = 1
|
||||||
|
while f"{part} (衝突 {idx})" in current:
|
||||||
|
idx += 1
|
||||||
|
current[f"{part} (衝突 {idx})"] = value_dict
|
||||||
else:
|
else:
|
||||||
if part not in current or not isinstance(current[part], dict):
|
if part not in current or not isinstance(current[part], dict):
|
||||||
current[part] = {}
|
if part in current and not isinstance(current[part], dict):
|
||||||
|
old_val = current[part]
|
||||||
|
current[part] = {"[0]": old_val}
|
||||||
|
else:
|
||||||
|
current[part] = {}
|
||||||
current = current[part]
|
current = current[part]
|
||||||
|
|
||||||
def group_leaves(leaf_strings):
|
def group_leaves(leaf_strings):
|
||||||
"""將單行指令依據共同前綴進行遞迴群組化"""
|
|
||||||
grouped = {}
|
grouped = {}
|
||||||
first_word_map = defaultdict(list)
|
first_word_map = defaultdict(list)
|
||||||
|
|
||||||
|
|
@ -165,8 +148,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
group_dict = {}
|
group_dict = {}
|
||||||
if valid_remainders:
|
if valid_remainders:
|
||||||
sub_grouped = group_leaves(valid_remainders)
|
sub_grouped = group_leaves(valid_remainders)
|
||||||
|
|
||||||
# 判斷是否全為純陣列值 (例如 IP 列表)
|
|
||||||
is_all_empty_vals = all(not isinstance(v, dict) and v == "" for v in sub_grouped.values())
|
is_all_empty_vals = all(not isinstance(v, dict) and v == "" for v in sub_grouped.values())
|
||||||
|
|
||||||
if is_all_empty_vals:
|
if is_all_empty_vals:
|
||||||
|
|
@ -183,7 +164,6 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
else:
|
else:
|
||||||
group_dict[k] = v
|
group_dict[k] = v
|
||||||
|
|
||||||
# 補回完全沒有後續參數的指令
|
|
||||||
for _ in range(empty_count):
|
for _ in range(empty_count):
|
||||||
idx = 0
|
idx = 0
|
||||||
while f"[{idx}]" in group_dict:
|
while f"[{idx}]" in group_dict:
|
||||||
|
|
@ -196,12 +176,10 @@ def parse_cli_to_tree(cli_text: str) -> dict:
|
||||||
|
|
||||||
def fold_nodes(nodes):
|
def fold_nodes(nodes):
|
||||||
result = {}
|
result = {}
|
||||||
# 1. 處理資料夾 (Folders)
|
|
||||||
for node in [n for n in nodes if n["type"] == "folder"]:
|
for node in [n for n in nodes if n["type"] == "folder"]:
|
||||||
folded_children = fold_nodes(node["children"])
|
folded_children = fold_nodes(node["children"])
|
||||||
nest_folder_key(node["content"], folded_children, result)
|
nest_folder_key(node["content"], folded_children, result)
|
||||||
|
|
||||||
# 2. 處理單行指令 (Leaves)
|
|
||||||
leaves = [n["content"] for n in nodes if n["type"] == "leaf"]
|
leaves = [n["content"] for n in nodes if n["type"] == "leaf"]
|
||||||
if leaves:
|
if leaves:
|
||||||
leaf_dict = group_leaves(leaves)
|
leaf_dict = group_leaves(leaves)
|
||||||
|
|
|
||||||
166
static/app.js
166
static/app.js
|
|
@ -387,28 +387,29 @@ async function executeQuery(mode) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 載入完整設備配置並生成樹狀圖,預設為 running
|
// 載入完整設備配置並生成樹狀圖,預設為 running (平順過渡終極版 🚀)
|
||||||
async function fetchFullConfig(configType = 'running') {
|
async function fetchFullConfig(configType = 'running') {
|
||||||
const loadingMsg = document.getElementById('loading-message');
|
const loadingMsg = document.getElementById('loading-message');
|
||||||
// 🌟 動態抓取當前模式的專屬容器
|
|
||||||
const treeContainer = document.getElementById(`tree-container-${configType}`);
|
const treeContainer = document.getElementById(`tree-container-${configType}`);
|
||||||
|
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
if (!connInfo) return;
|
if (!connInfo) return;
|
||||||
|
|
||||||
if (loadingMsg) loadingMsg.style.display = 'inline-block';
|
if (loadingMsg) {
|
||||||
|
loadingMsg.style.display = 'inline-block';
|
||||||
|
loadingMsg.innerHTML = '⏳ 準備連線至設備...';
|
||||||
|
}
|
||||||
if (treeContainer) treeContainer.innerHTML = '';
|
if (treeContainer) treeContainer.innerHTML = '';
|
||||||
|
|
||||||
let needAutoScan = false; // 🌟 標記是否需要自動掃描
|
let needAutoScan = false;
|
||||||
|
let progressInterval = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// --- 1. 版本守門員 ---
|
// --- 1. 版本守門員 (維持原樣) ---
|
||||||
try {
|
try {
|
||||||
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;
|
||||||
|
|
||||||
// 🌟 修正 1:讀取選項快取時,必須加上 config_type 參數
|
|
||||||
const optRes = await fetch(`/api/v1/cmts-leaf-options?host=${encodeURIComponent(connInfo.host)}&config_type=${configType}`);
|
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;
|
||||||
|
|
@ -417,12 +418,8 @@ async function fetchFullConfig(configType = 'running') {
|
||||||
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);
|
||||||
|
|
||||||
// 🌟 修正 2:清空快取時,必須傳遞 configType 告訴後端清哪一個
|
|
||||||
await apiClearCache(connInfo.host, allKeys, configType);
|
await apiClearCache(connInfo.host, allKeys, configType);
|
||||||
|
needAutoScan = true;
|
||||||
alert("✅ 舊版快取已清空!系統載入配置後,將自動為您重新掃描選項。");
|
|
||||||
needAutoScan = true; // 🌟 觸發自動掃描旗標
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -430,53 +427,109 @@ async function fetchFullConfig(configType = 'running') {
|
||||||
console.warn("版本檢查失敗,略過", vErr);
|
console.warn("版本檢查失敗,略過", vErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. 載入完整配置 ---
|
// --- 2. 載入完整配置 (ReadableStream 串流接收) ---
|
||||||
const result = await apiGetFullConfig(connInfo.host, connInfo.user, connInfo.pass, false, configType);
|
const response = await fetch(`/api/v1/cmts-full-config/stream?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&config_type=${configType}`);
|
||||||
|
|
||||||
// 🌟 新增:將原始資料存入全域變數,供全域掃描使用
|
|
||||||
window.currentTreeData = result.data;
|
|
||||||
|
|
||||||
// 🌟 新增防呆攔截:檢查使用者是否在等待期間切換了下拉選單
|
if (!response.ok) throw new Error(`伺服器回應錯誤: ${response.status}`);
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
let buffer = "";
|
||||||
|
let finalTreeData = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop();
|
||||||
|
|
||||||
|
for (let line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line);
|
||||||
|
|
||||||
|
if (data.status === 'progress') {
|
||||||
|
if (data.message.includes('正在下載')) {
|
||||||
|
let progress = 0;
|
||||||
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
|
|
||||||
|
progressInterval = setInterval(() => {
|
||||||
|
progress += (95 - progress) * 0.06;
|
||||||
|
if (loadingMsg) {
|
||||||
|
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (${Math.round(progress)}%)...`;
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
// 🌟 魔法核心:當收到下一個狀態時,先平順收尾動畫
|
||||||
|
if (progressInterval) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
progressInterval = null;
|
||||||
|
|
||||||
|
// 1. 強制填滿 100% 並給予成功提示
|
||||||
|
if (loadingMsg) {
|
||||||
|
loadingMsg.innerHTML = `📥 正在下載 ${configType} 配置檔 (100%) - 下載完成!`;
|
||||||
|
loadingMsg.style.color = "#27ae60"; // 瞬間變綠色增加爽感
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 刻意停頓 0.6 秒,讓大腦接收視覺回饋
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 600));
|
||||||
|
|
||||||
|
// 3. 恢復原本的顏色
|
||||||
|
if (loadingMsg) loadingMsg.style.color = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停頓結束後,才顯示新的狀態 (例如:🧩 正在解析配置...)
|
||||||
|
if (loadingMsg) loadingMsg.innerHTML = data.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (data.status === 'success') {
|
||||||
|
finalTreeData = data.data;
|
||||||
|
}
|
||||||
|
else if (data.status === 'error') {
|
||||||
|
throw new Error(data.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("解析串流 JSON 失敗:", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalTreeData) throw new Error("未收到完整的配置資料,連線可能中斷。");
|
||||||
|
|
||||||
|
window.currentTreeData = finalTreeData;
|
||||||
|
|
||||||
|
// --- 3. 防呆攔截與畫面渲染 ---
|
||||||
const currentSelectedTask = document.getElementById('configTask').value;
|
const currentSelectedTask = document.getElementById('configTask').value;
|
||||||
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
|
const expectedTask = configType === 'running' ? 'form-running-config' : 'form-full-config';
|
||||||
|
|
||||||
if (currentSelectedTask !== expectedTask) {
|
if (currentSelectedTask !== expectedTask) return;
|
||||||
console.warn(`[防呆攔截] 正在載入 ${configType},但使用者已切換至 ${currentSelectedTask},捨棄本次結果以防畫面錯亂。`);
|
|
||||||
return; // 🌟 發現模式不符,直接中斷,不要把舊樹狀圖貼上去!
|
if (treeContainer) {
|
||||||
}
|
treeContainer.innerHTML = buildTree(finalTreeData, '', false, configType);
|
||||||
if (result.status === 'success') {
|
if (loadingMsg) loadingMsg.style.display = 'none';
|
||||||
if (treeContainer) {
|
|
||||||
// 🌟 傳入 configType,讓 ID 加上前綴
|
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
|
||||||
treeContainer.innerHTML = buildTree(result.data, '', false, configType);
|
if (isSomeoneScanning) {
|
||||||
|
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
|
||||||
// 🌟 修正:樹狀圖一畫完,立刻隱藏載入訊息,讓使用者感覺「秒開」
|
lockScanButton(60000);
|
||||||
if (loadingMsg) loadingMsg.style.display = 'none';
|
} else {
|
||||||
|
enableGlobalScanButton();
|
||||||
// 🌟 3. 檢查後端是否已經有人在掃描
|
if (needAutoScan) {
|
||||||
const isSomeoneScanning = await apiGetScanStatus(connInfo.host, configType);
|
setTimeout(() => scanGlobalMissingOptions(configType), 500);
|
||||||
|
|
||||||
if (isSomeoneScanning) {
|
|
||||||
alert("💡 提示:系統正在背景更新設備選項快取,部分選單題示內容可能稍後才會顯示完整。");
|
|
||||||
lockScanButton(60000);
|
|
||||||
} else {
|
|
||||||
enableGlobalScanButton();
|
|
||||||
|
|
||||||
// 如果剛清空快取,自動啟動掃描
|
|
||||||
if (needAutoScan) {
|
|
||||||
setTimeout(() => {
|
|
||||||
// 🌟 修正 3:全域掃描也必須知道現在要掃哪一棵樹
|
|
||||||
scanGlobalMissingOptions(configType);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 錯誤: ${result.message}</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 連線或解析失敗: ${error.message}</div>`;
|
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 連線或解析失敗: ${error.message}</div>`;
|
||||||
} finally {
|
} finally {
|
||||||
if (loadingMsg) loadingMsg.style.display = 'none';
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
|
if (loadingMsg) {
|
||||||
|
loadingMsg.style.display = 'none';
|
||||||
|
loadingMsg.style.color = ""; // 確保重置顏色
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1152,12 +1205,11 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
const modalOutput = document.getElementById('modalOutput');
|
const modalOutput = document.getElementById('modalOutput');
|
||||||
|
|
||||||
// 2. 建立即時 Log 視窗 UI (模擬終端機風格)
|
// 2. 建立即時 Log 視窗 UI (極致滿版,交由外層 modal-body 控制捲軸)
|
||||||
|
// 移除 min-height 和 overflow-y,讓它完全撐滿父容器
|
||||||
modalOutput.innerHTML = `
|
modalOutput.innerHTML = `
|
||||||
<div style="color: #f39c12; font-weight: bold; margin-bottom: 10px; font-size: 15px;">
|
<div id="restore-log-container" style="width: 100%; height: 100%; background: transparent; border: none; padding: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #ecf0f1; font-size: 14px; line-height: 1.5; text-align: left; box-sizing: border-box;">
|
||||||
⏳ 正在透過 SSH 寫入指令至設備,請勿關閉視窗...
|
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] ⏳ 正在透過 SSH 寫入還原指令,請勿關閉視窗...</div>
|
||||||
</div>
|
|
||||||
<div id="restore-log-container" style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; height: 350px; overflow-y: auto; color: #ecf0f1; border: 1px solid #333; font-size: 13px; line-height: 1.5;">
|
|
||||||
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
|
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -1199,9 +1251,13 @@ window.executeSmartRestore = async function(snapshotId, snapshotName, commands)
|
||||||
if (data.status === 'progress') {
|
if (data.status === 'progress') {
|
||||||
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
|
logContainer.innerHTML += `<div style="margin-top: 4px;"><span style="color: #3498db;">[${timeStr}]</span> ${data.message}</div>`;
|
||||||
} else if (data.status === 'success') {
|
} else if (data.status === 'success') {
|
||||||
|
// 🌟 魔法核心:清理設備回傳的字串,移除 echoed 的 "commit" 指令
|
||||||
|
let cleanOutput = data.output || '無';
|
||||||
|
cleanOutput = cleanOutput.replace(/^commit\r?\n/i, '').trim();
|
||||||
|
|
||||||
logContainer.innerHTML += `
|
logContainer.innerHTML += `
|
||||||
<div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 15px;">[${timeStr}] ${data.message}</div>
|
<div style="margin-top: 15px; color: #2ecc71; font-weight: bold; font-size: 15px;">[${timeStr}] ${data.message}</div>
|
||||||
<div style="color: #ecf0f1; margin-top: 5px; border-top: 1px dashed #555; padding-top: 5px;">設備 Commit 回傳:<br><span style="color: #bdc3c7;">${data.output || '無'}</span></div>
|
<div style="color: #ecf0f1; margin-top: 5px; border-top: 1px dashed #555; padding-top: 5px;">設備 Commit 回傳:<br><span style="color: #bdc3c7;">${cleanOutput}</span></div>
|
||||||
`;
|
`;
|
||||||
document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`;
|
document.getElementById('modalTargetInfo').innerHTML = `✅ 還原完成: ${snapshotName}`;
|
||||||
} else if (data.status === 'error') {
|
} else if (data.status === 'error') {
|
||||||
|
|
|
||||||
|
|
@ -481,12 +481,10 @@ async function executeGeneratedCLI(script) {
|
||||||
|
|
||||||
const outputEl = document.getElementById('side-execution-result');
|
const outputEl = document.getElementById('side-execution-result');
|
||||||
|
|
||||||
// 1. 建立即時 Log 視窗 UI
|
// 1. 建立即時 Log 視窗 UI (無邊界滿版融合風格)
|
||||||
outputEl.innerHTML = `
|
outputEl.innerHTML = `
|
||||||
<div style="color: #f39c12; font-weight: bold; margin-bottom: 10px; font-size: 13px;">
|
<div id="edit-log-container" style="width: 100%; height: 100%; background: transparent; border: none; padding: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #ecf0f1; font-size: 14px; line-height: 1.5; text-align: left; margin: 0; box-sizing: border-box;">
|
||||||
⏳ 正在透過 SSH 寫入指令至設備,請勿關閉視窗...
|
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">[系統] ⏳ 正在透過 SSH 寫入指令,請勿關閉視窗...</div>
|
||||||
</div>
|
|
||||||
<div id="edit-log-container" style="background: #1e1e1e; padding: 12px; border-radius: 5px; font-family: monospace; color: #ecf0f1; border: 1px solid #333; font-size: 12px; line-height: 1.5;">
|
|
||||||
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
|
<div style="color: #7f8c8d;">[系統] 準備連線至設備 ${connInfo.host}...</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ h1 { color: #2c3e50; margin-top: 0; flex-shrink: 0; margin-bottom: 12px; font-si
|
||||||
.tab-btn.active { color: #2980b9; border-bottom: 3px solid #2980b9; margin-bottom: -2px; background-color: transparent; }
|
.tab-btn.active { color: #2980b9; border-bottom: 3px solid #2980b9; margin-bottom: -2px; background-color: transparent; }
|
||||||
|
|
||||||
/* 4. 內容區塊 */
|
/* 4. 內容區塊 */
|
||||||
.tab-content { display: none; background: white; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); flex-grow: 1; min-height: 0; }
|
.tab-content { display: none; background: white; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); flex-grow: 1; min-height: 0; box-sizing: border-box; max-width: 100%; overflow: hidden; }
|
||||||
.tab-content.active { display: flex; flex-direction: column; }
|
.tab-content.active { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
/* 5. 控制列樣式 (微縮元件) */
|
/* 5. 控制列樣式 (微縮元件) */
|
||||||
|
|
@ -106,8 +106,8 @@ button { padding: 6px 14px; background-color: #2980b9; color: white; border: non
|
||||||
button:hover { background-color: #3498db; }
|
button:hover { background-color: #3498db; }
|
||||||
|
|
||||||
/* 6. 終端機與輸出區塊 */
|
/* 6. 終端機與輸出區塊 */
|
||||||
#terminal-container { flex-grow: 1; width: 100%; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); box-sizing: border-box; }
|
#terminal-container { flex-grow: 1; width: 100%; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); box-sizing: border-box; min-height: 0; overflow: hidden; position: relative; }
|
||||||
.xterm { padding: 10px; }
|
.xterm { padding: 10px; height: 100%; box-sizing: border-box; }
|
||||||
|
|
||||||
/* 7. 專業級表單排版 (🌟 縮小間距,解決空白過多) */
|
/* 7. 專業級表單排版 (🌟 縮小間距,解決空白過多) */
|
||||||
.manager-container { width: 100%; display: flex; flex-direction: column; gap: 12px; }
|
.manager-container { width: 100%; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
@ -134,8 +134,34 @@ button:hover { background-color: #3498db; }
|
||||||
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
|
.modal-title { font-size: 16px; font-weight: bold; margin: 0; display: flex; align-items: center; gap: 10px; }
|
||||||
.modal-close { background: none; border: none; color: #bdc3c7; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; }
|
.modal-close { background: none; border: none; color: #bdc3c7; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; }
|
||||||
.modal-close:hover { color: #e74c3c; }
|
.modal-close:hover { color: #e74c3c; }
|
||||||
.modal-body { flex-grow: 1; padding: 20px; overflow: auto; background-color: #1e1e1e; }
|
/* 1. 移除 Modal 身體的多餘內距,讓內容可以貼齊邊緣 */
|
||||||
.readonly-terminal { margin: 0; color: #e0e0e0; font-family: 'Courier New', Courier, monospace; font-size: 14px; white-space: pre-wrap; word-wrap: break-word; }
|
.modal-body {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 0 !important; /* 🌟 關鍵:移除原本 20px 的留白 */
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* 將捲軸交給內層的 terminal 處理 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 徹底移除終端機的邊框,並接管滿版空間 */
|
||||||
|
.readonly-terminal {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 15px 20px !important; /* 保留適當的文字呼吸空間,不讓字完全貼死邊緣 */
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background-color: transparent !important; /* 🌟 關鍵:背景透明,融入 Modal */
|
||||||
|
border: none !important; /* 🌟 關鍵:拔除所有細邊框 */
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto; /* 垂直捲動 */
|
||||||
|
overflow-x: auto; /* 水平捲動 */
|
||||||
|
white-space: pre; /* 強制不折行以支援水平滑動,保護版面 */
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
/* 樹狀圖節點的容器樣式 */
|
/* 樹狀圖節點的容器樣式 */
|
||||||
.tree-node-header {
|
.tree-node-header {
|
||||||
|
|
@ -238,4 +264,12 @@ button:hover { background-color: #3498db; }
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 針對 SweetAlert2 彈窗內的終端機強制去框 */
|
||||||
|
.swal2-html-container pre {
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 15px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue