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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-07-18 01:18:14 +02:00
parent a3d0ed72bd
commit 91222faffe
17 changed files with 8865 additions and 223 deletions
+103 -23
View File
@@ -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, "&amp;")
@@ -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();