2026-04-29 10:00:40 +00:00
|
|
|
|
/* --- static/app.js --- */
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 1. 全域變數與初始化
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
let term, fitAddon, ws;
|
|
|
|
|
|
let currentMacDomainData = null;
|
2026-04-30 08:27:18 +00:00
|
|
|
|
let isConnected = false;
|
2026-04-29 10:00:40 +00:00
|
|
|
|
|
|
|
|
|
|
window.onload = initTerminal;
|
|
|
|
|
|
window.onresize = () => { if (fitAddon) fitAddon.fit(); };
|
|
|
|
|
|
|
2026-04-30 10:52:55 +00:00
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 💡 全域狀態與鎖定管理 (Lock Management)
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
const SESSION_USER_ID = crypto.randomUUID ? crypto.randomUUID() : 'user-' + Math.random().toString(36).substr(2, 9);
|
|
|
|
|
|
const ACTIVE_HEARTBEATS = {}; // 記錄正在打心跳的計時器: { "path": intervalId }
|
|
|
|
|
|
|
|
|
|
|
|
async function sendHeartbeat(path) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fetch('/api/v1/locks/heartbeat', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID })
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 💡 資料夾層級的編輯模式邏輯 (Node-Level Lock)
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
async function startEditFolder(path, elementId) {
|
|
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
const username = connInfo ? connInfo.user : 'admin';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/v1/locks/acquire', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID, username: username })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (response.status === 409) {
|
|
|
|
|
|
alert(`⚠️ ${result.detail}`); // 後端擋下衝突時顯示警告
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (result.status === 'success') {
|
|
|
|
|
|
// 啟動心跳
|
|
|
|
|
|
ACTIVE_HEARTBEATS[path] = setInterval(() => sendHeartbeat(path), 15000);
|
|
|
|
|
|
|
|
|
|
|
|
// UI 切換
|
|
|
|
|
|
document.getElementById(`edit-btn-${elementId}`).style.display = 'none';
|
|
|
|
|
|
document.getElementById(`actions-${elementId}`).style.display = 'inline-block';
|
|
|
|
|
|
document.getElementById(`details-${elementId}`).open = true;
|
|
|
|
|
|
|
|
|
|
|
|
// 轉為輸入框
|
|
|
|
|
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
|
|
|
|
|
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
|
|
|
|
|
|
|
|
|
|
|
leafContainers.forEach(container => {
|
|
|
|
|
|
const origVal = container.getAttribute('data-original');
|
|
|
|
|
|
const nodePath = container.getAttribute('data-path');
|
|
|
|
|
|
container.innerHTML = `
|
|
|
|
|
|
<input type="text" class="edit-input" data-path="${nodePath}" value="${origVal}"
|
|
|
|
|
|
style="padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-family: Courier New, monospace; font-size: 13px; width: 200px; outline: none; margin-left: 5px;">
|
|
|
|
|
|
`;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
alert("❌ 無法連線到鎖定伺服器:" + error.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function cancelEditFolder(path, elementId) {
|
|
|
|
|
|
if (ACTIVE_HEARTBEATS[path]) {
|
|
|
|
|
|
clearInterval(ACTIVE_HEARTBEATS[path]);
|
|
|
|
|
|
delete ACTIVE_HEARTBEATS[path];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fetch('/api/v1/locks/release', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ path: path, user_id: SESSION_USER_ID })
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {}
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById(`edit-btn-${elementId}`).style.display = 'inline-block';
|
|
|
|
|
|
document.getElementById(`actions-${elementId}`).style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
|
|
|
|
|
const leafContainers = contentDiv.querySelectorAll('.leaf-container');
|
|
|
|
|
|
|
|
|
|
|
|
leafContainers.forEach(container => {
|
|
|
|
|
|
const origVal = container.getAttribute('data-original');
|
|
|
|
|
|
container.innerHTML = `
|
|
|
|
|
|
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${origVal}</span>
|
|
|
|
|
|
`;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function previewFolderCLI(path, elementId) {
|
|
|
|
|
|
const contentDiv = document.getElementById(`content-${elementId}`);
|
|
|
|
|
|
const inputs = contentDiv.querySelectorAll('.edit-input');
|
|
|
|
|
|
|
|
|
|
|
|
let changes = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 掃描所有輸入框,找出被修改過的值
|
|
|
|
|
|
inputs.forEach(input => {
|
|
|
|
|
|
const nodePath = input.getAttribute('data-path');
|
|
|
|
|
|
const newVal = input.value;
|
|
|
|
|
|
const origVal = input.parentElement.getAttribute('data-original');
|
|
|
|
|
|
|
|
|
|
|
|
if (newVal !== origVal) {
|
|
|
|
|
|
changes.push(`- ${nodePath}:\n [舊] ${origVal} ➡️ [新] ${newVal}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (changes.length === 0) {
|
|
|
|
|
|
alert("您沒有修改任何數值!");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
alert(`【Phase 3 預覽】\n鎖定區塊: ${path}\n\n偵測到的修改:\n${changes.join('\n\n')}\n\n即將實作轉譯為 CMTS CLI 的功能!`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 💡 編輯模式 UI 切換邏輯
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
function renderEditModeUI(path, currentValue, elementId) {
|
|
|
|
|
|
const container = document.getElementById(elementId);
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 將原本的文字替換為 Input 與操作按鈕
|
|
|
|
|
|
// 注意:這裡將單引號跳脫,避免 HTML 屬性解析錯誤
|
|
|
|
|
|
const safeValue = currentValue.replace(/'/g, "'");
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = `
|
|
|
|
|
|
<input type="text" id="input-${elementId}" value="${safeValue}"
|
|
|
|
|
|
style="padding: 4px 8px; border: 2px solid #3498db; border-radius: 4px; font-family: Courier New, monospace; font-size: 14px; width: 250px; outline: none;">
|
|
|
|
|
|
|
|
|
|
|
|
<button onclick="previewCLI('${path}', '${elementId}')"
|
|
|
|
|
|
style="margin-left: 8px; background-color: #27ae60; color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 13px;">
|
|
|
|
|
|
✅ 預覽指令
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button onclick="cancelEdit('${path}', '${safeValue}', '${elementId}')"
|
|
|
|
|
|
style="margin-left: 4px; background-color: #e74c3c; color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 13px;">
|
|
|
|
|
|
❌ 取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function restoreViewModeUI(path, originalValue, elementId) {
|
|
|
|
|
|
const container = document.getElementById(elementId);
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 恢復成原本的文字與編輯按鈕
|
|
|
|
|
|
const safeValue = originalValue.replace(/'/g, "'");
|
|
|
|
|
|
container.innerHTML = `
|
|
|
|
|
|
<span style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${originalValue}</span>
|
|
|
|
|
|
<span onclick="startEdit('${path}', '${safeValue}', '${elementId}')"
|
|
|
|
|
|
style="margin-left: 10px; cursor: pointer; font-size: 14px; opacity: 0.6;"
|
|
|
|
|
|
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="編輯此項目">✏️</span>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 3 的預留函數:預覽 CLI 指令
|
|
|
|
|
|
function previewCLI(path, elementId) {
|
|
|
|
|
|
const newValue = document.getElementById(`input-${elementId}`).value;
|
|
|
|
|
|
alert(`【Phase 3 預覽】\n路徑: ${path}\n新值: ${newValue}\n\n即將實作轉譯為 CMTS CLI 的功能!`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 10:00:40 +00:00
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 2. UI 互動與頁籤切換
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
function switchTab(tabId, element) {
|
|
|
|
|
|
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
|
|
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
|
|
|
|
|
|
|
|
|
|
|
const targetTab = document.getElementById(tabId);
|
|
|
|
|
|
if (targetTab) targetTab.classList.add('active');
|
|
|
|
|
|
if (element) element.classList.add('active');
|
|
|
|
|
|
|
|
|
|
|
|
if(tabId === 'cli-tab' && fitAddon) setTimeout(() => fitAddon.fit(), 50);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function switchQueryTask() {
|
|
|
|
|
|
document.querySelectorAll('#query-tab .task-form').forEach(form => form.classList.remove('active'));
|
|
|
|
|
|
const selectedTaskId = document.getElementById('queryTask').value;
|
|
|
|
|
|
const targetForm = document.getElementById(selectedTaskId);
|
|
|
|
|
|
if (targetForm) targetForm.classList.add('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function switchConfigTask() {
|
2026-04-30 08:27:18 +00:00
|
|
|
|
document.querySelectorAll('#config-tab .task-form').forEach(form => {
|
|
|
|
|
|
form.classList.remove('active');
|
|
|
|
|
|
form.style.display = 'none';
|
|
|
|
|
|
});
|
2026-04-29 10:00:40 +00:00
|
|
|
|
const selectedTaskId = document.getElementById('configTask').value;
|
|
|
|
|
|
const targetForm = document.getElementById(selectedTaskId);
|
2026-04-30 08:27:18 +00:00
|
|
|
|
if (targetForm) {
|
|
|
|
|
|
targetForm.classList.add('active');
|
|
|
|
|
|
targetForm.style.display = 'block';
|
|
|
|
|
|
}
|
2026-04-29 10:00:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 08:27:18 +00:00
|
|
|
|
// 💡 合併修復:統一處理「載入任務」的邏輯與防呆
|
2026-04-29 10:00:40 +00:00
|
|
|
|
function startConfigTask() {
|
2026-04-30 08:27:18 +00:00
|
|
|
|
// 防呆 1:檢查是否已連線
|
2026-04-30 02:36:02 +00:00
|
|
|
|
if (!isConnected) {
|
2026-04-30 08:27:18 +00:00
|
|
|
|
alert("請先點擊畫面上方的「連線至 CMTS」成功後,再執行載入任務!");
|
2026-04-30 02:36:02 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 08:27:18 +00:00
|
|
|
|
// 先確保對應的表單有顯示出來
|
2026-04-29 10:00:40 +00:00
|
|
|
|
switchConfigTask();
|
|
|
|
|
|
|
|
|
|
|
|
const selectedTaskId = document.getElementById('configTask').value;
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedTaskId === 'form-bonding-config') {
|
2026-04-30 08:27:18 +00:00
|
|
|
|
// 任務 A:MAC Domain 配置精靈
|
2026-04-29 10:00:40 +00:00
|
|
|
|
if (currentMacDomainData && !confirm("重新載入將會清除您目前未套用的設定,確定要繼續嗎?")) {
|
2026-04-30 08:27:18 +00:00
|
|
|
|
return;
|
2026-04-29 10:00:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
resetAllManagerData();
|
|
|
|
|
|
loadMacDomainList();
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
} else if (selectedTaskId === 'form-full-config') {
|
|
|
|
|
|
// 💡 修復:任務 B:完整設備配置樹狀圖
|
|
|
|
|
|
// 當使用者點擊「載入任務」時,直接幫他觸發抓取樹狀圖的 API
|
|
|
|
|
|
fetchFullConfig();
|
2026-04-29 10:00:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadMacDomainList() {
|
|
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
if (!connInfo) return;
|
|
|
|
|
|
|
|
|
|
|
|
const mdSelect = document.getElementById('cfgMacDomain');
|
|
|
|
|
|
const statusSpan = document.getElementById('fetchStatus');
|
|
|
|
|
|
|
|
|
|
|
|
mdSelect.innerHTML = '<option value="">⏳ 查詢設備中...</option>';
|
|
|
|
|
|
statusSpan.textContent = "正在自動抓取現有 MAC Domain 清單...";
|
|
|
|
|
|
statusSpan.style.color = "#f39c12";
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = `/api/v1/cmts-mac-domain-list?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
|
|
|
|
|
|
const response = await fetch(url);
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (result.status === 'success' && result.data.length > 0) {
|
2026-04-30 08:27:18 +00:00
|
|
|
|
mdSelect.innerHTML = '';
|
2026-04-29 10:00:40 +00:00
|
|
|
|
result.data.forEach(md => {
|
|
|
|
|
|
const option = document.createElement('option');
|
|
|
|
|
|
option.value = md;
|
|
|
|
|
|
option.textContent = md;
|
|
|
|
|
|
mdSelect.appendChild(option);
|
|
|
|
|
|
});
|
|
|
|
|
|
statusSpan.textContent = "✅ 清單抓取成功!請選擇目標並點擊讀取配置。";
|
|
|
|
|
|
statusSpan.style.color = "#27ae60";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
mdSelect.innerHTML = '<option value="">❌ 找不到資料</option>';
|
|
|
|
|
|
statusSpan.textContent = "無法解析清單,請確認設備狀態。";
|
|
|
|
|
|
statusSpan.style.color = "#e74c3c";
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
mdSelect.innerHTML = '<option value="">❌ 連線錯誤</option>';
|
|
|
|
|
|
statusSpan.textContent = "連線失敗:" + error;
|
|
|
|
|
|
statusSpan.style.color = "#e74c3c";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleQueryInputs(mode) {
|
|
|
|
|
|
const type = document.getElementById('queryType' + mode).value;
|
|
|
|
|
|
const targetInput = document.getElementById('queryTarget' + mode);
|
|
|
|
|
|
const globalTypes = ['partial_mode', 'hop', 'flap_list', 'flap_sum'];
|
|
|
|
|
|
|
|
|
|
|
|
if (globalTypes.includes(type)) {
|
|
|
|
|
|
targetInput.disabled = true;
|
|
|
|
|
|
targetInput.style.backgroundColor = '#e8ecef';
|
|
|
|
|
|
targetInput.placeholder = "此查詢為全域指令,不需輸入目標";
|
|
|
|
|
|
targetInput.value = "";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
targetInput.disabled = false;
|
|
|
|
|
|
targetInput.style.backgroundColor = '#f8f9fa';
|
|
|
|
|
|
targetInput.placeholder = mode === 'Cm' ? "例: 6467.7240.4076 (留白則查詢全體)" : "例: 13:0 (留白則查詢全體)";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetAllManagerData() {
|
|
|
|
|
|
currentMacDomainData = null;
|
|
|
|
|
|
|
|
|
|
|
|
const configArea = document.getElementById('macDomainConfigArea');
|
|
|
|
|
|
if (configArea) configArea.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
const previewEl = document.getElementById('cliPreviewContainer');
|
|
|
|
|
|
if (previewEl) {
|
|
|
|
|
|
previewEl.textContent = '';
|
|
|
|
|
|
previewEl.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const deployBtn = document.getElementById('btnDeployConfig');
|
|
|
|
|
|
if (deployBtn) {
|
|
|
|
|
|
deployBtn.style.display = 'none';
|
|
|
|
|
|
deployBtn.disabled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mdSelect = document.getElementById('cfgMacDomain');
|
|
|
|
|
|
if (mdSelect) mdSelect.innerHTML = '<option value="">請先點擊上方載入任務...</option>';
|
|
|
|
|
|
|
|
|
|
|
|
const fetchStatus = document.getElementById('fetchStatus');
|
|
|
|
|
|
if (fetchStatus) {
|
|
|
|
|
|
fetchStatus.textContent = "請先讀取設備狀態...";
|
|
|
|
|
|
fetchStatus.style.color = "#7f8c8d";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('queryTargetCm').value = '';
|
|
|
|
|
|
document.getElementById('queryTargetRpd').value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 3. 終端機 (Terminal) 與 WebSocket 邏輯
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
function colorizeTerminalStream(text) {
|
|
|
|
|
|
if (text.includes('\x1b[')) return text;
|
|
|
|
|
|
let res = text;
|
|
|
|
|
|
res = res.replace(/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g, '\x1b[1;34m$1\x1b[0m');
|
|
|
|
|
|
res = res.replace(/\b([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\b/g, '\x1b[1;35m$1\x1b[0m');
|
|
|
|
|
|
res = res.replace(/\b(online|w-online|p-online|init\([^)]+\)|up|active|success|yes)\b/gi, '\x1b[1;32m$1\x1b[0m');
|
|
|
|
|
|
res = res.replace(/\b(offline|down|admin-down|error|fail|failed|shutdown|reject|invalid|no)\b/gi, '\x1b[1;31m$1\x1b[0m');
|
|
|
|
|
|
res = res.replace(/\b(Cable|GigabitEthernet|TenGigabitEthernet|Upstream|Downstream|Mac-domain|bonding-group|rpd)\b/gi, '\x1b[33m$1\x1b[0m');
|
|
|
|
|
|
return res;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function initTerminal() {
|
|
|
|
|
|
term = new Terminal({ cursorBlink: true, theme: { background: '#1e1e1e', foreground: '#e0e0e0' }, fontFamily: 'Courier New, monospace', fontSize: 15, scrollback: 100000 });
|
|
|
|
|
|
fitAddon = new FitAddon.FitAddon();
|
|
|
|
|
|
term.loadAddon(fitAddon);
|
|
|
|
|
|
term.open(document.getElementById('terminal-container'));
|
|
|
|
|
|
fitAddon.fit();
|
|
|
|
|
|
term.writeln('Welcome to Harmonic cOS Web Terminal.');
|
|
|
|
|
|
term.writeln('Please confirm the target device above and click "連線至 CMTS"... \r\n');
|
|
|
|
|
|
|
|
|
|
|
|
term.onData((data) => {
|
|
|
|
|
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getGlobalConnectionInfo() {
|
|
|
|
|
|
const host = document.getElementById('cmtsHost').value.trim();
|
|
|
|
|
|
const user = document.getElementById('cmtsUser').value.trim();
|
|
|
|
|
|
const pass = document.getElementById('cmtsPass').value.trim();
|
|
|
|
|
|
if (!host || !user || !pass) {
|
|
|
|
|
|
alert("請在畫面上方完整輸入目標設備的 IP、帳號與密碼!");
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return { host, user, pass };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function connectWebSocket() {
|
|
|
|
|
|
if (ws && ws.readyState === WebSocket.OPEN) return;
|
|
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
if (!connInfo) return;
|
|
|
|
|
|
|
|
|
|
|
|
resetAllManagerData();
|
|
|
|
|
|
|
|
|
|
|
|
term.writeln(`\x1b[33mConnecting to ${connInfo.host} via WebSocket...\x1b[0m`);
|
|
|
|
|
|
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)}`;
|
|
|
|
|
|
|
|
|
|
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
|
|
|
|
|
|
|
|
ws.onopen = () => {
|
2026-04-30 08:27:18 +00:00
|
|
|
|
isConnected = true;
|
2026-04-30 02:36:02 +00:00
|
|
|
|
const btnLoadTask = document.getElementById('btn-load-task');
|
|
|
|
|
|
if (btnLoadTask) {
|
2026-04-30 08:27:18 +00:00
|
|
|
|
btnLoadTask.disabled = false;
|
|
|
|
|
|
btnLoadTask.style.backgroundColor = '#c0392b';
|
|
|
|
|
|
btnLoadTask.style.cursor = 'pointer';
|
2026-04-30 02:36:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 10:00:40 +00:00
|
|
|
|
document.getElementById('wsStatus').textContent = `狀態:已連線`;
|
|
|
|
|
|
document.getElementById('wsStatus').style.color = '#27ae60';
|
|
|
|
|
|
document.getElementById('btnConnect').style.display = 'none';
|
|
|
|
|
|
document.getElementById('btnDisconnect').style.display = 'inline-block';
|
|
|
|
|
|
document.getElementById('cmtsHost').disabled = true;
|
|
|
|
|
|
document.getElementById('cmtsUser').disabled = true;
|
|
|
|
|
|
document.getElementById('cmtsPass').disabled = true;
|
|
|
|
|
|
term.focus();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
ws.onmessage = (event) => {
|
|
|
|
|
|
term.write(colorizeTerminalStream(event.data));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
ws.onclose = () => {
|
2026-04-30 08:27:18 +00:00
|
|
|
|
isConnected = false;
|
2026-04-30 02:36:02 +00:00
|
|
|
|
const btnLoadTask = document.getElementById('btn-load-task');
|
|
|
|
|
|
if (btnLoadTask) {
|
2026-04-30 08:27:18 +00:00
|
|
|
|
btnLoadTask.disabled = true;
|
|
|
|
|
|
btnLoadTask.style.backgroundColor = '#95a5a6';
|
|
|
|
|
|
btnLoadTask.style.cursor = 'not-allowed';
|
2026-04-30 02:36:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 10:00:40 +00:00
|
|
|
|
document.getElementById('wsStatus').textContent = '狀態:已斷線';
|
|
|
|
|
|
document.getElementById('wsStatus').style.color = '#c0392b';
|
|
|
|
|
|
document.getElementById('btnConnect').style.display = 'inline-block';
|
|
|
|
|
|
document.getElementById('btnDisconnect').style.display = 'none';
|
|
|
|
|
|
document.getElementById('cmtsHost').disabled = false;
|
|
|
|
|
|
document.getElementById('cmtsUser').disabled = false;
|
|
|
|
|
|
document.getElementById('cmtsPass').disabled = false;
|
|
|
|
|
|
term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n');
|
|
|
|
|
|
|
|
|
|
|
|
resetAllManagerData();
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function disconnectWebSocket() { if (ws) ws.close(); }
|
|
|
|
|
|
|
|
|
|
|
|
function injectCommand() {
|
|
|
|
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return alert("請先點擊「連線至 CMTS」!");
|
|
|
|
|
|
const cmd = document.getElementById('fixedCmd').value;
|
|
|
|
|
|
ws.send(cmd + '\r');
|
|
|
|
|
|
term.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getTerminalText() {
|
|
|
|
|
|
if (!term) return '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (term.hasSelection()) return term.getSelection();
|
|
|
|
|
|
const buffer = term.buffer.active;
|
|
|
|
|
|
let text = '';
|
|
|
|
|
|
for (let i = 0; i < buffer.length; i++) {
|
|
|
|
|
|
const line = buffer.getLine(i);
|
|
|
|
|
|
if (line) text += line.translateToString(true) + '\n';
|
|
|
|
|
|
}
|
|
|
|
|
|
return text;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("讀取終端機文字失敗:", err);
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function downloadTerminal() {
|
|
|
|
|
|
const text = getTerminalText();
|
|
|
|
|
|
if (!text.trim()) return alert("沒有內容可下載!");
|
|
|
|
|
|
try {
|
|
|
|
|
|
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
|
|
|
|
|
a.download = `cmts_terminal_log_${timestamp}.txt`;
|
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("下載失敗:", err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 4. 綜合查詢 API 呼叫
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
async function executeQuery(mode) {
|
|
|
|
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
|
|
|
|
alert("請先點擊畫面上方的「連線至 CMTS」按鈕!");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
if (!connInfo) return;
|
|
|
|
|
|
|
|
|
|
|
|
const queryType = document.getElementById('queryType' + mode).value;
|
|
|
|
|
|
const target = document.getElementById('queryTarget' + mode).value.trim();
|
|
|
|
|
|
const isDebug = document.getElementById('debugQuery' + mode).checked;
|
|
|
|
|
|
|
|
|
|
|
|
openModal(connInfo.host);
|
|
|
|
|
|
const outputEl = document.getElementById('modalOutput');
|
|
|
|
|
|
outputEl.innerHTML = `<span style="color: #f39c12;">正在向 ${connInfo.host} 查詢中,請稍候...</span>`;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = `/api/v1/cmts-query?query_type=${queryType}&target=${encodeURIComponent(target)}&host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
|
|
|
|
|
|
const response = await fetch(url);
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (result.status === 'success') {
|
|
|
|
|
|
outputEl.style.color = "#e0e0e0";
|
|
|
|
|
|
if (isDebug) {
|
|
|
|
|
|
outputEl.textContent = `[DEBUG] 實際發送指令: ${result.command}\n==================================================\n${result.data}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
outputEl.textContent = result.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
outputEl.style.color = "#e74c3c";
|
|
|
|
|
|
outputEl.textContent = `查詢失敗:\n${result.detail || result.message}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
outputEl.style.color = "#e74c3c";
|
|
|
|
|
|
outputEl.textContent = `連線失敗: ${error}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 5. MAC Domain 配置精靈邏輯
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
async function fetchMacDomainConfig() {
|
|
|
|
|
|
const target = document.getElementById('cfgMacDomain').value.trim();
|
|
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
if (!connInfo || !target) return alert("請確認設備連線資訊與 MAC Domain!");
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('fetchStatus').textContent = "⏳ 正在讀取並解析設定中...";
|
|
|
|
|
|
document.getElementById('fetchStatus').style.color = "#f39c12";
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = `/api/v1/cmts-mac-domain-config?target=${encodeURIComponent(target)}&host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
|
|
|
|
|
|
const response = await fetch(url);
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (result.status === 'success') {
|
|
|
|
|
|
currentMacDomainData = result.data;
|
|
|
|
|
|
populateFormFromData();
|
|
|
|
|
|
document.getElementById('macDomainConfigArea').style.display = 'block';
|
|
|
|
|
|
document.getElementById('fetchStatus').textContent = "✅ 讀取成功!";
|
|
|
|
|
|
document.getElementById('fetchStatus').style.color = "#27ae60";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert("讀取失敗:" + result.detail);
|
|
|
|
|
|
document.getElementById('fetchStatus').textContent = "❌ 讀取失敗";
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
alert("連線錯誤:" + error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function populateFormFromData() {
|
|
|
|
|
|
const d = currentMacDomainData;
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('g_ip_prov').value = d.common_settings['ip-provisioning-mode'] || 'dual-stack';
|
|
|
|
|
|
document.getElementById('g_diplexer').value = d.common_settings['diplexer-band-edge'] || 'enabled';
|
|
|
|
|
|
document.getElementById('g_bat31').value = d.common_settings['cm-battery-mode-31-support'] || 'disabled';
|
|
|
|
|
|
document.getElementById('g_bat30').value = d.common_settings['cm-battery-mode-30-support'] || 'disabled';
|
|
|
|
|
|
document.getElementById('g_docsis40').value = d.common_settings['docsis40'] || 'disabled';
|
|
|
|
|
|
document.getElementById('g_ds_dyn').value = d.common_settings['ds-dynamic-bonding-group'] || 'disabled';
|
|
|
|
|
|
document.getElementById('g_us_dyn').value = d.common_settings['us-dynamic-bonding-group'] || 'disabled';
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('b_admin').value = d.basic_channel_sets['admin-state'] || 'down';
|
|
|
|
|
|
document.getElementById('b_ds_pri').value = d.basic_channel_sets['ds-primary-set'] || '';
|
|
|
|
|
|
document.getElementById('b_ds_non_pri').value = d.basic_channel_sets['ds-non-primary-set'] || '';
|
|
|
|
|
|
document.getElementById('b_us_phy').value = d.basic_channel_sets['us-phy-channel-set'] || '';
|
|
|
|
|
|
document.getElementById('b_ds_ofdm').value = d.basic_channel_sets['ds-ofdm-set'] || '';
|
|
|
|
|
|
document.getElementById('b_us_ofdma').value = d.basic_channel_sets['us-ofdma-set'] || '';
|
|
|
|
|
|
|
|
|
|
|
|
const dsSelect = document.getElementById('select_ds_group');
|
|
|
|
|
|
dsSelect.innerHTML = '<option value="_new_">[➕ 新增 DS Group]</option>';
|
|
|
|
|
|
Object.keys(d.static_ds_bonding_groups).forEach(grp => dsSelect.innerHTML += `<option value="${grp}">${grp}</option>`);
|
|
|
|
|
|
if (Object.keys(d.static_ds_bonding_groups).length > 0) dsSelect.value = Object.keys(d.static_ds_bonding_groups)[0];
|
|
|
|
|
|
|
|
|
|
|
|
const usSelect = document.getElementById('select_us_group');
|
|
|
|
|
|
usSelect.innerHTML = '<option value="_new_">[➕ 新增 US Group]</option>';
|
|
|
|
|
|
Object.keys(d.static_us_bonding_groups).forEach(grp => usSelect.innerHTML += `<option value="${grp}">${grp}</option>`);
|
|
|
|
|
|
if (Object.keys(d.static_us_bonding_groups).length > 0) usSelect.value = Object.keys(d.static_us_bonding_groups)[0];
|
|
|
|
|
|
|
|
|
|
|
|
toggleGroupVisibility();
|
|
|
|
|
|
loadDsGroupData();
|
|
|
|
|
|
loadUsGroupData();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function revertFormChanges() {
|
|
|
|
|
|
if (!currentMacDomainData) return;
|
2026-04-30 08:27:18 +00:00
|
|
|
|
if (!confirm("確定要放棄目前的修改,還原回設備原始的設定值嗎?")) return;
|
2026-04-29 10:00:40 +00:00
|
|
|
|
|
|
|
|
|
|
populateFormFromData();
|
|
|
|
|
|
|
|
|
|
|
|
const previewEl = document.getElementById('cliPreviewContainer');
|
|
|
|
|
|
if (previewEl) {
|
|
|
|
|
|
previewEl.textContent = '';
|
|
|
|
|
|
previewEl.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const deployBtn = document.getElementById('btnDeployConfig');
|
|
|
|
|
|
if (deployBtn) {
|
|
|
|
|
|
deployBtn.style.display = 'none';
|
|
|
|
|
|
deployBtn.disabled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fetchStatus = document.getElementById('fetchStatus');
|
|
|
|
|
|
fetchStatus.textContent = "🔄 已還原為原始設定!";
|
|
|
|
|
|
fetchStatus.style.color = "#2980b9";
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
fetchStatus.textContent = "✅ 讀取成功!";
|
|
|
|
|
|
fetchStatus.style.color = "#27ae60";
|
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleGroupVisibility() {
|
|
|
|
|
|
const dsDyn = document.getElementById('g_ds_dyn').value;
|
|
|
|
|
|
const usDyn = document.getElementById('g_us_dyn').value;
|
|
|
|
|
|
document.getElementById('section_group_ds').style.display = (dsDyn === 'disabled') ? 'block' : 'none';
|
|
|
|
|
|
document.getElementById('section_group_us').style.display = (usDyn === 'disabled') ? 'block' : 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadDsGroupData() {
|
|
|
|
|
|
const grp = document.getElementById('select_ds_group').value;
|
|
|
|
|
|
const newGrpInput = document.getElementById('input_new_ds_group');
|
|
|
|
|
|
if (grp === '_new_') {
|
|
|
|
|
|
newGrpInput.style.display = 'inline-block';
|
|
|
|
|
|
document.getElementById('ds_g_admin').value = 'down';
|
|
|
|
|
|
document.getElementById('ds_g_down').value = '';
|
|
|
|
|
|
document.getElementById('ds_g_ofdm').value = '';
|
|
|
|
|
|
document.getElementById('ds_g_fdx').value = '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
newGrpInput.style.display = 'none';
|
|
|
|
|
|
const data = currentMacDomainData.static_ds_bonding_groups[grp];
|
|
|
|
|
|
document.getElementById('ds_g_admin').value = data['admin-state'] || 'down';
|
|
|
|
|
|
document.getElementById('ds_g_down').value = data['down-channel-set'] || '';
|
|
|
|
|
|
document.getElementById('ds_g_ofdm').value = data['ofdm-channel-set'] || '';
|
|
|
|
|
|
document.getElementById('ds_g_fdx').value = data['fdx-ofdm-channel-set'] || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadUsGroupData() {
|
|
|
|
|
|
const grp = document.getElementById('select_us_group').value;
|
|
|
|
|
|
const newGrpInput = document.getElementById('input_new_us_group');
|
|
|
|
|
|
if (grp === '_new_') {
|
|
|
|
|
|
newGrpInput.style.display = 'inline-block';
|
|
|
|
|
|
document.getElementById('us_g_admin').value = 'down';
|
|
|
|
|
|
document.getElementById('us_g_us').value = '';
|
|
|
|
|
|
document.getElementById('us_g_ofdma').value = '';
|
|
|
|
|
|
document.getElementById('us_g_fdx').value = '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
newGrpInput.style.display = 'none';
|
|
|
|
|
|
const data = currentMacDomainData.static_us_bonding_groups[grp];
|
|
|
|
|
|
document.getElementById('us_g_admin').value = data['admin-state'] || 'down';
|
|
|
|
|
|
document.getElementById('us_g_us').value = data['us-channel-set'] || '';
|
|
|
|
|
|
document.getElementById('us_g_ofdma').value = data['ofdma-channel-set'] || '';
|
|
|
|
|
|
document.getElementById('us_g_fdx').value = data['fdx-ofdma-channel-set'] || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function generateMacDomainCLI() {
|
|
|
|
|
|
const md = document.getElementById('cfgMacDomain').value.trim();
|
|
|
|
|
|
let cli = `! --- Harmonic MAC Domain Configuration SOP ---\n`;
|
|
|
|
|
|
|
|
|
|
|
|
cli += `! 1. [區塊] MAC Domain 全域與 Base 設定\n`;
|
|
|
|
|
|
cli += `cable mac-domain ${md} admin-state down\ncommit\n`;
|
|
|
|
|
|
|
|
|
|
|
|
const baseCmds = [
|
|
|
|
|
|
`ip-provisioning-mode ${document.getElementById('g_ip_prov').value}`,
|
|
|
|
|
|
`diplexer-band-edge control ${document.getElementById('g_diplexer').value}`,
|
|
|
|
|
|
`cm-battery-mode-31-support ${document.getElementById('g_bat31').value}`,
|
|
|
|
|
|
`cm-battery-mode-30-support ${document.getElementById('g_bat30').value}`,
|
|
|
|
|
|
`docsis40 ${document.getElementById('g_docsis40').value}`,
|
|
|
|
|
|
`ds-dynamic-bonding-group ${document.getElementById('g_ds_dyn').value}`,
|
|
|
|
|
|
`us-dynamic-bonding-group ${document.getElementById('g_us_dyn').value}`,
|
|
|
|
|
|
`ds-primary-set ${document.getElementById('b_ds_pri').value}`,
|
|
|
|
|
|
`ds-non-primary-set ${document.getElementById('b_ds_non_pri').value}`,
|
|
|
|
|
|
`us-phy-channel-set ${document.getElementById('b_us_phy').value}`,
|
|
|
|
|
|
`ds-ofdm-set ${document.getElementById('b_ds_ofdm').value}`,
|
|
|
|
|
|
`us-ofdma-set ${document.getElementById('b_us_ofdma').value}`
|
|
|
|
|
|
];
|
|
|
|
|
|
baseCmds.forEach(cmd => {
|
|
|
|
|
|
if (!cmd.endsWith(" ")) cli += `cable mac-domain ${md} ${cmd}\n`;
|
|
|
|
|
|
});
|
|
|
|
|
|
cli += `cable mac-domain ${md} admin-state ${document.getElementById('b_admin').value}\ncommit\n`;
|
|
|
|
|
|
|
|
|
|
|
|
if (document.getElementById('g_ds_dyn').value === 'disabled') {
|
|
|
|
|
|
const isNewDs = document.getElementById('select_ds_group').value === '_new_';
|
|
|
|
|
|
let dsName = isNewDs ? document.getElementById('input_new_ds_group').value.trim() : document.getElementById('select_ds_group').value;
|
|
|
|
|
|
|
|
|
|
|
|
if (dsName) {
|
|
|
|
|
|
cli += `\n! 2. [區塊] DS Bonding Group [${dsName}] 設定\n`;
|
|
|
|
|
|
if (!isNewDs) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} admin-state down\ncommit\n`;
|
|
|
|
|
|
|
|
|
|
|
|
if (document.getElementById('ds_g_down').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} down-channel-set ${document.getElementById('ds_g_down').value}\n`;
|
|
|
|
|
|
if (document.getElementById('ds_g_ofdm').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} ofdm-channel-set ${document.getElementById('ds_g_ofdm').value}\n`;
|
|
|
|
|
|
if (document.getElementById('ds_g_fdx').value) cli += `cable mac-domain ${md} ds-bonding-group ${dsName} fdx-ofdm-channel-set ${document.getElementById('ds_g_fdx').value}\n`;
|
|
|
|
|
|
|
|
|
|
|
|
cli += `cable mac-domain ${md} ds-bonding-group ${dsName} admin-state ${document.getElementById('ds_g_admin').value}\ncommit\n`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (document.getElementById('g_us_dyn').value === 'disabled') {
|
|
|
|
|
|
const isNewUs = document.getElementById('select_us_group').value === '_new_';
|
|
|
|
|
|
let usName = isNewUs ? document.getElementById('input_new_us_group').value.trim() : document.getElementById('select_us_group').value;
|
|
|
|
|
|
|
|
|
|
|
|
if (usName) {
|
|
|
|
|
|
cli += `\n! 3. [區塊] US Bonding Group [${usName}] 設定\n`;
|
|
|
|
|
|
if (!isNewUs) cli += `cable mac-domain ${md} us-bonding-group ${usName} admin-state down\ncommit\n`;
|
|
|
|
|
|
|
|
|
|
|
|
if (document.getElementById('us_g_us').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} us-channel-set ${document.getElementById('us_g_us').value}\n`;
|
|
|
|
|
|
if (document.getElementById('us_g_ofdma').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} ofdma-channel-set ${document.getElementById('us_g_ofdma').value}\n`;
|
|
|
|
|
|
if (document.getElementById('us_g_fdx').value) cli += `cable mac-domain ${md} us-bonding-group ${usName} fdx-ofdma-channel-set ${document.getElementById('us_g_fdx').value}\n`;
|
|
|
|
|
|
|
|
|
|
|
|
cli += `cable mac-domain ${md} us-bonding-group ${usName} admin-state ${document.getElementById('us_g_admin').value}\ncommit\n`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const previewEl = document.getElementById('cliPreviewContainer');
|
|
|
|
|
|
previewEl.textContent = cli;
|
|
|
|
|
|
previewEl.style.display = 'block';
|
|
|
|
|
|
|
|
|
|
|
|
const deployBtn = document.getElementById('btnDeployConfig');
|
|
|
|
|
|
deployBtn.style.display = 'inline-block';
|
|
|
|
|
|
deployBtn.disabled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function executeBondingConfig() {
|
|
|
|
|
|
if (!confirm("⚠️ 警告:此操作將導致 MAC Domain 重新啟動,影響用戶服務。確定要執行嗎?")) return;
|
|
|
|
|
|
|
|
|
|
|
|
const cliScript = document.getElementById('cliPreviewContainer').textContent;
|
|
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
if (!connInfo) return;
|
|
|
|
|
|
|
|
|
|
|
|
openModal(connInfo.host);
|
|
|
|
|
|
const outputEl = document.getElementById('modalOutput');
|
|
|
|
|
|
outputEl.innerHTML = `<span style="color: #f39c12;">🚀 正在排隊並執行配置腳本,這可能需要幾秒鐘,請勿關閉視窗...</span>\n\n${cliScript}`;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/v1/cmts-config', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
script: cliScript,
|
|
|
|
|
|
host: connInfo.host,
|
|
|
|
|
|
username: connInfo.user,
|
|
|
|
|
|
password: connInfo.pass
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (result.status === 'success') {
|
|
|
|
|
|
outputEl.style.color = "#e0e0e0";
|
|
|
|
|
|
outputEl.textContent = `✅ 設定執行完成!\n==================================================\n${result.data}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
outputEl.style.color = "#e74c3c";
|
|
|
|
|
|
outputEl.textContent = `❌ 設定執行失敗:\n${result.message}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
outputEl.style.color = "#e74c3c";
|
|
|
|
|
|
outputEl.textContent = `❌ 連線或伺服器錯誤: ${error}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
2026-04-30 10:52:55 +00:00
|
|
|
|
// 💡 6. 完整設備配置樹狀圖邏輯 (支援資料夾層級鎖定)
|
2026-04-30 08:27:18 +00:00
|
|
|
|
// ==========================================
|
2026-04-30 10:52:55 +00:00
|
|
|
|
function buildTree(data, parentPath = "") {
|
2026-04-30 08:27:18 +00:00
|
|
|
|
if (typeof data !== 'object' || data === null) {
|
|
|
|
|
|
return `<span>${data}</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let html = '<div style="margin-left: 20px;">';
|
|
|
|
|
|
for (const key in data) {
|
|
|
|
|
|
const value = data[key];
|
2026-04-30 10:52:55 +00:00
|
|
|
|
const currentPath = parentPath ? `${parentPath}.${key}` : key;
|
|
|
|
|
|
|
2026-04-30 08:27:18 +00:00
|
|
|
|
if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) {
|
2026-04-30 10:52:55 +00:00
|
|
|
|
// 📁 渲染資料夾 (加入編輯與操作按鈕)
|
|
|
|
|
|
const elementId = `folder-${currentPath.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
|
|
|
|
|
|
2026-04-30 08:27:18 +00:00
|
|
|
|
html += `
|
2026-04-30 10:52:55 +00:00
|
|
|
|
<details id="details-${elementId}">
|
|
|
|
|
|
<summary style="cursor: pointer; font-weight: bold; color: #2c3e50; margin-top: 5px; padding: 2px 0; display: flex; align-items: center;">
|
|
|
|
|
|
<span style="margin-right: 5px;">📁</span> ${key}
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ✏️ 資料夾專屬編輯按鈕 -->
|
|
|
|
|
|
<span id="edit-btn-${elementId}" onclick="event.stopPropagation(); event.preventDefault(); startEditFolder('${currentPath}', '${elementId}')"
|
|
|
|
|
|
style="margin-left: 10px; cursor: pointer; font-size: 14px; opacity: 0.3; transition: opacity 0.2s;"
|
|
|
|
|
|
onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.3" title="鎖定並編輯此區塊">
|
|
|
|
|
|
✏️
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 儲存與取消按鈕 (預設隱藏) -->
|
|
|
|
|
|
<span id="actions-${elementId}" style="display:none; margin-left: 10px;">
|
|
|
|
|
|
<button onclick="event.stopPropagation(); event.preventDefault(); previewFolderCLI('${currentPath}', '${elementId}')" style="background-color: #27ae60; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">✅ 預覽指令</button>
|
|
|
|
|
|
<button onclick="event.stopPropagation(); event.preventDefault(); cancelEditFolder('${currentPath}', '${elementId}')" style="background-color: #e74c3c; color: white; border: none; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 5px;">❌ 取消</button>
|
|
|
|
|
|
</span>
|
2026-04-30 08:27:18 +00:00
|
|
|
|
</summary>
|
2026-04-30 10:52:55 +00:00
|
|
|
|
|
|
|
|
|
|
<!-- 💡 包裝子內容,方便後續用 JS 抓取裡面的葉節點 -->
|
|
|
|
|
|
<div id="content-${elementId}">
|
|
|
|
|
|
${buildTree(value, currentPath)}
|
|
|
|
|
|
</div>
|
2026-04-30 08:27:18 +00:00
|
|
|
|
</details>
|
|
|
|
|
|
`;
|
|
|
|
|
|
} else {
|
2026-04-30 10:52:55 +00:00
|
|
|
|
// 📄 渲染檔案 (移除獨立按鈕,改為埋入 data 屬性供父節點控制)
|
|
|
|
|
|
const safeValue = (value === null ? '' : value).toString().replace(/"/g, """);
|
|
|
|
|
|
|
2026-04-30 08:27:18 +00:00
|
|
|
|
html += `
|
2026-04-30 10:52:55 +00:00
|
|
|
|
<div style="padding: 4px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px; display: flex; align-items: center;">
|
|
|
|
|
|
<span style="margin-right: 5px;">📄</span> <b>${key}</b>:
|
|
|
|
|
|
|
|
|
|
|
|
<span class="leaf-container" data-path="${currentPath}" data-original="${safeValue}" style="display: inline-flex; align-items: center; min-height: 24px;">
|
|
|
|
|
|
<span class="leaf-value" style="color: #16a085; margin-left: 5px; font-family: Courier New, monospace;">${value === null ? '' : value}</span>
|
|
|
|
|
|
</span>
|
2026-04-30 08:27:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchFullConfig() {
|
|
|
|
|
|
const loadingMsg = document.getElementById('loading-message');
|
|
|
|
|
|
const treeContainer = document.getElementById('tree-container');
|
|
|
|
|
|
|
|
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
if (!connInfo) return;
|
|
|
|
|
|
|
2026-04-30 10:52:55 +00:00
|
|
|
|
// 💡 1. 顯示標題旁邊的載入提示 (改為 inline-block 讓它排在同一行)
|
|
|
|
|
|
if (loadingMsg) loadingMsg.style.display = 'inline-block';
|
|
|
|
|
|
|
|
|
|
|
|
// 💡 2. 清空樹狀圖區塊,保持畫面乾淨
|
|
|
|
|
|
if (treeContainer) {
|
|
|
|
|
|
treeContainer.innerHTML = '';
|
|
|
|
|
|
}
|
2026-04-30 08:27:18 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-30 10:52:55 +00:00
|
|
|
|
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}`;
|
|
|
|
|
|
const response = await fetch(url);
|
2026-04-30 08:27:18 +00:00
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (result.status === 'success') {
|
2026-04-30 10:52:55 +00:00
|
|
|
|
if (treeContainer) treeContainer.innerHTML = buildTree(result.data);
|
2026-04-30 08:27:18 +00:00
|
|
|
|
} else {
|
2026-04-30 10:52:55 +00:00
|
|
|
|
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 錯誤: ${result.message}</div>`;
|
2026-04-30 08:27:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-04-30 10:52:55 +00:00
|
|
|
|
if (treeContainer) treeContainer.innerHTML = `<div style="color: #c0392b; font-weight: bold; margin: 20px;">❌ 連線或解析失敗: ${error.message}</div>`;
|
2026-04-30 08:27:18 +00:00
|
|
|
|
} finally {
|
2026-04-30 10:52:55 +00:00
|
|
|
|
// 💡 3. 完成後隱藏載入提示
|
|
|
|
|
|
if (loadingMsg) loadingMsg.style.display = 'none';
|
2026-04-30 08:27:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 7. 系統設定 (System Settings) 邏輯
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
let currentHiddenKeys = [];
|
|
|
|
|
|
|
|
|
|
|
|
async function openSystemSettings() {
|
|
|
|
|
|
// 切換到此頁籤時,先默默讀取目前的隱藏名單
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/v1/settings/tree-filters');
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
if (result.status === 'success') {
|
|
|
|
|
|
currentHiddenKeys = result.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("讀取設定失敗:", error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadRealConfigForFilters() {
|
|
|
|
|
|
const connInfo = getGlobalConnectionInfo();
|
|
|
|
|
|
if (!connInfo) {
|
|
|
|
|
|
alert("請先在畫面上方輸入設備連線資訊!");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loadingMsg = document.getElementById('filter-loading-msg');
|
|
|
|
|
|
const container = document.getElementById('filter-checkboxes');
|
|
|
|
|
|
|
|
|
|
|
|
loadingMsg.style.display = 'inline-block';
|
|
|
|
|
|
container.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 💡 加上 skip_filter=true,確保能抓到被隱藏的節點以便取消勾選
|
|
|
|
|
|
const url = `/api/v1/cmts-full-config?host=${encodeURIComponent(connInfo.host)}&username=${encodeURIComponent(connInfo.user)}&password=${encodeURIComponent(connInfo.pass)}&skip_filter=true`;
|
|
|
|
|
|
const response = await fetch(url);
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (result.status === 'success') {
|
|
|
|
|
|
container.style.display = 'block';
|
|
|
|
|
|
container.innerHTML = buildRealFilterTree(result.data, "", currentHiddenKeys);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
container.style.display = 'block';
|
|
|
|
|
|
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 錯誤: ${result.message}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
container.style.display = 'block';
|
|
|
|
|
|
container.innerHTML = `<div style="color: #c0392b; font-weight: bold;">❌ 連線失敗: ${error.message}</div>`;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingMsg.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildRealFilterTree(data, parentPath, hiddenKeys) {
|
|
|
|
|
|
if (typeof data !== 'object' || data === null) return '';
|
|
|
|
|
|
|
|
|
|
|
|
let html = `<div style="margin-left: ${parentPath ? '25px' : '0'};">`;
|
|
|
|
|
|
for (const key in data) {
|
|
|
|
|
|
const value = data[key];
|
|
|
|
|
|
// 💡 組合絕對路徑,例如 "cable.mac-domain"
|
|
|
|
|
|
const currentPath = parentPath ? `${parentPath}.${key}` : key;
|
|
|
|
|
|
const isChecked = hiddenKeys.includes(currentPath) ? "checked" : "";
|
|
|
|
|
|
|
|
|
|
|
|
if (typeof value === 'object' && value !== null && Object.keys(value).length > 0) {
|
|
|
|
|
|
html += `
|
|
|
|
|
|
<details>
|
|
|
|
|
|
<summary style="cursor: pointer; font-weight: bold; color: #2c3e50; margin-top: 5px; padding: 4px 0; display: flex; align-items: center; list-style: none;">
|
|
|
|
|
|
<input type="checkbox" value="${currentPath}" class="filter-checkbox" style="margin-right: 8px; cursor: pointer; width: 16px; height: 16px; accent-color: #e74c3c;" ${isChecked}>
|
|
|
|
|
|
<span style="margin-right: 5px;">📁</span> ${key}
|
|
|
|
|
|
</summary>
|
|
|
|
|
|
${buildRealFilterTree(value, currentPath, hiddenKeys)}
|
|
|
|
|
|
</details>
|
|
|
|
|
|
`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
html += `
|
|
|
|
|
|
<div style="padding: 4px 0; color: #34495e; margin-left: 20px; border-left: 1px solid #ecf0f1; padding-left: 10px; display: flex; align-items: center;">
|
|
|
|
|
|
<input type="checkbox" value="${currentPath}" class="filter-checkbox" style="margin-right: 8px; cursor: pointer; width: 16px; height: 16px; accent-color: #e74c3c;" ${isChecked}>
|
|
|
|
|
|
<span style="margin-right: 5px;">📄</span> <b>${key}</b>: <span style="color: #16a085; margin-left: 5px;">${value === null ? '' : value}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveSystemSettings() {
|
|
|
|
|
|
const checkboxes = document.querySelectorAll('.filter-checkbox:checked');
|
|
|
|
|
|
const selectedHiddenKeys = Array.from(checkboxes).map(cb => cb.value);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/v1/settings/tree-filters', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ hidden_keys: selectedHiddenKeys })
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
if (result.status === 'success') {
|
|
|
|
|
|
const statusText = document.getElementById('settings-status');
|
|
|
|
|
|
statusText.style.display = 'inline-block';
|
|
|
|
|
|
currentHiddenKeys = selectedHiddenKeys; // 更新本地狀態
|
|
|
|
|
|
setTimeout(() => { statusText.style.display = 'none'; }, 3000);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("儲存設定失敗:", error);
|
|
|
|
|
|
alert("儲存設定時發生錯誤!");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// 8. Modal 彈出視窗控制
|
2026-04-29 10:00:40 +00:00
|
|
|
|
// ==========================================
|
|
|
|
|
|
function openModal(targetInfo) {
|
|
|
|
|
|
document.getElementById('outputModal').classList.add('active');
|
|
|
|
|
|
document.getElementById('modalTargetInfo').textContent = targetInfo ? `(${targetInfo})` : '';
|
|
|
|
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeModal(event) {
|
|
|
|
|
|
if (event && event.target !== event.currentTarget && event.target.className !== 'modal-close') return;
|
|
|
|
|
|
document.getElementById('outputModal').classList.remove('active');
|
|
|
|
|
|
document.body.style.overflow = '';
|
2026-04-30 08:27:18 +00:00
|
|
|
|
}
|