Files
ome-cockpit/ui/ops.js
T
mo b73ff9465c Initial import of Dell OpenManage Cockpit for ATC.
Interactive OME fleet UI with topology, KPI popups, in-browser SSH, chat, tickets, and full inventory landscape.
2026-07-17 04:00:38 +02:00

867 lines
31 KiB
JavaScript

(() => {
const $ = (sel) => document.querySelector(sel);
const state = window.__cockpitState || null;
// Hook into app.js state if exposed; else work via DOM + fetch
function ensureApi() {
return {
async get(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
return r.json();
},
async post(url, body) {
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
},
async patch(url, body) {
const r = await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
},
async del(url) {
const r = await fetch(url, { method: "DELETE" });
if (!r.ok) throw new Error(await r.text());
return r.json();
},
};
}
const api = ensureApi();
const chatHistory = [];
let tickets = [];
let activeTicketId = null;
let gpuData = null;
let chatModelsLoaded = false;
function selectedChatModel() {
const sel = $("#chat-model");
const saved = localStorage.getItem("cockpit_chat_model");
return (sel?.value || saved || "llama3-70b-gptq").trim();
}
async function loadChatModels() {
const sel = $("#chat-model");
if (!sel) return;
try {
const data = await api.get("/api/models");
const models = data.models || [];
const preferred = localStorage.getItem("cockpit_chat_model") || data.default || "llama3-70b-gptq";
if (models.length) {
sel.innerHTML = models
.map((m) => {
const id = m.id || m.name;
const label = m.name && m.name !== id ? `${m.name} (${id})` : id;
return `<option value="${id}">${label}</option>`;
})
.join("");
const ids = models.map((m) => m.id);
sel.value = ids.includes(preferred) ? preferred : ids[0];
}
chatModelsLoaded = true;
} catch (e) {
console.warn("model list failed", e);
}
}
function actor() {
return $("#ops-actor")?.value || localStorage.getItem("atc_actor") || "jody";
}
function adminName(id) {
return id === "laurens" ? "Laurens Rammers" : "Jody van Dongen";
}
function openDrawer(id) {
closeDrawers();
const el = $(id);
el?.classList.add("open");
el?.setAttribute("aria-hidden", "false");
const scrim = $("#scrim");
scrim?.classList.add("open");
if (scrim) scrim.dataset.mode = id.replace("#", "");
}
function closeDrawers() {
["#chat-drawer", "#ops-drawer", "#ai-drawer"].forEach((id) => {
const el = $(id);
el?.classList.remove("open");
el?.setAttribute("aria-hidden", "true");
});
const scrim = $("#scrim");
if (scrim && ["chat-drawer", "ops-drawer", "ai-drawer"].includes(scrim.dataset.mode)) {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function renderGpu(data) {
gpuData = data;
const box = $("#gpu-matrix");
const sum = $("#gpu-summary");
if (!box) return;
if (!data || data.error) {
if (sum) sum.textContent = "GPU telemetry unreachable";
box.innerHTML = "";
return;
}
const s = data.summary || {};
if (sum) {
sum.innerHTML = `<strong>${s.avg_util ?? 0}%</strong> avg util · <strong>${Math.round(s.total_power_w || 0)} W</strong> · mem ${Math.round((s.total_mem_used_mb || 0) / 1024)} / ${Math.round((s.total_mem_mb || 0) / 1024)} GB`;
}
box.innerHTML = (data.gpus || [])
.map((g) => {
const busy = g.util_gpu >= 5;
const pct = Math.max(0, Math.min(100, g.util_gpu || 0));
return `<div class="gpu-card ${busy ? "busy" : "idle"}">
<div class="gpu-top"><span>GPU ${g.index}</span><span>${pct.toFixed(0)}%</span></div>
<div class="gpu-bar"><i style="width:${pct}%"></i></div>
<div class="gpu-meta">${Math.round(g.mem_used_mb)} / ${Math.round(g.mem_total_mb)} MB · ${Math.round(g.temp_c)}°C · ${Math.round(g.power_w)} W</div>
<div class="gpu-name">${g.name || "V100"}</div>
</div>`;
})
.join("");
}
function appendChat(role, text) {
const body = $("#chat-body");
if (!body) return;
const div = document.createElement("div");
div.className = `chat-bubble ${role}`;
div.textContent = text;
body.appendChild(div);
body.scrollTop = body.scrollHeight;
}
function refreshChatContext() {
const d = window.cockpit?.getState?.()?.data || {};
const s = d.summary || {};
const fleet = $("#chat-ctx-fleet");
if (fleet) {
fleet.textContent = `${s.connected ?? "—"} connected · ${s.total_watts != null ? Math.round(s.total_watts) + "W" : "—"} · ${(d.alerts || []).filter((a) => a.severity === "Critical").length} crit`;
}
const focus = $("#chat-ctx-focus");
const sel = window.cockpit?.getState?.()?.selectedId;
const node = (d.devices || []).find((x) => x.id === sel);
if (focus) focus.textContent = node ? `focus: ${(node.name || "").slice(0, 28)}` : "focus: none";
}
async function sendChat(msg) {
refreshChatContext();
appendChat("user", msg);
chatHistory.push({ role: "user", content: msg });
const model = selectedChatModel();
appendChat("assistant", `Thinking with ${model}…`);
const thinking = $("#chat-body")?.lastElementChild;
try {
const focusId = window.cockpit?.getState?.()?.selectedId || null;
const res = await api.post("/api/chat", {
message: msg,
history: chatHistory.slice(0, -1),
focus_device_id: focusId,
model,
});
if (thinking) thinking.textContent = res.reply || "(empty reply)";
chatHistory.push({ role: "assistant", content: res.reply || "" });
const g = await api.get("/api/gpu");
renderGpu(g);
refreshChatContext();
} catch (e) {
if (thinking) thinking.textContent = "Chat error: " + e.message;
}
}
function ticketFilterValue() {
return $("#ops-filter")?.value || "openish";
}
async function loadTickets() {
const data = await api.get("/api/tickets");
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>`;
return;
}
list.innerHTML = filtered
.map((tkt) => {
const active = tkt.id === activeTicketId ? " active" : "";
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>
</button>`;
})
.join("");
}
function escape(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function openTicket(id) {
activeTicketId = id;
await loadTickets();
const data = await api.get(`/api/tickets/${id}`);
const tkt = data.ticket;
const msgs = data.messages || [];
$("#ops-empty")?.classList.add("hidden");
const thread = $("#ops-thread");
thread?.classList.remove("hidden");
if (!thread) return;
const me = actor();
const canAccept = tkt.assignee === me && !tkt.accepted_by && tkt.status === "open";
thread.innerHTML = `
<div class="ops-ticket-head">
<h3>#${tkt.id} · ${escape(tkt.title)}</h3>
<div class="ops-meta-grid">
<div><span>Status</span>${escape(tkt.status)}</div>
<div><span>Priority</span>${escape(tkt.priority)}</div>
<div><span>Created by</span>${escape(adminName(tkt.created_by))}</div>
<div><span>Assignee</span>${escape(adminName(tkt.assignee))}</div>
<div><span>Accepted by</span>${tkt.accepted_by ? escape(adminName(tkt.accepted_by)) : "—"}</div>
<div><span>Updated</span>${new Date(tkt.updated_at * 1000).toLocaleString()}</div>
</div>
<div class="ops-toolbar">
<select id="ops-status">
<option value="open">Open</option>
<option value="accepted">Accepted</option>
<option value="in_progress">In progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
<select id="ops-assign">
<option value="jody">Assign Jody</option>
<option value="laurens">Assign Laurens</option>
</select>
<select id="ops-priority">
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
<button type="button" class="btn" id="ops-save-meta">Update</button>
${canAccept ? `<button type="button" class="btn primary" id="ops-accept">Accept as ${escape(adminName(me))}</button>` : ""}
<button type="button" class="btn" id="ops-close-ticket">Close ticket</button>
<button type="button" class="btn danger" id="ops-delete-ticket">Delete</button>
</div>
</div>
<div class="ops-msgs" id="ops-msgs">
${msgs
.map(
(m) => `<div class="ops-msg"><strong>${escape(adminName(m.author))}</strong>
<span class="ops-time">${new Date(m.created_at * 1000).toLocaleString()}</span>
<p>${escape(m.body)}</p></div>`
)
.join("")}
</div>
<form class="ops-reply" id="ops-reply">
<textarea id="ops-reply-body" rows="3" placeholder="Write a note to the other admin…"></textarea>
<button class="btn primary" type="submit">Send note</button>
</form>`;
$("#ops-status").value = tkt.status || "open";
$("#ops-assign").value = tkt.assignee || "jody";
$("#ops-priority").value = tkt.priority || "normal";
$("#ops-save-meta")?.addEventListener("click", async () => {
await api.patch(`/api/tickets/${id}`, {
status: $("#ops-status").value,
assignee: $("#ops-assign").value,
priority: $("#ops-priority").value,
});
openTicket(id);
});
$("#ops-accept")?.addEventListener("click", async () => {
await api.patch(`/api/tickets/${id}`, { accepted_by: me, status: "accepted" });
openTicket(id);
});
$("#ops-close-ticket")?.addEventListener("click", async () => {
await api.patch(`/api/tickets/${id}`, { status: "closed" });
openTicket(id);
});
$("#ops-delete-ticket")?.addEventListener("click", async () => {
if (!confirm(`Delete ticket #${id}? This cannot be undone.`)) return;
await api.del(`/api/tickets/${id}`);
activeTicketId = null;
$("#ops-thread")?.classList.add("hidden");
$("#ops-empty")?.classList.remove("hidden");
await loadTickets();
});
$("#ops-reply")?.addEventListener("submit", async (e) => {
e.preventDefault();
const body = $("#ops-reply-body")?.value?.trim();
if (!body) return;
await api.post(`/api/tickets/${id}/messages`, { author: actor(), body });
openTicket(id);
});
}
let ticketDraft = null;
function openTicketModal(draft = null) {
ticketDraft = draft;
const me = actor();
const other = me === "jody" ? "laurens" : "jody";
$("#ticket-title").value = draft?.title || "";
$("#ticket-body").value = draft?.body || "";
$("#ticket-priority").value = draft?.priority || "normal";
$("#ticket-assignee").value = draft?.assignee || other;
const modal = $("#ticket-modal");
const scrim = $("#scrim");
modal?.classList.remove("hidden");
modal?.setAttribute("aria-hidden", "false");
scrim?.classList.add("open");
if (scrim) scrim.dataset.mode = "ticket";
setTimeout(() => $("#ticket-title")?.focus(), 50);
}
function closeTicketModal() {
const modal = $("#ticket-modal");
modal?.classList.add("hidden");
modal?.setAttribute("aria-hidden", "true");
const scrim = $("#scrim");
if (scrim?.dataset.mode === "ticket") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
ticketDraft = null;
}
async function newTicket() {
openTicketModal(null);
}
// Collapsible + lightly draggable live feed
function initFeedDrag() {
const feed = $("#ctx-feed");
const handle = $("#ctx-feed-drag");
if (!feed || !handle) return;
const applyCollapsed = (collapsed) => {
feed.classList.toggle("collapsed", collapsed);
document.querySelector(".stage")?.classList.toggle("feed-collapsed", collapsed);
$("#btn-feed-collapse")?.classList.toggle("hidden", collapsed);
$("#btn-feed-expand")?.classList.toggle("hidden", !collapsed);
localStorage.setItem("feed_collapsed", collapsed ? "1" : "0");
const body = $("#ctx-feed-body");
if (collapsed) {
// park as compact bar near bottom-right; purge DOM to avoid Chromium ghost text
feed.style.bottom = "0.55rem";
feed.style.left = "";
feed.style.right = "0.75rem";
feed.style.top = "";
feed.style.width = "";
feed.style.height = "";
if (body) body.innerHTML = "";
} else {
const saved = localStorage.getItem("feed_bottom");
feed.style.bottom = saved && !saved.includes("rem") ? saved : "2.4rem";
// ask app to refill feed on next snapshot
window.dispatchEvent(new CustomEvent("cockpit-feed-expand"));
}
};
// migrate: clear broken bottom values that pinned feed mid-screen
const savedBottom = localStorage.getItem("feed_bottom");
if (savedBottom) {
const n = parseFloat(savedBottom);
if (!Number.isFinite(n) || n > 200) localStorage.removeItem("feed_bottom");
}
applyCollapsed(localStorage.getItem("feed_collapsed") === "1");
$("#btn-feed-collapse")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
applyCollapsed(true);
});
$("#btn-feed-expand")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
applyCollapsed(false);
});
// click header when collapsed = expand
handle.addEventListener("click", (e) => {
if (e.target.closest("button")) return;
if (feed.classList.contains("collapsed")) applyCollapsed(false);
});
const feedBottomPx = () => {
const raw = feed.style.bottom || getComputedStyle(feed).bottom || "38px";
const n = parseFloat(raw);
return Number.isFinite(n) ? n : 38;
};
const setFeedBottom = (px) => {
if (feed.classList.contains("collapsed")) return;
const next = Math.max(8, Math.min(160, px));
feed.style.bottom = next + "px";
localStorage.setItem("feed_bottom", feed.style.bottom);
};
let dragging = false;
let startY = 0;
let startBottom = 0;
handle.addEventListener("mousedown", (e) => {
if (e.target.closest("button")) return;
if (feed.classList.contains("collapsed")) return;
dragging = true;
startY = e.clientY;
startBottom = feedBottomPx();
e.preventDefault();
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const dy = e.clientY - startY;
setFeedBottom(startBottom - dy);
});
window.addEventListener("mouseup", () => {
dragging = false;
});
}
// GPU via fleet snapshot + dedicated WS messages: monkey-patch WebSocket if needed
function hookGpuFromFleet() {
const orig = window.__applyGpu;
// poll GPU every 2s as backup
async function tick() {
try {
renderGpu(await api.get("/api/gpu"));
} catch (_) {}
}
tick();
setInterval(tick, 2500);
// listen custom event from app.js if dispatched
window.addEventListener("cockpit-gpu", (e) => renderGpu(e.detail));
window.addEventListener("cockpit-snapshot", (e) => {
if (e.detail?.gpu) renderGpu(e.detail.gpu);
});
}
// ===== Realtime triage popup =====
let triageMode = "critical";
let triageSelected = null;
let triageFilter = "";
function closeTriage() {
const modal = $("#triage-modal");
modal?.classList.add("hidden");
modal?.setAttribute("aria-hidden", "true");
const scrim = $("#scrim");
if (scrim?.dataset.mode === "triage") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function openTriage(mode = "critical", opts = {}) {
triageMode = mode || "critical";
triageFilter = "";
const search = $("#triage-search");
if (search) search.value = "";
const modal = $("#triage-modal");
const scrim = $("#scrim");
if (!modal) return;
// close other overlays lightly
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
scrim?.classList.add("open");
if (scrim) scrim.dataset.mode = "triage";
$("#triage-tabs")?.querySelectorAll("[data-triage]").forEach((c) => {
c.classList.toggle("active", c.dataset.triage === triageMode);
});
const titles = {
critical: "Critical alerts",
warning: "Warning alerts",
events: "Fleet deltas",
hottest: "Live power ranking",
all: "Realtime alert feed",
};
const elTitle = $("#triage-title");
if (elTitle) elTitle.textContent = titles[triageMode] || "Realtime triage";
renderTriageList(opts);
}
function cockpitData() {
return window.cockpit?.getState?.()?.data || {};
}
function deviceById(id) {
if (id == null || id === "") return null;
return (cockpitData().devices || []).find((d) => String(d.id) === String(id)) || null;
}
function triageItems(mode) {
const d = cockpitData();
const items = [];
if (mode === "critical" || mode === "warning" || mode === "all") {
const want = mode === "all" ? null : mode === "critical" ? "Critical" : "Warning";
for (const a of d.alerts || []) {
if (want && String(a.severity) !== want) continue;
items.push({
kind: "alert",
id: "alert-" + a.id,
alertId: a.id,
deviceId: a.device_id,
severity: a.severity,
title: a.device || "Unknown system",
message: a.message || "",
meta: [a.ip || "no-ip", a.category, a.subcategory, a.message_id, a.time].filter(Boolean).join(" · "),
raw: a,
sort: Date.parse(String(a.time || "").replace(" ", "T") + "Z") || 0,
});
}
}
if (mode === "events" || mode === "all") {
for (const e of d.events || []) {
items.push({
kind: "event",
id: "event-" + e.ts + "-" + e.device_id + "-" + e.kind,
deviceId: e.device_id,
severity: e.severity || "info",
title: e.title || e.kind,
message: e.text || "",
meta: (e.kind || "delta") + " · " + new Date((e.ts || 0) * 1000).toLocaleString(),
raw: e,
sort: (e.ts || 0) * 1000,
});
}
}
if (mode === "hottest" || mode === "all") {
for (const h of (d.context || {}).hottest || []) {
items.push({
kind: "power",
id: "hot-" + h.id,
deviceId: h.id,
severity: "info",
title: h.name || "node",
message: Math.round(h.watts || 0) + " W live sample",
meta: (h.subnet || "") + " · power ranking",
raw: h,
sort: (h.watts || 0) * 1e6,
});
}
}
items.sort((x, y) => (y.sort || 0) - (x.sort || 0));
return items;
}
function renderTriageList(opts = {}) {
const list = $("#triage-list");
if (!list) return;
let items = triageItems(triageMode);
const q = (triageFilter || "").trim().toLowerCase();
if (q) {
items = items.filter((it) =>
[it.title, it.message, it.meta, it.severity].join(" ").toLowerCase().includes(q)
);
}
const sub = $("#triage-sub");
if (sub) {
const systems = new Set(items.map((i) => i.title));
sub.textContent = items.length + " items · " + systems.size + " systems · inspect & create ATC ticket";
}
if (!items.length) {
list.innerHTML = '<p class="hint">No matching items in the current live snapshot.</p>';
$("#triage-detail").innerHTML = '<p class="hint">Nothing selected.</p>';
triageSelected = null;
return;
}
const groups = new Map();
for (const it of items) {
const g = it.title || "Unknown";
if (!groups.has(g)) groups.set(g, []);
groups.get(g).push(it);
}
let html = "";
for (const [sys, rows] of groups) {
html += '<div class="triage-group-label">' + escape(sys) + " · " + rows.length + "</div>";
for (const it of rows) {
const sev = String(it.severity || "").toLowerCase();
const cls = sev.includes("crit") ? "critical" : sev.includes("warn") ? "warning" : "info";
const active = triageSelected && triageSelected.id === it.id ? " active" : "";
html +=
'<button type="button" class="triage-item ' +
cls +
active +
'" data-triage-id="' +
escape(it.id) +
'"><div class="ti-top"><span>' +
escape(it.severity || it.kind) +
"</span><span>" +
escape((it.raw && it.raw.ip) || "") +
'</span></div><div class="ti-msg">' +
escape((it.message || "").slice(0, 160)) +
'</div><div class="ti-meta">' +
escape(it.meta) +
"</div></button>";
}
}
list.innerHTML = html;
let pick = items[0];
if (opts.alertId) pick = items.find((i) => String(i.alertId) === String(opts.alertId)) || pick;
else if (opts.deviceId) pick = items.find((i) => String(i.deviceId) === String(opts.deviceId)) || pick;
selectTriageItem(pick);
}
function selectTriageItem(it) {
triageSelected = it;
$("#triage-list")?.querySelectorAll(".triage-item").forEach((el) => {
el.classList.toggle("active", el.dataset.triageId === (it && it.id));
});
const detail = $("#triage-detail");
if (!detail || !it) return;
const node = deviceById(it.deviceId);
const a = it.kind === "alert" ? it.raw : null;
const connected = node && node.connected ? "CONNECTED" : "OFFLINE / unknown";
const watts = node && node.watts != null ? Math.round(node.watts) + " W" : "n/a";
detail.innerHTML =
"<h3>" +
escape(it.title) +
'</h3><div class="badge-row"><span class="badge">' +
escape(it.severity || it.kind) +
'</span><span class="badge ' +
(node && node.connected ? "on" : "off") +
'">' +
connected +
"</span>" +
(node && node.watts != null ? '<span class="badge power">' + watts + "</span>" : "") +
'</div><div class="kv">' +
'<div class="kv-row"><span class="k">System</span><span class="v">' +
escape(it.title) +
"</span></div>" +
'<div class="kv-row"><span class="k">IP</span><span class="v">' +
escape((node && node.ip) || (a && a.ip) || "—") +
"</span></div>" +
'<div class="kv-row"><span class="k">Model</span><span class="v">' +
escape((node && node.model) || "—") +
"</span></div>" +
'<div class="kv-row"><span class="k">Service tag</span><span class="v">' +
escape((node && node.service_tag) || "—") +
"</span></div>" +
'<div class="kv-row"><span class="k">Subnet</span><span class="v">' +
escape((node && node.subnet) || "—") +
"</span></div>" +
'<div class="kv-row"><span class="k">Message</span><span class="v">' +
escape(it.message || "—") +
"</span></div>" +
(a && a.action
? '<div class="kv-row"><span class="k">Recommended</span><span class="v">' + escape(a.action) + "</span></div>"
: "") +
'<div class="kv-row"><span class="k">Meta</span><span class="v">' +
escape(it.meta) +
"</span></div></div>" +
'<div class="triage-actions">' +
'<button type="button" class="btn primary" id="triage-ticket">Create ticket</button>' +
'<button type="button" class="btn" id="triage-focus">Focus on map</button>' +
'<button type="button" class="btn conn-link" id="triage-connect"' +
(node ? "" : " disabled") +
">Quick Connect</button>" +
'<button type="button" class="btn" id="triage-ops">Open Ops desk</button>' +
'<button type="button" class="btn" id="triage-ai">Ask Cockpit chat</button>' +
'<button type="button" class="btn ghost" id="triage-inspect">Inspector</button></div>';
$("#triage-ticket")?.addEventListener("click", () => createTicketFromTriage(it, node));
$("#triage-focus")?.addEventListener("click", () => {
if (it.deviceId != null) window.cockpit?.focusDeviceId?.(it.deviceId);
closeTriage();
});
$("#triage-connect")?.addEventListener("click", () => {
if (!node) return;
closeTriage();
window.cockpit?.openConnect?.(node);
});
$("#triage-ops")?.addEventListener("click", async () => {
closeTriage();
openDrawer("#ops-drawer");
await loadTickets();
});
$("#triage-ai")?.addEventListener("click", () => {
closeTriage();
openDrawer("#chat-drawer");
sendChat(
"Triage this " +
(it.severity || it.kind) +
" on " +
it.title +
" (" +
((node && node.ip) || (a && a.ip) || "no-ip") +
"): " +
it.message +
". Suggest next steps for ATC admins Jody and Laurens."
);
});
$("#triage-inspect")?.addEventListener("click", () => {
if (node) window.cockpit?.showInspector?.(node);
closeTriage();
});
}
async function createTicketFromTriage(it, node) {
const me = actor();
const other = me === "jody" ? "laurens" : "jody";
const sev = it.severity || it.kind || "alert";
const title = ("[" + sev + "] " + it.title).slice(0, 180);
const body = [
"Auto-triage from OpenManage Cockpit",
"Reporter: " + adminName(me),
"Assignee: " + adminName(other),
"System: " + it.title,
"IP: " + ((node && node.ip) || (it.raw && it.raw.ip) || "—"),
"Model: " + ((node && node.model) || "—"),
"Service tag: " + ((node && node.service_tag) || "—"),
"Subnet: " + ((node && node.subnet) || "—"),
"Severity: " + sev,
"Message: " + it.message,
it.meta ? "Meta: " + it.meta : "",
it.raw && it.raw.action ? "Recommended: " + it.raw.action : "",
]
.filter(Boolean)
.join("\n");
closeTriage();
openTicketModal({
title,
body,
assignee: other,
priority: String(sev).toLowerCase().includes("crit") ? "critical" : "high",
});
}
function wire() {
$("#ops-actor")?.addEventListener("change", (e) => {
localStorage.setItem("atc_actor", e.target.value);
});
const saved = localStorage.getItem("atc_actor");
if (saved && $("#ops-actor")) $("#ops-actor").value = saved;
if (window.cockpit) window.cockpit.openTriage = openTriage;
else window.cockpit = { openTriage };
$("#btn-triage-close")?.addEventListener("click", closeTriage);
$("#triage-tabs")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-triage]");
if (!btn) return;
openTriage(btn.dataset.triage);
});
$("#triage-search")?.addEventListener("input", (e) => {
triageFilter = e.target.value || "";
renderTriageList();
});
$("#triage-list")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-triage-id]");
if (!btn) return;
const it = triageItems(triageMode).find((x) => x.id === btn.dataset.triageId);
if (it) selectTriageItem(it);
});
$("#btn-chat")?.addEventListener("click", () => {
openDrawer("#chat-drawer");
loadChatModels();
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."
);
}
});
$("#chat-model")?.addEventListener("change", (e) => {
localStorage.setItem("cockpit_chat_model", e.target.value);
chatHistory.length = 0;
const body = $("#chat-body");
if (body) body.innerHTML = "";
appendChat("assistant", `Model switched to ${e.target.value}. Context cleared.`);
});
$("#btn-chat-close")?.addEventListener("click", closeDrawers);
$("#btn-ops")?.addEventListener("click", async () => {
openDrawer("#ops-drawer");
await loadTickets();
});
$("#btn-ops-close")?.addEventListener("click", closeDrawers);
$("#ops-filter")?.addEventListener("change", () => loadTickets());
$("#btn-ticket-modal-close")?.addEventListener("click", closeTicketModal);
$("#btn-ticket-cancel")?.addEventListener("click", closeTicketModal);
$("#ticket-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
const title = $("#ticket-title")?.value?.trim();
const body = $("#ticket-body")?.value?.trim();
if (!title || !body) return;
try {
const created = await api.post("/api/tickets", {
title,
body,
created_by: actor(),
assignee: $("#ticket-assignee").value,
priority: $("#ticket-priority").value,
});
closeTicketModal();
openDrawer("#ops-drawer");
await loadTickets();
await openTicket(created.id);
} catch (err) {
alert("Ticket create failed: " + err.message);
}
});
$("#chat-quick")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-q]");
if (!btn) return;
sendChat(btn.dataset.q);
});
window.addEventListener("cockpit-snapshot", () => refreshChatContext());
$("#btn-new-ticket")?.addEventListener("click", () => newTicket().catch((e) => alert(e.message)));
$("#ticket-list")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-ticket]");
if (!btn) return;
openTicket(Number(btn.dataset.ticket)).catch((err) => alert(err.message));
});
$("#chat-form")?.addEventListener("submit", (e) => {
e.preventDefault();
const input = $("#chat-input");
const msg = input?.value?.trim();
if (!msg) return;
input.value = "";
sendChat(msg);
});
// Extend scrim close
const scrim = $("#scrim");
scrim?.addEventListener("click", () => {
const mode = scrim.dataset.mode;
if (mode === "chat-drawer" || mode === "ops-drawer" || mode === "ai-drawer") closeDrawers();
});
initFeedDrag();
hookGpuFromFleet();
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wire);
else wire();
})();