Add Network Design Studio with multi-U Dell rack designer.

Includes rack catalog/API, Visio-style drag-and-drop, faceplate assets, readable nameplates, and theme/RDP UI updates so multi-U placement stays correct during design.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-07-18 01:18:14 +02:00
parent a3d0ed72bd
commit 91222faffe
17 changed files with 8865 additions and 223 deletions
+2934 -63
View File
File diff suppressed because it is too large Load Diff
+77 -1
View File
@@ -757,6 +757,7 @@
<div class="kpi-actions"> <div class="kpi-actions">
<button type="button" class="btn primary" data-kpi-act="focus">Focus on map</button> <button type="button" class="btn primary" data-kpi-act="focus">Focus on map</button>
<button type="button" class="btn conn-link" data-kpi-act="ssh" ${node.ip ? "" : "disabled"}>SSH terminal</button> <button type="button" class="btn conn-link" data-kpi-act="ssh" ${node.ip ? "" : "disabled"}>SSH terminal</button>
<button type="button" class="btn conn-link" data-kpi-act="rdp" ${node.ip ? "" : "disabled"}>RDP popout</button>
<button type="button" class="btn" data-kpi-act="connect">Quick Connect</button> <button type="button" class="btn" data-kpi-act="connect">Quick Connect</button>
<button type="button" class="btn" data-kpi-act="inventory">Inspector + inventory</button> <button type="button" class="btn" data-kpi-act="inventory">Inspector + inventory</button>
<button type="button" class="btn" data-kpi-act="chat">Ask Cockpit chat</button> <button type="button" class="btn" data-kpi-act="chat">Ask Cockpit chat</button>
@@ -1480,7 +1481,13 @@
<span class="ct-t">SSH terminal</span> <span class="ct-t">SSH terminal</span>
<span class="ct-s">Login with your username · ${escapeHtml(ip)}</span> <span class="ct-s">Login with your username · ${escapeHtml(ip)}</span>
</button>` : ""} </button>` : ""}
${ip ? `<button type="button" class="connect-tile conn-link" id="btn-modal-rdp">
<span class="ct-ico">▭</span>
<span class="ct-t">RDP popout</span>
<span class="ct-s">${node.rdp_host ? `OS ${escapeHtml(node.rdp_host)}` : node.os_hostname ? `Resolve ${escapeHtml(node.os_hostname)}` : `Edit host · ${escapeHtml(ip)}`}${node.is_windows ? " · Windows" : ""}</span>
</button>` : ""}
<button type="button" class="connect-tile" id="btn-modal-ai"> <button type="button" class="connect-tile" id="btn-modal-ai">
<span class="ct-ico">✦</span> <span class="ct-ico">✦</span>
<span class="ct-t">Ask AI</span> <span class="ct-t">Ask AI</span>
<span class="ct-s">OpenManage AI about this node</span> <span class="ct-s">OpenManage AI about this node</span>
@@ -1512,6 +1519,11 @@
window.cockpitSsh?.open(node); window.cockpitSsh?.open(node);
return; return;
} }
if (e.target.closest("#btn-modal-rdp")) {
closeConnect();
window.cockpitRdp?.open(node);
return;
}
if (e.target.closest("#btn-modal-ai")) { if (e.target.closest("#btn-modal-ai")) {
closeConnect(); closeConnect();
openAi(node); openAi(node);
@@ -1583,7 +1595,47 @@
updateFocusContext(); updateFocusContext();
} }
async function loadWarrantyCompliance(deviceId) { async function loadExpansionPreview(deviceId) {
const mount = $("#warranty-compliance-mount");
if (!mount) return;
try {
const r = await fetch(`/api/devices/${deviceId}/expansion`);
if (!r.ok) return;
const data = await r.json();
const x = data.expansion || {};
const mem = x.memory || {};
const spec = x.spec || {};
const findings = (x.findings || []).filter((f) => f.severity === "warning").slice(0, 2);
const box = document.createElement("div");
box.className = "inv-section";
box.innerHTML = `<h3>Memory / expansion</h3>
<div class="inv-line">${mem.total_gb ?? "—"} GB in ${mem.populated ?? 0} DIMMs · ${mem.empty ?? 0} empty slots · Dell max ${spec.max_memory_tb != null ? spec.max_memory_tb + " TB" : "—"}</div>
<div class="inv-line">Disks ${x.disks?.installed ?? "—"} / ${spec.drive_bays_max ?? "—"} bays · PCIe max ${spec.pcie_slots_max ?? "—"}</div>
${findings.map((f) => `<div class="inv-line">${escapeHtml(f.text)}</div>`).join("")}
<div class="inv-line"><button type="button" class="btn compact" id="btn-open-hw">Open full DIMM map &amp; Dell guidance</button></div>`;
mount.appendChild(box);
$("#btn-open-hw")?.addEventListener("click", () => {
window.cockpitReports?.open?.();
// slight delay for drawer open
setTimeout(() => {
if (window.cockpitReports) {
window.cockpitReports.state = window.cockpitReports.state || {};
}
const st = document.querySelector('#reports-tabs [data-tab="hardware"]');
st?.click();
// set device after tab loads
setTimeout(() => {
const btn = document.querySelector(`[data-hw-id="${deviceId}"]`);
btn?.click();
}, 400);
}, 200);
});
} catch (_) {
/* ignore */
}
}
async function loadWarrantyCompliance(deviceId) {
const mount = $("#warranty-compliance-mount"); const mount = $("#warranty-compliance-mount");
if (!mount) return; if (!mount) return;
try { try {
@@ -1681,6 +1733,7 @@
<button type="button" class="btn primary" id="btn-quick-connect">Quick Connect · iDRAC / SSH</button> <button type="button" class="btn primary" id="btn-quick-connect">Quick Connect · iDRAC / SSH</button>
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web</a>` : ""} ${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web</a>` : ""}
${node.ip ? `<button type="button" class="btn conn-link" id="btn-ssh-term">SSH terminal · ${escapeHtml(node.ip)}</button>` : ""} ${node.ip ? `<button type="button" class="btn conn-link" id="btn-ssh-term">SSH terminal · ${escapeHtml(node.ip)}</button>` : ""}
${node.ip ? `<button type="button" class="btn conn-link" id="btn-rdp-term">RDP popout · ${escapeHtml(node.rdp_host || node.os_hostname || node.ip)}</button>` : ""}
<button type="button" class="btn" id="btn-detail">Load full inventory + app landscape</button> <button type="button" class="btn" id="btn-detail">Load full inventory + app landscape</button>
<button type="button" class="btn" id="btn-ask-ai">Ask OpenManage AI</button> <button type="button" class="btn" id="btn-ask-ai">Ask OpenManage AI</button>
<button type="button" class="btn ghost" id="btn-focus-model">Filter this model</button> <button type="button" class="btn ghost" id="btn-focus-model">Filter this model</button>
@@ -1715,8 +1768,10 @@
updateFocusContext(); updateFocusContext();
$("#btn-quick-connect")?.addEventListener("click", () => openConnect(node)); $("#btn-quick-connect")?.addEventListener("click", () => openConnect(node));
$("#btn-ssh-term")?.addEventListener("click", () => window.cockpitSsh?.open(node)); $("#btn-ssh-term")?.addEventListener("click", () => window.cockpitSsh?.open(node));
$("#btn-rdp-term")?.addEventListener("click", () => window.cockpitRdp?.open(node));
$("#btn-ask-ai")?.addEventListener("click", () => openAi(node)); $("#btn-ask-ai")?.addEventListener("click", () => openAi(node));
loadWarrantyCompliance(node.id); loadWarrantyCompliance(node.id);
loadExpansionPreview(node.id);
const cachedInv = state.inventoryCache[node.id]; const cachedInv = state.inventoryCache[node.id];
if (cachedInv) { if (cachedInv) {
const mount = $("#inv-mount"); const mount = $("#inv-mount");
@@ -2061,6 +2116,8 @@
showToast("Focused " + (node.name || "")); showToast("Focused " + (node.name || ""));
} else if (a === "ssh") { } else if (a === "ssh") {
window.cockpitSsh?.open(node); window.cockpitSsh?.open(node);
} else if (a === "rdp") {
window.cockpitRdp?.open(node);
} else if (a === "connect") { } else if (a === "connect") {
closeKpiPopup(); closeKpiPopup();
openConnect(node); openConnect(node);
@@ -2238,6 +2295,25 @@
} }
}); });
function applyTheme(theme) {
const t = theme === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
try {
localStorage.setItem("cockpit_theme", t);
} catch (_) {}
const btn = $("#btn-theme");
if (btn) {
btn.dataset.theme = t;
btn.title = t === "light" ? "Switch to dark theme" : "Switch to light theme";
btn.setAttribute("aria-label", btn.title);
}
}
applyTheme(localStorage.getItem("cockpit_theme") || "light");
$("#btn-theme")?.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme") || "light";
applyTheme(cur === "light" ? "dark" : "light");
});
$("#btn-reset-view").addEventListener("click", (e) => { $("#btn-reset-view").addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+80 -33
View File
@@ -7,9 +7,19 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css?v=reports1" /> <link rel="stylesheet" href="/styles.css?v=plate4" />
<link rel="icon" href="/dell.png" type="image/png" /> <link rel="icon" href="/dell.png" type="image/png" />
<link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" /> <link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" />
<script>
(function () {
try {
var t = localStorage.getItem("cockpit_theme") || "light";
document.documentElement.setAttribute("data-theme", t === "dark" ? "dark" : "light");
} catch (e) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>
</head> </head>
<body> <body>
<div id="notify-stack" aria-live="polite" aria-atomic="false"></div> <div id="notify-stack" aria-live="polite" aria-atomic="false"></div>
@@ -28,9 +38,10 @@
<div class="top-actions"> <div class="top-actions">
<button type="button" class="btn ghost" id="btn-reset-view" title="Fit fleet in view">Reset view</button> <button type="button" class="btn ghost" id="btn-reset-view" title="Fit fleet in view">Reset view</button>
<button type="button" class="btn ghost" id="btn-ome-console" title="Open OME console">OME console</button> <button type="button" class="btn ghost" id="btn-ome-console" title="Open OME console">OME console</button>
<button type="button" class="btn ghost" id="btn-network" title="Fabric map &amp; ATC racks">Network</button>
<button type="button" class="btn ghost" id="btn-reports" title="Fleet reports &amp; Dell compliance">Reports</button> <button type="button" class="btn ghost" id="btn-reports" title="Fleet reports &amp; Dell compliance">Reports</button>
<button type="button" class="btn ghost" id="btn-ops" title="ATC admin tickets">Ops desk</button> <button type="button" class="btn ghost" id="btn-ops" title="ATC admin tickets">Ops desk</button>
<button type="button" class="btn primary" id="btn-chat">Cockpit chat</button> <button type="button" class="btn primary" id="btn-chat">Cockpit Copilot</button>
</div> </div>
</header> </header>
@@ -96,18 +107,6 @@
</div> </div>
</section> </section>
<section class="rail-block open" data-module="gpu">
<button type="button" class="rail-toggle" aria-expanded="true">
<span>V100 GPU matrix</span>
<span class="chev" aria-hidden="true"></span>
</button>
<div class="rail-panel">
<p class="hint">Live atc-gpu-prod · llama3-70b-gptq</p>
<div id="gpu-summary" class="gpu-summary">Waiting for GPU telemetry…</div>
<div id="gpu-matrix" class="gpu-matrix"></div>
</div>
</section>
<section class="rail-block open" data-module="live"> <section class="rail-block open" data-module="live">
<button type="button" class="rail-toggle" aria-expanded="true"> <button type="button" class="rail-toggle" aria-expanded="true">
<span>Realtime context</span> <span>Realtime context</span>
@@ -192,17 +191,26 @@
<footer class="ticker" id="ticker"></footer> <footer class="ticker" id="ticker"></footer>
</div> </div>
<!-- Cockpit chat --> <button type="button" class="theme-fab" id="btn-theme" title="Switch theme" aria-label="Switch light or dark theme">
<div class="drawer" id="chat-drawer" aria-hidden="true"> <svg class="theme-ico theme-ico-moon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M21 14.3A8.5 8.5 0 0 1 9.7 3 7 7 0 1 0 21 14.3z"/>
</svg>
<svg class="theme-ico theme-ico-sun" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M12 4.5a1 1 0 0 1 1 1V7a1 1 0 1 1-2 0V5.5a1 1 0 0 1 1-1zm0 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM5.5 11a1 1 0 0 1-1 1H3a1 1 0 1 1 0-2h1.5a1 1 0 0 1 1 1zm15.5 0a1 1 0 0 1-1 1H18.5a1 1 0 1 1 0-2H20a1 1 0 0 1 1 1zM6.7 6.7a1 1 0 0 1 0 1.4L5.6 9.2A1 1 0 1 1 4.2 7.8l1.1-1.1a1 1 0 0 1 1.4 0zm11.6 0a1 1 0 0 1 1.4 0l1.1 1.1a1 1 0 1 1-1.4 1.4l-1.1-1.1a1 1 0 0 1 0-1.4zM12 17a1 1 0 0 1 1 1v1.5a1 1 0 1 1-2 0V18a1 1 0 0 1 1-1zm-5.3.3a1 1 0 0 1 1.4 0l1.1 1.1a1 1 0 1 1-1.4 1.4L6.7 18.7a1 1 0 0 1 0-1.4zm11.6 0a1 1 0 0 1 0 1.4l-1.1 1.1a1 1 0 1 1-1.4-1.4l1.1-1.1a1 1 0 0 1 1.4 0z"/>
</svg>
</button>
<!-- Cockpit chat / Copilot -->
<div class="drawer chat-copilot" id="chat-drawer" aria-hidden="true">
<div class="drawer-head"> <div class="drawer-head">
<div class="drawer-brand"> <div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" /> <img src="/dell.png" alt="Dell" height="22" />
<span>Cockpit chat</span> <span>Cockpit Copilot</span>
</div> </div>
<button type="button" class="btn ghost" id="btn-chat-close">Close</button> <button type="button" class="btn ghost" id="btn-chat-close">Close</button>
</div> </div>
<div class="chat-context" id="chat-context"> <div class="chat-context" id="chat-context">
<label class="chat-model-wrap" title="Select model"> <label class="chat-model-wrap" title="Select model (Llama / OpenManage AI)">
<span class="chat-model-label">Model</span> <span class="chat-model-label">Model</span>
<select id="chat-model" class="chat-model-select" aria-label="Chat model"> <select id="chat-model" class="chat-model-select" aria-label="Chat model">
<option value="llama3-70b-gptq">llama3-70b-gptq</option> <option value="llama3-70b-gptq">llama3-70b-gptq</option>
@@ -211,16 +219,25 @@
<span class="chat-ctx-pill" id="chat-ctx-fleet">fleet …</span> <span class="chat-ctx-pill" id="chat-ctx-fleet">fleet …</span>
<span class="chat-ctx-pill" id="chat-ctx-focus">focus: none</span> <span class="chat-ctx-pill" id="chat-ctx-focus">focus: none</span>
</div> </div>
<section class="chat-gpu" aria-label="Live GPU matrix">
<div class="chat-gpu-head">
<strong>V100 GPU matrix</strong>
<span class="chat-gpu-host">atc-gpu-prod · live</span>
</div>
<div id="gpu-summary" class="gpu-summary">Waiting for GPU telemetry…</div>
<div id="gpu-matrix" class="gpu-matrix chat-gpu-matrix"></div>
</section>
<div class="chat-quick" id="chat-quick"> <div class="chat-quick" id="chat-quick">
<button type="button" data-q="List every device with its Service Tag, model, and IP.">All service tags</button> <button type="button" data-q="List every device with its Service Tag, model, and IP.">All service tags</button>
<button type="button" data-q="How much memory (RAM GB) does each server currently have? List Service Tag, name, model, and installed GB.">Memory per server</button>
<button type="button" data-q="What is the current fleet health? Include Service Tags for critical systems.">Fleet health</button> <button type="button" data-q="What is the current fleet health? Include Service Tags for critical systems.">Fleet health</button>
<button type="button" data-q="List all critical alerts with system name, Service Tag, and IP.">Critical alerts</button> <button type="button" data-q="List all critical alerts with system name, Service Tag, and IP.">Critical alerts</button>
<button type="button" data-q="Which systems are firmware outdated vs the Dell catalog? Include Service Tags and BIOS/iDRAC versions if known.">Outdated firmware</button> <button type="button" data-q="Which systems are firmware outdated vs the Dell catalog? Include Service Tags and BIOS/iDRAC versions if known.">Outdated firmware</button>
<button type="button" data-q="Summarize V100 GPU utilization on atc-gpu-prod.">GPU status</button> <button type="button" data-q="Summarize V100 GPU utilization on atc-gpu-prod from the live matrix.">GPU status</button>
</div> </div>
<div class="chat-body" id="chat-body"></div> <div class="chat-body" id="chat-body"></div>
<form class="chat-form" id="chat-form"> <form class="chat-form" id="chat-form">
<input type="text" id="chat-input" placeholder="Ask about live OME fleet, alerts, power, iDRACs, GPUs…" autocomplete="off" /> <input type="text" id="chat-input" placeholder="Ask about fleet memory, alerts, power, iDRACs, GPUs…" autocomplete="off" />
<button type="submit" class="btn primary">Send</button> <button type="submit" class="btn primary">Send</button>
</form> </form>
</div> </div>
@@ -244,9 +261,9 @@
</label> </label>
<button type="button" class="btn primary" id="btn-new-ticket">New ticket</button> <button type="button" class="btn primary" id="btn-new-ticket">New ticket</button>
<div class="ops-filters"> <div class="ops-filters">
<select id="ops-filter"> <select id="ops-filter" title="Ticket history filter">
<option value="openish">Active</option> <option value="all" selected>All history</option>
<option value="all">All</option> <option value="openish">Active only</option>
<option value="open">Open</option> <option value="open">Open</option>
<option value="accepted">Accepted</option> <option value="accepted">Accepted</option>
<option value="in_progress">In progress</option> <option value="in_progress">In progress</option>
@@ -254,6 +271,7 @@
<option value="closed">Closed</option> <option value="closed">Closed</option>
<option value="mine">Assigned to me</option> <option value="mine">Assigned to me</option>
</select> </select>
<span class="ops-count" id="ops-count">0 tickets</span>
</div> </div>
<div class="list" id="ticket-list"></div> <div class="list" id="ticket-list"></div>
</div> </div>
@@ -278,6 +296,30 @@
<iframe id="ai-frame" title="OpenManage AI" src="about:blank"></iframe> <iframe id="ai-frame" title="OpenManage AI" src="about:blank"></iframe>
</div> </div>
<!-- Network: fabric + ATC racks -->
<div class="drawer wide network-drawer" id="network-drawer" aria-hidden="true">
<div class="drawer-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<span>Network · ATC1 / ATC2</span>
</div>
<div class="net-head-meta">
<span id="network-status" class="hint"></span>
<button type="button" class="btn ghost" id="btn-network-close">Close</button>
</div>
</div>
<div class="chip-grid" id="network-tabs">
<button type="button" class="chip active" data-ntab="fabric">Fabric</button>
<button type="button" class="chip" data-ntab="racks">Racks 42U</button>
<button type="button" class="chip" data-ntab="vlans">VLANs</button>
</div>
<div class="network-body">
<div id="network-fabric" class="network-panel"></div>
<div id="network-racks" class="network-panel hidden"></div>
<div id="network-vlans" class="network-panel hidden"></div>
</div>
</div>
<!-- Reports / compliance / export --> <!-- Reports / compliance / export -->
<div class="drawer wide reports-drawer" id="reports-drawer" aria-hidden="true"> <div class="drawer wide reports-drawer" id="reports-drawer" aria-hidden="true">
<div class="drawer-head"> <div class="drawer-head">
@@ -288,11 +330,13 @@
<button type="button" class="btn ghost" id="btn-reports-close">Close</button> <button type="button" class="btn ghost" id="btn-reports-close">Close</button>
</div> </div>
<div class="chip-grid reports-tabs" id="reports-tabs"> <div class="chip-grid reports-tabs" id="reports-tabs">
<button type="button" class="chip active" data-tab="fleet">Fleet inventory</button> <button type="button" class="chip active" data-tab="analytics" title="Dashboard KPIs — click tiles to drill into filtered views">Analytics</button>
<button type="button" class="chip" data-tab="firmware">Firmware / BIOS</button> <button type="button" class="chip" data-tab="hardware" title="DIMM slot map, disk/PCIe expansion, Dell best practices">Hardware / Expansion</button>
<button type="button" class="chip" data-tab="warranty">Warranty</button> <button type="button" class="chip" data-tab="fleet" title="Full fleet table with Service Tags — filter & export">Fleet inventory</button>
<button type="button" class="chip" data-tab="ome">OME reports</button> <button type="button" class="chip" data-tab="firmware" title="BIOS/iDRAC vs Dell Online Baseline — outdated filter">Firmware / BIOS</button>
<button type="button" class="chip" data-tab="brief">Customer brief</button> <button type="button" class="chip" data-tab="warranty" title="Warranty end dates — expired / expiring filters">Warranty</button>
<button type="button" class="chip" data-tab="ome" title="OME ReportDefs, catalogs, baselines, jobs">OME reports</button>
<button type="button" class="chip" data-tab="brief" title="Printable customer brief with Service Tag appendix">Customer brief</button>
</div> </div>
<div class="reports-body" id="reports-mount"> <div class="reports-body" id="reports-mount">
<p class="hint">Select a report tab to load live OME data.</p> <p class="hint">Select a report tab to load live OME data.</p>
@@ -421,12 +465,13 @@
<div class="modal-badges" id="connect-badges"></div> <div class="modal-badges" id="connect-badges"></div>
<div class="modal-context" id="connect-context"></div> <div class="modal-context" id="connect-context"></div>
<div class="connect-grid" id="connect-grid"></div> <div class="connect-grid" id="connect-grid"></div>
<p class="modal-foot">iDRAC opens in a new tab · SSH opens an in-browser terminal — enter your own username</p> <p class="modal-foot">iDRAC opens in a new tab · SSH = in-browser terminal · RDP = popout for Windows (local client)</p>
</div> </div>
</div> </div>
<div class="modal hidden" id="ssh-modal" aria-hidden="true" role="dialog" aria-labelledby="ssh-device"> <div class="modal hidden" id="ssh-modal" aria-hidden="true" role="dialog" aria-labelledby="ssh-device">
<div class="modal-card ssh-card"> <div class="modal-card ssh-card">
<button type="button" class="modal-close" id="btn-ssh-close" aria-label="Close">×</button> <button type="button" class="modal-close" id="btn-ssh-close" aria-label="Close">×</button>
<div class="modal-brand"> <div class="modal-brand">
@@ -466,9 +511,11 @@
<script src="/vendor/xterm/xterm.min.js?v=home1"></script> <script src="/vendor/xterm/xterm.min.js?v=home1"></script>
<script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script> <script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script>
<script src="/app.js?v=reports1"></script> <script src="/app.js?v=plate4"></script>
<script src="/ops.js?v=reports1"></script> <script src="/ops.js?v=plate4"></script>
<script src="/ssh.js?v=reports1"></script> <script src="/ssh.js?v=plate4"></script>
<script src="/reports.js?v=reports1"></script> <script src="/rdp.js?v=plate4"></script>
<script src="/reports.js?v=plate4"></script>
<script src="/network.js?v=plate4"></script>
</body> </body>
</html> </html>
+1454
View File
File diff suppressed because it is too large Load Diff
+103 -23
View File
@@ -39,6 +39,7 @@
const api = ensureApi(); const api = ensureApi();
const chatHistory = []; const chatHistory = [];
let tickets = []; let tickets = [];
let filteredTickets = [];
let activeTicketId = null; let activeTicketId = null;
let gpuData = null; let gpuData = null;
let chatModelsLoaded = false; let chatModelsLoaded = false;
@@ -92,14 +93,14 @@
} }
function closeDrawers() { function closeDrawers() {
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer"].forEach((id) => { ["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#network-drawer"].forEach((id) => {
const el = $(id); const el = $(id);
el?.classList.remove("open"); el?.classList.remove("open");
el?.setAttribute("aria-hidden", "true"); el?.setAttribute("aria-hidden", "true");
}); });
window.dispatchEvent(new CustomEvent("cockpit-close-drawers")); window.dispatchEvent(new CustomEvent("cockpit-close-drawers"));
const scrim = $("#scrim"); const scrim = $("#scrim");
if (scrim && ["chat-drawer", "ops-drawer", "ai-drawer", "reports-drawer"].includes(scrim.dataset.mode)) { if (scrim && ["chat-drawer", "ops-drawer", "ai-drawer", "reports-drawer", "network-drawer"].includes(scrim.dataset.mode)) {
scrim.classList.remove("open"); scrim.classList.remove("open");
delete scrim.dataset.mode; delete scrim.dataset.mode;
} }
@@ -133,14 +134,28 @@
.join(""); .join("");
} }
function appendChat(role, text) { function appendChat(role, text, meta) {
const body = $("#chat-body"); const body = $("#chat-body");
if (!body) return; if (!body) return;
const div = document.createElement("div"); const div = document.createElement("div");
div.className = `chat-bubble ${role}`; div.className = `chat-bubble ${role}`;
div.textContent = text; div.textContent = text;
if (meta?.tools?.length) {
const tools = document.createElement("div");
tools.className = "chat-tools";
tools.title = "Live OME lookups used for this answer";
meta.tools.forEach((t) => {
const chip = document.createElement("span");
chip.className = "chat-tool-chip";
const q = t.args?.query || t.args?.service_tag || t.args?.device_id || "";
chip.textContent = q ? `${t.name}:${q}` : t.name;
tools.appendChild(chip);
});
div.appendChild(tools);
}
body.appendChild(div); body.appendChild(div);
body.scrollTop = body.scrollHeight; body.scrollTop = body.scrollHeight;
return div;
} }
function refreshChatContext() { function refreshChatContext() {
@@ -161,8 +176,7 @@
appendChat("user", msg); appendChat("user", msg);
chatHistory.push({ role: "user", content: msg }); chatHistory.push({ role: "user", content: msg });
const model = selectedChatModel(); const model = selectedChatModel();
appendChat("assistant", `Thinking with ${model}`); const thinking = appendChat("assistant", `Looking up live OME facts with ${model}`);
const thinking = $("#chat-body")?.lastElementChild;
try { try {
const focusId = window.cockpit?.getState?.()?.selectedId || null; const focusId = window.cockpit?.getState?.()?.selectedId || null;
const res = await api.post("/api/chat", { const res = await api.post("/api/chat", {
@@ -171,7 +185,22 @@
focus_device_id: focusId, focus_device_id: focusId,
model, model,
}); });
if (thinking) thinking.textContent = res.reply || "(empty reply)"; if (thinking) {
thinking.textContent = res.reply || "(empty reply)";
if (res.tools_used?.length) {
const tools = document.createElement("div");
tools.className = "chat-tools";
tools.title = "Live OME lookups used for this answer";
res.tools_used.forEach((t) => {
const chip = document.createElement("span");
chip.className = "chat-tool-chip";
const q = t.args?.query || t.args?.service_tag || t.args?.device_id || "";
chip.textContent = q ? `${t.name}:${q}` : t.name;
tools.appendChild(chip);
});
thinking.appendChild(tools);
}
}
chatHistory.push({ role: "assistant", content: res.reply || "" }); chatHistory.push({ role: "assistant", content: res.reply || "" });
const g = await api.get("/api/gpu"); const g = await api.get("/api/gpu");
renderGpu(g); renderGpu(g);
@@ -182,7 +211,25 @@
} }
function ticketFilterValue() { function ticketFilterValue() {
return $("#ops-filter")?.value || "openish"; const saved = localStorage.getItem("cockpit_ops_filter");
const sel = $("#ops-filter");
if (sel && !sel.dataset.ready) {
if (saved) sel.value = saved;
else sel.value = "all";
sel.dataset.ready = "1";
}
return sel?.value || saved || "all";
}
function filteredTicketList() {
const f = ticketFilterValue();
const me = actor();
return (tickets || []).filter((t) => {
if (f === "all") return true;
if (f === "mine") return t.assignee === me;
if (f === "openish") return ["open", "accepted", "in_progress"].includes(t.status);
return t.status === f;
});
} }
async function loadTickets() { async function loadTickets() {
@@ -190,29 +237,46 @@
tickets = data.tickets || []; tickets = data.tickets || [];
const list = $("#ticket-list"); const list = $("#ticket-list");
if (!list) return; if (!list) return;
const f = ticketFilterValue(); filteredTickets = filteredTicketList();
const me = actor(); const count = $("#ops-count");
const filtered = tickets.filter((t) => { if (count) {
if (f === "all") return true; count.textContent =
if (f === "mine") return t.assignee === me; filteredTickets.length === tickets.length
if (f === "openish") return ["open", "accepted", "in_progress"].includes(t.status); ? `${tickets.length} ticket${tickets.length === 1 ? "" : "s"}`
return t.status === f; : `${filteredTickets.length} / ${tickets.length} tickets`;
}); }
if (!filtered.length) { if (!filteredTickets.length) {
list.innerHTML = `<p class="hint">No tickets in this filter.</p>`; list.innerHTML = tickets.length
? `<p class="hint">No tickets in this filter. Switch to <strong>All history</strong>.</p>`
: `<p class="hint">No tickets yet. Create one to start the ops history.</p>`;
return; return;
} }
list.innerHTML = filtered list.innerHTML = filteredTickets
.map((tkt) => { .map((tkt) => {
const active = tkt.id === activeTicketId ? " active" : ""; const active = tkt.id === activeTicketId ? " active" : "";
const when = tkt.updated_at
? new Date(tkt.updated_at * 1000).toLocaleString()
: "";
return `<button type="button" class="card${active}" data-ticket="${tkt.id}"> return `<button type="button" class="card${active}" data-ticket="${tkt.id}">
<span class="t">${escape(tkt.title)}</span> <span class="t">#${tkt.id} · ${escape(tkt.title)}</span>
<span class="s"><span class="st-${escape(tkt.status)}">${escape(tkt.status)}</span> · ${escape(tkt.priority)} · ${escape(adminName(tkt.created_by))}${escape(adminName(tkt.assignee))}${tkt.accepted_by ? " · accepted " + escape(adminName(tkt.accepted_by)) : ""}</span> <span class="s"><span class="st-${escape(tkt.status)}">${escape(tkt.status)}</span> · ${escape(tkt.priority)} · ${escape(adminName(tkt.created_by))}${escape(adminName(tkt.assignee))}${tkt.accepted_by ? " · accepted " + escape(adminName(tkt.accepted_by)) : ""}${when ? " · " + escape(when) : ""}</span>
</button>`; </button>`;
}) })
.join(""); .join("");
} }
function ticketNavHtml(id) {
const idx = filteredTickets.findIndex((t) => t.id === id);
if (idx < 0) return "";
const prev = idx > 0 ? filteredTickets[idx - 1] : null;
const next = idx < filteredTickets.length - 1 ? filteredTickets[idx + 1] : null;
return `<div class="ops-nav">
<button type="button" class="btn" id="ops-prev" ${prev ? "" : "disabled"} title="Previous ticket">← Prev</button>
<span class="ops-nav-pos">${idx + 1} / ${filteredTickets.length}</span>
<button type="button" class="btn" id="ops-next" ${next ? "" : "disabled"} title="Next ticket">Next →</button>
</div>`;
}
function escape(s) { function escape(s) {
return String(s ?? "") return String(s ?? "")
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -234,6 +298,7 @@
const me = actor(); const me = actor();
const canAccept = tkt.assignee === me && !tkt.accepted_by && tkt.status === "open"; const canAccept = tkt.assignee === me && !tkt.accepted_by && tkt.status === "open";
thread.innerHTML = ` thread.innerHTML = `
${ticketNavHtml(id)}
<div class="ops-ticket-head"> <div class="ops-ticket-head">
<h3>#${tkt.id} · ${escape(tkt.title)}</h3> <h3>#${tkt.id} · ${escape(tkt.title)}</h3>
<div class="ops-meta-grid"> <div class="ops-meta-grid">
@@ -268,6 +333,10 @@
<button type="button" class="btn danger" id="ops-delete-ticket">Delete</button> <button type="button" class="btn danger" id="ops-delete-ticket">Delete</button>
</div> </div>
</div> </div>
<div class="ops-body-box">
<h4>Original request</h4>
<p class="ops-body-text">${escape(tkt.body)}</p>
</div>
<div class="ops-msgs" id="ops-msgs"> <div class="ops-msgs" id="ops-msgs">
${msgs ${msgs
.map( .map(
@@ -286,6 +355,14 @@
$("#ops-assign").value = tkt.assignee || "jody"; $("#ops-assign").value = tkt.assignee || "jody";
$("#ops-priority").value = tkt.priority || "normal"; $("#ops-priority").value = tkt.priority || "normal";
const idx = filteredTickets.findIndex((t) => t.id === id);
$("#ops-prev")?.addEventListener("click", () => {
if (idx > 0) openTicket(filteredTickets[idx - 1].id);
});
$("#ops-next")?.addEventListener("click", () => {
if (idx >= 0 && idx < filteredTickets.length - 1) openTicket(filteredTickets[idx + 1].id);
});
$("#ops-save-meta")?.addEventListener("click", async () => { $("#ops-save-meta")?.addEventListener("click", async () => {
await api.patch(`/api/tickets/${id}`, { await api.patch(`/api/tickets/${id}`, {
status: $("#ops-status").value, status: $("#ops-status").value,
@@ -786,7 +863,7 @@
if (!$("#chat-body")?.children.length) { if (!$("#chat-body")?.children.length) {
appendChat( appendChat(
"assistant", "assistant",
"Cockpit Copilot online. Pick a model above (same catalog as OpenManage AI), then ask about fleet health, alerts, power, or GPUs." "Cockpit Copilot online. Live V100 GPUs are shown above. Pick a model (Llama or OpenManage AI), then ask about fleet memory, alerts, power, or GPUs."
); );
} }
}); });
@@ -804,7 +881,10 @@
}); });
$("#btn-ops-close")?.addEventListener("click", closeDrawers); $("#btn-ops-close")?.addEventListener("click", closeDrawers);
$("#ops-filter")?.addEventListener("change", () => loadTickets()); $("#ops-filter")?.addEventListener("change", (e) => {
localStorage.setItem("cockpit_ops_filter", e.target.value);
loadTickets();
});
$("#btn-ticket-modal-close")?.addEventListener("click", closeTicketModal); $("#btn-ticket-modal-close")?.addEventListener("click", closeTicketModal);
$("#btn-ticket-cancel")?.addEventListener("click", closeTicketModal); $("#btn-ticket-cancel")?.addEventListener("click", closeTicketModal);
$("#ticket-form")?.addEventListener("submit", async (e) => { $("#ticket-form")?.addEventListener("submit", async (e) => {
@@ -855,7 +935,7 @@
const scrim = $("#scrim"); const scrim = $("#scrim");
scrim?.addEventListener("click", () => { scrim?.addEventListener("click", () => {
const mode = scrim.dataset.mode; const mode = scrim.dataset.mode;
if (mode === "chat-drawer" || mode === "ops-drawer" || mode === "ai-drawer" || mode === "reports-drawer") closeDrawers(); if (mode === "chat-drawer" || mode === "ops-drawer" || mode === "ai-drawer" || mode === "reports-drawer" || mode === "network-drawer") closeDrawers();
}); });
initFeedDrag(); initFeedDrag();
+353
View File
@@ -0,0 +1,353 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OME Cockpit · RDP</title>
<link rel="stylesheet" href="/styles.css?v=rdp1" />
<style>
body.rdp-body {
min-height: 100vh;
margin: 0;
background:
radial-gradient(ellipse 70% 50% at 50% 0%, rgba(0, 118, 206, 0.22), transparent 55%),
var(--bg-deep);
color: var(--text);
font-family: var(--sans);
}
.rdp-shell {
max-width: 720px;
margin: 0 auto;
padding: 1.25rem 1.35rem 2rem;
}
.rdp-brand {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 1.1rem;
}
.rdp-brand img { width: 40px; height: 40px; border-radius: 8px; }
.rdp-brand .kicker {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--cyan);
}
.rdp-brand h1 {
margin: 0.1rem 0 0;
font-size: 1.25rem;
font-weight: 650;
color: #e8f4ff;
}
.rdp-card {
background: rgba(8, 18, 32, 0.94);
border: 1px solid rgba(0, 168, 232, 0.28);
border-radius: 14px;
padding: 1.1rem 1.15rem 1.25rem;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
}
.rdp-meta {
margin: 0 0 0.85rem;
color: #c5d6e6;
font-size: 0.86rem;
line-height: 1.4;
}
.rdp-warn {
margin: 0 0 0.85rem;
padding: 0.55rem 0.7rem;
border-radius: 8px;
border: 1px solid rgba(255, 176, 32, 0.4);
background: rgba(255, 176, 32, 0.1);
color: #ffe0a8;
font-size: 0.8rem;
line-height: 1.4;
}
.rdp-grid {
display: grid;
grid-template-columns: 1fr 7.5rem;
gap: 0.65rem;
margin-bottom: 0.75rem;
}
.rdp-grid label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.72rem;
color: #9fd0c8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.rdp-grid label.wide { grid-column: 1 / -1; }
.rdp-grid input {
background: rgba(2, 8, 16, 0.9);
border: 1px solid rgba(0, 168, 232, 0.28);
border-radius: 8px;
padding: 0.55rem 0.65rem;
color: #e8f4ff;
font-size: 0.95rem;
}
.rdp-grid input:focus {
outline: none;
border-color: rgba(61, 255, 224, 0.55);
box-shadow: 0 0 0 2px rgba(0, 168, 232, 0.2);
}
.rdp-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.35rem;
}
.rdp-actions .btn { min-height: 2.4rem; }
.rdp-status {
margin: 0.85rem 0 0;
min-height: 1.2em;
font-size: 0.82rem;
color: #9fd0c8;
}
.rdp-status.ok { color: var(--cyan); }
.rdp-status.error { color: #ff8a8a; }
.rdp-help {
margin: 1rem 0 0;
padding-top: 0.85rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
font-size: 0.78rem;
color: #b7c9d9;
line-height: 1.45;
}
.rdp-help code {
font-family: var(--mono);
color: var(--cyan);
font-size: 0.74rem;
}
.rdp-ip-picks { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 0.35rem; margin: -0.25rem 0 0.35rem; }
.rdp-ip-picks button {
background: rgba(0,118,206,0.18);
border: 1px solid rgba(61,255,224,0.3);
color: #e8f4ff;
border-radius: 999px;
padding: 0.25rem 0.65rem;
font-size: 0.75rem;
}
.rdp-ip-picks button.active { background: rgba(61,255,224,0.22); border-color: rgba(61,255,224,0.55); }
.rdp-meta code { color: var(--cyan); font-family: var(--mono); font-size: 0.8rem; }
</style>
</head>
<body class="rdp-body">
<div class="rdp-shell">
<header class="rdp-brand">
<img src="/dell.png" alt="Dell" />
<div>
<p class="kicker">Remote Desktop</p>
<h1 id="rdp-title">RDP session</h1>
</div>
</header>
<section class="rdp-card">
<p class="rdp-meta" id="rdp-meta"></p>
<p class="rdp-warn hidden" id="rdp-warn">
This OME entry looks like an <strong>iDRAC</strong> address. RDP needs the <strong>Windows OS IP/hostname</strong> — change Host below if needed.
</p>
<form id="rdp-form" autocomplete="on">
<div class="rdp-grid">
<label class="wide">Host (Windows OS IP or DNS)
<input type="text" id="rdp-host" name="host" autocomplete="off" spellcheck="false" required list="rdp-ip-list" />
<datalist id="rdp-ip-list"></datalist>
</label>
<div class="rdp-ip-picks" id="rdp-ip-picks"></div>
<label>Port
<input type="number" id="rdp-port" name="port" value="3389" min="1" max="65535" />
</label>
<label class="wide">Username (optional — client will prompt if empty)
<input type="text" id="rdp-user" name="username" autocomplete="username" spellcheck="false" placeholder="DOMAIN\user or user@domain" />
</label>
</div>
<div class="rdp-actions">
<button type="submit" class="btn primary" id="rdp-launch">Open Remote Desktop</button>
<button type="button" class="btn" id="rdp-download">Download .rdp</button>
<button type="button" class="btn ghost" id="rdp-copy">Copy host:port</button>
</div>
</form>
<p class="rdp-status" id="rdp-status">Ready — opens your local RDP client (mstsc / Windows App / Remmina).</p>
<div class="rdp-help">
<p>Cockpit launches a standard <code>.rdp</code> file so Windows nodes without SSH stay reachable from this popout.</p>
<p>Windows: opens Remote Desktop Connection. Linux: Remmina / FreeRDP if associated with <code>.rdp</code>. macOS: Microsoft Remote Desktop.</p>
</div>
</section>
</div>
<script>
(() => {
const q = new URLSearchParams(location.search);
const name = q.get("name") || "RDP session";
const model = q.get("model") || "";
const st = q.get("st") || "";
const osHost = (q.get("os_hostname") || "").trim();
const idracIp = (q.get("idrac_ip") || "").trim();
const hostIn = (q.get("host") || "").trim();
const rdpIps = (q.get("rdp_ips") || "").split(",").map((s) => s.trim()).filter(Boolean);
const idracHint = /idrac|bmc|ilo/i.test(name) || (idracIp && hostIn === idracIp);
document.title = `RDP · ${name}`;
document.getElementById("rdp-title").textContent = name;
document.getElementById("rdp-meta").innerHTML = [
model,
st && `ST ${st}`,
osHost && `OS <code>${osHost}</code>`,
idracIp && `iDRAC <code>${idracIp}</code>`,
].filter(Boolean).join(" · ") || "—";
document.getElementById("rdp-host").value = hostIn || rdpIps[0] || osHost || "";
document.getElementById("rdp-user").value = localStorage.getItem("cockpit_rdp_user") || "";
const warn = document.getElementById("rdp-warn");
if (idracHint) warn.classList.remove("hidden");
const status = document.getElementById("rdp-status");
function setStatus(msg, kind) {
status.textContent = msg;
status.className = "rdp-status" + (kind ? " " + kind : "");
}
const list = document.getElementById("rdp-ip-list");
const picks = document.getElementById("rdp-ip-picks");
const candidates = [...new Set([...(rdpIps || []), hostIn, osHost].filter(Boolean))];
candidates.forEach((ip) => {
const opt = document.createElement("option");
opt.value = ip;
list.appendChild(opt);
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = ip;
if (ip === (hostIn || rdpIps[0])) btn.classList.add("active");
btn.addEventListener("click", () => {
document.getElementById("rdp-host").value = ip;
picks.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
setStatus(`Selected ${ip}`, "ok");
});
picks.appendChild(btn);
});
function vals() {
const host = document.getElementById("rdp-host").value.trim();
const port = Number(document.getElementById("rdp-port").value || 3389);
const username = document.getElementById("rdp-user").value.trim();
return { host, port, username };
}
function rememberUser(u) {
if (u) localStorage.setItem("cockpit_rdp_user", u);
}
function buildRdp({ host, port, username }) {
const full = port && port !== 3389 ? `${host}:${port}` : host;
const lines = [
"screen mode id:i:2",
"use multimon:i:0",
"desktopwidth:i:1920",
"desktopheight:i:1080",
"session bpp:i:32",
"compression:i:1",
"keyboardhook:i:2",
"audiocapturemode:i:0",
"videoplaybackmode:i:1",
"connection type:i:7",
"networkautodetect:i:1",
"bandwidthautodetect:i:1",
"displayconnectionbar:i:1",
"enableworkspacereconnect:i:0",
"disable wallpaper:i:0",
"allow font smoothing:i:1",
"allow desktop composition:i:1",
"disable full window drag:i:0",
"disable menu anims:i:0",
"disable themes:i:0",
"disable cursor setting:i:0",
"bitmapcachepersistenable:i:1",
`full address:s:${full}`,
"audiomode:i:0",
"redirectprinters:i:0",
"redirectcomports:i:0",
"redirectsmartcards:i:0",
"redirectclipboard:i:1",
"redirectposdevices:i:0",
"autoreconnection enabled:i:1",
"authentication level:i:2",
"prompt for credentials:i:1",
"negotiate security layer:i:1",
"remoteapplicationmode:i:0",
"alternate shell:s:",
"shell working directory:s:",
"gatewayhostname:s:",
"gatewayusagemethod:i:4",
"gatewaycredentialssource:i:4",
"gatewayprofileusagemethod:i:0",
"promptcredentialonce:i:0",
"gatewaybrokeringtype:i:0",
"use redirection server name:i:0",
"rdgiskdcproxy:i:0",
"kdcproxyname:s:",
];
if (username) lines.splice(24, 0, `username:s:${username}`);
return lines.join("\r\n") + "\r\n";
}
function downloadRdp(autoOpen) {
const v = vals();
if (!v.host) {
setStatus("Host is required", "error");
document.getElementById("rdp-host").focus();
return;
}
rememberUser(v.username);
const body = buildRdp(v);
const blob = new Blob([body], { type: "application/x-rdp" });
const safe = v.host.replace(/[^\w.-]+/g, "_");
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${safe}.rdp`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
// Also hit server endpoint (same content) for clients that prefer navigation
if (autoOpen) {
const api = `/api/rdp.rdp?host=${encodeURIComponent(v.host)}&port=${v.port}&username=${encodeURIComponent(v.username || "")}`;
// secondary attempt via hidden iframe — helps some browsers associate .rdp
const iframe = document.createElement("iframe");
iframe.style.display = "none";
iframe.src = api;
document.body.appendChild(iframe);
setTimeout(() => iframe.remove(), 4000);
}
setStatus(`RDP file ready for ${v.host}:${v.port} — open it with your Remote Desktop client.`, "ok");
}
document.getElementById("rdp-form").addEventListener("submit", (e) => {
e.preventDefault();
downloadRdp(true);
});
document.getElementById("rdp-download").addEventListener("click", () => downloadRdp(false));
document.getElementById("rdp-copy").addEventListener("click", async () => {
const v = vals();
if (!v.host) return;
const text = `${v.host}:${v.port || 3389}`;
try {
await navigator.clipboard.writeText(text);
setStatus(`Copied ${text}`, "ok");
} catch (_) {
setStatus("Copy failed", "error");
}
});
setTimeout(() => {
const u = document.getElementById("rdp-user");
if (u && !u.value) u.focus();
else document.getElementById("rdp-host")?.focus();
}, 40);
})();
</script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
(() => {
function looksLikeIdrac(node) {
const blob = `${node?.name || ""} ${node?.sub_type || ""} ${node?.ip || ""}`;
return !!node?.is_idrac || /idrac|bmc/i.test(blob);
}
function openRdpPopout(node = {}) {
const rdpIps = Array.isArray(node.rdp_ips) ? node.rdp_ips.filter(Boolean) : [];
const host = (node.host || node.rdp_host || rdpIps[0] || node.ip || "").trim();
if (!host && !node.allowEmpty) {
alert("No RDP / OS IP resolved yet — enter the Windows OS address in the RDP popout.");
}
const q = new URLSearchParams({
host: host || "",
name: node.name || host || "RDP session",
model: node.model || "",
st: node.service_tag || "",
id: node.id != null ? String(node.id) : "",
os_hostname: node.os_hostname || "",
idrac_ip: node.idrac_ip || (looksLikeIdrac(node) ? node.ip || "" : ""),
});
if (rdpIps.length) q.set("rdp_ips", rdpIps.join(","));
if (looksLikeIdrac(node) || node.is_windows) q.set("windows", "1");
const w = 780;
const h = 760;
const left = Math.max(0, Math.round((window.screen.width - w) / 2));
const top = Math.max(0, Math.round((window.screen.height - h) / 2));
const features = [
"popup=yes",
`width=${w}`,
`height=${h}`,
`left=${left}`,
`top=${top}`,
"resizable=yes",
"scrollbars=yes",
].join(",");
const win = window.open(`/rdp-popout.html?${q.toString()}`, `ome-rdp-${node.id || host || "x"}`, features);
if (!win) {
alert("Popout blocked — allow popups for this site, or open RDP from Quick Connect again.");
return null;
}
try {
win.focus();
} catch (_) {}
return win;
}
window.cockpitRdp = {
open: openRdpPopout,
looksLikeIdrac,
};
})();
+990 -68
View File
File diff suppressed because it is too large Load Diff
+2821 -35
View File
File diff suppressed because it is too large Load Diff