perf(core): 實施 Priority 3 效能與體驗極致優化
- 前端渲染優化:將 tree-ui.js 的巨量字串拼接改為 Array.join,大幅降低 V8 引擎 GC 負擔與畫面卡頓。 - Terminal 資源回收:強化 WebSocket 斷線偵測,主動 cancel 背景任務並確保 SSH 連線乾淨釋放 (terminal.py)。 - 智慧 Prompt 偵測:全面導入 Regex 匹配設備 Prompt,取代盲目 Timeout 等待,巨幅提升爬蟲與設定寫入速度 (cmts_scraper.py, backup.py, config.py)。
This commit is contained in:
parent
60195a735e
commit
2168ccf770
96
all_code.txt
96
all_code.txt
|
|
@ -1429,7 +1429,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as 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):
|
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
||||||
output = ""
|
output = ""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -1439,6 +1439,9 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
if "--More--" in chunk or "More" in chunk:
|
if "--More--" in chunk or "More" in chunk:
|
||||||
process.stdin.write(" ")
|
process.stdin.write(" ")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
# 🌟 精準 Prompt 偵測
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, output):
|
||||||
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
break
|
break
|
||||||
return output
|
return output
|
||||||
|
|
@ -1447,14 +1450,14 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
if cmts_version == "unknown":
|
if cmts_version == "unknown":
|
||||||
process.stdin.write("show version\n")
|
process.stdin.write("show version\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
version_output = await read_until_quiet(timeout=1.5)
|
version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
|
||||||
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
|
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
|
||||||
if match:
|
if match:
|
||||||
cmts_version = match.group(1)
|
cmts_version = match.group(1)
|
||||||
|
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.5)
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
|
||||||
|
|
||||||
for path in batch:
|
for path in batch:
|
||||||
# 🌟 優化 1:強迫讓出事件迴圈控制權,讓 FastAPI 去處理其他使用者的請求
|
# 🌟 優化 1:強迫讓出事件迴圈控制權,讓 FastAPI 去處理其他使用者的請求
|
||||||
|
|
@ -1609,7 +1612,7 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
|
||||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as 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):
|
async def read_until_quiet(timeout=2.0, prompt_pattern: str = None):
|
||||||
output = ""
|
output = ""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -1620,25 +1623,28 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
|
||||||
if "--More--" in chunk or "More" in chunk:
|
if "--More--" in chunk or "More" in chunk:
|
||||||
process.stdin.write(" ")
|
process.stdin.write(" ")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
# 🌟 精準 Prompt 偵測
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, output):
|
||||||
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
break
|
break
|
||||||
return output
|
return output
|
||||||
|
|
||||||
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
|
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
|
||||||
await read_until_quiet(timeout=1.0)
|
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
|
||||||
|
|
||||||
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
|
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
|
||||||
if config_type == "full":
|
if config_type == "full":
|
||||||
# 1. 先進入 config 模式
|
# 1. 先進入 config 模式
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)#
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") # 等待提示字元變成 (config)#
|
||||||
|
|
||||||
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
|
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
|
||||||
cmd = "show full-configuration | nomore"
|
cmd = "show full-configuration | nomore"
|
||||||
process.stdin.write(f"{cmd}\n")
|
process.stdin.write(f"{cmd}\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
raw_output = await read_until_quiet(timeout=3.0)
|
raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
|
||||||
|
|
||||||
# 3. 抓完後退回上一層 (保持良好習慣)
|
# 3. 抓完後退回上一層 (保持良好習慣)
|
||||||
process.stdin.write("exit\n")
|
process.stdin.write("exit\n")
|
||||||
|
|
@ -5127,8 +5133,8 @@ export function collapseAll(elementId) {
|
||||||
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
|
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
|
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
|
||||||
let html = '';
|
const htmlParts = [];
|
||||||
if (!node || typeof node !== 'object') return html;
|
if (!node || typeof node !== 'object') return htmlParts.join('');
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(node)) {
|
for (const [key, value] of Object.entries(node)) {
|
||||||
const currentPath = path ? `${path}::${key}` : key;
|
const currentPath = path ? `${path}::${key}` : key;
|
||||||
|
|
@ -5169,8 +5175,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder
|
htmlParts.push(`
|
||||||
html += `
|
|
||||||
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
|
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
|
||||||
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
||||||
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
|
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
|
||||||
|
|
@ -5211,9 +5216,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
<!-- 🌟 延遲渲染:這裡一開始是空的,展開時才會填入 -->
|
<!-- 🌟 延遲渲染:這裡一開始是空的,展開時才會填入 -->
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`);
|
||||||
} else {
|
} else {
|
||||||
// 葉節點邏輯保持不變
|
|
||||||
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
||||||
const isNoCommand = (key === 'no');
|
const isNoCommand = (key === 'no');
|
||||||
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||||
|
|
@ -5258,7 +5262,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `
|
htmlParts.push(`
|
||||||
<div class="tree-leaf-node"
|
<div class="tree-leaf-node"
|
||||||
title="${cliCommand}"
|
title="${cliCommand}"
|
||||||
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||||||
|
|
@ -5286,10 +5290,10 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return html;
|
return htmlParts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
@ -5298,7 +5302,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
|
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
|
||||||
if (typeof data !== 'object' || data === null) return '';
|
if (typeof data !== 'object' || data === null) return '';
|
||||||
|
|
||||||
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
|
const htmlParts = [];
|
||||||
|
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
|
||||||
|
|
||||||
for (const key in data) {
|
for (const key in data) {
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
|
|
@ -5348,7 +5353,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 🌟 加入 lazyLoadFolder 觸發
|
// 🌟 加入 lazyLoadFolder 觸發
|
||||||
html += `
|
htmlParts.push(`
|
||||||
<details id="details-${elementId}" class="tree-folder"
|
<details id="details-${elementId}" class="tree-folder"
|
||||||
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
||||||
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
|
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
|
||||||
|
|
@ -5366,9 +5371,8 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
<!-- 🌟 延遲渲染 -->
|
<!-- 🌟 延遲渲染 -->
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`);
|
||||||
} else {
|
} else {
|
||||||
// 葉節點邏輯保持不變
|
|
||||||
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
||||||
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
|
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
|
||||||
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||||
|
|
@ -5389,7 +5393,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `
|
htmlParts.push(`
|
||||||
<div class="tree-leaf-node"
|
<div class="tree-leaf-node"
|
||||||
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||||||
onmouseout="this.style.backgroundColor='transparent'"
|
onmouseout="this.style.backgroundColor='transparent'"
|
||||||
|
|
@ -5402,11 +5406,11 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
<span style="margin-right: 5px;">${displayName}${colonHtml}</span>
|
<span style="margin-right: 5px;">${displayName}${colonHtml}</span>
|
||||||
${valueHtml}
|
${valueHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
html += '</div>';
|
htmlParts.push('</div>');
|
||||||
return html;
|
return htmlParts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
|
|
@ -5655,7 +5659,7 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as conn:
|
async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as 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):
|
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
||||||
output = ""
|
output = ""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -5665,6 +5669,9 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
if "--More--" in chunk or "More" in chunk:
|
if "--More--" in chunk or "More" in chunk:
|
||||||
process.stdin.write(" ")
|
process.stdin.write(" ")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
# 🌟 精準 Prompt 偵測
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, output):
|
||||||
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
break
|
break
|
||||||
return output
|
return output
|
||||||
|
|
@ -5672,14 +5679,14 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
# 2. 進入設定模式
|
# 2. 進入設定模式
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.5)
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
|
||||||
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
|
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
|
||||||
|
|
||||||
# 3. 逐行寫入並進行 Fail-safe 檢查
|
# 3. 逐行寫入並進行 Fail-safe 檢查
|
||||||
for cmd in commands:
|
for cmd in commands:
|
||||||
process.stdin.write(f"{cmd}\n")
|
process.stdin.write(f"{cmd}\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
out = await read_until_quiet(timeout=0.2)
|
out = await read_until_quiet(timeout=0.2, prompt_pattern=r"\(config.*\)#")
|
||||||
|
|
||||||
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
|
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
|
||||||
|
|
||||||
|
|
@ -5694,7 +5701,7 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
# 2. 對設備下達 abort 指令
|
# 2. 對設備下達 abort 指令
|
||||||
process.stdin.write("abort\n")
|
process.stdin.write("abort\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.0) # 等待設備處理 abort 並退出 config 模式
|
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") # 等待設備處理 abort 並退出 config 模式
|
||||||
|
|
||||||
# 3. 回報最終錯誤並關閉連線
|
# 3. 回報最終錯誤並關閉連線
|
||||||
yield json.dumps({
|
yield json.dumps({
|
||||||
|
|
@ -5702,7 +5709,6 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
|
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
|
||||||
"output": clean_out
|
"output": clean_out
|
||||||
}) + "\n"
|
}) + "\n"
|
||||||
# 🌟 移除手動 conn.close(),交由 async with 處理
|
|
||||||
return
|
return
|
||||||
|
|
||||||
yield json.dumps({
|
yield json.dumps({
|
||||||
|
|
@ -5718,8 +5724,7 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
|
||||||
process.stdin.write("commit\n")
|
process.stdin.write("commit\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
commit_out = await read_until_quiet(timeout=6.0)
|
commit_out = await read_until_quiet(timeout=6.0, prompt_pattern=r"\(config\)#")
|
||||||
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
|
|
||||||
|
|
||||||
# 5. 退出並關閉連線
|
# 5. 退出並關閉連線
|
||||||
process.stdin.write("exit\n")
|
process.stdin.write("exit\n")
|
||||||
|
|
@ -5753,6 +5758,9 @@ router = APIRouter()
|
||||||
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
|
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
conn = None
|
conn = None
|
||||||
|
process = None
|
||||||
|
ws_task = None
|
||||||
|
ssh_task = None
|
||||||
try:
|
try:
|
||||||
# 建立連線
|
# 建立連線
|
||||||
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
|
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
|
||||||
|
|
@ -5774,6 +5782,8 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
|
|
||||||
safe_text = data_bytes.decode('utf-8', errors='replace')
|
safe_text = data_bytes.decode('utf-8', errors='replace')
|
||||||
await websocket.send_text(safe_text)
|
await websocket.send_text(safe_text)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:")
|
print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
@ -5791,6 +5801,9 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
process.stdin.write(data_bytes)
|
process.stdin.write(data_bytes)
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
# 主動拋出,讓外層捕捉以進行資源回收
|
||||||
|
raise
|
||||||
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:")
|
print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:")
|
||||||
|
|
@ -5807,6 +5820,12 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
for task in pending:
|
for task in pending:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
print("[DEBUG] WebSocket 正常斷線,正在終止背景任務...")
|
||||||
|
if ws_task and not ws_task.done():
|
||||||
|
ws_task.cancel()
|
||||||
|
if ssh_task and not ssh_task.done():
|
||||||
|
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"
|
error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n"
|
||||||
try:
|
try:
|
||||||
|
|
@ -5816,6 +5835,9 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:")
|
print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
finally:
|
finally:
|
||||||
|
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
|
||||||
|
if process:
|
||||||
|
process.close()
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
try:
|
try:
|
||||||
|
|
@ -5824,6 +5846,7 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
FILE: routers/lock.py
|
FILE: routers/lock.py
|
||||||
================================================================================
|
================================================================================
|
||||||
|
|
@ -6294,7 +6317,7 @@ async def execute_cmts_config(req: ConfigRequest):
|
||||||
async with conn.create_process() as process:
|
async with conn.create_process() as process:
|
||||||
|
|
||||||
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
|
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
|
||||||
async def read_until_quiet(timeout=0.5):
|
async def read_until_quiet(timeout=0.5, prompt_pattern: str = None):
|
||||||
out_data = ""
|
out_data = ""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -6303,6 +6326,9 @@ async def execute_cmts_config(req: ConfigRequest):
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
out_data += str(chunk)
|
out_data += str(chunk)
|
||||||
|
# 🌟 精準 Prompt 偵測
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, out_data):
|
||||||
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
break
|
break
|
||||||
return out_data
|
return out_data
|
||||||
|
|
@ -6310,7 +6336,7 @@ async def execute_cmts_config(req: ConfigRequest):
|
||||||
# 1. 進入設定模式
|
# 1. 進入設定模式
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(0.5) # 清空歡迎訊息
|
await read_until_quiet(0.5, prompt_pattern=r"\(config\)#") # 清空歡迎訊息
|
||||||
|
|
||||||
# 2. 逐行送出指令並回報進度
|
# 2. 逐行送出指令並回報進度
|
||||||
for cmd in commands:
|
for cmd in commands:
|
||||||
|
|
@ -6320,7 +6346,7 @@ async def execute_cmts_config(req: ConfigRequest):
|
||||||
|
|
||||||
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
|
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
|
||||||
wait_time = 5.0 if cmd.strip() == "commit" else 0.5
|
wait_time = 5.0 if cmd.strip() == "commit" else 0.5
|
||||||
output = await read_until_quiet(wait_time)
|
output = await read_until_quiet(wait_time, prompt_pattern=r"\(config.*\)#")
|
||||||
|
|
||||||
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
|
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
|
||||||
lower_output = output.lower()
|
lower_output = output.lower()
|
||||||
|
|
@ -6338,7 +6364,7 @@ async def execute_cmts_config(req: ConfigRequest):
|
||||||
# 3. 退出設定模式
|
# 3. 退出設定模式
|
||||||
process.stdin.write("exit\n")
|
process.stdin.write("exit\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
final_output = await read_until_quiet(1.0)
|
final_output = await read_until_quiet(1.0, prompt_pattern=r"(?:#|>)")
|
||||||
|
|
||||||
yield json.dumps({
|
yield json.dumps({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as 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):
|
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
||||||
output = ""
|
output = ""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -173,6 +173,9 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
if "--More--" in chunk or "More" in chunk:
|
if "--More--" in chunk or "More" in chunk:
|
||||||
process.stdin.write(" ")
|
process.stdin.write(" ")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
# 🌟 精準 Prompt 偵測
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, output):
|
||||||
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
break
|
break
|
||||||
return output
|
return output
|
||||||
|
|
@ -181,14 +184,14 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
|
||||||
if cmts_version == "unknown":
|
if cmts_version == "unknown":
|
||||||
process.stdin.write("show version\n")
|
process.stdin.write("show version\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
version_output = await read_until_quiet(timeout=1.5)
|
version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
|
||||||
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
|
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
|
||||||
if match:
|
if match:
|
||||||
cmts_version = match.group(1)
|
cmts_version = match.group(1)
|
||||||
|
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.5)
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
|
||||||
|
|
||||||
for path in batch:
|
for path in batch:
|
||||||
# 🌟 優化 1:強迫讓出事件迴圈控制權,讓 FastAPI 去處理其他使用者的請求
|
# 🌟 優化 1:強迫讓出事件迴圈控制權,讓 FastAPI 去處理其他使用者的請求
|
||||||
|
|
@ -343,7 +346,7 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
|
||||||
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as conn:
|
async with asyncssh.connect(host, username=username, password=password, known_hosts=None) as 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):
|
async def read_until_quiet(timeout=2.0, prompt_pattern: str = None):
|
||||||
output = ""
|
output = ""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -354,25 +357,28 @@ async def fetch_raw_config(host: str, username: str, password: str, config_type:
|
||||||
if "--More--" in chunk or "More" in chunk:
|
if "--More--" in chunk or "More" in chunk:
|
||||||
process.stdin.write(" ")
|
process.stdin.write(" ")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
# 🌟 精準 Prompt 偵測
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, output):
|
||||||
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
break
|
break
|
||||||
return output
|
return output
|
||||||
|
|
||||||
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
|
# 🌟 新增這行:剛連線成功後,先清空終端機的登入歡迎詞 (MOTD) 與雜訊
|
||||||
await read_until_quiet(timeout=1.0)
|
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)")
|
||||||
|
|
||||||
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
|
# 關鍵修改:根據 config_type 切換模式與指令,並加上 | nomore 關閉分頁
|
||||||
if config_type == "full":
|
if config_type == "full":
|
||||||
# 1. 先進入 config 模式
|
# 1. 先進入 config 模式
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.5) # 等待提示字元變成 (config)#
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") # 等待提示字元變成 (config)#
|
||||||
|
|
||||||
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
|
# 2. 下達 full-configuration 指令 (🌟 加上 | nomore)
|
||||||
cmd = "show full-configuration | nomore"
|
cmd = "show full-configuration | nomore"
|
||||||
process.stdin.write(f"{cmd}\n")
|
process.stdin.write(f"{cmd}\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
raw_output = await read_until_quiet(timeout=3.0)
|
raw_output = await read_until_quiet(timeout=3.0, prompt_pattern=r"\(config\)#")
|
||||||
|
|
||||||
# 3. 抓完後退回上一層 (保持良好習慣)
|
# 3. 抓完後退回上一層 (保持良好習慣)
|
||||||
process.stdin.write("exit\n")
|
process.stdin.write("exit\n")
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as conn:
|
async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as 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):
|
async def read_until_quiet(timeout=1.0, prompt_pattern: str = None):
|
||||||
output = ""
|
output = ""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -251,6 +251,9 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
if "--More--" in chunk or "More" in chunk:
|
if "--More--" in chunk or "More" in chunk:
|
||||||
process.stdin.write(" ")
|
process.stdin.write(" ")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
|
# 🌟 精準 Prompt 偵測
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, output):
|
||||||
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
break
|
break
|
||||||
return output
|
return output
|
||||||
|
|
@ -258,14 +261,14 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
# 2. 進入設定模式
|
# 2. 進入設定模式
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.5)
|
await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#")
|
||||||
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
|
yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n"
|
||||||
|
|
||||||
# 3. 逐行寫入並進行 Fail-safe 檢查
|
# 3. 逐行寫入並進行 Fail-safe 檢查
|
||||||
for cmd in commands:
|
for cmd in commands:
|
||||||
process.stdin.write(f"{cmd}\n")
|
process.stdin.write(f"{cmd}\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
out = await read_until_quiet(timeout=0.2)
|
out = await read_until_quiet(timeout=0.2, prompt_pattern=r"\(config.*\)#")
|
||||||
|
|
||||||
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
|
clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip()
|
||||||
|
|
||||||
|
|
@ -280,7 +283,7 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
# 2. 對設備下達 abort 指令
|
# 2. 對設備下達 abort 指令
|
||||||
process.stdin.write("abort\n")
|
process.stdin.write("abort\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(timeout=1.0) # 等待設備處理 abort 並退出 config 模式
|
await read_until_quiet(timeout=1.0, prompt_pattern=r"(?:#|>)") # 等待設備處理 abort 並退出 config 模式
|
||||||
|
|
||||||
# 3. 回報最終錯誤並關閉連線
|
# 3. 回報最終錯誤並關閉連線
|
||||||
yield json.dumps({
|
yield json.dumps({
|
||||||
|
|
@ -288,7 +291,6 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
|
"message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}",
|
||||||
"output": clean_out
|
"output": clean_out
|
||||||
}) + "\n"
|
}) + "\n"
|
||||||
# 🌟 移除手動 conn.close(),交由 async with 處理
|
|
||||||
return
|
return
|
||||||
|
|
||||||
yield json.dumps({
|
yield json.dumps({
|
||||||
|
|
@ -304,8 +306,7 @@ async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
|
yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n"
|
||||||
process.stdin.write("commit\n")
|
process.stdin.write("commit\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
commit_out = await read_until_quiet(timeout=6.0)
|
commit_out = await read_until_quiet(timeout=6.0, prompt_pattern=r"\(config\)#")
|
||||||
clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip()
|
|
||||||
|
|
||||||
# 5. 退出並關閉連線
|
# 5. 退出並關閉連線
|
||||||
process.stdin.write("exit\n")
|
process.stdin.write("exit\n")
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ async def execute_cmts_config(req: ConfigRequest):
|
||||||
async with conn.create_process() as process:
|
async with conn.create_process() as process:
|
||||||
|
|
||||||
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
|
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
|
||||||
async def read_until_quiet(timeout=0.5):
|
async def read_until_quiet(timeout=0.5, prompt_pattern: str = None):
|
||||||
out_data = ""
|
out_data = ""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -114,6 +114,9 @@ async def execute_cmts_config(req: ConfigRequest):
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
out_data += str(chunk)
|
out_data += str(chunk)
|
||||||
|
# 🌟 精準 Prompt 偵測
|
||||||
|
if prompt_pattern and re.search(prompt_pattern, out_data):
|
||||||
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
break
|
break
|
||||||
return out_data
|
return out_data
|
||||||
|
|
@ -121,7 +124,7 @@ async def execute_cmts_config(req: ConfigRequest):
|
||||||
# 1. 進入設定模式
|
# 1. 進入設定模式
|
||||||
process.stdin.write("config\n")
|
process.stdin.write("config\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
await read_until_quiet(0.5) # 清空歡迎訊息
|
await read_until_quiet(0.5, prompt_pattern=r"\(config\)#") # 清空歡迎訊息
|
||||||
|
|
||||||
# 2. 逐行送出指令並回報進度
|
# 2. 逐行送出指令並回報進度
|
||||||
for cmd in commands:
|
for cmd in commands:
|
||||||
|
|
@ -131,7 +134,7 @@ async def execute_cmts_config(req: ConfigRequest):
|
||||||
|
|
||||||
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
|
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
|
||||||
wait_time = 5.0 if cmd.strip() == "commit" else 0.5
|
wait_time = 5.0 if cmd.strip() == "commit" else 0.5
|
||||||
output = await read_until_quiet(wait_time)
|
output = await read_until_quiet(wait_time, prompt_pattern=r"\(config.*\)#")
|
||||||
|
|
||||||
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
|
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
|
||||||
lower_output = output.lower()
|
lower_output = output.lower()
|
||||||
|
|
@ -149,7 +152,7 @@ async def execute_cmts_config(req: ConfigRequest):
|
||||||
# 3. 退出設定模式
|
# 3. 退出設定模式
|
||||||
process.stdin.write("exit\n")
|
process.stdin.write("exit\n")
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
final_output = await read_until_quiet(1.0)
|
final_output = await read_until_quiet(1.0, prompt_pattern=r"(?:#|>)")
|
||||||
|
|
||||||
yield json.dumps({
|
yield json.dumps({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ router = APIRouter()
|
||||||
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
|
async def websocket_terminal(websocket: WebSocket, host: str, username: str, password: str):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
conn = None
|
conn = None
|
||||||
|
process = None
|
||||||
|
ws_task = None
|
||||||
|
ssh_task = None
|
||||||
try:
|
try:
|
||||||
# 建立連線
|
# 建立連線
|
||||||
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
|
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
|
||||||
|
|
@ -30,6 +33,8 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
|
|
||||||
safe_text = data_bytes.decode('utf-8', errors='replace')
|
safe_text = data_bytes.decode('utf-8', errors='replace')
|
||||||
await websocket.send_text(safe_text)
|
await websocket.send_text(safe_text)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:")
|
print("\n❌ [WS Forward Error] 讀取設備畫面時發生錯誤:")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
@ -47,6 +52,9 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
process.stdin.write(data_bytes)
|
process.stdin.write(data_bytes)
|
||||||
await process.stdin.drain()
|
await process.stdin.drain()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
# 主動拋出,讓外層捕捉以進行資源回收
|
||||||
|
raise
|
||||||
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:")
|
print("\n❌ [SSH Forward Error] 寫入指令到設備時發生錯誤:")
|
||||||
|
|
@ -63,6 +71,12 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
for task in pending:
|
for task in pending:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
print("[DEBUG] WebSocket 正常斷線,正在終止背景任務...")
|
||||||
|
if ws_task and not ws_task.done():
|
||||||
|
ws_task.cancel()
|
||||||
|
if ssh_task and not ssh_task.done():
|
||||||
|
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"
|
error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n"
|
||||||
try:
|
try:
|
||||||
|
|
@ -72,9 +86,13 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
|
||||||
print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:")
|
print("\n❌ [Connection Error] 建立連線或執行過程中發生錯誤:")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
finally:
|
finally:
|
||||||
|
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
|
||||||
|
if process:
|
||||||
|
process.close()
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
try:
|
try:
|
||||||
await websocket.close()
|
await websocket.close()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,8 @@ export function collapseAll(elementId) {
|
||||||
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
|
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
|
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running') {
|
||||||
let html = '';
|
const htmlParts = [];
|
||||||
if (!node || typeof node !== 'object') return html;
|
if (!node || typeof node !== 'object') return htmlParts.join('');
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(node)) {
|
for (const [key, value] of Object.entries(node)) {
|
||||||
const currentPath = path ? `${path}::${key}` : key;
|
const currentPath = path ? `${path}::${key}` : key;
|
||||||
|
|
@ -116,8 +116,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 🌟 關鍵修改:加入 data-* 屬性,並在 ontoggle 時呼叫 lazyLoadFolder
|
htmlParts.push(`
|
||||||
html += `
|
|
||||||
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
|
<details id="details-${elementId}" class="tree-folder" style="margin-top: 4px; margin-left: 20px;"
|
||||||
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
||||||
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
|
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', false);">
|
||||||
|
|
@ -158,9 +157,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
<!-- 🌟 延遲渲染:這裡一開始是空的,展開時才會填入 -->
|
<!-- 🌟 延遲渲染:這裡一開始是空的,展開時才會填入 -->
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`);
|
||||||
} else {
|
} else {
|
||||||
// 葉節點邏輯保持不變
|
|
||||||
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
||||||
const isNoCommand = (key === 'no');
|
const isNoCommand = (key === 'no');
|
||||||
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||||
|
|
@ -205,7 +203,7 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `
|
htmlParts.push(`
|
||||||
<div class="tree-leaf-node"
|
<div class="tree-leaf-node"
|
||||||
title="${cliCommand}"
|
title="${cliCommand}"
|
||||||
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||||||
|
|
@ -233,10 +231,10 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return html;
|
return htmlParts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
@ -245,7 +243,8 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
|
||||||
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
|
export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isParentCommandGroup = false, mode = 'running') {
|
||||||
if (typeof data !== 'object' || data === null) return '';
|
if (typeof data !== 'object' || data === null) return '';
|
||||||
|
|
||||||
let html = `<div style="margin-left: ${parentPath ? '24px' : '0'};">`;
|
const htmlParts = [];
|
||||||
|
htmlParts.push(`<div style="margin-left: ${parentPath ? '24px' : '0'};">`);
|
||||||
|
|
||||||
for (const key in data) {
|
for (const key in data) {
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
|
|
@ -295,7 +294,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 🌟 加入 lazyLoadFolder 觸發
|
// 🌟 加入 lazyLoadFolder 觸發
|
||||||
html += `
|
htmlParts.push(`
|
||||||
<details id="details-${elementId}" class="tree-folder"
|
<details id="details-${elementId}" class="tree-folder"
|
||||||
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
data-path="${currentPath}" data-is-group="${isCommandGroup}" data-mode="${mode}"
|
||||||
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
|
ontoggle="this.querySelector('.icon-closed').style.display = this.open ? 'none' : 'inline-block'; this.querySelector('.icon-open').style.display = this.open ? 'inline-block' : 'none'; if(this.open) window.lazyLoadFolder('${elementId}', true);">
|
||||||
|
|
@ -313,9 +312,8 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
<!-- 🌟 延遲渲染 -->
|
<!-- 🌟 延遲渲染 -->
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`);
|
||||||
} else {
|
} else {
|
||||||
// 葉節點邏輯保持不變
|
|
||||||
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
const isVirtualIndex = /^\[\d+\]$/.test(key);
|
||||||
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
|
let displayName = isVirtualIndex ? `<span style="color: #95a5a6; font-weight: normal;">項目 ${key}</span>` : `<b>${key}</b>`;
|
||||||
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
||||||
|
|
@ -336,7 +334,7 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
currentIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#e74c3c"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `
|
htmlParts.push(`
|
||||||
<div class="tree-leaf-node"
|
<div class="tree-leaf-node"
|
||||||
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
onmouseover="this.style.backgroundColor='#f1f2f6'"
|
||||||
onmouseout="this.style.backgroundColor='transparent'"
|
onmouseout="this.style.backgroundColor='transparent'"
|
||||||
|
|
@ -349,9 +347,9 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
|
||||||
<span style="margin-right: 5px;">${displayName}${colonHtml}</span>
|
<span style="margin-right: 5px;">${displayName}${colonHtml}</span>
|
||||||
${valueHtml}
|
${valueHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
html += '</div>';
|
htmlParts.push('</div>');
|
||||||
return html;
|
return htmlParts.join('');
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue