feat: Smart Diff Recovery & Preview
This commit is contained in:
parent
eb8c3af98e
commit
77821fc620
|
|
@ -1,7 +1,10 @@
|
||||||
import logging
|
import logging
|
||||||
|
import asyncssh
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
|
||||||
# 引入 DB 函數
|
# 引入 DB 函數
|
||||||
from database import (
|
from database import (
|
||||||
|
|
@ -11,8 +14,8 @@ from database import (
|
||||||
delete_config_backup
|
delete_config_backup
|
||||||
)
|
)
|
||||||
|
|
||||||
# ✅ 修正:引入我們剛剛在 cmts_scraper.py 新增的函數
|
from cmts_scraper import fetch_raw_config
|
||||||
from cmts_scraper import fetch_raw_config, parse_config_to_tree
|
from shared import parse_cli_to_tree # <== 引入真正的解析器
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -21,6 +24,9 @@ router = APIRouter(
|
||||||
tags=["Backups"]
|
tags=["Backups"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 📦 Pydantic Models
|
||||||
|
# ==========================================
|
||||||
class SnapshotRequest(BaseModel):
|
class SnapshotRequest(BaseModel):
|
||||||
host: str
|
host: str
|
||||||
username: str
|
username: str
|
||||||
|
|
@ -28,6 +34,91 @@ class SnapshotRequest(BaseModel):
|
||||||
snapshot_name: str
|
snapshot_name: str
|
||||||
config_type: str = "running"
|
config_type: str = "running"
|
||||||
|
|
||||||
|
class RestoreRequest(BaseModel):
|
||||||
|
host: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class ExecuteRestoreRequest(BaseModel):
|
||||||
|
host: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
commands: List[str] # 接收前端確認後的差異指令清單
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 🛠️ 核心演算法:深度差異比對 (Deep Diff)
|
||||||
|
# ==========================================
|
||||||
|
def build_add_commands(node: dict, current_path: str) -> list:
|
||||||
|
"""遞迴展開所有需要新增的絕對路徑指令"""
|
||||||
|
commands = []
|
||||||
|
if not node:
|
||||||
|
commands.append(current_path)
|
||||||
|
return commands
|
||||||
|
|
||||||
|
for key, val in node.items():
|
||||||
|
# 🌟 新增這行:忽略系統中介資料
|
||||||
|
if key.startswith('_'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = f"{current_path} {key}".strip()
|
||||||
|
if isinstance(val, dict):
|
||||||
|
commands.extend(build_add_commands(val, path))
|
||||||
|
else:
|
||||||
|
val_str = f" {val}" if val else ""
|
||||||
|
commands.append(f"{path}{val_str}")
|
||||||
|
return commands
|
||||||
|
|
||||||
|
def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path: str = "") -> list:
|
||||||
|
"""比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單"""
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
# 1. 找出需要刪除或覆蓋的 (存在於 current)
|
||||||
|
for key, curr_val in current_node.items():
|
||||||
|
# 🌟 新增這行:忽略系統中介資料
|
||||||
|
if key.startswith('_'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = f"{current_path} {key}".strip()
|
||||||
|
|
||||||
|
if key not in snapshot_node:
|
||||||
|
# 快照裡沒有,代表這是後來新增的殘留設定 -> 刪除
|
||||||
|
commands.append(f"no {path}")
|
||||||
|
else:
|
||||||
|
snap_val = snapshot_node[key]
|
||||||
|
if isinstance(curr_val, dict) and isinstance(snap_val, dict):
|
||||||
|
commands.extend(generate_diff_commands(curr_val, snap_val, path))
|
||||||
|
elif isinstance(curr_val, dict) or isinstance(snap_val, dict):
|
||||||
|
# 型態不一致,先刪除舊的再新增新的
|
||||||
|
commands.append(f"no {path}")
|
||||||
|
if isinstance(snap_val, dict):
|
||||||
|
commands.extend(build_add_commands(snap_val, path))
|
||||||
|
else:
|
||||||
|
val_str = f" {snap_val}" if snap_val else ""
|
||||||
|
commands.append(f"{path}{val_str}")
|
||||||
|
elif curr_val != snap_val:
|
||||||
|
# 兩邊都是最終值,但內容不同 -> 直接覆寫
|
||||||
|
val_str = f" {snap_val}" if snap_val else ""
|
||||||
|
commands.append(f"{path}{val_str}")
|
||||||
|
|
||||||
|
# 2. 找出需要新增的 (存在於 snapshot,但 current 沒有)
|
||||||
|
for key, snap_val in snapshot_node.items():
|
||||||
|
# 🌟 新增這行:忽略系統中介資料
|
||||||
|
if key.startswith('_'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = f"{current_path} {key}".strip()
|
||||||
|
if key not in current_node:
|
||||||
|
if isinstance(snap_val, dict):
|
||||||
|
commands.extend(build_add_commands(snap_val, path))
|
||||||
|
else:
|
||||||
|
val_str = f" {snap_val}" if snap_val else ""
|
||||||
|
commands.append(f"{path}{val_str}")
|
||||||
|
|
||||||
|
return commands
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 🚀 API 路由
|
||||||
|
# ==========================================
|
||||||
@router.get("/", summary="取得歷史快照列表")
|
@router.get("/", summary="取得歷史快照列表")
|
||||||
async def list_backups(host: str, config_type: str = "running"):
|
async def list_backups(host: str, config_type: str = "running"):
|
||||||
records = await get_config_backup_list(host, config_type)
|
records = await get_config_backup_list(host, config_type)
|
||||||
|
|
@ -52,11 +143,9 @@ async def delete_backup(backup_id: str):
|
||||||
@router.post("/snapshot", summary="手動建立設備快照")
|
@router.post("/snapshot", summary="手動建立設備快照")
|
||||||
async def create_snapshot(req: SnapshotRequest):
|
async def create_snapshot(req: SnapshotRequest):
|
||||||
try:
|
try:
|
||||||
# ✅ 修正:使用函數式呼叫來抓取與解析設定檔
|
|
||||||
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)
|
||||||
parsed_tree = parse_config_to_tree(raw_cli)
|
parsed_tree = parse_cli_to_tree(raw_cli)
|
||||||
|
|
||||||
# 寫入資料庫
|
|
||||||
backup_id = await insert_config_backup(
|
backup_id = await insert_config_backup(
|
||||||
host=req.host,
|
host=req.host,
|
||||||
config_type=req.config_type,
|
config_type=req.config_type,
|
||||||
|
|
@ -80,3 +169,93 @@ async def create_snapshot(req: SnapshotRequest):
|
||||||
logger.error(f"❌ 建立快照失敗: {e}")
|
logger.error(f"❌ 建立快照失敗: {e}")
|
||||||
return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}
|
return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}
|
||||||
|
|
||||||
|
@router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異")
|
||||||
|
async def analyze_backup_diff(backup_id: str, req: RestoreRequest):
|
||||||
|
try:
|
||||||
|
backup_record = await get_config_backup_detail(backup_id)
|
||||||
|
if not backup_record:
|
||||||
|
return {"status": "error", "message": "找不到指定的備份紀錄"}
|
||||||
|
|
||||||
|
snapshot_tree = backup_record.get("parsed_tree")
|
||||||
|
if not snapshot_tree:
|
||||||
|
return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"}
|
||||||
|
|
||||||
|
raw_current = await fetch_raw_config(req.host, req.username, req.password, "running")
|
||||||
|
if not raw_current:
|
||||||
|
return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"}
|
||||||
|
|
||||||
|
current_tree = parse_cli_to_tree(raw_current)
|
||||||
|
diff_commands = generate_diff_commands(current_tree, snapshot_tree)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"commands": diff_commands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"差異分析失敗: {str(e)}")
|
||||||
|
return {"status": "error", "message": f"分析過程發生例外錯誤: {str(e)}"}
|
||||||
|
|
||||||
|
@router.post("/{backup_id}/restore", summary="執行差異還原 (寫入指定的指令清單)")
|
||||||
|
async def execute_restore(backup_id: str, req: ExecuteRestoreRequest):
|
||||||
|
commands = req.commands
|
||||||
|
|
||||||
|
if not commands:
|
||||||
|
return {"status": "success", "message": "沒有需要執行的指令,設備狀態已同步。"}
|
||||||
|
|
||||||
|
output_log = []
|
||||||
|
try:
|
||||||
|
conn = await asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None)
|
||||||
|
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8')
|
||||||
|
|
||||||
|
async def read_until_quiet(timeout=1.0):
|
||||||
|
output = ""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
|
||||||
|
if not chunk: break
|
||||||
|
output += chunk
|
||||||
|
if "--More--" in chunk or "More" in chunk:
|
||||||
|
process.stdin.write(" ")
|
||||||
|
await process.stdin.drain()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
return output
|
||||||
|
|
||||||
|
# 進入設定模式
|
||||||
|
process.stdin.write("config\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
await read_until_quiet(timeout=1.5)
|
||||||
|
|
||||||
|
# 逐行寫入精準的差異指令
|
||||||
|
for cmd in commands:
|
||||||
|
process.stdin.write(f"{cmd}\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
out = await read_until_quiet(timeout=0.2)
|
||||||
|
output_log.append(out)
|
||||||
|
|
||||||
|
# 提交變更
|
||||||
|
process.stdin.write("commit\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
commit_out = await read_until_quiet(timeout=2.0)
|
||||||
|
output_log.append(commit_out)
|
||||||
|
|
||||||
|
# 退出並關閉連線
|
||||||
|
process.stdin.write("exit\n")
|
||||||
|
await process.stdin.drain()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
clean_log = re.sub(r'\x1b\[[0-9;]*[mGK]', '', "".join(output_log))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "差異還原指令已成功送出並 Commit",
|
||||||
|
"output": clean_log
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"還原執行失敗: {str(e)}")
|
||||||
|
return {"status": "error", "message": f"SSH 執行失敗: {str(e)}"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -864,8 +864,12 @@ async function loadBackupHistory() {
|
||||||
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
|
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</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>
|
<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; 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>
|
||||||
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">刪除</button>
|
|
||||||
|
<!-- 🌟 新增:還原按鈕 -->
|
||||||
|
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}')" class="btn-modern btn-save" style="padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
|
||||||
|
|
||||||
|
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
@ -924,6 +928,75 @@ async function deleteBackup(backupId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. 請求設備差異分析 (安全還原第一步)
|
||||||
|
async function restoreBackup(snapshotId, snapshotName) {
|
||||||
|
const connInfo = getGlobalConnectionInfo();
|
||||||
|
if (!connInfo || !connInfo.host) {
|
||||||
|
return alert("請先連線至 CMTS 設備!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 開啟 Modal 顯示分析進度
|
||||||
|
const modal = document.getElementById('outputModal');
|
||||||
|
const modalOutput = document.getElementById('modalOutput');
|
||||||
|
const modalTargetInfo = document.getElementById('modalTargetInfo');
|
||||||
|
|
||||||
|
modalTargetInfo.innerText = `正在分析快照差異: ${snapshotName}`;
|
||||||
|
modalOutput.innerHTML = `<span style="color: #3498db;">🔍 正在抓取設備當前配置,並與快照進行深度比對,請稍候...</span>`;
|
||||||
|
modal.classList.add('active');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 呼叫後端全新的 Diff API (我們接下來要實作的)
|
||||||
|
const response = await fetch(`/api/v1/backups/${snapshotId}/diff`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
host: connInfo.host,
|
||||||
|
username: connInfo.user,
|
||||||
|
password: connInfo.pass
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'success') {
|
||||||
|
// 渲染差異預覽畫面
|
||||||
|
let diffHtml = `<div style="margin-bottom: 15px; color: #ecf0f1;">以下是將設備還原至 <b>${snapshotName}</b> 所需執行的指令清單:</div>`;
|
||||||
|
|
||||||
|
if (result.data.commands.length === 0) {
|
||||||
|
diffHtml += `<div style="color: #27ae60; font-weight: bold; padding: 10px; background: rgba(39, 174, 96, 0.1); border-radius: 5px;">✅ 設備當前狀態與快照完全一致,無需進行任何還原動作!</div>`;
|
||||||
|
} else {
|
||||||
|
diffHtml += `<pre style="background: #1e1e1e; padding: 15px; border-radius: 5px; font-family: monospace; overflow-x: auto;">`;
|
||||||
|
result.data.commands.forEach(cmd => {
|
||||||
|
if (cmd.startsWith('no ')) {
|
||||||
|
diffHtml += `<span style="color: #e74c3c;">- ${cmd}</span>\n`; // 刪除用紅色
|
||||||
|
} else {
|
||||||
|
diffHtml += `<span style="color: #2ecc71;">+ ${cmd}</span>\n`; // 新增/修改用綠色
|
||||||
|
}
|
||||||
|
});
|
||||||
|
diffHtml += `</pre>`;
|
||||||
|
|
||||||
|
// 加入最終確認按鈕
|
||||||
|
diffHtml += `
|
||||||
|
<div style="margin-top: 20px; text-align: right;">
|
||||||
|
<button onclick="executeSmartRestore('${snapshotId}', '${snapshotName}')" class="btn-modern btn-save" style="background-color: #e74c3c; padding: 8px 16px;">⚠️ 確認無誤,立即寫入設備並 Commit</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
modalOutput.innerHTML = diffHtml;
|
||||||
|
} else {
|
||||||
|
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 差異分析失敗:\n${result.message}</span>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
modalOutput.innerHTML = `<span style="color: #e74c3c; font-weight: bold;">❌ 系統錯誤:無法連線至後端伺服器。\n${error.message}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 真正執行差異還原 (掛載到 window 供按鈕呼叫)
|
||||||
|
window.executeSmartRestore = async function(snapshotId, snapshotName) {
|
||||||
|
// 這裡將會呼叫我們原本設計好的寫入 API
|
||||||
|
alert("即將實作:將上方預覽的指令寫入設備!");
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States)
|
// 🌟 6. 共用 UI 元件與狀態管理 (Modals & Button States)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -1095,6 +1168,7 @@ window.createSnapshot = createSnapshot;
|
||||||
window.loadBackupHistory = loadBackupHistory;
|
window.loadBackupHistory = loadBackupHistory;
|
||||||
window.viewBackup = viewBackup;
|
window.viewBackup = viewBackup;
|
||||||
window.deleteBackup = deleteBackup;
|
window.deleteBackup = deleteBackup;
|
||||||
|
window.restoreBackup = restoreBackup; // 🌟 新增綁定還原函數
|
||||||
|
|
||||||
// 將備份還原相關函數綁定到全域,供 HTML onclick 使用
|
// 將備份還原相關函數綁定到全域,供 HTML onclick 使用
|
||||||
window.loadBackupHistory = loadBackupHistory;
|
window.loadBackupHistory = loadBackupHistory;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import {
|
import {
|
||||||
apiAcquireLock, apiReleaseLock, apiSendHeartbeat,
|
apiAcquireLock, apiReleaseLock, apiSendHeartbeat,
|
||||||
apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig,
|
apiGetLeafOptions, apiSyncLeafOptions, apiGenerateCli, apiExecuteConfig,
|
||||||
apiSyncLeafOptionsStream // 🌟 新增這一個
|
apiSyncLeafOptionsStream
|
||||||
} from './api.js';
|
} from './api.js';
|
||||||
import { getGlobalConnectionInfo } from './utils.js';
|
import { getGlobalConnectionInfo } from './utils.js';
|
||||||
|
|
||||||
|
|
@ -24,19 +24,17 @@ function escapeHTML(str) {
|
||||||
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
|
export const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
|
||||||
const ACTIVE_HEARTBEATS = {};
|
const ACTIVE_HEARTBEATS = {};
|
||||||
|
|
||||||
// 💡 追蹤當前編輯狀態
|
|
||||||
export let currentEditPath = null;
|
export let currentEditPath = null;
|
||||||
export let currentEditElementId = null;
|
export let currentEditElementId = null;
|
||||||
export let currentEditIsSuccess = false;
|
export let currentEditIsSuccess = false;
|
||||||
export let currentEditType = null;
|
export let currentEditType = null;
|
||||||
export let currentEditDiffs = []; // 🌟 新增:用來記錄這次修改了哪些路徑
|
export let currentEditDiffs = [];
|
||||||
|
|
||||||
export async function releaseAllLocks() {
|
export async function releaseAllLocks() {
|
||||||
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
|
||||||
if (keysToRelease.length === 0) return;
|
if (keysToRelease.length === 0) return;
|
||||||
|
|
||||||
const releasePromises = keysToRelease.map(key => {
|
const releasePromises = keysToRelease.map(key => {
|
||||||
// 🌟 解析出 host 與 path
|
|
||||||
const [host, path] = key.split('@@');
|
const [host, path] = key.split('@@');
|
||||||
clearInterval(ACTIVE_HEARTBEATS[key]);
|
clearInterval(ACTIVE_HEARTBEATS[key]);
|
||||||
delete ACTIVE_HEARTBEATS[key];
|
delete ACTIVE_HEARTBEATS[key];
|
||||||
|
|
@ -49,7 +47,6 @@ export async function releaseAllLocks() {
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 加入 host 與 lockKey 參數
|
|
||||||
async function sendHeartbeat(path, host, intervalId, lockKey) {
|
async function sendHeartbeat(path, host, intervalId, lockKey) {
|
||||||
try {
|
try {
|
||||||
const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
|
const response = await apiSendHeartbeat(path, SESSION_USER_ID, host);
|
||||||
|
|
@ -74,7 +71,7 @@ async function sendHeartbeat(path, host, intervalId, lockKey) {
|
||||||
export async function startEditFolder(path, elementId) {
|
export async function startEditFolder(path, elementId) {
|
||||||
const connInfo = getGlobalConnectionInfo();
|
const connInfo = getGlobalConnectionInfo();
|
||||||
const username = connInfo ? connInfo.user : 'admin';
|
const username = connInfo ? connInfo.user : 'admin';
|
||||||
const host = connInfo ? connInfo.host : ''; // 🌟 取得設備 IP
|
const host = connInfo ? connInfo.host : '';
|
||||||
const lockKey = `${host}@@${path}`;
|
const lockKey = `${host}@@${path}`;
|
||||||
|
|
||||||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||||||
|
|
@ -89,7 +86,6 @@ export async function startEditFolder(path, elementId) {
|
||||||
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
|
intervalId = setInterval(() => sendHeartbeat(path, host, intervalId, lockKey), 15000);
|
||||||
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
ACTIVE_HEARTBEATS[lockKey] = intervalId;
|
||||||
|
|
||||||
// 🌟 修正:加入安全檢查,避免找不到元素時發生 null 錯誤
|
|
||||||
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
const editBtn = document.getElementById(`edit-btn-${elementId}`);
|
||||||
if (editBtn) editBtn.style.display = 'none';
|
if (editBtn) editBtn.style.display = 'none';
|
||||||
|
|
||||||
|
|
@ -102,21 +98,17 @@ export async function startEditFolder(path, elementId) {
|
||||||
const contentDiv = document.getElementById(`content-${elementId}`);
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
||||||
if (!contentDiv) {
|
if (!contentDiv) {
|
||||||
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
|
console.warn(`[防呆警告] 找不到 content-${elementId} 的容器,請略過此資料夾的編輯。`);
|
||||||
return; // 找不到內容容器就提早結束,避免後續 querySelectorAll 報錯
|
return;
|
||||||
}
|
}
|
||||||
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
||||||
|
|
||||||
let optData = {};
|
let optData = {};
|
||||||
// 🌟 修改 1:傳入 host 給 apiGetLeafOptions
|
|
||||||
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
|
try { optData = await apiGetLeafOptions(host, currentMode); } catch (e) {}
|
||||||
|
|
||||||
const pathsToSync = [];
|
const pathsToSync = [];
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 🌟 效能急救:分批渲染 (Chunking) 避免卡死主執行緒
|
|
||||||
// ==========================================
|
|
||||||
const containersArray = Array.from(leafContainers);
|
const containersArray = Array.from(leafContainers);
|
||||||
const CHUNK_SIZE = 50; // 每次處理 50 個,確保畫面不結凍
|
const CHUNK_SIZE = 50;
|
||||||
|
|
||||||
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
for (let i = 0; i < containersArray.length; i += CHUNK_SIZE) {
|
||||||
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
|
const chunk = containersArray.slice(i, i + CHUNK_SIZE);
|
||||||
|
|
@ -159,12 +151,10 @@ export async function startEditFolder(path, elementId) {
|
||||||
container.innerHTML = inputHtml;
|
container.innerHTML = inputHtml;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🌟 關鍵魔法:每處理完 50 個,就暫停 0 毫秒,讓瀏覽器有空檔去畫畫面或回應滑鼠
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 傳入 host 給 apiSyncLeafOptions
|
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode, username, connInfo.pass);
|
||||||
if (pathsToSync.length > 0) apiSyncLeafOptions(host, pathsToSync, currentMode);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
||||||
|
|
@ -199,19 +189,16 @@ export async function startEditLeaf(path, elementId) {
|
||||||
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
|
container.innerHTML = `<span style="font-size: 12px; color: #e67e22; margin-left: 5px;">⏳ 設備連線與載入選項中...</span>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🌟 修改 1:傳入 host
|
|
||||||
let optData = await apiGetLeafOptions(host, currentMode);
|
let optData = await apiGetLeafOptions(host, currentMode);
|
||||||
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
let cacheKey = path.replace(/::/g, ' ').replace(/\s*\[\d+\]/g, '');
|
||||||
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
if (path.endsWith('::no')) cacheKey = cacheKey.replace(/ no$/, '') + ' ' + origVal;
|
||||||
let leafCache = optData[cacheKey];
|
let leafCache = optData[cacheKey];
|
||||||
|
|
||||||
if (!leafCache) {
|
if (!leafCache) {
|
||||||
// 🌟 修改 2:傳入 host
|
apiSyncLeafOptions(host, [cacheKey], currentMode, username, connInfo.pass);
|
||||||
apiSyncLeafOptions(host, [cacheKey], currentMode);
|
|
||||||
let found = false;
|
let found = false;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
// 🌟 修改 3:傳入 host
|
|
||||||
optData = await apiGetLeafOptions(host, currentMode);
|
optData = await apiGetLeafOptions(host, currentMode);
|
||||||
leafCache = optData[cacheKey];
|
leafCache = optData[cacheKey];
|
||||||
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
if (leafCache && leafCache.options && leafCache.options.length > 0) {
|
||||||
|
|
@ -301,7 +288,6 @@ export async function cancelEditLeaf(path, elementId) {
|
||||||
// 3. CLI 生成與預覽 (CLI Generation)
|
// 3. CLI 生成與預覽 (CLI Generation)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
function getInterfacesWithAdminState() {
|
function getInterfacesWithAdminState() {
|
||||||
// 🌟 限制只從當前顯示的樹狀圖中抓取狀態
|
|
||||||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||||||
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
|
const activeContainer = document.getElementById(`tree-container-${currentMode}`);
|
||||||
if (!activeContainer) return [];
|
if (!activeContainer) return [];
|
||||||
|
|
@ -343,7 +329,7 @@ export async function previewFolderCLI(path, elementId) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!");
|
if (diffs.length === 0) return alert("沒有偵測到任何修改,無需生成指令!");
|
||||||
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異
|
currentEditDiffs = diffs;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
|
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
|
||||||
|
|
@ -375,7 +361,7 @@ export async function previewLeafCLI(path, elementId) {
|
||||||
if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!");
|
if (originalVal === newVal) return alert("沒有偵測到任何修改,無需生成指令!");
|
||||||
|
|
||||||
const diffs = [{ path: path, old_val: originalVal, new_val: newVal }];
|
const diffs = [{ path: path, old_val: originalVal, new_val: newVal }];
|
||||||
currentEditDiffs = diffs; // 🌟 新增這行:記錄修改差異
|
currentEditDiffs = diffs;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
|
const result = await apiGenerateCli(diffs, getInterfacesWithAdminState());
|
||||||
|
|
@ -390,7 +376,6 @@ export async function previewLeafCLI(path, elementId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showCliPreviewModal(cliScript) {
|
function showCliPreviewModal(cliScript) {
|
||||||
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
|
|
||||||
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
||||||
const resizer = document.getElementById('drag-resizer');
|
const resizer = document.getElementById('drag-resizer');
|
||||||
const rightPane = document.getElementById('side-cli-preview');
|
const rightPane = document.getElementById('side-cli-preview');
|
||||||
|
|
@ -407,12 +392,10 @@ function showCliPreviewModal(cliScript) {
|
||||||
const textarea = document.getElementById('side-cli-textarea');
|
const textarea = document.getElementById('side-cli-textarea');
|
||||||
textarea.value = cliScript;
|
textarea.value = cliScript;
|
||||||
|
|
||||||
// 👇 確保正常模式可以編輯,並明確恢復深色主題
|
|
||||||
textarea.readOnly = false;
|
textarea.readOnly = false;
|
||||||
textarea.style.backgroundColor = '#1e1e1e'; // 恢復成標準的深色背景 (您可以依喜好微調顏色碼)
|
textarea.style.backgroundColor = '#1e1e1e';
|
||||||
textarea.style.color = '#ecf0f1'; // 確保字體是清晰的淺灰色
|
textarea.style.color = '#ecf0f1';
|
||||||
|
|
||||||
// 🌟 修改:遍歷所有樹狀圖容器進行縮放
|
|
||||||
leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)');
|
leftPanes.forEach(pane => pane.style.width = 'calc(50% - 11px)');
|
||||||
rightPane.style.width = 'calc(50% - 11px)';
|
rightPane.style.width = 'calc(50% - 11px)';
|
||||||
resizer.style.display = 'flex';
|
resizer.style.display = 'flex';
|
||||||
|
|
@ -428,7 +411,6 @@ function showCliPreviewModal(cliScript) {
|
||||||
// 4. 側邊欄與執行邏輯
|
// 4. 側邊欄與執行邏輯
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export async function hideSideCLI() {
|
export async function hideSideCLI() {
|
||||||
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
|
|
||||||
document.querySelectorAll('.tree-view-instance').forEach(pane => pane.style.width = '100%');
|
document.querySelectorAll('.tree-view-instance').forEach(pane => pane.style.width = '100%');
|
||||||
document.getElementById('drag-resizer').style.display = 'none';
|
document.getElementById('drag-resizer').style.display = 'none';
|
||||||
document.getElementById('side-cli-preview').style.display = 'none';
|
document.getElementById('side-cli-preview').style.display = 'none';
|
||||||
|
|
@ -517,23 +499,29 @@ async function executeGeneratedCLI(script) {
|
||||||
const uniquePaths = [...new Set(pathsToSync)];
|
const uniquePaths = [...new Set(pathsToSync)];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🌟 修改:取得當前模式,並將 connInfo.host 與 currentMode 傳給 apiSyncLeafOptionsStream
|
|
||||||
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
const currentMode = document.getElementById('configTask').value === 'form-full-config' ? 'full' : 'running';
|
||||||
|
|
||||||
|
apiSyncLeafOptions(connInfo.host, uniquePaths, currentMode, connInfo.user, connInfo.pass);
|
||||||
|
|
||||||
await apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
|
await apiSyncLeafOptionsStream(connInfo.host, uniquePaths, (data) => {
|
||||||
|
|
||||||
if (data.event === 'done') {
|
if (data.event === 'done') {
|
||||||
document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成';
|
document.getElementById('side-pane-title').innerHTML = '✅ 執行與快取同步完美達成';
|
||||||
document.getElementById('side-pane-title').style.color = '#2ecc71';
|
document.getElementById('side-pane-title').style.color = '#2ecc71';
|
||||||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||||||
outputEl.innerHTML += `<br><br><span style="color: #2ecc71;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
|
outputEl.innerHTML += `<br><br><span style="color: #2ecc71;">[系統] 快取同步完成!您可以關閉此面板。</span>`;
|
||||||
|
|
||||||
} else if (data.event === 'error') {
|
} else if (data.event === 'error') {
|
||||||
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功 (但同步快取失敗)';
|
document.getElementById('side-pane-title').innerHTML = '✅ 寫入成功 (但同步快取失敗)';
|
||||||
document.getElementById('side-pane-title').style.color = '#e74c3c';
|
document.getElementById('side-pane-title').style.color = '#e74c3c';
|
||||||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||||||
outputEl.innerHTML += `<br><br><span style="color: #e74c3c;">[系統] 同步失敗: ${data.message}</span>`;
|
outputEl.innerHTML += `<br><br><span style="color: #e74c3c;">[系統] 同步失敗: ${data.message || '未知錯誤'}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
}, currentMode);
|
}, currentMode);
|
||||||
|
|
||||||
} catch (syncErr) {
|
} catch (syncErr) {
|
||||||
|
console.error("同步設定失敗:", syncErr);
|
||||||
document.getElementById('btn-side-close').style.display = 'inline-block';
|
document.getElementById('btn-side-close').style.display = 'inline-block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -558,7 +546,6 @@ async function executeGeneratedCLI(script) {
|
||||||
// ==========================================
|
// ==========================================
|
||||||
function initResizer() {
|
function initResizer() {
|
||||||
const resizer = document.getElementById('drag-resizer');
|
const resizer = document.getElementById('drag-resizer');
|
||||||
// 🌟 修改:改用 class 統一控制所有樹狀圖容器
|
|
||||||
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
||||||
const rightPane = document.getElementById('side-cli-preview');
|
const rightPane = document.getElementById('side-cli-preview');
|
||||||
const container = document.getElementById('split-container');
|
const container = document.getElementById('split-container');
|
||||||
|
|
@ -581,7 +568,6 @@ function initResizer() {
|
||||||
|
|
||||||
const leftPercentage = (newLeftWidth / containerRect.width) * 100;
|
const leftPercentage = (newLeftWidth / containerRect.width) * 100;
|
||||||
const rightPercentage = 100 - leftPercentage;
|
const rightPercentage = 100 - leftPercentage;
|
||||||
// 🌟 修改:遍歷所有樹狀圖容器進行縮放
|
|
||||||
leftPanes.forEach(pane => pane.style.width = `calc(${leftPercentage}% - 11px)`);
|
leftPanes.forEach(pane => pane.style.width = `calc(${leftPercentage}% - 11px)`);
|
||||||
rightPane.style.width = `calc(${rightPercentage}% - 11px)`;
|
rightPane.style.width = `calc(${rightPercentage}% - 11px)`;
|
||||||
});
|
});
|
||||||
|
|
@ -639,11 +625,9 @@ function transformNoToActive(elementId, newCommand) {
|
||||||
// 💻 開發者工具:預覽節點完整指令 (唯讀 Debug 模式)
|
// 💻 開發者工具:預覽節點完整指令 (唯讀 Debug 模式)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
export async function previewNodeCLI(btnElement, rootPath) {
|
export async function previewNodeCLI(btnElement, rootPath) {
|
||||||
// 1. 定位當前點擊的容器
|
|
||||||
let container = btnElement.closest('details');
|
let container = btnElement.closest('details');
|
||||||
if (container) {
|
if (container) {
|
||||||
const elementId = container.id.replace('details-', '');
|
const elementId = container.id.replace('details-', '');
|
||||||
// 確保延遲渲染的內容已經載入 (Lazy Load)
|
|
||||||
if (container.dataset.loaded !== 'true') {
|
if (container.dataset.loaded !== 'true') {
|
||||||
await window.lazyLoadFolder(elementId, false);
|
await window.lazyLoadFolder(elementId, false);
|
||||||
}
|
}
|
||||||
|
|
@ -651,7 +635,6 @@ export async function previewNodeCLI(btnElement, rootPath) {
|
||||||
const targetNode = container || btnElement.closest('.tree-leaf-node');
|
const targetNode = container || btnElement.closest('.tree-leaf-node');
|
||||||
if (!targetNode) return;
|
if (!targetNode) return;
|
||||||
|
|
||||||
// 2. 收集所有子節點資料
|
|
||||||
const leafNodes = targetNode.querySelectorAll('.leaf-container');
|
const leafNodes = targetNode.querySelectorAll('.leaf-container');
|
||||||
const interfaceGroups = {};
|
const interfaceGroups = {};
|
||||||
|
|
||||||
|
|
@ -662,7 +645,6 @@ export async function previewNodeCLI(btnElement, rootPath) {
|
||||||
|
|
||||||
const parts = path.split('::');
|
const parts = path.split('::');
|
||||||
const paramName = parts.pop();
|
const paramName = parts.pop();
|
||||||
// 移除虛擬陣列索引 [0], [1] 等,還原真實介面路徑
|
|
||||||
let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');
|
let interfacePath = parts.join(' ').replace(/\s*\[\d+\]/g, '');
|
||||||
|
|
||||||
if (!interfaceGroups[interfacePath]) {
|
if (!interfaceGroups[interfacePath]) {
|
||||||
|
|
@ -671,7 +653,6 @@ export async function previewNodeCLI(btnElement, rootPath) {
|
||||||
interfaceGroups[interfacePath].push({ param: paramName, val: val });
|
interfaceGroups[interfacePath].push({ param: paramName, val: val });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 補強邏輯:處理使用者直接點擊葉節點的情況
|
|
||||||
if (Object.keys(interfaceGroups).length === 0 && targetNode.classList.contains('tree-leaf-node')) {
|
if (Object.keys(interfaceGroups).length === 0 && targetNode.classList.contains('tree-leaf-node')) {
|
||||||
const singleNode = targetNode.querySelector('.leaf-container');
|
const singleNode = targetNode.querySelector('.leaf-container');
|
||||||
if (singleNode) {
|
if (singleNode) {
|
||||||
|
|
@ -688,22 +669,18 @@ export async function previewNodeCLI(btnElement, rootPath) {
|
||||||
return alert("此節點下沒有任何數值可供預覽!");
|
return alert("此節點下沒有任何數值可供預覽!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 在前端組裝平坦化 CLI (模擬真實設備指令)
|
|
||||||
let cliLines = [];
|
let cliLines = [];
|
||||||
cliLines.push("! --- Read-Only Configuration Preview ---");
|
cliLines.push("! --- Read-Only Configuration Preview ---");
|
||||||
for (const [interfacePath, params] of Object.entries(interfaceGroups)) {
|
for (const [interfacePath, params] of Object.entries(interfaceGroups)) {
|
||||||
cliLines.push(`! Node: ${interfacePath || 'Global'}`);
|
cliLines.push(`! Node: ${interfacePath || 'Global'}`);
|
||||||
params.forEach(({param, val}) => {
|
params.forEach(({param, val}) => {
|
||||||
if (/^\[\d+\]$/.test(param)) {
|
if (/^\[\d+\]$/.test(param)) {
|
||||||
// 陣列元素本身就是值
|
|
||||||
if (val) {
|
if (val) {
|
||||||
cliLines.push(interfacePath ? `${interfacePath} ${val}` : val);
|
cliLines.push(interfacePath ? `${interfacePath} ${val}` : val);
|
||||||
}
|
}
|
||||||
} else if (param === 'no') {
|
} else if (param === 'no') {
|
||||||
// 處理 no 指令
|
|
||||||
cliLines.push(interfacePath ? `${interfacePath} no ${val}` : `no ${val}`);
|
cliLines.push(interfacePath ? `${interfacePath} no ${val}` : `no ${val}`);
|
||||||
} else {
|
} else {
|
||||||
// 一般鍵值對 (若 val 為空,代表是單純的 flag 指令)
|
|
||||||
let line = interfacePath ? `${interfacePath} ${param}` : param;
|
let line = interfacePath ? `${interfacePath} ${param}` : param;
|
||||||
if (val) line += ` ${val}`;
|
if (val) line += ` ${val}`;
|
||||||
cliLines.push(line);
|
cliLines.push(line);
|
||||||
|
|
@ -713,11 +690,9 @@ export async function previewNodeCLI(btnElement, rootPath) {
|
||||||
}
|
}
|
||||||
const finalScript = cliLines.join('\n');
|
const finalScript = cliLines.join('\n');
|
||||||
|
|
||||||
// 4. 顯示唯讀預覽面板
|
|
||||||
showReadOnlyCliPreviewModal(finalScript, rootPath);
|
showReadOnlyCliPreviewModal(finalScript, rootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 專為 God-Mode 設計的唯讀面板 UI
|
|
||||||
function showReadOnlyCliPreviewModal(cliScript, rootPath) {
|
function showReadOnlyCliPreviewModal(cliScript, rootPath) {
|
||||||
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
const leftPanes = document.querySelectorAll('.tree-view-instance');
|
||||||
const resizer = document.getElementById('drag-resizer');
|
const resizer = document.getElementById('drag-resizer');
|
||||||
|
|
@ -726,7 +701,6 @@ function showReadOnlyCliPreviewModal(cliScript, rootPath) {
|
||||||
document.getElementById('side-pane-title').innerHTML = `🔍 [唯讀預覽] ${rootPath}`;
|
document.getElementById('side-pane-title').innerHTML = `🔍 [唯讀預覽] ${rootPath}`;
|
||||||
document.getElementById('side-pane-title').style.color = '#3498db';
|
document.getElementById('side-pane-title').style.color = '#3498db';
|
||||||
|
|
||||||
// 隱藏執行按鈕,只保留關閉
|
|
||||||
document.getElementById('btn-side-confirm').style.display = 'none';
|
document.getElementById('btn-side-confirm').style.display = 'none';
|
||||||
document.getElementById('btn-side-cancel').style.display = 'none';
|
document.getElementById('btn-side-cancel').style.display = 'none';
|
||||||
const closeBtn = document.getElementById('btn-side-close');
|
const closeBtn = document.getElementById('btn-side-close');
|
||||||
|
|
@ -736,8 +710,8 @@ function showReadOnlyCliPreviewModal(cliScript, rootPath) {
|
||||||
const textarea = document.getElementById('side-cli-textarea');
|
const textarea = document.getElementById('side-cli-textarea');
|
||||||
textarea.style.display = 'block';
|
textarea.style.display = 'block';
|
||||||
textarea.value = cliScript;
|
textarea.value = cliScript;
|
||||||
textarea.readOnly = true; // 強制唯讀
|
textarea.readOnly = true;
|
||||||
textarea.style.backgroundColor = '#2c3e50'; // 改變背景色提示唯讀狀態
|
textarea.style.backgroundColor = '#2c3e50';
|
||||||
|
|
||||||
document.getElementById('side-execution-result').style.display = 'none';
|
document.getElementById('side-execution-result').style.display = 'none';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue