Compare commits
7 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
4198d17e42 | |
|
|
10d53db467 | |
|
|
69447981bc | |
|
|
d4721b163f | |
|
|
050936668d | |
|
|
387d1d600a | |
|
|
8f026aac9e |
|
|
@ -9,3 +9,5 @@ filters_*.json
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
target_code.txt
|
||||||
|
all_code.txt
|
||||||
|
|
|
||||||
|
|
@ -34,15 +34,14 @@
|
||||||
### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management)
|
### Phase 4: 自動化防護、進階管理與指令精準度 (Automation & Advanced Management)
|
||||||
- [x] **智能視覺診斷 (Visual Diagnostics)**:
|
- [x] **智能視覺診斷 (Visual Diagnostics)**:
|
||||||
- CM 一鍵診斷中心:整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。
|
- CM 一鍵診斷中心:整合基礎狀態、PHY 射頻指標與 OFDM MER 頻譜。引入 Chart.js 將 ASCII 報表轉化為紅黃綠狀態圖與長條圖。
|
||||||
- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)**:
|
- [x] **深度代碼審查與並發加固 (Deep Code Review & Concurrency Hardening)**
|
||||||
- 重新 Review `generate_diff_commands` 演算法。
|
- [x] **備份保留策略 (Retention Policy)**:
|
||||||
- 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。
|
- **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時,採用 FIFO (先進先出) 機制自動清理舊資料,防止資料庫無限膨脹。
|
||||||
- [ ] **自動備份攔截防護 (Auto-Backup Interceptor)**:
|
- [x] **系統日誌動態儀表板 (Log Viewer UI)** :
|
||||||
- 在執行任何 `generate_cli` (寫入設備變更) 的 API 之前,實作強制背景備份 `running-config` 的防呆機制。
|
- 基於 FastAPI 實作一個 WebSocket Log Streamer。
|
||||||
- 將此類備份標記為 `is_auto = true`,確保每次人為變更前都有絕對的還原點。
|
1. **Custom Log Handler**: 在現有的 Python `logging` 模組中,撰寫一個自訂的 Handler,能夠攔截系統的 Log 訊息(包含 ANSI 色碼)。
|
||||||
- [ ] **雙軌制備份保留策略 (Dual-Track Retention Policy)**:
|
2. **WebSocket Endpoint**: 建立一個 FastAPI WebSocket 路由 `/ws/logs`。
|
||||||
- **自動快照滾動 (Rolling Window)**:限制每台設備最多保留 30 份自動快照,採用 FIFO (先進先出) 機制自動清理舊資料。
|
3. **Broadcaster**: 實作一個簡單的機制,當 Custom Log Handler 收到新日誌時,能非同步地將訊息推播給所有連線中的 WebSocket 客戶端。
|
||||||
- **手動快照配額 (Manual Quota)**:限制每台設備最多保留 20 份手動快照,達上限時於前端 UI 提示使用者進行清理,防止資料庫無限膨脹。
|
|
||||||
|
|
||||||
### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard)
|
### Phase 5: RPD 快速擴容與部署精靈 (Rapid RPD Provisioning Wizard)
|
||||||
- [ ] **RPD 樣板克隆引擎 (Template Cloning)**:
|
- [ ] **RPD 樣板克隆引擎 (Template Cloning)**:
|
||||||
|
|
@ -51,6 +50,9 @@
|
||||||
- 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。
|
- 透過底層 Tree 架構,完整複製複雜的 RF 與通道設定,並提供 UI 介面供使用者修改唯一識別碼 (如 MAC Address、RPD Name)。
|
||||||
- [ ] **安全寫入與校驗 (Safe Provisioning)**:
|
- [ ] **安全寫入與校驗 (Safe Provisioning)**:
|
||||||
- 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。
|
- 結合 Phase 4 的防護機制,在將全新 RPD 配置寫入 CMTS 前,進行參數衝突檢查(避免 MAC 或 IP 重複),實現零錯誤的設備擴容。
|
||||||
|
- [ ] **Diff 引擎指令精準度強化 (Diff Logic Hardening)**:
|
||||||
|
- 重新 Review `generate_diff_commands` 演算法。
|
||||||
|
- 實作「指令截斷機制」,確保生成 `no` 移除指令時,能精準剝離多餘的 Value 或參數,避免 CMTS 拒絕執行或引發非預期刪除。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
994
all_code.txt
994
all_code.txt
File diff suppressed because it is too large
Load Diff
|
|
@ -154,7 +154,13 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
||||||
logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
|
logger.info(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
||||||
|
|
||||||
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
||||||
|
|
@ -284,7 +290,13 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list):
|
||||||
|
|
||||||
async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
|
async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
|
||||||
try:
|
try:
|
||||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
||||||
|
|
||||||
async def read_until_quiet(timeout=2.0, prompt_pattern: str = None):
|
async def read_until_quiet(timeout=2.0, prompt_pattern: str = None):
|
||||||
|
|
|
||||||
123
database.py
123
database.py
|
|
@ -251,7 +251,7 @@ async def insert_config_backup(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
|
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
"""取得歷史快照列表 (支援 config_type='all' 撈取全部)"""
|
"""取得歷史快照列表 (支援 config_type='all' 撈取全部,包含 is_pinned 狀態)"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
if not pool:
|
if not pool:
|
||||||
return None
|
return None
|
||||||
|
|
@ -259,18 +259,18 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
if config_type == "all":
|
if config_type == "all":
|
||||||
# 🟢 SELECT 加入 description
|
# 🌟 SQL 查詢補上 is_pinned
|
||||||
query = """
|
query = """
|
||||||
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
|
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto, is_pinned
|
||||||
FROM config_backups
|
FROM config_backups
|
||||||
WHERE host = $1
|
WHERE host = $1
|
||||||
ORDER BY timestamp DESC;
|
ORDER BY timestamp DESC;
|
||||||
"""
|
"""
|
||||||
records = await conn.fetch(query, host)
|
records = await conn.fetch(query, host)
|
||||||
else:
|
else:
|
||||||
# 🟢 SELECT 加入 description
|
# 🌟 SQL 查詢補上 is_pinned
|
||||||
query = """
|
query = """
|
||||||
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto
|
SELECT id, host, config_type, timestamp, snapshot_name, description, is_auto, is_pinned
|
||||||
FROM config_backups
|
FROM config_backups
|
||||||
WHERE host = $1 AND config_type = $2
|
WHERE host = $1 AND config_type = $2
|
||||||
ORDER BY timestamp DESC;
|
ORDER BY timestamp DESC;
|
||||||
|
|
@ -284,8 +284,9 @@ async def get_config_backup_list(host: str, config_type: str) -> Optional[List[D
|
||||||
"config_type": r["config_type"],
|
"config_type": r["config_type"],
|
||||||
"timestamp": r["timestamp"].isoformat(),
|
"timestamp": r["timestamp"].isoformat(),
|
||||||
"snapshot_name": r["snapshot_name"],
|
"snapshot_name": r["snapshot_name"],
|
||||||
"description": r["description"], # 🟢 將資料庫的描述放入回傳字典
|
"description": r["description"],
|
||||||
"is_auto": r["is_auto"]
|
"is_auto": r["is_auto"],
|
||||||
|
"is_pinned": r["is_pinned"] # 🌟 放入回傳字典
|
||||||
}
|
}
|
||||||
for r in records
|
for r in records
|
||||||
]
|
]
|
||||||
|
|
@ -350,3 +351,111 @@ async def delete_config_backup(backup_id: str) -> bool:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
|
logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 🧹 備份滾動淘汰機制 (Retention Policy)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def cleanup_old_backups(pool, host: str) -> None:
|
||||||
|
"""
|
||||||
|
執行手動備份的滾動淘汰機制 (Retention Policy)
|
||||||
|
- 每個設備 (host) 保留最新 20 筆未釘選 (is_pinned=FALSE) 的手動備份
|
||||||
|
- 釘選的備份 (is_pinned=True) 永遠不刪除
|
||||||
|
"""
|
||||||
|
if not pool:
|
||||||
|
logger.warning("⚠️ [Retention] 無法執行備份清理:Database Pool 未初始化。")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 使用 PostgreSQL CTE (Common Table Expression) 語法
|
||||||
|
# 先選出該設備最新 20 筆需要保留的 ID,再將其餘未釘選的舊備份一次性刪除
|
||||||
|
query = """
|
||||||
|
WITH kept_backups AS (
|
||||||
|
SELECT id FROM config_backups
|
||||||
|
WHERE host = $1 AND is_pinned = FALSE
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 20
|
||||||
|
)
|
||||||
|
DELETE FROM config_backups
|
||||||
|
WHERE host = $1 AND is_pinned = FALSE
|
||||||
|
AND id NOT IN (SELECT id FROM kept_backups)
|
||||||
|
RETURNING id;
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
deleted_records = await conn.fetch(query, host)
|
||||||
|
total_deleted = len(deleted_records)
|
||||||
|
|
||||||
|
if total_deleted > 0:
|
||||||
|
logger.info(f"🧹 [Retention Policy] 設備 {host} 清理完畢:已自動淘汰 {total_deleted} 筆過期手動備份。")
|
||||||
|
else:
|
||||||
|
logger.debug(f"ℹ️ [Retention Policy] 設備 {host} 備份數量未達 20 筆上限,無需清理。")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ [Retention Policy] 清理設備 {host} 過期備份時發生錯誤: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 📌 釘選防護與容量指標計算 (Pinning & Metrics)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def toggle_config_backup_pin(backup_id: str) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
切換特定備份的釘選狀態 (True -> False, False -> True)
|
||||||
|
回傳更新後的 is_pinned 狀態,若失敗則回傳 None
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 使用 PostgreSQL 的 UPDATE ... RETURNING 語法,一步完成切換與讀取,保證原子性
|
||||||
|
query = """
|
||||||
|
UPDATE config_backups
|
||||||
|
SET is_pinned = NOT is_pinned
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING is_pinned;
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
new_state = await conn.fetchval(query, backup_id)
|
||||||
|
return new_state
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ DB Error (toggle_config_backup_pin): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_backup_metrics(pool, host: str) -> dict:
|
||||||
|
"""
|
||||||
|
計算特定設備的備份容量指標
|
||||||
|
- total: 總備份數 (含釘選與未釘選)
|
||||||
|
- pinned: 已釘選保護的數量
|
||||||
|
- unpinned: 未釘選的數量 (上限為 20 筆)
|
||||||
|
- remaining: 剩餘可用手動備份額度 (20 - unpinned)
|
||||||
|
"""
|
||||||
|
if not pool:
|
||||||
|
return {"total": 0, "pinned": 0, "unpinned": 0, "remaining": 20}
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE is_pinned = TRUE) as pinned,
|
||||||
|
COUNT(*) FILTER (WHERE is_pinned = FALSE) as unpinned
|
||||||
|
FROM config_backups
|
||||||
|
WHERE host = $1;
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
r = await conn.fetchrow(query, host)
|
||||||
|
total = r["total"] or 0
|
||||||
|
pinned = r["pinned"] or 0
|
||||||
|
unpinned = r["unpinned"] or 0
|
||||||
|
remaining = max(0, 20 - unpinned)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"pinned": pinned,
|
||||||
|
"unpinned": unpinned,
|
||||||
|
"remaining": remaining
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ DB Error (get_backup_metrics): {e}")
|
||||||
|
return {"total": 0, "pinned": 0, "unpinned": 0, "remaining": 20}
|
||||||
110
index.html
110
index.html
|
|
@ -21,9 +21,9 @@
|
||||||
<div class="global-settings">
|
<div class="global-settings">
|
||||||
<label><strong>🌐 目標設備:</strong></label>
|
<label><strong>🌐 目標設備:</strong></label>
|
||||||
<!-- 🌟 修改後 (徹底淨化) -->
|
<!-- 🌟 修改後 (徹底淨化) -->
|
||||||
<input type="text" id="cmtsHost" placeholder="IP 地址" style="width: 130px;">
|
<input type="text" id="cmtsHost" aria-label="IP 地址" placeholder="IP 地址" style="width: 130px;">
|
||||||
<input type="text" id="cmtsUser" placeholder="帳號" style="width: 100px;">
|
<input type="text" id="cmtsUser" aria-label="帳號" placeholder="帳號" style="width: 100px;">
|
||||||
<input type="password" id="cmtsPass" placeholder="密碼" style="width: 100px;">
|
<input type="password" id="cmtsPass" aria-label="密碼" placeholder="密碼" style="width: 100px;">
|
||||||
|
|
||||||
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
|
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
|
||||||
<button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
|
<button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
<div id="cli-tab" class="tab-content active">
|
<div id="cli-tab" class="tab-content active">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label><strong>快捷指令:</strong></label>
|
<label><strong>快捷指令:</strong></label>
|
||||||
<select id="fixedCmd">
|
<select id="fixedCmd" aria-label="選擇快捷指令" >
|
||||||
<option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
|
<option value="show cable modem | nomore">數據機狀態 (show cable modem | nomore)</option>
|
||||||
<option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
|
<option value="show cable rpd | nomore">RPD 狀態 (show cable rpd | nomore)</option>
|
||||||
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
|
<option value="show running-config | nomore">系統實時設定檔 (show running-config | nomore)</option>
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
|
|
||||||
<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: #2c3e50; font-size: 16px; margin-right: 10px;">📌 選擇查詢任務:</label>
|
<label style="font-weight: bold; color: #2c3e50; font-size: 16px; margin-right: 10px;">📌 選擇查詢任務:</label>
|
||||||
<select id="queryTask" onchange="switchQueryTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;">
|
<select id="queryTask" onchange="switchQueryTask()" aria-label="選擇查詢任務" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #f8f9fa;">
|
||||||
<option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
|
<option value="form-cm-query">🔎 Cable 狀態綜合查詢</option>
|
||||||
<option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
|
<option value="form-rpd-query">📡 RPD 狀態綜合查詢</option>
|
||||||
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
|
<option value="form-cm-diagnostics">🩺 CM 一鍵診斷中心</option>
|
||||||
|
|
@ -79,13 +79,13 @@
|
||||||
<h3 style="margin-top: 0; font-size: 18px; color: #2980b9; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">Cable 狀態綜合查詢 (show cable modem...)</h3>
|
<h3 style="margin-top: 0; font-size: 18px; color: #2980b9; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">Cable 狀態綜合查詢 (show cable modem...)</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>目標設備 (Cable Modem MAC)</label>
|
<label for="queryTargetCm">目標設備 (Cable Modem MAC)</label>
|
||||||
<input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
|
<input type="text" id="queryTargetCm" list="cm-mac-list" placeholder="例: 6467.7240.4076 (留白則查詢全體)">
|
||||||
<datalist id="cm-mac-list"></datalist>
|
<datalist id="cm-mac-list"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>查詢動作 (Action)</label>
|
<label>查詢動作 (Action)</label>
|
||||||
<select id="queryTypeCm" onchange="toggleQueryInputs('Cm')">
|
<select id="queryTypeCm" onchange="toggleQueryInputs('Cm')" aria-label="選擇 Cable Modem 查詢動作">
|
||||||
<optgroup label="Cable Modem 查詢 (支援特定目標或全體)">
|
<optgroup label="Cable Modem 查詢 (支援特定目標或全體)">
|
||||||
<option value="base">基本狀態 (show cable modem)</option>
|
<option value="base">基本狀態 (show cable modem)</option>
|
||||||
<option value="cpe">CPE 資訊 (cpe)</option>
|
<option value="cpe">CPE 資訊 (cpe)</option>
|
||||||
|
|
@ -129,13 +129,13 @@
|
||||||
<h3 style="margin-top: 0; font-size: 18px; color: #e67e22; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">RPD 狀態綜合查詢 (show cable rpd...)</h3>
|
<h3 style="margin-top: 0; font-size: 18px; color: #e67e22; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px;">RPD 狀態綜合查詢 (show cable rpd...)</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>目標設備 (RPD VC:VS)</label>
|
<label for="queryTargetRpd">目標設備 (RPD VC:VS)</label>
|
||||||
<input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
|
<input type="text" id="queryTargetRpd" list="rpd-vcvs-list" placeholder="例: 13:0 (留白則查詢全體)">
|
||||||
<datalist id="rpd-vcvs-list"></datalist>
|
<datalist id="rpd-vcvs-list"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>查詢動作 (Action)</label>
|
<label>查詢動作 (Action)</label>
|
||||||
<select id="queryTypeRpd" onchange="toggleQueryInputs('Rpd')">
|
<select id="queryTypeRpd" onchange="toggleQueryInputs('Rpd')" aria-label="選擇 RPD 查詢動作">
|
||||||
<option value="rpd_base">基本狀態 (show cable rpd)</option>
|
<option value="rpd_base">基本狀態 (show cable rpd)</option>
|
||||||
<option value="rpd_verbose">詳細資訊 (verbose)</option>
|
<option value="rpd_verbose">詳細資訊 (verbose)</option>
|
||||||
<option value="rpd_ptp_time">PTP Time Property (ptp time-property)</option>
|
<option value="rpd_ptp_time">PTP Time Property (ptp time-property)</option>
|
||||||
|
|
@ -166,7 +166,7 @@
|
||||||
|
|
||||||
<!-- 搜尋區塊 -->
|
<!-- 搜尋區塊 -->
|
||||||
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
|
<div class="control-group" style="background: #fdfefe; padding: 15px; border-radius: 6px; border: 1px solid #e8daef; margin-bottom: 20px;">
|
||||||
<label style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC:</label>
|
<label for="diagMacInput" style="font-weight: bold; color: #2c3e50; font-size: 15px;">🎯 目標 CM MAC:</label>
|
||||||
<input type="text" id="diagMacInput" list="diag-cm-mac-list" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
|
<input type="text" id="diagMacInput" list="diag-cm-mac-list" placeholder="例如: 6467.7240.4076" style="width: 200px; padding: 6px 8px; font-size: 14px; margin: 0 10px; border: 1px solid #bdc3c7; border-radius: 4px;">
|
||||||
<datalist id="diag-cm-mac-list"></datalist>
|
<datalist id="diag-cm-mac-list"></datalist>
|
||||||
<button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
|
<button onclick="runCmDiagnostics()" id="btnRunDiag" class="btn-modern btn-scan" style="background-color: #8e44ad;">🚀 執行深度診斷</button>
|
||||||
|
|
@ -237,7 +237,7 @@
|
||||||
|
|
||||||
<div class="control-group" style="background: #ffffff; padding: 10px 15px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
|
<div class="control-group" style="background: #ffffff; padding: 10px 15px; border-radius: 8px; margin-bottom: 0; box-shadow: 0 2px 4px rgba(0,0,0,0.02); border: 1px solid #e2e8f0;">
|
||||||
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">📌 選擇配置任務:</label>
|
<label style="font-weight: bold; color: #c0392b; font-size: 16px; margin-right: 10px;">📌 選擇配置任務:</label>
|
||||||
<select id="configTask" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
|
<select id="configTask" aria-label="選擇配置任務" onchange="switchConfigTask()" style="width: 400px; max-width: 100%; font-weight: bold; font-size: 15px; padding: 8px; background-color: #fcf3f2; border-color: #fadbd8;">
|
||||||
<!-- 🌟 拆分為兩個獨立的選項 -->
|
<!-- 🌟 拆分為兩個獨立的選項 -->
|
||||||
<option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option>
|
<option value="form-running-config">🌳 設備配置樹狀圖 (running-config)</option>
|
||||||
<option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option>
|
<option value="form-full-config">🌳 完整設備配置樹狀圖 (full configuration)</option>
|
||||||
|
|
@ -251,7 +251,7 @@
|
||||||
<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;">
|
||||||
<label style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain:</label>
|
<label for="cfgMacDomain" style="font-weight: bold; color: #d35400;">1. 目標 MAC Domain:</label>
|
||||||
<input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
|
<input type="text" id="cfgMacDomain" list="mac-domain-list" placeholder="請先點擊上方載入任務..." style="width: 220px; padding: 6px; font-weight: bold; border: 1px solid #fadbd8; border-radius: 4px; background-color: #fff;">
|
||||||
<datalist id="mac-domain-list"></datalist>
|
<datalist id="mac-domain-list"></datalist>
|
||||||
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
|
<button onclick="fetchMacDomainConfig()" class="btn-modern btn-load">🔍 讀取現有配置</button>
|
||||||
|
|
@ -262,39 +262,39 @@
|
||||||
<!-- 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">
|
||||||
<div class="form-row"><label>IP Provisioning Mode</label><select id="g_ip_prov"><option value="alternate">alternate</option><option value="dual-stack">dual-stack</option><option value="ipv4-only">ipv4-only</option><option value="ipv6-only">ipv6-only</option></select></div>
|
<div class="form-row"><label for="g_ip_prov">IP Provisioning Mode</label><select id="g_ip_prov"><option value="alternate">alternate</option><option value="dual-stack">dual-stack</option><option value="ipv4-only">ipv4-only</option><option value="ipv6-only">ipv6-only</option></select></div>
|
||||||
<div class="form-row"><label>Diplexer Band Edge Control</label><select id="g_diplexer"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_diplexer">Diplexer Band Edge Control</label><select id="g_diplexer"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
<div class="form-row"><label>CM Battery Mode 3.1</label><select id="g_bat31"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_bat31">CM Battery Mode 3.1</label><select id="g_bat31"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
<div class="form-row"><label>CM Battery Mode 3.0</label><select id="g_bat30"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_bat30">CM Battery Mode 3.0</label><select id="g_bat30"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
<div class="form-row"><label>DOCSIS 4.0</label><select id="g_docsis40"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_docsis40">DOCSIS 4.0</label><select id="g_docsis40"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
<div class="form-row"><label>DS Dynamic Bonding Group</label><select id="g_ds_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_ds_dyn">DS Dynamic Bonding Group</label><select id="g_ds_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
<div class="form-row"><label>US Dynamic Bonding Group</label><select id="g_us_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
<div class="form-row"><label for="g_us_dyn">US Dynamic Bonding Group</label><select id="g_us_dyn" onchange="toggleGroupVisibility()"><option value="enabled">enabled</option><option value="disabled">disabled</option></select></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- [Basic] DS/US Channel Sets -->
|
<!-- [Basic] DS/US Channel Sets -->
|
||||||
<h4 style="color: #27ae60; border-left: 4px solid #27ae60; padding-left: 8px; margin-top: 30px;">[Basic] DS/US Channel Sets</h4>
|
<h4 style="color: #27ae60; border-left: 4px solid #27ae60; padding-left: 8px; margin-top: 30px;">[Basic] DS/US Channel Sets</h4>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row"><label>Admin State</label><select id="b_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
<div class="form-row"><label for="b_admin">Admin State</label><select id="b_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||||||
<div class="form-row"><label>DS Primary Set (0..157)</label><input type="text" id="b_ds_pri" placeholder="例: 0-2"></div>
|
<div class="form-row"><label for="b_ds_pri">DS Primary Set (0..157)</label><input type="text" id="b_ds_pri" placeholder="例: 0-2"></div>
|
||||||
<div class="form-row"><label>DS Non-Primary Set (0..157)</label><input type="text" id="b_ds_non_pri" placeholder="例: 3-4"></div>
|
<div class="form-row"><label for="b_ds_non_pri">DS Non-Primary Set (0..157)</label><input type="text" id="b_ds_non_pri" placeholder="例: 3-4"></div>
|
||||||
<div class="form-row"><label>US PHY Channel Set (0..255)</label><input type="text" id="b_us_phy" placeholder="例: 0-3"></div>
|
<div class="form-row"><label for="b_us_phy">US PHY Channel Set (0..255)</label><input type="text" id="b_us_phy" placeholder="例: 0-3"></div>
|
||||||
<div class="form-row"><label>DS OFDM Set (0..7)</label><input type="text" id="b_ds_ofdm" placeholder="例: 0"></div>
|
<div class="form-row"><label for="b_ds_ofdm">DS OFDM Set (0..7)</label><input type="text" id="b_ds_ofdm" placeholder="例: 0"></div>
|
||||||
<div class="form-row"><label>US OFDMA Set (0..1)</label><input type="text" id="b_us_ofdma" placeholder="例: 0"></div>
|
<div class="form-row"><label for="b_us_ofdma">US OFDMA Set (0..1)</label><input type="text" id="b_us_ofdma" placeholder="例: 0"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- [Static] Downstream Bonding Groups -->
|
<!-- [Static] Downstream Bonding Groups -->
|
||||||
<div id="section_group_ds" style="margin-top: 30px;">
|
<div id="section_group_ds" style="margin-top: 30px;">
|
||||||
<h4 style="color: #8e44ad; border-left: 4px solid #8e44ad; padding-left: 8px;">[Static] Downstream Bonding Groups</h4>
|
<h4 style="color: #8e44ad; border-left: 4px solid #8e44ad; padding-left: 8px;">[Static] Downstream Bonding Groups</h4>
|
||||||
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
||||||
<label>選擇要編輯的 DS Group:</label>
|
<label for="select_ds_group">選擇要編輯的 DS Group:</label>
|
||||||
<select id="select_ds_group" onchange="loadDsGroupData()" style="width: 200px;"></select>
|
<select id="select_ds_group" onchange="loadDsGroupData()" style="width: 200px;"></select>
|
||||||
<input type="text" id="input_new_ds_group" placeholder="輸入新 Group 名稱 (例: D4A)" style="display: none; width: 200px;">
|
<input type="text" id="input_new_ds_group" aria-label="輸入新 DS Group 名稱" placeholder="輸入新 Group 名稱 (例: D4A)" style="display: none; width: 200px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row"><label>Admin State</label><select id="ds_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
<div class="form-row"><label for="ds_g_admin">Admin State</label><select id="ds_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||||||
<div class="form-row"><label>Down Channel Set (0..157)</label><input type="text" id="ds_g_down" placeholder="例: 0-4"></div>
|
<div class="form-row"><label for="ds_g_down">Down Channel Set (0..157)</label><input type="text" id="ds_g_down" placeholder="例: 0-4"></div>
|
||||||
<div class="form-row"><label>OFDM Channel Set (0..7)</label><input type="text" id="ds_g_ofdm" placeholder="例: 0-1"></div>
|
<div class="form-row"><label for="ds_g_ofdm">OFDM Channel Set (0..7)</label><input type="text" id="ds_g_ofdm" placeholder="例: 0-1"></div>
|
||||||
<div class="form-row"><label>FDX OFDM Channel Set (0..7)</label><input type="text" id="ds_g_fdx" placeholder="例: 0-2"></div>
|
<div class="form-row"><label for="ds_g_fdx">FDX OFDM Channel Set (0..7)</label><input type="text" id="ds_g_fdx" placeholder="例: 0-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -302,15 +302,15 @@
|
||||||
<div id="section_group_us" style="margin-top: 30px;">
|
<div id="section_group_us" style="margin-top: 30px;">
|
||||||
<h4 style="color: #f39c12; border-left: 4px solid #f39c12; padding-left: 8px;">[Static] Upstream Bonding Groups</h4>
|
<h4 style="color: #f39c12; border-left: 4px solid #f39c12; padding-left: 8px;">[Static] Upstream Bonding Groups</h4>
|
||||||
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
<div class="control-group" style="background: #f4f6f7; padding: 10px; border-radius: 4px;">
|
||||||
<label>選擇要編輯的 US Group:</label>
|
<label for="select_us_group">選擇要編輯的 US Group:</label>
|
||||||
<select id="select_us_group" onchange="loadUsGroupData()" style="width: 200px;"></select>
|
<select id="select_us_group" onchange="loadUsGroupData()" style="width: 200px;"></select>
|
||||||
<input type="text" id="input_new_us_group" placeholder="輸入新 Group 名稱 (例: U4A)" style="display: none; width: 200px;">
|
<input type="text" id="input_new_us_group" aria-label="輸入新 US Group 名稱" placeholder="輸入新 Group 名稱 (例: U4A)" style="display: none; width: 200px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-row"><label>Admin State</label><select id="us_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
<div class="form-row"><label for="us_g_admin">Admin State</label><select id="us_g_admin"><option value="up">up</option><option value="down">down</option></select></div>
|
||||||
<div class="form-row"><label>US Channel Set</label><input type="text" id="us_g_us" placeholder="例: 0-3.0"></div>
|
<div class="form-row"><label for="us_g_us">US Channel Set</label><input type="text" id="us_g_us" placeholder="例: 0-3.0"></div>
|
||||||
<div class="form-row"><label>OFDMA Channel Set (0..1)</label><input type="text" id="us_g_ofdma" placeholder="例: 0"></div>
|
<div class="form-row"><label for="us_g_ofdma">OFDMA Channel Set (0..1)</label><input type="text" id="us_g_ofdma" placeholder="例: 0"></div>
|
||||||
<div class="form-row"><label>FDX OFDMA Channel Set (0..5)</label><input type="text" id="us_g_fdx" placeholder="例: 0-5"></div>
|
<div class="form-row"><label for="us_g_fdx">FDX OFDMA Channel Set (0..5)</label><input type="text" id="us_g_fdx" placeholder="例: 0-5"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -407,7 +407,7 @@
|
||||||
|
|
||||||
<!-- 🌟 新增:過濾器模式切換下拉選單 -->
|
<!-- 🌟 新增:過濾器模式切換下拉選單 -->
|
||||||
<div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
|
<div style="margin-bottom: 15px; display: flex; align-items: center; gap: 10px;">
|
||||||
<label style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器:</label>
|
<label for="filter-mode-select" style="font-weight: bold; color: #2c3e50;">🎯 選擇要編輯的過濾器:</label>
|
||||||
<select id="filter-mode-select" onchange="switchFilterMode()" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 14px; background-color: #fcf3f2; color: #c0392b;">
|
<select id="filter-mode-select" onchange="switchFilterMode()" style="padding: 6px 10px; border-radius: 4px; border: 1px solid #bdc3c7; font-weight: bold; font-size: 14px; background-color: #fcf3f2; color: #c0392b;">
|
||||||
<option value="running">Running 配置過濾器</option>
|
<option value="running">Running 配置過濾器</option>
|
||||||
<option value="full">Full 配置過濾器</option>
|
<option value="full">Full 配置過濾器</option>
|
||||||
|
|
@ -449,6 +449,21 @@
|
||||||
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
|
<span style="color: #7f8c8d;">⏳ 正在載入日誌設定...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 🌟 新增:🎙️ 系統實時日誌面板 (System Live Logs) -->
|
||||||
|
<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; margin-top: 20px;">
|
||||||
|
<h3 style="margin-top: 0; font-size: 18px; color: #2c3e50; border-bottom: 2px solid #ecf0f1; padding-bottom: 10px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span>🎙️ 系統實時日誌 (System Live Logs)</span>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
|
<span id="log-ws-status" style="font-size: 13px; color: #7f8c8d; font-weight: normal;">狀態:已暫停 ⚪</span>
|
||||||
|
<button id="btnToggleLog" onclick="toggleLogStream()" class="btn-modern btn-disconnect" style="padding: 4px 12px; font-size: 12px;">🔌 啟動監聽</button>
|
||||||
|
</div>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- 日誌終端機容器 -->
|
||||||
|
<div id="log-terminal-container" style="width: 100%; height: 300px; border-radius: 6px; background-color: #1e1e1e; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); overflow: hidden; position: relative; padding: 10px; box-sizing: border-box;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -477,13 +492,13 @@
|
||||||
<!-- 表單網格區 -->
|
<!-- 表單網格區 -->
|
||||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 15px;">
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 15px;">
|
||||||
<div>
|
<div>
|
||||||
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
<label for="snapshotName" style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
||||||
快照名稱 <span style="color: #e74c3c;">*</span>
|
快照名稱 <span style="color: #e74c3c;">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" id="snapshotName" placeholder="例如: 例行備份、升級前備份" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; transition: border-color 0.2s;">
|
<input type="text" id="snapshotName" placeholder="例如: 例行備份、升級前備份" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; transition: border-color 0.2s;">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
<label for="backupConfigType" style="display: block; font-size: 14px; font-weight: bold; color: #34495e; margin-bottom: 8px;">
|
||||||
配置類型
|
配置類型
|
||||||
</label>
|
</label>
|
||||||
<select id="backupConfigType" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; background-color: #f8f9fa; cursor: pointer;">
|
<select id="backupConfigType" style="width: 100%; padding: 10px 12px; border: 1px solid #bdc3c7; border-radius: 5px; box-sizing: border-box; font-size: 14px; background-color: #f8f9fa; cursor: pointer;">
|
||||||
|
|
@ -513,9 +528,9 @@
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div style="display: flex; gap: 10px; align-items: center;">
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
<input type="text" id="filter-keyword" placeholder="🔍 搜尋名稱或描述..." class="edit-input" style="width: 180px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onkeyup="applyBackupFilters()">
|
<input type="text" id="filter-keyword" aria-label="搜尋名稱或描述" placeholder="🔍 搜尋名稱或描述..." class="edit-input" style="width: 180px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onkeyup="applyBackupFilters()">
|
||||||
|
|
||||||
<select id="filter-type" class="edit-input" style="width: 110px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onchange="applyBackupFilters()">
|
<select id="filter-type" aria-label="選擇過濾類型" class="edit-input" style="width: 110px; padding: 6px 10px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px;" onchange="applyBackupFilters()">
|
||||||
<option value="">所有類型</option>
|
<option value="">所有類型</option>
|
||||||
<option value="running">running</option>
|
<option value="running">running</option>
|
||||||
<option value="full">full</option>
|
<option value="full">full</option>
|
||||||
|
|
@ -523,9 +538,9 @@
|
||||||
|
|
||||||
<!-- 💡 修正:回歸原生 type="date",交由系統決定語系顯示 -->
|
<!-- 💡 修正:回歸原生 type="date",交由系統決定語系顯示 -->
|
||||||
<div style="display: flex; align-items: center; gap: 5px; border-left: 1px solid #bdc3c7; padding-left: 10px; margin-left: 2px;">
|
<div style="display: flex; align-items: center; gap: 5px; border-left: 1px solid #bdc3c7; padding-left: 10px; margin-left: 2px;">
|
||||||
<input type="date" id="filter-date-start" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
|
<input type="date" id="filter-date-start" aria-label="開始日期" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
|
||||||
<span style="color: #7f8c8d; font-size: 13px;">至</span>
|
<span style="color: #7f8c8d; font-size: 13px;">至</span>
|
||||||
<input type="date" id="filter-date-end" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
|
<input type="date" id="filter-date-end" aria-label="結束日期" class="edit-input" style="padding: 5px 8px; border: 1px solid #bdc3c7; border-radius: 4px; font-size: 13px; color: #7f8c8d;" onchange="applyBackupFilters()">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onclick="loadBackupHistory()" class="btn-modern btn-slate" style="margin-left: 5px; padding: 8px 20px; font-size: 14px;">
|
<button onclick="loadBackupHistory()" class="btn-modern btn-slate" style="margin-left: 5px; padding: 8px 20px; font-size: 14px;">
|
||||||
|
|
@ -539,10 +554,11 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
|
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #bdc3c7;">
|
||||||
<th style="padding: 10px; color: #34495e; width: 20%;">時間</th>
|
<th style="padding: 10px; color: #34495e; width: 20%;">時間</th>
|
||||||
<th style="padding: 10px; color: #34495e; width: 25%;">快照名稱</th>
|
<th style="padding: 10px; color: #34495e; width: 8%; text-align: center;">保護</th> <!-- 🌟 新增:獨立的保護欄位 -->
|
||||||
<th style="padding: 10px; color: #34495e; width: 25%;">描述</th>
|
<th style="padding: 10px; color: #34495e; width: 22%;">快照名稱</th>
|
||||||
|
<th style="padding: 10px; color: #34495e; width: 22%;">描述</th>
|
||||||
<th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
|
<th style="padding: 10px; color: #34495e; width: 10%;">類型</th>
|
||||||
<th style="padding: 10px; color: #34495e; text-align: right; width: 20%;">操作</th>
|
<th style="padding: 10px; color: #34495e; text-align: right; width: 18%;">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="backup-history-tbody">
|
<tbody id="backup-history-tbody">
|
||||||
|
|
|
||||||
10
init_db.py
10
init_db.py
|
|
@ -79,7 +79,7 @@ async def init_database(force_reset: bool = False):
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# 4. 建立設備配置備份表 config_backups
|
# 4. 建立設備配置備份表 config_backups (手動備份 + 釘選防護版)
|
||||||
logger.info("🛠️ 正在檢查/建立 config_backups 資料表與索引...")
|
logger.info("🛠️ 正在檢查/建立 config_backups 資料表與索引...")
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS config_backups (
|
CREATE TABLE IF NOT EXISTS config_backups (
|
||||||
|
|
@ -90,17 +90,25 @@ async def init_database(force_reset: bool = False):
|
||||||
snapshot_name VARCHAR(255),
|
snapshot_name VARCHAR(255),
|
||||||
description TEXT DEFAULT '',
|
description TEXT DEFAULT '',
|
||||||
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
|
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_pinned BOOLEAN NOT NULL DEFAULT FALSE, -- 🌟 新增:釘選防護欄位
|
||||||
raw_cli TEXT,
|
raw_cli TEXT,
|
||||||
parsed_tree JSONB,
|
parsed_tree JSONB,
|
||||||
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
|
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# 建立基礎查詢索引
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
|
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
|
||||||
ON config_backups (host, config_type, timestamp DESC);
|
ON config_backups (host, config_type, timestamp DESC);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# 🌟 新增:建立滾動淘汰專用索引
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_backups_retention
|
||||||
|
ON config_backups (host, is_pinned, timestamp DESC);
|
||||||
|
""")
|
||||||
|
|
||||||
await conn.close()
|
await conn.close()
|
||||||
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
||||||
|
|
||||||
|
|
|
||||||
10
logger.py
10
logger.py
|
|
@ -26,7 +26,8 @@ MANAGED_LOGGERS = [
|
||||||
"app.scraper", # 爬蟲與快取
|
"app.scraper", # 爬蟲與快取
|
||||||
"app.diagnostics", # CM 診斷
|
"app.diagnostics", # CM 診斷
|
||||||
"app.backup", # 備份與還原
|
"app.backup", # 備份與還原
|
||||||
"app.ssh" # SSH 連線引擎
|
"app.ssh", # 🔌 SSH 連線引擎 (注意這裡要有逗號!)
|
||||||
|
"app.auth" # 🔐 上帝模式與安全授權
|
||||||
]
|
]
|
||||||
|
|
||||||
def setup_logger(name: str, level=logging.INFO):
|
def setup_logger(name: str, level=logging.INFO):
|
||||||
|
|
@ -61,3 +62,10 @@ def set_log_level(name: str, level_str: str):
|
||||||
logging.getLogger(name).setLevel(level)
|
logging.getLogger(name).setLevel(level)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def register_websocket_handler(handler: logging.Handler):
|
||||||
|
"""🌟 新增:註冊自訂 Handler 到所有受管控的 Logger,並防止重複添加"""
|
||||||
|
for name in MANAGED_LOGGERS:
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
if handler not in logger.handlers:
|
||||||
|
logger.addHandler(handler)
|
||||||
27
main.py
27
main.py
|
|
@ -4,21 +4,45 @@ from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware # 🌟 新增引入
|
||||||
import database
|
import database
|
||||||
|
import asyncio
|
||||||
|
|
||||||
# 引入我們剛剛拆分出來的路由模組
|
# 引入我們剛剛拆分出來的路由模組
|
||||||
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth
|
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth, logs
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
await database.init_db_pool()
|
await database.init_db_pool()
|
||||||
|
# 🌟 新增:初始化 Log Broadcaster 的 Event Loop 參考,確保跨執行緒排程安全
|
||||||
|
logs.log_broadcaster.loop = asyncio.get_running_loop()
|
||||||
yield
|
yield
|
||||||
# Shutdown
|
# Shutdown
|
||||||
await database.close_db_pool()
|
await database.close_db_pool()
|
||||||
|
|
||||||
app = FastAPI(title="Harmonic CMTS Manager", version="2.0", lifespan=lifespan)
|
app = FastAPI(title="Harmonic CMTS Manager", version="2.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
# 🌟 新增:全域安全與快取 Middleware
|
||||||
|
class SecurityAndCacheMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# 1. 補上安全性標頭 (圖片 7 的警告)
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
|
||||||
|
# 2. 針對靜態檔案補上 Cache-Control (圖片 2 的警告)
|
||||||
|
# 讓 JS/CSS 快取 1 小時 (3600秒),提升前端載入效能
|
||||||
|
if request.url.path.startswith("/static/"):
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||||
|
else:
|
||||||
|
# API 請求不快取,確保拿到最新資料
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
app.add_middleware(SecurityAndCacheMiddleware) # 🌟 掛載 Middleware
|
||||||
|
|
||||||
# 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js)
|
# 掛載靜態檔案目錄 (對應 static/style.css 與 static/app.js)
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
|
@ -33,6 +57,7 @@ app.include_router(auth.router, prefix="/api/v1")
|
||||||
|
|
||||||
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
|
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
|
||||||
app.include_router(terminal.router)
|
app.include_router(terminal.router)
|
||||||
|
app.include_router(logs.router) # 🌟 新增:掛載日誌 WebSocket 路由
|
||||||
|
|
||||||
# 根目錄路由:回傳前端 UI
|
# 根目錄路由:回傳前端 UI
|
||||||
@app.get("/", response_class=HTMLResponse, tags=["UI"])
|
@app.get("/", response_class=HTMLResponse, tags=["UI"])
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import asyncssh
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
@ -11,15 +11,19 @@ from logger import get_logger
|
||||||
|
|
||||||
# 引入 DB 函數
|
# 引入 DB 函數
|
||||||
from database import (
|
from database import (
|
||||||
|
get_pool,
|
||||||
insert_config_backup,
|
insert_config_backup,
|
||||||
get_config_backup_list,
|
get_config_backup_list,
|
||||||
get_config_backup_detail,
|
get_config_backup_detail,
|
||||||
delete_config_backup
|
delete_config_backup,
|
||||||
|
cleanup_old_backups,
|
||||||
|
toggle_config_backup_pin, # 🌟 新增
|
||||||
|
get_backup_metrics # 🌟 新增
|
||||||
)
|
)
|
||||||
|
|
||||||
from cmts_scraper import fetch_raw_config
|
from cmts_scraper import fetch_raw_config
|
||||||
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
|
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
|
||||||
from shared import parse_cli_to_tree, cmts_config_locks
|
from shared import parse_cli_to_tree, get_cmts_lock
|
||||||
|
|
||||||
logger = get_logger("app.backup")
|
logger = get_logger("app.backup")
|
||||||
|
|
||||||
|
|
@ -149,23 +153,35 @@ async def delete_backup(backup_id: str):
|
||||||
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
|
return {"status": "error", "message": "刪除失敗或找不到該筆資料"}
|
||||||
return {"status": "success", "message": "快照已成功刪除"}
|
return {"status": "success", "message": "快照已成功刪除"}
|
||||||
|
|
||||||
|
@router.post("/{backup_id}/toggle-pin", summary="切換快照的釘選防護狀態")
|
||||||
|
async def toggle_backup_pin(backup_id: str):
|
||||||
|
new_state = await toggle_config_backup_pin(backup_id)
|
||||||
|
if new_state is None:
|
||||||
|
raise HTTPException(status_code=500, detail="資料庫更新失敗")
|
||||||
|
|
||||||
|
status_str = "已啟用釘選保護,系統絕不自動淘汰。" if new_state else "已取消釘選,該備份將納入滾動淘汰範圍。"
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"is_pinned": new_state,
|
||||||
|
"message": f"快照狀態更新成功!{status_str}"
|
||||||
|
}
|
||||||
|
|
||||||
@router.post("/snapshot", summary="手動建立設備快照 (串流版)")
|
@router.post("/snapshot", summary="手動建立設備快照 (串流版)")
|
||||||
async def create_snapshot(req: SnapshotRequest):
|
async def create_snapshot(req: SnapshotRequest, background_tasks: BackgroundTasks):
|
||||||
async def backup_streamer():
|
async def backup_streamer():
|
||||||
try:
|
try:
|
||||||
# 1. 廣播:正在連線
|
pool = await get_pool()
|
||||||
|
|
||||||
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n"
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# 2. 廣播:正在下載
|
|
||||||
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
|
||||||
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
|
raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type)
|
||||||
|
|
||||||
# 3. 廣播:正在解析
|
|
||||||
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
|
parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli)
|
||||||
|
|
||||||
# 4. 廣播:寫入資料庫
|
|
||||||
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n"
|
||||||
backup_id = await insert_config_backup(
|
backup_id = await insert_config_backup(
|
||||||
host=req.host,
|
host=req.host,
|
||||||
|
|
@ -181,12 +197,23 @@ async def create_snapshot(req: SnapshotRequest):
|
||||||
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
|
yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n"
|
||||||
return
|
return
|
||||||
|
|
||||||
# 5. 廣播:成功
|
# 🌟 關鍵變更:在同一個串流中「立即執行」清理與指標計算,確保回傳最新數據
|
||||||
|
yield json.dumps({"status": "progress", "message": "🧹 正在執行備份滾動淘汰與容量計算..."}) + "\n"
|
||||||
|
if pool:
|
||||||
|
# 1. 立即執行清理
|
||||||
|
await cleanup_old_backups(pool, req.host)
|
||||||
|
# 2. 立即計算最新指標
|
||||||
|
metrics = await get_backup_metrics(pool, req.host)
|
||||||
|
else:
|
||||||
|
metrics = {"total": 1, "pinned": 0, "unpinned": 1, "remaining": 19}
|
||||||
|
|
||||||
|
# 🌟 3. 在成功訊息中,將 metrics 字典一併回傳給前端
|
||||||
yield json.dumps({
|
yield json.dumps({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"快照 '{req.snapshot_name}' 建立成功!",
|
"message": f"快照 '{req.snapshot_name}' 建立成功!",
|
||||||
"backup_id": backup_id,
|
"backup_id": backup_id,
|
||||||
"host": req.host
|
"host": req.host,
|
||||||
|
"metrics": metrics # 🌟 包含容量指標
|
||||||
}) + "\n"
|
}) + "\n"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -215,8 +242,9 @@ async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
|
||||||
if not raw_current:
|
if not raw_current:
|
||||||
return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"}
|
return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"}
|
||||||
|
|
||||||
current_tree = parse_cli_to_tree(raw_current)
|
# ✅ 安全升級:將 CPU 密集運算移交給 ThreadPool,保護 Event Loop 不卡死
|
||||||
diff_commands = generate_diff_commands(current_tree, snapshot_tree)
|
current_tree = await asyncio.to_thread(parse_cli_to_tree, raw_current)
|
||||||
|
diff_commands = await asyncio.to_thread(generate_diff_commands, current_tree, snapshot_tree)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
|
@ -242,7 +270,7 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
|
|
||||||
async def restore_generator():
|
async def restore_generator():
|
||||||
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
|
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
|
||||||
host_lock = cmts_config_locks[req.host]
|
host_lock = get_cmts_lock[req.host]
|
||||||
|
|
||||||
# 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
|
# 1. 嘗試取得全域寫入鎖 (避免多人同時打指令)
|
||||||
if host_lock.locked():
|
if host_lock.locked():
|
||||||
|
|
@ -254,7 +282,13 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n"
|
||||||
|
|
||||||
# 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放
|
# 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放
|
||||||
async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as conn:
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process:
|
||||||
|
|
||||||
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from pydantic import BaseModel
|
||||||
from typing import List
|
from typing import List
|
||||||
from netmiko import ConnectHandler
|
from netmiko import ConnectHandler
|
||||||
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
|
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
|
||||||
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree
|
from shared import CMTS_DEVICE, get_cmts_lock, parse_cli_to_tree, deep_split_tree
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from logger import get_all_log_levels, set_log_level
|
from logger import get_all_log_levels, set_log_level
|
||||||
|
|
@ -64,18 +64,18 @@ async def execute_cmts_config(req: ConfigRequest):
|
||||||
|
|
||||||
async def config_streamer():
|
async def config_streamer():
|
||||||
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
|
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
|
||||||
host_lock = cmts_config_locks[req.host]
|
host_lock = get_cmts_lock[req.host]
|
||||||
async with host_lock:
|
async with host_lock:
|
||||||
try:
|
try:
|
||||||
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
|
||||||
|
|
||||||
# 使用 asyncssh 建立非同步連線
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
async with asyncssh.connect(
|
conn = await asyncio.wait_for(
|
||||||
req.host,
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
username=req.username,
|
timeout=10.0
|
||||||
password=req.password,
|
)
|
||||||
known_hosts=None
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
) as conn:
|
async with conn:
|
||||||
async with conn.create_process() as process:
|
async with conn.create_process() as process:
|
||||||
|
|
||||||
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
|
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# --- routers/diagnostics.py ---
|
# --- routers/diagnostics.py ---
|
||||||
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import asyncssh
|
import asyncssh
|
||||||
from logger import get_logger
|
from logger import get_logger
|
||||||
|
|
@ -34,7 +35,14 @@ async def get_cm_diagnostics(
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
|
|
||||||
logger.debug(f"🚀 [Diagnostics] 開始診斷 MAC: {mac}")
|
logger.debug(f"🚀 [Diagnostics] 開始診斷 MAC: {mac}")
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ async def sse_stream(request: Request, host: str):
|
||||||
finally:
|
finally:
|
||||||
if channel_key in active_clients:
|
if channel_key in active_clients:
|
||||||
active_clients[channel_key].discard(client_queue)
|
active_clients[channel_key].discard(client_queue)
|
||||||
|
# ✅ 安全升級:如果該設備已經沒有任何客戶端監聽,徹底刪除 Key 釋放記憶體
|
||||||
|
if not active_clients[channel_key]:
|
||||||
|
del active_clients[channel_key]
|
||||||
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
# --- routers/logs.py ---
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from logger import ColoredFormatter, register_websocket_handler
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
class LogBroadcaster:
|
||||||
|
"""管理所有活躍 WebSocket 連線的廣播器 (Connection Manager)"""
|
||||||
|
def __init__(self):
|
||||||
|
self.active_connections: set[WebSocket] = set()
|
||||||
|
self.loop: asyncio.AbstractEventLoop = None
|
||||||
|
|
||||||
|
async def connect(self, websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
self.active_connections.add(websocket)
|
||||||
|
|
||||||
|
def disconnect(self, websocket: WebSocket):
|
||||||
|
self.active_connections.discard(websocket)
|
||||||
|
|
||||||
|
async def broadcast(self, message: str):
|
||||||
|
"""非同步推播日誌給所有連線中的客戶端"""
|
||||||
|
if not self.active_connections:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 併發發送,並透過 return_exceptions=True 確保單一連線異常不影響其他客戶端
|
||||||
|
tasks = [self._safe_send(conn, message) for conn in self.active_connections]
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
async def _safe_send(self, websocket: WebSocket, message: str):
|
||||||
|
try:
|
||||||
|
await websocket.send_text(message)
|
||||||
|
except Exception:
|
||||||
|
self.disconnect(websocket)
|
||||||
|
|
||||||
|
# 建立全域廣播器實例
|
||||||
|
log_broadcaster = LogBroadcaster()
|
||||||
|
|
||||||
|
class WebSocketLogHandler(logging.Handler):
|
||||||
|
"""自訂 Log Handler,攔截系統日誌並安全地派發至非同步廣播器"""
|
||||||
|
def __init__(self, broadcaster: LogBroadcaster):
|
||||||
|
super().__init__()
|
||||||
|
self.broadcaster = broadcaster
|
||||||
|
# 沿用系統 ColoredFormatter,完美保留 ANSI 色碼
|
||||||
|
self.setFormatter(ColoredFormatter())
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
try:
|
||||||
|
msg = self.format(record)
|
||||||
|
loop = self.broadcaster.loop
|
||||||
|
# 確保在 Event Loop 處於執行狀態時,安全地跨執行緒派發任務
|
||||||
|
if loop and loop.is_running():
|
||||||
|
loop.call_soon_threadsafe(
|
||||||
|
lambda: asyncio.create_task(self.broadcaster.broadcast(msg))
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
# 建立 Handler 實例並註冊至所有受管控的 Logger
|
||||||
|
websocket_log_handler = WebSocketLogHandler(log_broadcaster)
|
||||||
|
register_websocket_handler(websocket_log_handler)
|
||||||
|
|
||||||
|
@router.websocket("/ws/logs")
|
||||||
|
async def websocket_logs(websocket: WebSocket):
|
||||||
|
"""WebSocket 實時日誌串流端點"""
|
||||||
|
# 若尚未綁定 Event Loop,則於首次連線時動態綁定
|
||||||
|
if not log_broadcaster.loop:
|
||||||
|
log_broadcaster.loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
await log_broadcaster.connect(websocket)
|
||||||
|
try:
|
||||||
|
# 保持連線,監聽客戶端斷線狀態
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
log_broadcaster.disconnect(websocket)
|
||||||
|
except Exception:
|
||||||
|
log_broadcaster.disconnect(websocket)
|
||||||
|
|
@ -17,7 +17,13 @@ async def execute_single_command(host, username, password, command, timeout=15.0
|
||||||
自動防呆:確保指令帶有取消分頁的後綴,防止 Event Loop 卡死
|
自動防呆:確保指令帶有取消分頁的後綴,防止 Event Loop 卡死
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
# 確保指令包含取消分頁的參數
|
# 確保指令包含取消分頁的參數
|
||||||
if "| nomore" not in command.lower():
|
if "| nomore" not in command.lower():
|
||||||
command += " | nomore"
|
command += " | nomore"
|
||||||
|
|
@ -38,7 +44,13 @@ async def execute_interactive_command(host, username, password, commands: list,
|
||||||
動態處理終端機的 --More-- 提示
|
動態處理終端機的 --More-- 提示
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
# 先用 wait_for 取得連線 (超過 10 秒會拋出 asyncio.TimeoutError)
|
||||||
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
# 再進入 async with 確保資源會被自動關閉
|
||||||
|
async with conn:
|
||||||
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24)) as process:
|
async with conn.create_process(term_type='xterm-256color', term_size=(200, 24)) as process:
|
||||||
|
|
||||||
async def read_until_quiet(wait_time):
|
async def read_until_quiet(wait_time):
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
ssh_task = None
|
ssh_task = None
|
||||||
try:
|
try:
|
||||||
# 建立連線
|
# 建立連線
|
||||||
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
|
conn = await asyncio.wait_for(
|
||||||
|
asyncssh.connect(host, username=username, password=password, known_hosts=None),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
|
||||||
# 💡 關鍵修復:使用 term_size 參數來指定寬高 (width, height)
|
# 💡 關鍵修復:使用 term_size 參數來指定寬高 (width, height)
|
||||||
process = await conn.create_process(
|
process = await conn.create_process(
|
||||||
|
|
@ -37,7 +40,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ [WS Forward Error] 讀取設備畫面時發生錯誤", exc_info=True)
|
logger.error(f"❌ [WS Forward Error] 讀取設備畫面時發生錯誤: {str(e)}")
|
||||||
|
|
||||||
async def forward_to_ssh():
|
async def forward_to_ssh():
|
||||||
try:
|
try:
|
||||||
|
|
@ -60,7 +63,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ [SSH Forward Error] 寫入指令到設備時發生錯誤", exc_info=True)
|
logger.error(f"❌ [SSH Forward Error] 寫入指令到設備時發生錯誤: {str(e)}")
|
||||||
|
|
||||||
ws_task = asyncio.create_task(forward_to_ws())
|
ws_task = asyncio.create_task(forward_to_ws())
|
||||||
ssh_task = asyncio.create_task(forward_to_ssh())
|
ssh_task = asyncio.create_task(forward_to_ssh())
|
||||||
|
|
@ -80,12 +83,19 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
if ssh_task and not ssh_task.done():
|
if ssh_task and not ssh_task.done():
|
||||||
ssh_task.cancel()
|
ssh_task.cancel()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n"
|
err_str = str(e)
|
||||||
|
error_msg = f"\r\n\x1b[31mSSH Connection Error: {err_str}\x1b[0m\r\n"
|
||||||
try:
|
try:
|
||||||
|
# 先把錯誤印在終端機畫面上
|
||||||
await websocket.send_text(error_msg)
|
await websocket.send_text(error_msg)
|
||||||
|
|
||||||
|
# 🌟 關鍵修復:使用自訂斷線碼 4001,並附上錯誤原因 (WebSocket 規範 reason 最長 123 bytes)
|
||||||
|
safe_reason = err_str[:120] if err_str else "Authentication or Connection Failed"
|
||||||
|
await websocket.close(code=4001, reason=safe_reason)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True)
|
logger.error(f"❌ [Connection Error] 建立連線或執行過程中發生錯誤: {str(e)}")
|
||||||
|
return # 🌟 提早結束,避免下方的 finally 再次執行 close 導致報錯
|
||||||
finally:
|
finally:
|
||||||
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
|
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
|
||||||
if process:
|
if process:
|
||||||
|
|
|
||||||
|
|
@ -257,5 +257,10 @@ def save_settings(settings_data):
|
||||||
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
|
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
|
||||||
json.dump(settings_data, f, indent=4, ensure_ascii=False)
|
json.dump(settings_data, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
# 🌟 [Priority 1 修復] 將全域非同步鎖改為依設備 IP (Host) 隔離的鎖
|
# 🌟 [Priority 1 修復] 將全域非同步鎖改為依設備 IP (Host) 隔離的鎖 (安全動態獲取版)
|
||||||
cmts_config_locks = defaultdict(asyncio.Lock)
|
_cmts_config_locks = {}
|
||||||
|
|
||||||
|
def get_cmts_lock(host: str) -> asyncio.Lock:
|
||||||
|
if host not in _cmts_config_locks:
|
||||||
|
_cmts_config_locks[host] = asyncio.Lock()
|
||||||
|
return _cmts_config_locks[host]
|
||||||
|
|
@ -227,3 +227,14 @@ export async function apiVerifyGodMode(password) {
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 📌 釘選防護 API (Pinning)
|
||||||
|
// ==========================================
|
||||||
|
export async function apiToggleBackupPin(backupId) {
|
||||||
|
const response = await fetch(`/api/v1/backups/${backupId}/toggle-pin`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
235
static/app.js
235
static/app.js
|
|
@ -18,7 +18,8 @@ import {
|
||||||
apiGetLogLevels, apiSetLogLevel,
|
apiGetLogLevels, apiSetLogLevel,
|
||||||
apiVerifyGodMode,
|
apiVerifyGodMode,
|
||||||
apiGetLeafOptions, // 🌟 補上這個
|
apiGetLeafOptions, // 🌟 補上這個
|
||||||
apiSyncLeafOptions // 🌟 補上這個
|
apiSyncLeafOptions, // 🌟 補上這個
|
||||||
|
apiToggleBackupPin // 🌟 新增這行
|
||||||
} from './api.js';
|
} from './api.js';
|
||||||
|
|
||||||
// 3. 共用工具模組 (Utils)
|
// 3. 共用工具模組 (Utils)
|
||||||
|
|
@ -55,6 +56,10 @@ let idleTimer;
|
||||||
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
|
const IDLE_TIMEOUT = 300000; // 5 分鐘 (毫秒)
|
||||||
let globalSSE = null;
|
let globalSSE = null;
|
||||||
|
|
||||||
|
let logTerm = null;
|
||||||
|
let logWs = null;
|
||||||
|
let isLogEnabled = false; // 預設關閉,不主動消耗資源
|
||||||
|
|
||||||
// 初始化全域 SSE 監聽器 (設備級別)
|
// 初始化全域 SSE 監聽器 (設備級別)
|
||||||
function initGlobalSSE() {
|
function initGlobalSSE() {
|
||||||
// 🌟 新增防線:如果尚未連線,強制關閉舊的 SSE 並退出
|
// 🌟 新增防線:如果尚未連線,強制關閉舊的 SSE 並退出
|
||||||
|
|
@ -488,19 +493,26 @@ function toggleQueryInputs(mode) {
|
||||||
function resetAllManagerData() {
|
function resetAllManagerData() {
|
||||||
resetMacDomainData();
|
resetMacDomainData();
|
||||||
|
|
||||||
// 🌟 新增:清空動態下拉選單的快取,確保切換設備時重新抓取
|
// 🧹 1. 清空動態下拉選單的快取
|
||||||
if (typeof cachedDeviceLists !== 'undefined') {
|
if (typeof cachedDeviceLists !== 'undefined') {
|
||||||
cachedDeviceLists = { cms: null, rpds: null, host: null };
|
cachedDeviceLists = { cms: null, rpds: null, host: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🧹 [修復 Leak] 清空前端樹狀圖快取,避免切換設備時發生 OOM
|
// 🧹 2. 清空前端樹狀圖快取與 DOM (設備配置樹狀圖)
|
||||||
clearTreeCache();
|
clearTreeCache();
|
||||||
|
window.treeDataStore = { running: null, full: null }; // 🌟 清空雙軌記憶體
|
||||||
|
|
||||||
// 🛡️ [跨設備防護] 切換設備時,清空鎖定輪詢的記憶體
|
const treeRunning = document.getElementById('tree-container-running');
|
||||||
|
const treeFull = document.getElementById('tree-container-full');
|
||||||
|
if (treeRunning) treeRunning.innerHTML = '<span style="color: #7f8c8d;">尚未載入 Running 資料。請點擊上方「載入任務」按鈕開始。</span>';
|
||||||
|
if (treeFull) treeFull.innerHTML = '<span style="color: #7f8c8d;">尚未載入 Full 資料。請點擊上方「載入任務」按鈕開始。</span>';
|
||||||
|
|
||||||
|
// 🛡️ 3. 跨設備防護:清空鎖定輪詢的記憶體
|
||||||
if (typeof activeLockState !== 'undefined' && activeLockState.clear) {
|
if (typeof activeLockState !== 'undefined' && activeLockState.clear) {
|
||||||
activeLockState.clear();
|
activeLockState.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🧹 4. 清空 MAC Domain 配置精靈
|
||||||
const configArea = document.getElementById('macDomainConfigArea');
|
const configArea = document.getElementById('macDomainConfigArea');
|
||||||
if (configArea) configArea.style.display = 'none';
|
if (configArea) configArea.style.display = 'none';
|
||||||
|
|
||||||
|
|
@ -530,10 +542,39 @@ function resetAllManagerData() {
|
||||||
fetchStatus.style.color = "#7f8c8d";
|
fetchStatus.style.color = "#7f8c8d";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🧹 5. 清空 CMTS 狀態查詢輸入框
|
||||||
document.getElementById('queryTargetCm').value = '';
|
document.getElementById('queryTargetCm').value = '';
|
||||||
document.getElementById('queryTargetRpd').value = '';
|
document.getElementById('queryTargetRpd').value = '';
|
||||||
|
|
||||||
// 🌟 新增:切換設備連線後,自動重整備份歷史紀錄
|
// 🧹 6. 清空 CM 深度診斷與 MER 分析
|
||||||
|
const diagMacInput = document.getElementById('diagMacInput');
|
||||||
|
if (diagMacInput) diagMacInput.value = '';
|
||||||
|
|
||||||
|
const diagResultArea = document.getElementById('diagResultArea');
|
||||||
|
if (diagResultArea) diagResultArea.style.display = 'none';
|
||||||
|
|
||||||
|
if (typeof merChartInstance !== 'undefined' && merChartInstance) {
|
||||||
|
merChartInstance.destroy();
|
||||||
|
merChartInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧹 7. 清空系統設定 (God Mode Filters) 的樹狀圖
|
||||||
|
const filterContainer = document.getElementById('filter-checkboxes');
|
||||||
|
if (filterContainer) {
|
||||||
|
filterContainer.innerHTML = '';
|
||||||
|
filterContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧹 8. 清空設備備份與還原歷史紀錄
|
||||||
|
if (typeof allBackupsData !== 'undefined') {
|
||||||
|
allBackupsData = []; // 清空記憶體中的快照陣列
|
||||||
|
}
|
||||||
|
const backupTbody = document.getElementById('backup-history-tbody');
|
||||||
|
if (backupTbody) {
|
||||||
|
backupTbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">請點擊「重新整理」載入歷史紀錄...</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🌟 觸發重新載入備份歷史 (因為已經連上新設備)
|
||||||
if (typeof loadBackupHistory === 'function') {
|
if (typeof loadBackupHistory === 'function') {
|
||||||
loadBackupHistory();
|
loadBackupHistory();
|
||||||
}
|
}
|
||||||
|
|
@ -1202,9 +1243,102 @@ async function switchFilterMode() {
|
||||||
await openSystemSettings();
|
await openSystemSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 開啟系統設定並讀取目前的過濾器清單
|
function initLogTerminal() {
|
||||||
|
const container = document.getElementById('log-terminal-container');
|
||||||
|
if (!container || logTerm) return;
|
||||||
|
|
||||||
|
logTerm = new Terminal({
|
||||||
|
cursorBlink: false,
|
||||||
|
disableStdin: true,
|
||||||
|
theme: { background: '#1e1e1e', foreground: '#e0e0e0' },
|
||||||
|
fontFamily: 'Courier New, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
scrollback: 1000,
|
||||||
|
cols: 250 // 確保不折行
|
||||||
|
});
|
||||||
|
logTerm.open(container);
|
||||||
|
logTerm.writeln('\x1b[33m[System] Live Log Terminal Ready. Click "啟動監聽" to start streaming.\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLogStream() {
|
||||||
|
isLogEnabled = !isLogEnabled;
|
||||||
|
const btn = document.getElementById('btnToggleLog');
|
||||||
|
|
||||||
|
if (isLogEnabled) {
|
||||||
|
initLogTerminal();
|
||||||
|
connectLogWebSocket();
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = '⏸️ 暫停監聽';
|
||||||
|
btn.className = 'btn-modern btn-clear';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
disconnectLogWebSocket();
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = '🔌 啟動監聽';
|
||||||
|
btn.className = 'btn-modern btn-connect';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectLogWebSocket() {
|
||||||
|
if (!isLogEnabled) return;
|
||||||
|
if (logWs && logWs.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('log-ws-status');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '狀態:正在連線... 🟡';
|
||||||
|
statusEl.style.color = '#f39c12';
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
|
||||||
|
|
||||||
|
logWs = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
logWs.onopen = () => {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '狀態:實時監聽中 🟢';
|
||||||
|
statusEl.style.color = '#27ae60';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logWs.onmessage = (event) => {
|
||||||
|
if (logTerm && isLogEnabled) {
|
||||||
|
const formattedLog = event.data.replace(/\n/g, '\r\n') + '\r\n';
|
||||||
|
logTerm.write(formattedLog);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logWs.onclose = () => {
|
||||||
|
const statusEl = document.getElementById('log-ws-status');
|
||||||
|
if (isLogEnabled) {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '狀態:連線中斷,嘗試重連... 🔴';
|
||||||
|
statusEl.style.color = '#c0392b';
|
||||||
|
}
|
||||||
|
setTimeout(connectLogWebSocket, 5000);
|
||||||
|
} else {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '狀態:已暫停 ⚪';
|
||||||
|
statusEl.style.color = '#7f8c8d';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectLogWebSocket() {
|
||||||
|
if (logWs) {
|
||||||
|
logWs.close();
|
||||||
|
logWs = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. 確保 openSystemSettings 正常初始化 ---
|
||||||
async function openSystemSettings() {
|
async function openSystemSettings() {
|
||||||
// 🌟 動態抓取過濾器模式
|
setTimeout(() => {
|
||||||
|
initLogTerminal();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
const modeSelect = document.getElementById('filter-mode-select');
|
const modeSelect = document.getElementById('filter-mode-select');
|
||||||
const mode = modeSelect ? modeSelect.value : 'running';
|
const mode = modeSelect ? modeSelect.value : 'running';
|
||||||
|
|
||||||
|
|
@ -1215,7 +1349,6 @@ async function openSystemSettings() {
|
||||||
console.error("讀取設定失敗:", error);
|
console.error("讀取設定失敗:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 新增:同時載入日誌等級
|
|
||||||
loadLogLevels();
|
loadLogLevels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1232,7 +1365,8 @@ async function loadLogLevels() {
|
||||||
'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)',
|
'app.scraper': '🕷️ 爬蟲與快取模組 (Scraper)',
|
||||||
'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)',
|
'app.diagnostics': '🩺 CM 診斷模組 (Diagnostics)',
|
||||||
'app.backup': '💾 備份與還原模組 (Backup)',
|
'app.backup': '💾 備份與還原模組 (Backup)',
|
||||||
'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)'
|
'app.ssh': '🔌 SSH 連線引擎 (SSH Engine)',
|
||||||
|
'app.auth': '🔐 安全授權模組 (Auth)' // 🌟 新增這行
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [module, level] of Object.entries(res.data)) {
|
for (const [module, level] of Object.entries(res.data)) {
|
||||||
|
|
@ -1432,7 +1566,7 @@ async function saveSystemSettings() {
|
||||||
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
|
// 🌟 新增:全域變數用來暫存所有快照資料,實現前端秒速過濾
|
||||||
let allBackupsData = [];
|
let allBackupsData = [];
|
||||||
|
|
||||||
// 1. 建立新快照 - 🌟 升級為真實動態訊息流 (含百分比動畫)
|
// 🌟 修正版:手動建立設備快照 (解決容量看板型別解析 Bug)
|
||||||
async function createSnapshot() {
|
async function createSnapshot() {
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行備份任務!\n(確保您清楚目前正在操作哪一台設備)");
|
return alert("⚠️ 請先點擊畫面上方的「連線至 CMTS」成功後,再執行備份任務!\n(確保您清楚目前正在操作哪一台設備)");
|
||||||
|
|
@ -1451,10 +1585,10 @@ async function createSnapshot() {
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
msg.style.display = 'inline-block';
|
msg.style.display = 'inline-block';
|
||||||
msg.style.color = '#f39c12'; // 🌟 確保橘色
|
msg.style.color = '#f39c12'; // 確保橘色
|
||||||
msg.innerHTML = '⏳ 準備連線至設備...';
|
msg.innerHTML = '⏳ 準備連線至設備...';
|
||||||
|
|
||||||
let progressInterval = null; // 🌟 新增:進度條計時器
|
let progressInterval = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/backups/snapshot', {
|
const response = await fetch('/api/v1/backups/snapshot', {
|
||||||
|
|
@ -1476,7 +1610,7 @@ async function createSnapshot() {
|
||||||
const decoder = new TextDecoder("utf-8");
|
const decoder = new TextDecoder("utf-8");
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let isSuccess = false;
|
let isSuccess = false;
|
||||||
let finalMessage = "";
|
let finalData = null; // 🌟 修正:改為儲存完整的 JSON 物件,而非只有字串
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
|
|
@ -1491,7 +1625,6 @@ async function createSnapshot() {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line);
|
const data = JSON.parse(line);
|
||||||
if (data.status === 'progress') {
|
if (data.status === 'progress') {
|
||||||
// 🌟 導入百分比動畫邏輯
|
|
||||||
if (data.message.includes('正在下載')) {
|
if (data.message.includes('正在下載')) {
|
||||||
let progress = 0;
|
let progress = 0;
|
||||||
if (progressInterval) clearInterval(progressInterval);
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
|
|
@ -1520,7 +1653,7 @@ async function createSnapshot() {
|
||||||
}
|
}
|
||||||
} else if (data.status === 'success') {
|
} else if (data.status === 'success') {
|
||||||
isSuccess = true;
|
isSuccess = true;
|
||||||
finalMessage = data.message;
|
finalData = data; // 🌟 修正:儲存完整的成功資料物件 (包含 metrics)
|
||||||
} else if (data.status === 'error') {
|
} else if (data.status === 'error') {
|
||||||
throw new Error(data.message);
|
throw new Error(data.message);
|
||||||
}
|
}
|
||||||
|
|
@ -1532,7 +1665,23 @@ async function createSnapshot() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
alert(`✅ ${finalMessage}`);
|
// 🌟 修正:從 finalData 中安全提取 metrics,並補齊所有預設值
|
||||||
|
const metrics = finalData?.metrics || { total: 0, pinned: 0, unpinned: 0, remaining: 20 };
|
||||||
|
const successMsg = finalData?.message || `快照 '${snapshotName}' 建立成功!`;
|
||||||
|
|
||||||
|
// 建立精美的容量看板 HTML 內容
|
||||||
|
const alertMsg =
|
||||||
|
`🎉 ${successMsg}\n\n` +
|
||||||
|
`📊 【當前設備備份容量看板】\n` +
|
||||||
|
`----------------------------------------\n` +
|
||||||
|
`🗄️ 總備份檔案數:${metrics.total} 筆\n` +
|
||||||
|
`📌 已釘選保護數:${metrics.pinned} 筆\n` +
|
||||||
|
`🔓 未釘選備份數:${metrics.unpinned} 筆\n` +
|
||||||
|
`🔋 剩餘可用空間:${metrics.remaining} 筆 (上限 20 筆)\n\n` +
|
||||||
|
`💡 提示:當可用空間為 0 時,新備份將會自動淘汰最舊的未釘選備份。您可以點擊 📌 圖示來保護重要備份!`;
|
||||||
|
|
||||||
|
alert(alertMsg);
|
||||||
|
|
||||||
document.getElementById('snapshotName').value = '';
|
document.getElementById('snapshotName').value = '';
|
||||||
if (document.getElementById('snapshotDescription')) {
|
if (document.getElementById('snapshotDescription')) {
|
||||||
document.getElementById('snapshotDescription').value = '';
|
document.getElementById('snapshotDescription').value = '';
|
||||||
|
|
@ -1542,7 +1691,7 @@ async function createSnapshot() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(`❌ 發生錯誤: ${error.message}`);
|
alert(`❌ 發生錯誤: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (progressInterval) clearInterval(progressInterval); // 🌟 確保清除計時器
|
if (progressInterval) clearInterval(progressInterval);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
msg.style.display = 'none';
|
msg.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
@ -1558,7 +1707,7 @@ async function loadBackupHistory() {
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
// 🌟 修正:將原本的灰色改為橘色並加粗
|
// 🌟 修正:將原本的灰色改為橘色並加粗
|
||||||
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" style="padding: 20px; text-align: center; color: #f39c12; font-weight: bold;">⏳ 正在載入歷史紀錄...</td></tr>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
|
// 🌟 關鍵修改:將 config_type 改為 'all',一次把該設備的所有快照撈回來
|
||||||
|
|
@ -1616,29 +1765,48 @@ window.applyBackupFilters = function() {
|
||||||
|
|
||||||
// 渲染過濾後的結果
|
// 渲染過濾後的結果
|
||||||
if (filteredData.length > 0) {
|
if (filteredData.length > 0) {
|
||||||
// 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode)
|
|
||||||
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
|
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
|
||||||
const displayAdmin = isGodMode ? 'inline-block' : 'none';
|
const displayAdmin = isGodMode ? 'inline-block' : 'none';
|
||||||
|
|
||||||
tbody.innerHTML = filteredData.map(item => `
|
tbody.innerHTML = filteredData.map(item => {
|
||||||
|
// 🌟 根據 is_pinned 狀態,決定 📌 的外觀與提示文字 (移除 margin-right)
|
||||||
|
const pinIcon = item.is_pinned
|
||||||
|
? `<span onclick="togglePin('${item.id}')" style="cursor: pointer; font-size: 16px; display: inline-block; transform: scale(1.2); transition: transform 0.1s;" title="📌 已釘選保護(系統絕不自動淘汰),點擊取消保護">📌</span>`
|
||||||
|
: `<span onclick="togglePin('${item.id}')" class="unpinned-icon" style="cursor: pointer; font-size: 16px; opacity: 0.2; display: inline-block; transition: all 0.2s;" title="📍 點擊釘選保護,防止被滾動淘汰">📌</span>`;
|
||||||
|
|
||||||
|
return `
|
||||||
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
|
||||||
|
<!-- 1. 時間 -->
|
||||||
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
|
||||||
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
|
|
||||||
<!-- 🟢 描述欄位 -->
|
<!-- 2. 🌟 獨立的保護 (釘選) 欄位,採水平與垂直置中對齊 -->
|
||||||
|
<td style="padding: 12px 10px; text-align: center; vertical-align: middle;">
|
||||||
|
${pinIcon}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- 3. 快照名稱 (純文字,完美對齊!) -->
|
||||||
|
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">
|
||||||
|
${item.snapshot_name}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- 4. 描述 -->
|
||||||
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
|
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
|
||||||
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
|
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<!-- 5. 類型 -->
|
||||||
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
|
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
|
||||||
|
|
||||||
|
<!-- 6. 操作 -->
|
||||||
<td style="padding: 12px 10px; text-align: right;">
|
<td style="padding: 12px 10px; text-align: right;">
|
||||||
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁️ 檢視</button>
|
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁️ 檢視</button>
|
||||||
<!-- 🌟 加上 admin-only-action class 與動態 display 樣式,只有解鎖後才會出現 -->
|
|
||||||
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
|
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
|
||||||
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
|
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
} else {
|
} else {
|
||||||
// 🟢 修正:沒有資料時,colspan 改為 5
|
|
||||||
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #7f8c8d;">找不到符合條件的備份紀錄。</td></tr>';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1694,6 +1862,21 @@ async function deleteBackup(backupId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🌟 新增:切換釘選狀態
|
||||||
|
window.togglePin = async function(backupId) {
|
||||||
|
try {
|
||||||
|
const result = await apiToggleBackupPin(backupId);
|
||||||
|
if (result.status === 'success') {
|
||||||
|
// 重新載入歷史紀錄,即時更新 📌 狀態
|
||||||
|
await loadBackupHistory();
|
||||||
|
} else {
|
||||||
|
alert(`❌ 操作失敗: ${result.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`❌ 連線失敗: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 5. 請求設備差異分析 (安全還原第一步)
|
// 5. 請求設備差異分析 (安全還原第一步)
|
||||||
// 🌟 接收第三個參數 backupHost
|
// 🌟 接收第三個參數 backupHost
|
||||||
async function restoreBackup(snapshotId, snapshotName, backupHost) {
|
async function restoreBackup(snapshotId, snapshotName, backupHost) {
|
||||||
|
|
@ -2157,6 +2340,7 @@ window.scanMissingOptions = scanMissingOptions;
|
||||||
window.clearOptionsCache = clearOptionsCache;
|
window.clearOptionsCache = clearOptionsCache;
|
||||||
window.previewNodeCLI = previewNodeCLI;
|
window.previewNodeCLI = previewNodeCLI;
|
||||||
window.switchFilterMode = switchFilterMode;
|
window.switchFilterMode = switchFilterMode;
|
||||||
|
window.toggleLogStream = toggleLogStream;
|
||||||
|
|
||||||
// --- 樹狀圖展開與編輯操作 ---
|
// --- 樹狀圖展開與編輯操作 ---
|
||||||
window.expandAll = expandAll;
|
window.expandAll = expandAll;
|
||||||
|
|
@ -2183,3 +2367,4 @@ window.deleteBackup = deleteBackup;
|
||||||
window.restoreBackup = restoreBackup;
|
window.restoreBackup = restoreBackup;
|
||||||
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
|
window.applyBackupFilters = applyBackupFilters; // 🌟 確保過濾器綁定到全域
|
||||||
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
|
window.changeLogLevel = changeLogLevel; // 🌟 新增這行,統一在這裡綁定!
|
||||||
|
window.togglePin = togglePin;
|
||||||
|
|
@ -127,7 +127,7 @@ button:hover { background-color: #3498db; }
|
||||||
.form-actions button { padding: 8px 20px; font-size: 14px; min-width: 120px; }
|
.form-actions button { padding: 8px 20px; font-size: 14px; min-width: 120px; }
|
||||||
|
|
||||||
/* 8. 彈出式輸出視窗 (Modal) 樣式 */
|
/* 8. 彈出式輸出視窗 (Modal) 樣式 */
|
||||||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; backdrop-filter: blur(3px); }
|
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); } /* 🌟 新增這行支援 Safari */
|
||||||
.modal-overlay.active { display: flex; animation: fadeIn 0.2s ease-out; }
|
.modal-overlay.active { display: flex; animation: fadeIn 0.2s ease-out; }
|
||||||
.modal-container { background: #1e1e1e; width: 95vw; max-width: 1800px; height: 85vh; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 15px 40px rgba(0,0,0,0.6); transform: translateY(20px); transition: transform 0.3s ease-out; }
|
.modal-container { background: #1e1e1e; width: 95vw; max-width: 1800px; height: 85vh; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 15px 40px rgba(0,0,0,0.6); transform: translateY(20px); transition: transform 0.3s ease-out; }
|
||||||
.modal-overlay.active .modal-container { transform: translateY(0); }
|
.modal-overlay.active .modal-container { transform: translateY(0); }
|
||||||
|
|
@ -168,6 +168,7 @@ button:hover { background-color: #3498db; }
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
|
-webkit-user-select: none; /* 🌟 新增這行支援 Safari */
|
||||||
user-select: none;
|
user-select: none;
|
||||||
color: #2c3e50; /* 保持原本的深色文字 */
|
color: #2c3e50; /* 保持原本的深色文字 */
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
|
|
@ -302,3 +303,40 @@ button:hover { background-color: #3498db; }
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: 0 24px;
|
contain-intrinsic-size: 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
🎙️ 系統實時日誌終端機 - 滾動條隔離優化 (Scrollbar Isolation - 修正版)
|
||||||
|
============================================================================ */
|
||||||
|
#log-terminal-container .xterm {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-terminal-container .xterm-screen {
|
||||||
|
/* 🌟 關鍵:限制文字區最大寬度,扣除右側垂直滾動條的寬度 */
|
||||||
|
max-width: calc(100% - 18px) !important;
|
||||||
|
|
||||||
|
/* 🌟 關鍵:只允許水平滾動,強制關閉垂直滾動,消除多餘的藍灰色軌道 */
|
||||||
|
overflow-x: auto !important;
|
||||||
|
overflow-y: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 讓水平滾動條呈現精美的扁平化現代風格 */
|
||||||
|
#log-terminal-container .xterm-screen::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-terminal-container .xterm-screen::-webkit-scrollbar-track {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-terminal-container .xterm-screen::-webkit-scrollbar-thumb {
|
||||||
|
background: #475569;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-terminal-container .xterm-screen::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unpinned-icon:hover { opacity: 1 !important; transform: scale(1.2); }
|
||||||
|
|
@ -65,6 +65,11 @@ export function connectWebSocket(resetCallback) {
|
||||||
|
|
||||||
if (resetCallback) resetCallback(); // 呼叫 app.js 傳進來的重置函數
|
if (resetCallback) resetCallback(); // 呼叫 app.js 傳進來的重置函數
|
||||||
|
|
||||||
|
// 🌟 [修復] 切換設備時,徹底清空終端機舊有畫面與緩衝區
|
||||||
|
if (term) {
|
||||||
|
term.clear();
|
||||||
|
}
|
||||||
|
|
||||||
term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`);
|
term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`);
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/terminal?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
|
const wsUrl = `${protocol}//${window.location.host}/ws/terminal?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
|
||||||
|
|
@ -79,8 +84,9 @@ export function connectWebSocket(resetCallback) {
|
||||||
btnLoadTask.style.backgroundColor = '#c0392b';
|
btnLoadTask.style.backgroundColor = '#c0392b';
|
||||||
btnLoadTask.style.cursor = 'pointer';
|
btnLoadTask.style.cursor = 'pointer';
|
||||||
}
|
}
|
||||||
document.getElementById('wsStatus').textContent = `狀態:已連線`;
|
// 🌟 UX 升級:剛連上 WS 時,顯示「驗證中...」而不是直接顯示「已連線」
|
||||||
document.getElementById('wsStatus').style.color = '#27ae60';
|
document.getElementById('wsStatus').textContent = `狀態:連線與驗證中...`;
|
||||||
|
document.getElementById('wsStatus').style.color = '#f39c12'; // 橘色
|
||||||
document.getElementById('btnConnect').style.display = 'none';
|
document.getElementById('btnConnect').style.display = 'none';
|
||||||
document.getElementById('btnDisconnect').style.display = 'inline-block';
|
document.getElementById('btnDisconnect').style.display = 'inline-block';
|
||||||
document.getElementById('cmtsHost').disabled = true;
|
document.getElementById('cmtsHost').disabled = true;
|
||||||
|
|
@ -102,6 +108,12 @@ export function connectWebSocket(resetCallback) {
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
|
// 🌟 UX 升級:只要收到設備傳來的第一個字元,就代表 SSH 驗證成功,正式轉為綠色「已連線」
|
||||||
|
const statusEl = document.getElementById('wsStatus');
|
||||||
|
if (statusEl.textContent.includes('驗證中')) {
|
||||||
|
statusEl.textContent = `狀態:已連線`;
|
||||||
|
statusEl.style.color = '#27ae60'; // 綠色
|
||||||
|
}
|
||||||
term.write(colorizeTerminalStream(event.data));
|
term.write(colorizeTerminalStream(event.data));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -122,6 +134,11 @@ export function connectWebSocket(resetCallback) {
|
||||||
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');
|
||||||
|
|
||||||
|
// 🌟 關鍵修復:攔截後端傳來的 4001 錯誤碼,彈出明確的警告視窗
|
||||||
|
if (event.code === 4001) {
|
||||||
|
alert(`❌ 連線失敗!\n請檢查目標設備 IP、帳號與密碼是否正確。\n\n系統訊息: ${event.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 🌟 新增:斷線時,強制觸發配置任務切換,藉此「關閉」SSE 監聽
|
// 🌟 新增:斷線時,強制觸發配置任務切換,藉此「關閉」SSE 監聽
|
||||||
const configTaskSelect = document.getElementById('configTask');
|
const configTaskSelect = document.getElementById('configTask');
|
||||||
if (configTaskSelect) {
|
if (configTaskSelect) {
|
||||||
|
|
|
||||||
|
|
@ -475,7 +475,11 @@ window.forceRenderFolderHTML = function(elementId) {
|
||||||
const contentDiv = document.getElementById(`content-${elementId}`);
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
const cache = folderDataCache[elementId];
|
const cache = folderDataCache[elementId];
|
||||||
|
|
||||||
if (detailsEl && contentDiv && cache && detailsEl.dataset.loaded !== 'true') {
|
// 🌟 關鍵修復:移除 detailsEl.dataset.loaded !== 'true' 的限制!
|
||||||
|
// 因為如果使用者先手動展開了父資料夾,再點擊編輯,
|
||||||
|
// 原本的邏輯會因為 loaded === true 而拒絕往下遞迴渲染子資料夾,
|
||||||
|
// 導致子資料夾內的項目沒有被轉成輸入框。
|
||||||
|
if (detailsEl && contentDiv && cache) {
|
||||||
const currentPath = detailsEl.dataset.path;
|
const currentPath = detailsEl.dataset.path;
|
||||||
const isCommandGroup = detailsEl.dataset.isGroup === 'true';
|
const isCommandGroup = detailsEl.dataset.isGroup === 'true';
|
||||||
const mode = detailsEl.dataset.mode;
|
const mode = detailsEl.dataset.mode;
|
||||||
|
|
|
||||||
2428
target_code.txt
2428
target_code.txt
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue