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>
@@ -757,6 +757,7 @@
|
||||
<div class="kpi-actions">
|
||||
<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="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="inventory">Inspector + inventory</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-s">Login with your username · ${escapeHtml(ip)}</span>
|
||||
</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">
|
||||
|
||||
<span class="ct-ico">✦</span>
|
||||
<span class="ct-t">Ask AI</span>
|
||||
<span class="ct-s">OpenManage AI about this node</span>
|
||||
@@ -1512,6 +1519,11 @@
|
||||
window.cockpitSsh?.open(node);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest("#btn-modal-rdp")) {
|
||||
closeConnect();
|
||||
window.cockpitRdp?.open(node);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest("#btn-modal-ai")) {
|
||||
closeConnect();
|
||||
openAi(node);
|
||||
@@ -1583,7 +1595,47 @@
|
||||
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 & 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");
|
||||
if (!mount) return;
|
||||
try {
|
||||
@@ -1681,6 +1733,7 @@
|
||||
<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.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-ask-ai">Ask OpenManage AI</button>
|
||||
<button type="button" class="btn ghost" id="btn-focus-model">Filter this model</button>
|
||||
@@ -1715,8 +1768,10 @@
|
||||
updateFocusContext();
|
||||
$("#btn-quick-connect")?.addEventListener("click", () => openConnect(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));
|
||||
loadWarrantyCompliance(node.id);
|
||||
loadExpansionPreview(node.id);
|
||||
const cachedInv = state.inventoryCache[node.id];
|
||||
if (cachedInv) {
|
||||
const mount = $("#inv-mount");
|
||||
@@ -2061,6 +2116,8 @@
|
||||
showToast("Focused " + (node.name || ""));
|
||||
} else if (a === "ssh") {
|
||||
window.cockpitSsh?.open(node);
|
||||
} else if (a === "rdp") {
|
||||
window.cockpitRdp?.open(node);
|
||||
} else if (a === "connect") {
|
||||
closeKpiPopup();
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -7,9 +7,19 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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 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="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>
|
||||
<body>
|
||||
<div id="notify-stack" aria-live="polite" aria-atomic="false"></div>
|
||||
@@ -28,9 +38,10 @@
|
||||
<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-ome-console" title="Open OME console">OME console</button>
|
||||
<button type="button" class="btn ghost" id="btn-network" title="Fabric map & ATC racks">Network</button>
|
||||
<button type="button" class="btn ghost" id="btn-reports" title="Fleet reports & 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 primary" id="btn-chat">Cockpit chat</button>
|
||||
<button type="button" class="btn primary" id="btn-chat">Cockpit Copilot</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -96,18 +107,6 @@
|
||||
</div>
|
||||
</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">
|
||||
<button type="button" class="rail-toggle" aria-expanded="true">
|
||||
<span>Realtime context</span>
|
||||
@@ -192,17 +191,26 @@
|
||||
<footer class="ticker" id="ticker"></footer>
|
||||
</div>
|
||||
|
||||
<!-- Cockpit chat -->
|
||||
<div class="drawer" id="chat-drawer" aria-hidden="true">
|
||||
<button type="button" class="theme-fab" id="btn-theme" title="Switch theme" aria-label="Switch light or dark theme">
|
||||
<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-brand">
|
||||
<img src="/dell.png" alt="Dell" height="22" />
|
||||
<span>Cockpit chat</span>
|
||||
<span>Cockpit Copilot</span>
|
||||
</div>
|
||||
<button type="button" class="btn ghost" id="btn-chat-close">Close</button>
|
||||
</div>
|
||||
<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>
|
||||
<select id="chat-model" class="chat-model-select" aria-label="Chat model">
|
||||
<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-focus">focus: none</span>
|
||||
</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">
|
||||
<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="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="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 class="chat-body" id="chat-body"></div>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -244,9 +261,9 @@
|
||||
</label>
|
||||
<button type="button" class="btn primary" id="btn-new-ticket">New ticket</button>
|
||||
<div class="ops-filters">
|
||||
<select id="ops-filter">
|
||||
<option value="openish">Active</option>
|
||||
<option value="all">All</option>
|
||||
<select id="ops-filter" title="Ticket history filter">
|
||||
<option value="all" selected>All history</option>
|
||||
<option value="openish">Active only</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="accepted">Accepted</option>
|
||||
<option value="in_progress">In progress</option>
|
||||
@@ -254,6 +271,7 @@
|
||||
<option value="closed">Closed</option>
|
||||
<option value="mine">Assigned to me</option>
|
||||
</select>
|
||||
<span class="ops-count" id="ops-count">0 tickets</span>
|
||||
</div>
|
||||
<div class="list" id="ticket-list"></div>
|
||||
</div>
|
||||
@@ -278,6 +296,30 @@
|
||||
<iframe id="ai-frame" title="OpenManage AI" src="about:blank"></iframe>
|
||||
</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 -->
|
||||
<div class="drawer wide reports-drawer" id="reports-drawer" aria-hidden="true">
|
||||
<div class="drawer-head">
|
||||
@@ -288,11 +330,13 @@
|
||||
<button type="button" class="btn ghost" id="btn-reports-close">Close</button>
|
||||
</div>
|
||||
<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" data-tab="firmware">Firmware / BIOS</button>
|
||||
<button type="button" class="chip" data-tab="warranty">Warranty</button>
|
||||
<button type="button" class="chip" data-tab="ome">OME reports</button>
|
||||
<button type="button" class="chip" data-tab="brief">Customer brief</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="hardware" title="DIMM slot map, disk/PCIe expansion, Dell best practices">Hardware / Expansion</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="firmware" title="BIOS/iDRAC vs Dell Online Baseline — outdated filter">Firmware / BIOS</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 class="reports-body" id="reports-mount">
|
||||
<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-context" id="connect-context"></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 class="modal hidden" id="ssh-modal" aria-hidden="true" role="dialog" aria-labelledby="ssh-device">
|
||||
|
||||
<div class="modal-card ssh-card">
|
||||
<button type="button" class="modal-close" id="btn-ssh-close" aria-label="Close">×</button>
|
||||
<div class="modal-brand">
|
||||
@@ -466,9 +511,11 @@
|
||||
|
||||
<script src="/vendor/xterm/xterm.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="/ops.js?v=reports1"></script>
|
||||
<script src="/ssh.js?v=reports1"></script>
|
||||
<script src="/reports.js?v=reports1"></script>
|
||||
<script src="/app.js?v=plate4"></script>
|
||||
<script src="/ops.js?v=plate4"></script>
|
||||
<script src="/ssh.js?v=plate4"></script>
|
||||
<script src="/rdp.js?v=plate4"></script>
|
||||
<script src="/reports.js?v=plate4"></script>
|
||||
<script src="/network.js?v=plate4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
const api = ensureApi();
|
||||
const chatHistory = [];
|
||||
let tickets = [];
|
||||
let filteredTickets = [];
|
||||
let activeTicketId = null;
|
||||
let gpuData = null;
|
||||
let chatModelsLoaded = false;
|
||||
@@ -92,14 +93,14 @@
|
||||
}
|
||||
|
||||
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);
|
||||
el?.classList.remove("open");
|
||||
el?.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("cockpit-close-drawers"));
|
||||
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");
|
||||
delete scrim.dataset.mode;
|
||||
}
|
||||
@@ -133,14 +134,28 @@
|
||||
.join("");
|
||||
}
|
||||
|
||||
function appendChat(role, text) {
|
||||
function appendChat(role, text, meta) {
|
||||
const body = $("#chat-body");
|
||||
if (!body) return;
|
||||
const div = document.createElement("div");
|
||||
div.className = `chat-bubble ${role}`;
|
||||
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.scrollTop = body.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
function refreshChatContext() {
|
||||
@@ -161,8 +176,7 @@
|
||||
appendChat("user", msg);
|
||||
chatHistory.push({ role: "user", content: msg });
|
||||
const model = selectedChatModel();
|
||||
appendChat("assistant", `Thinking with ${model}…`);
|
||||
const thinking = $("#chat-body")?.lastElementChild;
|
||||
const thinking = appendChat("assistant", `Looking up live OME facts with ${model}…`);
|
||||
try {
|
||||
const focusId = window.cockpit?.getState?.()?.selectedId || null;
|
||||
const res = await api.post("/api/chat", {
|
||||
@@ -171,7 +185,22 @@
|
||||
focus_device_id: focusId,
|
||||
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 || "" });
|
||||
const g = await api.get("/api/gpu");
|
||||
renderGpu(g);
|
||||
@@ -182,7 +211,25 @@
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -190,29 +237,46 @@
|
||||
tickets = data.tickets || [];
|
||||
const list = $("#ticket-list");
|
||||
if (!list) return;
|
||||
const f = ticketFilterValue();
|
||||
const me = actor();
|
||||
const filtered = 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;
|
||||
});
|
||||
if (!filtered.length) {
|
||||
list.innerHTML = `<p class="hint">No tickets in this filter.</p>`;
|
||||
filteredTickets = filteredTicketList();
|
||||
const count = $("#ops-count");
|
||||
if (count) {
|
||||
count.textContent =
|
||||
filteredTickets.length === tickets.length
|
||||
? `${tickets.length} ticket${tickets.length === 1 ? "" : "s"}`
|
||||
: `${filteredTickets.length} / ${tickets.length} tickets`;
|
||||
}
|
||||
if (!filteredTickets.length) {
|
||||
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;
|
||||
}
|
||||
list.innerHTML = filtered
|
||||
list.innerHTML = filteredTickets
|
||||
.map((tkt) => {
|
||||
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}">
|
||||
<span class="t">${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="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)) : ""}${when ? " · " + escape(when) : ""}</span>
|
||||
</button>`;
|
||||
})
|
||||
.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) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
@@ -234,6 +298,7 @@
|
||||
const me = actor();
|
||||
const canAccept = tkt.assignee === me && !tkt.accepted_by && tkt.status === "open";
|
||||
thread.innerHTML = `
|
||||
${ticketNavHtml(id)}
|
||||
<div class="ops-ticket-head">
|
||||
<h3>#${tkt.id} · ${escape(tkt.title)}</h3>
|
||||
<div class="ops-meta-grid">
|
||||
@@ -268,6 +333,10 @@
|
||||
<button type="button" class="btn danger" id="ops-delete-ticket">Delete</button>
|
||||
</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">
|
||||
${msgs
|
||||
.map(
|
||||
@@ -286,6 +355,14 @@
|
||||
$("#ops-assign").value = tkt.assignee || "jody";
|
||||
$("#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 () => {
|
||||
await api.patch(`/api/tickets/${id}`, {
|
||||
status: $("#ops-status").value,
|
||||
@@ -786,7 +863,7 @@
|
||||
if (!$("#chat-body")?.children.length) {
|
||||
appendChat(
|
||||
"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);
|
||||
|
||||
$("#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-cancel")?.addEventListener("click", closeTicketModal);
|
||||
$("#ticket-form")?.addEventListener("submit", async (e) => {
|
||||
@@ -855,7 +935,7 @@
|
||||
const scrim = $("#scrim");
|
||||
scrim?.addEventListener("click", () => {
|
||||
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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||