1e87f22006
Seed all ATC/FDE VLANs with live host inventory and editable notes, fix cluster membership on the map, and ship Present/network UI polish. Co-authored-by: Cursor <cursoragent@cursor.com>
1066 lines
39 KiB
JavaScript
1066 lines
39 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 filteredTickets = [];
|
|
let activeTicketId = null;
|
|
let gpuData = null;
|
|
let chatModelsLoaded = false;
|
|
let opsUsers = [
|
|
{ id: "jody", name: "Jody van Dongen", team: "admin", role: "ATC Datacenter Admin" },
|
|
{ id: "laurens", name: "Laurens Rammers", team: "admin", role: "ATC Datacenter Admin" },
|
|
{ id: "mo", name: "Mohamed El Kadi", team: "fde", role: "Data Forward Deployed Engineer" },
|
|
{ id: "bart", name: "Bart Sjerps", team: "fde", role: "Data Forward Deployed Engineer" },
|
|
];
|
|
|
|
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) {
|
|
const u = opsUsers.find((x) => x.id === id);
|
|
return u?.name || id || "—";
|
|
}
|
|
|
|
function userTeam(id) {
|
|
return opsUsers.find((x) => x.id === id)?.team || "admin";
|
|
}
|
|
|
|
function defaultAssignee(me) {
|
|
if (me === "jody") return "laurens";
|
|
if (me === "laurens") return "jody";
|
|
if (me === "mo") return "bart";
|
|
if (me === "bart") return "mo";
|
|
return "jody";
|
|
}
|
|
|
|
function assigneeOptionsHtml(selected) {
|
|
const admins = opsUsers.filter((u) => u.team === "admin");
|
|
const fde = opsUsers.filter((u) => u.team === "fde");
|
|
const opt = (u) =>
|
|
`<option value="${u.id}" ${u.id === selected ? "selected" : ""}>${escape(u.name)}</option>`;
|
|
return `<optgroup label="ATC Admins">${admins.map(opt).join("")}</optgroup>
|
|
<optgroup label="FDE team">${fde.map(opt).join("")}</optgroup>`;
|
|
}
|
|
|
|
async function loadOpsUsers() {
|
|
try {
|
|
const data = await api.get("/api/ops/users");
|
|
if (data.users?.length) opsUsers = data.users;
|
|
} catch (_) {
|
|
try {
|
|
const data = await api.get("/api/admins");
|
|
if (data.users?.length) opsUsers = data.users;
|
|
else if (data.admins?.length) opsUsers = data.admins;
|
|
} catch (_) {
|
|
/* keep defaults */
|
|
}
|
|
}
|
|
}
|
|
|
|
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", "#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", "network-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, 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() {
|
|
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();
|
|
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", {
|
|
message: msg,
|
|
history: chatHistory.slice(0, -1),
|
|
focus_device_id: focusId,
|
|
model,
|
|
});
|
|
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);
|
|
refreshChatContext();
|
|
} catch (e) {
|
|
if (thinking) thinking.textContent = "Chat error: " + e.message;
|
|
}
|
|
}
|
|
|
|
function ticketFilterValue() {
|
|
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();
|
|
const fdeIds = new Set(opsUsers.filter((u) => u.team === "fde").map((u) => u.id));
|
|
const adminIds = new Set(opsUsers.filter((u) => u.team === "admin").map((u) => u.id));
|
|
return (tickets || []).filter((t) => {
|
|
if (f === "all") return true;
|
|
if (f === "mine") return t.assignee === me;
|
|
if (f === "created") return t.created_by === me;
|
|
if (f === "openish") return ["open", "accepted", "in_progress"].includes(t.status);
|
|
if (f === "fde") return fdeIds.has(t.assignee) || fdeIds.has(t.created_by);
|
|
if (f === "admin") return adminIds.has(t.assignee) && adminIds.has(t.created_by);
|
|
return t.status === f;
|
|
});
|
|
}
|
|
|
|
function renderOpsStats() {
|
|
const box = $("#ops-stats");
|
|
if (!box) return;
|
|
const all = tickets || [];
|
|
const active = all.filter((t) => ["open", "accepted", "in_progress"].includes(t.status)).length;
|
|
const mine = all.filter((t) => t.assignee === actor()).length;
|
|
const crit = all.filter((t) => t.priority === "critical" || t.priority === "high").length;
|
|
box.innerHTML = `
|
|
<div class="ops-stat"><em>${all.length}</em><span>Total</span></div>
|
|
<div class="ops-stat"><em>${active}</em><span>Active</span></div>
|
|
<div class="ops-stat"><em>${mine}</em><span>Assigned to me</span></div>
|
|
<div class="ops-stat"><em>${crit}</em><span>High / critical</span></div>`;
|
|
}
|
|
|
|
async function loadTickets() {
|
|
const data = await api.get("/api/tickets");
|
|
tickets = data.tickets || [];
|
|
if (data.users?.length) opsUsers = data.users;
|
|
else if (data.admins?.length) opsUsers = data.admins;
|
|
const list = $("#ticket-list");
|
|
if (!list) return;
|
|
filteredTickets = filteredTicketList();
|
|
renderOpsStats();
|
|
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 = filteredTickets
|
|
.map((tkt) => {
|
|
const active = tkt.id === activeTicketId ? " active" : "";
|
|
const when = tkt.updated_at
|
|
? new Date(tkt.updated_at * 1000).toLocaleString()
|
|
: "";
|
|
const team = userTeam(tkt.assignee);
|
|
return `<button type="button" class="card${active}" data-ticket="${tkt.id}">
|
|
<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)} · <span class="ops-team-pill ${team}">${team}</span> · ${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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
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 = `
|
|
${ticketNavHtml(id)}
|
|
<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">
|
|
${assigneeOptionsHtml(tkt.assignee || "jody")}
|
|
</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-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(
|
|
(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 team…"></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";
|
|
|
|
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,
|
|
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 = draft?.assignee || defaultAssignee(me);
|
|
const assigneeSel = $("#ticket-assignee");
|
|
if (assigneeSel) assigneeSel.innerHTML = assigneeOptionsHtml(other);
|
|
$("#ticket-title").value = draft?.title || "";
|
|
$("#ticket-body").value = draft?.body || "";
|
|
$("#ticket-priority").value = draft?.priority || "normal";
|
|
$("#ticket-assignee").value = 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 = userTeam(me) === "fde" ? "jody" : defaultAssignee(me);
|
|
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);
|
|
loadTickets();
|
|
});
|
|
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. Live V100 GPUs are shown above. Pick a model (Llama or OpenManage AI), then ask about fleet memory, 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 loadOpsUsers();
|
|
await loadTickets();
|
|
$("#ops-empty")?.classList.remove("hidden");
|
|
$("#ops-thread")?.classList.add("hidden");
|
|
activeTicketId = null;
|
|
});
|
|
$("#btn-ops-close")?.addEventListener("click", closeDrawers);
|
|
$("#btn-ops-refresh")?.addEventListener("click", () => loadTickets().catch((e) => alert(e.message)));
|
|
|
|
$("#ops-quick")?.addEventListener("click", (e) => {
|
|
const btn = e.target.closest("[data-ops-quick]");
|
|
if (!btn) return;
|
|
const q = btn.dataset.opsQuick;
|
|
if (q === "ticket") {
|
|
openTicketModal();
|
|
return;
|
|
}
|
|
if (q === "to-admin") {
|
|
openTicketModal({
|
|
title: "Handoff to ATC admin",
|
|
body: "Context for Jody / Laurens:\n\n",
|
|
assignee: "jody",
|
|
priority: "normal",
|
|
});
|
|
return;
|
|
}
|
|
if (q === "to-fde") {
|
|
openTicketModal({
|
|
title: "Data FDE / AI cluster request",
|
|
body: "Request for Mohamed El Kadi / Bart Sjerps (Data Forward Deployed Engineers · AI workloads):\n\n",
|
|
assignee: "mo",
|
|
priority: "normal",
|
|
});
|
|
return;
|
|
}
|
|
if (q === "active") {
|
|
const sel = $("#ops-filter");
|
|
if (sel) {
|
|
sel.value = "openish";
|
|
localStorage.setItem("cockpit_ops_filter", "openish");
|
|
}
|
|
loadTickets();
|
|
return;
|
|
}
|
|
if (q === "chat") {
|
|
openDrawer("#chat-drawer");
|
|
loadChatModels();
|
|
return;
|
|
}
|
|
if (q === "reports") {
|
|
window.cockpitReports?.open?.();
|
|
}
|
|
});
|
|
|
|
$("#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) => {
|
|
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" || mode === "reports-drawer" || mode === "network-drawer") closeDrawers();
|
|
});
|
|
|
|
initFeedDrag();
|
|
hookGpuFromFleet();
|
|
}
|
|
|
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wire);
|
|
else wire();
|
|
})();
|