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">
|
<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 & 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();
|
||||||
|
|||||||
|
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.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 & 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-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 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>
|
||||||
|
|||||||
@@ -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, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
})();
|
||||||