Compare commits

..

10 Commits

Author SHA1 Message Date
root b4714c70d1 Add ATC Team chat, soft identity, and update Present decks.
Ship team/DM chat with files, avatars, unread alerts, and a first-open identity gate (including Guest); fold Team into Ops desk; clarify Datacenter Engineer vs "Data Plumbers" FDE roles; refresh Present story/tech slides to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 01:42:00 +02:00
root 03e207b46d Add iDRAC Console wall, power control, and proxy embeds.
Operators can power nodes via OME jobs and view multiple live iDRAC
consoles in-panel (4/8 wall) through a same-origin reverse proxy;
Present decks updated for the new remote-ops story.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 00:16:19 +02:00
root 795707e47b Replace Constellation with Radar sector sweep layout.
VLAN wedges on range rings with a rotating sweep beam, distinct from subnet islands.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 22:14:41 +02:00
root 64208eb344 Replace Bloom with Constellation and fix viz picker grid.
Keep all layout cards in a strict 2x2 grid; constellation uses star-chart packing with neighbor edges and leader pipes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 22:12:01 +02:00
root ae2a7163ef Add Bloom map layout with VLAN petals around OME.
Each subnet fans out as a colored petal with spine pulse, soft outlines, and leader-pipe labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 22:07:52 +02:00
root 01837b19f2 Disable subnet leader-pipe labels on Orbit and Galaxy.
Keep pipes on Clusters, Lanes, and Helix where they stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 22:04:08 +02:00
root 126e34093e Extend subnet leader-pipe labels to all map layouts.
Orbit, Galaxy, Lanes, and Helix now place VLAN/CIDR pills on long pipes clear of edges.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 22:01:50 +02:00
root cd347e6e4c Offset cluster subnet labels with outward leader pipes.
Keep CIDR/VLAN pills outside the spoke ring so pulse edges no longer cover the IP text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 21:57:19 +02:00
root 1e87f22006 Add VLAN inventory, Present decks, and network fabric mapping.
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>
2026-07-18 21:56:30 +02:00
root 91222faffe 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>
2026-07-18 01:18:14 +02:00
28 changed files with 20418 additions and 361 deletions
+4926 -91
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,6 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
websockets==14.1
python-multipart==0.0.20
pydantic==2.10.4
pydantic-settings==2.7.0
asyncssh==2.18.0
+932 -55
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+629
View File
@@ -0,0 +1,629 @@
/**
* Console workspace — live iDRAC wall (4 or 8 draggable tiles).
* Only real iDRAC management endpoints (not OS host IPs).
*/
(() => {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const STORE_KEY = "cockpit_console_slots_v2";
const DENSITY_KEY = "cockpit_console_density_v1";
const SLOTS_KEY = "cockpit_console_slotcount_v1";
const state = {
slotCount: Number(localStorage.getItem(SLOTS_KEY) || 4) === 8 ? 8 : 4,
slots: [],
density: localStorage.getItem(DENSITY_KEY) || "medium",
filter: "",
subnet: "all",
fsDeviceId: null,
dragDeviceId: null,
dragFromSlot: null,
};
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fleetDevices() {
return window.cockpit?.getState?.()?.data?.devices || [];
}
function fleetSubnets() {
return window.cockpit?.getState?.()?.data?.subnets || [];
}
/** Real iDRAC / BMC endpoints only — never bare OS inventory hosts. */
function isIdracHost(n) {
if (!n || n.id == null) return false;
if (n.is_idrac) return !!(n.idrac_ip || n.ip);
if (n.idrac_ip) return true;
if (n.idrac_url && /idrac/i.test(String(n.name || "") + String(n.idrac_url))) return true;
const name = String(n.name || "").toLowerCase();
if (name.includes("idrac") && (n.ip || n.idrac_ip)) return true;
return false;
}
function mgmtIp(n) {
return n?.idrac_ip || (n?.is_idrac ? n.ip : null) || null;
}
function idracPool() {
return fleetDevices()
.filter(isIdracHost)
.filter((n) => mgmtIp(n))
.slice()
.sort((a, b) => {
const sa = String(a.subnet || "");
const sb = String(b.subnet || "");
if (sa !== sb) return sa.localeCompare(sb);
return String(a.name || "").localeCompare(String(b.name || ""));
});
}
function deviceById(id) {
if (id == null) return null;
return fleetDevices().find((d) => String(d.id) === String(id)) || null;
}
function subnetMeta(cidr) {
const meta = fleetSubnets().find((s) => s.cidr === cidr) || {};
const colors = window.cockpit?.getState?.()?.subnetColors;
let color = meta.vlan_color;
if (!color && colors?.get) color = colors.get(cidr || "?");
if (!color) {
let h = 0;
for (const c of String(cidr || "?")) h = (h * 31 + c.charCodeAt(0)) >>> 0;
color = `hsl(${h % 360} 72% 52%)`;
}
return {
cidr: cidr || "—",
vlan: meta.vlan_id,
name: meta.name || meta.label || "",
color,
};
}
function embedUrl(deviceId) {
return `/api/idrac-proxy/${deviceId}/restgui/start.html?console`;
}
function resizeSlots(count) {
const next = Number(count) === 8 ? 8 : 4;
const prev = state.slots.slice();
state.slotCount = next;
state.slots = Array.from({ length: next }, (_, i) => prev[i] ?? null);
localStorage.setItem(SLOTS_KEY, String(next));
saveSlots();
}
function loadSlots() {
try {
const raw = JSON.parse(localStorage.getItem(STORE_KEY) || "[]");
if (Array.isArray(raw) && raw.length) {
state.slots = Array.from({ length: state.slotCount }, (_, i) => {
const id = raw[i] ?? null;
const n = deviceById(id);
return n && isIdracHost(n) ? id : null;
});
return;
}
} catch {
/* ignore */
}
state.slots = Array(state.slotCount).fill(null);
}
function saveSlots() {
try {
localStorage.setItem(STORE_KEY, JSON.stringify(state.slots));
} catch {
/* ignore */
}
}
function closeOtherDrawers() {
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#network-drawer", "#team-drawer"].forEach((id) => {
const el = $(id);
if (el) {
el.classList.remove("open");
el.setAttribute("aria-hidden", "true");
}
});
window.cockpitNetwork?.close?.();
}
function openConsole() {
const drawer = $("#console-drawer");
const scrim = $("#scrim");
if (!drawer) return;
closeOtherDrawers();
loadSlots();
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
if (scrim) {
scrim.classList.add("open");
scrim.dataset.mode = "console-drawer";
}
renderAll();
requestAnimationFrame(() => drawer.classList.add("console-ready"));
}
function closeConsole() {
closeFullscreen();
const drawer = $("#console-drawer");
const scrim = $("#scrim");
drawer?.classList.remove("open", "console-ready", "is-dragging");
drawer?.setAttribute("aria-hidden", "true");
if (scrim?.dataset.mode === "console-drawer") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function setDensity(mode) {
state.density = mode === "small" ? "small" : "medium";
localStorage.setItem(DENSITY_KEY, state.density);
applyStageClasses();
$$("[data-console-density]").forEach((b) => {
b.classList.toggle("active", b.dataset.consoleDensity === state.density);
});
}
function setSlotCount(count) {
resizeSlots(count);
applyStageClasses();
$$("[data-console-slots]").forEach((b) => {
b.classList.toggle("active", Number(b.dataset.consoleSlots) === state.slotCount);
});
renderAll();
}
function applyStageClasses() {
const stage = $("#console-stage");
if (!stage) return;
stage.classList.toggle("density-small", state.density === "small");
stage.classList.toggle("density-medium", state.density === "medium");
stage.classList.toggle("slots-4", state.slotCount === 4);
stage.classList.toggle("slots-8", state.slotCount === 8);
}
function toast(msg) {
if (typeof window.showToast === "function") window.showToast(msg);
else if (window.cockpit?.showToast) window.cockpit.showToast(msg);
}
function assignSlot(index, deviceId) {
if (index < 0 || index >= state.slotCount) return;
if (deviceId != null) {
const n = deviceById(deviceId);
if (!n || !isIdracHost(n)) {
toast("Kies een iDRAC-endpoint (geen OS-host)");
return;
}
state.slots = state.slots.map((id, i) => (i !== index && String(id) === String(deviceId) ? null : id));
}
state.slots[index] = deviceId;
saveSlots();
renderStage();
renderFleet();
renderStats();
}
function clearSlot(index) {
assignSlot(index, null);
}
function swapSlots(a, b) {
if (a === b || a < 0 || b < 0 || a >= state.slotCount || b >= state.slotCount) return;
const tmp = state.slots[a];
state.slots[a] = state.slots[b];
state.slots[b] = tmp;
saveSlots();
renderStage();
}
function openFullscreen(deviceId) {
const node = deviceById(deviceId);
if (!node) return;
state.fsDeviceId = deviceId;
const modal = $("#console-fs-modal");
const frame = $("#console-fs-frame");
const title = $("#console-fs-title");
const sub = $("#console-fs-sub");
const net = $("#console-fs-net");
if (!modal || !frame) return;
const sm = subnetMeta(node.subnet);
title.textContent = node.name || "iDRAC";
sub.textContent = `${node.model || "—"} · ${node.service_tag || "no tag"} · ${mgmtIp(node) || ""}`;
net.innerHTML = netBadgeHtml(sm, node);
$$(`.console-tile[data-device-id="${deviceId}"] iframe`).forEach((f) => {
f.dataset.pausedSrc = f.src;
f.removeAttribute("src");
});
frame.src = embedUrl(deviceId);
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
}
function closeFullscreen() {
const modal = $("#console-fs-modal");
const frame = $("#console-fs-frame");
const id = state.fsDeviceId;
if (frame) frame.removeAttribute("src");
modal?.classList.add("hidden");
modal?.setAttribute("aria-hidden", "true");
if (id != null) {
$$(`.console-tile[data-device-id="${id}"] iframe`).forEach((f) => {
if (f.dataset.pausedSrc) {
f.src = f.dataset.pausedSrc;
delete f.dataset.pausedSrc;
} else {
f.src = embedUrl(id);
}
});
}
state.fsDeviceId = null;
}
function netBadgeHtml(sm, node) {
const vlan = sm.vlan != null ? `VLAN ${esc(sm.vlan)}` : "";
const name = sm.name ? esc(sm.name) : "";
const bits = [vlan, name, esc(sm.cidr)].filter(Boolean);
return `<span class="console-net-badge" style="--net:${esc(sm.color)}">
<i class="console-net-dot"></i>
<span>${bits.join(" · ") || "network —"}</span>
${node?.connected ? `<em class="on">live</em>` : `<em class="off">offline</em>`}
</span>`;
}
function renderStats() {
const pool = idracPool();
const live = pool.filter((n) => n.connected).length;
const filled = state.slots.filter(Boolean).length;
const nets = new Set(pool.map((n) => n.subnet).filter(Boolean)).size;
const el = $("#console-stats");
if (el) {
el.innerHTML = `
<span class="cs-pill"><strong>${pool.length}</strong> iDRACs</span>
<span class="cs-pill live"><strong>${live}</strong> connected</span>
<span class="cs-pill"><strong>${nets}</strong> networks</span>
<span class="cs-pill"><strong>${filled}/${state.slotCount}</strong> wall</span>`;
}
}
function filteredPool() {
const q = state.filter.trim().toLowerCase();
return idracPool().filter((n) => {
if (state.subnet !== "all" && n.subnet !== state.subnet) return false;
if (!q) return true;
const hay = [n.name, n.ip, n.idrac_ip, n.model, n.service_tag, n.subnet]
.join(" ")
.toLowerCase();
return hay.includes(q);
});
}
function renderSubnetChips() {
const host = $("#console-subnet-chips");
if (!host) return;
const pool = idracPool();
const counts = new Map();
pool.forEach((n) => {
const c = n.subnet || "unknown";
counts.set(c, (counts.get(c) || 0) + 1);
});
const chips = [
`<button type="button" class="chip ${state.subnet === "all" ? "active" : ""}" data-console-subnet="all">All nets · ${pool.length}</button>`,
];
[...counts.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.forEach(([cidr, n]) => {
const sm = subnetMeta(cidr);
const label = sm.vlan != null ? `VLAN ${sm.vlan}` : cidr;
chips.push(
`<button type="button" class="chip ${state.subnet === cidr ? "active" : ""}" data-console-subnet="${esc(cidr)}" style="--chip:${esc(sm.color)}">
<i class="console-chip-dot"></i>${esc(label)} · ${n}
</button>`
);
});
host.innerHTML = chips.join("");
}
function renderFleet() {
const host = $("#console-fleet-list");
if (!host) return;
const used = new Set(state.slots.filter(Boolean).map(String));
const list = filteredPool();
if (!list.length) {
host.innerHTML = `<p class="hint">No iDRAC endpoints match. OS hosts are hidden — only BMC/iDRAC IPs.</p>`;
return;
}
let lastSubnet = null;
const parts = [];
list.forEach((n) => {
if (n.subnet !== lastSubnet) {
lastSubnet = n.subnet;
const sm = subnetMeta(n.subnet);
parts.push(`<div class="console-fleet-group" style="--net:${esc(sm.color)}">
<span class="cfg-label">${sm.vlan != null ? `VLAN ${esc(sm.vlan)}` : ""}${sm.name ? ` · ${esc(sm.name)}` : ""} · <code>${esc(sm.cidr)}</code></span>
</div>`);
}
const inWall = used.has(String(n.id));
const ip = mgmtIp(n);
parts.push(`<div class="console-fleet-card ${n.connected ? "is-live" : "is-off"} ${inWall ? "on-wall" : ""}"
draggable="true" data-device-id="${esc(n.id)}" title="Drag onto a wall slot">
<span class="cfc-top">
<strong>${esc(n.name || "device")}</strong>
<span class="cfc-status">${n.connected ? "LIVE" : "OFF"}</span>
</span>
<span class="cfc-meta">${esc(n.model || "—")} · ${esc(n.service_tag || "no tag")}</span>
<span class="cfc-ip">${esc(ip || "—")}</span>
<span class="cfc-actions">
<button type="button" class="btn compact primary" data-fleet-act="add">${inWall ? "On wall" : "+ Wall"}</button>
</span>
</div>`);
});
host.innerHTML = parts.join("");
}
function tileHtml(index, deviceId) {
const node = deviceById(deviceId);
if (!node || !isIdracHost(node)) {
return `<div class="console-tile is-empty" data-slot="${index}" data-device-id="">
<div class="console-dropzone">
<span class="cd-kicker">Slot ${index + 1}</span>
<strong>Drop an iDRAC here</strong>
<p>Only BMC/iDRAC IPs · drag from fleet or click + Wall</p>
</div>
</div>`;
}
const sm = subnetMeta(node.subnet);
const ip = mgmtIp(node);
return `<div class="console-tile is-filled ${node.connected ? "is-live" : "is-off"}" data-slot="${index}" data-device-id="${esc(node.id)}" draggable="true" style="--net:${esc(sm.color)}">
<header class="console-tile-chrome">
<div class="ctc-id">
<span class="ctc-slot">#${index + 1}</span>
<strong class="ctc-name" title="${esc(node.name)}">${esc(node.name || "iDRAC")}</strong>
${netBadgeHtml(sm, node)}
</div>
<div class="ctc-actions">
<button type="button" class="btn ghost compact" data-tile-act="reload" title="Reload">↻</button>
<button type="button" class="btn ghost compact" data-tile-act="pop" title="Open direct iDRAC">↗</button>
<button type="button" class="btn idrac-console compact" data-tile-act="fs" title="Fullscreen popup">Fullscreen</button>
<button type="button" class="btn ghost compact" data-tile-act="clear" title="Clear slot">×</button>
</div>
</header>
<div class="console-tile-meta">
<span>${esc(node.model || "—")}</span>
<span>${esc(node.service_tag || "—")}</span>
<span class="mono">${esc(ip || "")}</span>
<span class="${node.powered_on ? "on" : "off"}">${node.powered_on ? "POWERED" : "POWER N/A"}</span>
</div>
<div class="console-frame-wrap">
<div class="console-drop-shield" aria-hidden="true"></div>
<iframe title="iDRAC ${esc(node.name)}" src="${esc(embedUrl(node.id))}" allow="fullscreen; clipboard-read; clipboard-write" referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
</div>`;
}
function renderStage() {
const stage = $("#console-stage");
if (!stage) return;
applyStageClasses();
const prev = new Map();
$$(".console-tile iframe", stage).forEach((f) => {
const tile = f.closest(".console-tile");
if (!tile) return;
prev.set(`${tile.dataset.slot}:${tile.dataset.deviceId}`, f);
});
stage.innerHTML = state.slots.map((id, i) => tileHtml(i, id)).join("");
$$(".console-tile.is-filled", stage).forEach((tile) => {
const key = `${tile.dataset.slot}:${tile.dataset.deviceId}`;
const old = prev.get(key);
const frame = $("iframe", tile);
if (old && frame && old !== frame && old.src) frame.replaceWith(old);
});
}
function renderAll() {
renderStats();
renderSubnetChips();
renderFleet();
renderStage();
setDensity(state.density);
$$("[data-console-slots]").forEach((b) => {
b.classList.toggle("active", Number(b.dataset.consoleSlots) === state.slotCount);
});
}
function fillNextEmpty(deviceId) {
const idx = state.slots.findIndex((id) => id == null);
if (idx === -1) {
assignSlot(state.slotCount - 1, deviceId);
return;
}
assignSlot(idx, deviceId);
}
function autoFillLive() {
const live = idracPool().filter((n) => n.connected);
const picks = (live.length ? live : idracPool()).slice(0, state.slotCount);
state.slots = Array.from({ length: state.slotCount }, (_, i) => (picks[i] ? picks[i].id : null));
saveSlots();
renderAll();
}
function bindDrag() {
const drawer = $("#console-drawer");
if (!drawer || drawer.dataset.dragBound) return;
drawer.dataset.dragBound = "1";
drawer.addEventListener("dragstart", (e) => {
const card = e.target.closest(".console-fleet-card");
const tile = e.target.closest(".console-tile.is-filled");
// don't start drag from action buttons
if (e.target.closest("button")) return;
if (card) {
state.dragDeviceId = card.dataset.deviceId;
state.dragFromSlot = null;
e.dataTransfer.setData("text/plain", String(state.dragDeviceId));
e.dataTransfer.effectAllowed = "copyMove";
card.classList.add("dragging");
drawer.classList.add("is-dragging");
} else if (tile) {
state.dragDeviceId = tile.dataset.deviceId;
state.dragFromSlot = Number(tile.dataset.slot);
e.dataTransfer.setData("text/plain", String(state.dragDeviceId));
e.dataTransfer.effectAllowed = "move";
tile.classList.add("dragging");
drawer.classList.add("is-dragging");
} else {
return;
}
});
drawer.addEventListener("dragend", () => {
$$(".dragging", drawer).forEach((t) => t.classList.remove("dragging"));
$$(".console-tile.drag-over", drawer).forEach((t) => t.classList.remove("drag-over"));
drawer.classList.remove("is-dragging");
state.dragDeviceId = null;
state.dragFromSlot = null;
});
drawer.addEventListener("dragover", (e) => {
const tile = e.target.closest(".console-tile");
if (!tile) return;
e.preventDefault();
tile.classList.add("drag-over");
e.dataTransfer.dropEffect = state.dragFromSlot != null ? "move" : "copy";
});
drawer.addEventListener("dragleave", (e) => {
const tile = e.target.closest(".console-tile");
if (tile && !tile.contains(e.relatedTarget)) tile.classList.remove("drag-over");
});
drawer.addEventListener("drop", (e) => {
const tile = e.target.closest(".console-tile");
if (!tile) return;
e.preventDefault();
tile.classList.remove("drag-over");
const to = Number(tile.dataset.slot);
const deviceId = e.dataTransfer.getData("text/plain") || state.dragDeviceId;
if (deviceId == null || deviceId === "") return;
if (state.dragFromSlot != null) swapSlots(state.dragFromSlot, to);
else assignSlot(to, Number(deviceId) || deviceId);
drawer.classList.remove("is-dragging");
});
}
function bind() {
$("#btn-console")?.addEventListener("click", openConsole);
$("#btn-console-close")?.addEventListener("click", closeConsole);
$("#btn-console-autofill")?.addEventListener("click", autoFillLive);
$("#btn-console-clear")?.addEventListener("click", () => {
state.slots = Array(state.slotCount).fill(null);
saveSlots();
renderAll();
});
$("#console-search")?.addEventListener("input", (e) => {
state.filter = e.target.value || "";
renderFleet();
});
$("#console-subnet-chips")?.addEventListener("click", (e) => {
const chip = e.target.closest("[data-console-subnet]");
if (!chip) return;
state.subnet = chip.dataset.consoleSubnet || "all";
renderSubnetChips();
renderFleet();
});
$$("[data-console-density]").forEach((b) => {
b.addEventListener("click", () => setDensity(b.dataset.consoleDensity));
});
$$("[data-console-slots]").forEach((b) => {
b.addEventListener("click", () => setSlotCount(b.dataset.consoleSlots));
});
$("#console-fleet-list")?.addEventListener("click", (e) => {
const addBtn = e.target.closest("[data-fleet-act='add']");
const card = e.target.closest(".console-fleet-card");
if (addBtn && card) {
e.preventDefault();
e.stopPropagation();
fillNextEmpty(Number(card.dataset.deviceId) || card.dataset.deviceId);
return;
}
if (!card) return;
$$(".console-fleet-card.selected").forEach((c) => c.classList.remove("selected"));
card.classList.add("selected");
});
$("#console-fleet-list")?.addEventListener("dblclick", (e) => {
const card = e.target.closest(".console-fleet-card");
if (!card || e.target.closest("button")) return;
fillNextEmpty(Number(card.dataset.deviceId) || card.dataset.deviceId);
});
$("#console-stage")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-tile-act]");
if (!btn) return;
const tile = btn.closest(".console-tile");
if (!tile) return;
const slot = Number(tile.dataset.slot);
const id = tile.dataset.deviceId;
const act = btn.dataset.tileAct;
if (act === "clear") clearSlot(slot);
else if (act === "reload") {
const frame = $("iframe", tile);
if (frame?.src) {
const u = frame.src;
frame.src = "about:blank";
setTimeout(() => {
frame.src = u;
}, 40);
}
} else if (act === "fs" && id) openFullscreen(id);
else if (act === "pop" && id) {
const n = deviceById(id);
const url = n?.idrac_url || (mgmtIp(n) ? `https://${mgmtIp(n)}/` : null);
if (url) window.open(url, "_blank", "noopener");
}
});
$("#btn-console-fs-close")?.addEventListener("click", closeFullscreen);
$("#console-fs-modal")?.addEventListener("click", (e) => {
if (e.target.id === "console-fs-modal") closeFullscreen();
});
$("#scrim")?.addEventListener("click", () => {
if ($("#scrim")?.dataset.mode === "console-drawer") closeConsole();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (!$("#console-fs-modal")?.classList.contains("hidden")) closeFullscreen();
else if ($("#console-drawer")?.classList.contains("open")) closeConsole();
}
});
bindDrag();
setInterval(() => {
if (!$("#console-drawer")?.classList.contains("open")) return;
renderStats();
renderSubnetChips();
renderFleet();
$$(".console-tile.is-filled").forEach((tile) => {
const n = deviceById(tile.dataset.deviceId);
if (!n) return;
tile.classList.toggle("is-live", !!n.connected);
tile.classList.toggle("is-off", !n.connected);
const badge = $(".console-net-badge", tile);
if (badge) badge.outerHTML = netBadgeHtml(subnetMeta(n.subnet), n);
});
}, 8000);
}
state.slots = Array(state.slotCount).fill(null);
loadSlots();
bind();
window.cockpitConsole = { open: openConsole, close: closeConsole, refresh: renderAll };
})();
+384 -46
View File
@@ -7,9 +7,19 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css?v=reports1" />
<link rel="stylesheet" href="/styles.css?v=team6" />
<link rel="icon" href="/dell.png" type="image/png" />
<link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" />
<script>
(function () {
try {
var t = localStorage.getItem("cockpit_theme") || "light";
document.documentElement.setAttribute("data-theme", t === "dark" ? "dark" : "light");
} catch (e) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>
</head>
<body>
<div id="notify-stack" aria-live="polite" aria-atomic="false"></div>
@@ -28,9 +38,30 @@
<div class="top-actions">
<button type="button" class="btn ghost" id="btn-reset-view" title="Fit fleet in view">Reset view</button>
<button type="button" class="btn ghost" id="btn-ome-console" title="Open OME console">OME console</button>
<button type="button" class="btn ghost" id="btn-network" title="Fabric map &amp; ATC racks">Network</button>
<button type="button" class="btn console-btn" id="btn-console" title="Live iDRAC console wall">Console</button>
<button type="button" class="btn ghost" id="btn-reports" title="Fleet reports &amp; Dell compliance">Reports</button>
<button type="button" class="btn ghost" id="btn-ops" title="ATC admin tickets">Ops desk</button>
<button type="button" class="btn primary" id="btn-chat">Cockpit chat</button>
<div class="ops-team-combo" title="Ops desk &amp; team chat">
<button type="button" class="btn ghost" id="btn-ops" title="ATC admin tickets">Ops desk</button>
<button type="button" class="btn team-btn" id="btn-team" title="ATC team chat · files">
<span class="team-btn-label">Team</span>
<span class="team-unread hidden" id="team-unread" aria-label="Unread messages">0</span>
<span class="team-btn-who hidden" id="team-identity-badge" title="Switch identity"></span>
</button>
</div>
<button type="button" class="btn theme-toggle" id="btn-theme" title="Switch theme" aria-label="Switch light or dark theme">
<svg class="theme-ico theme-ico-to-dark" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M12 3a9 9 0 1 0 9 9c0-.3 0-.6-.05-.9A7 7 0 0 1 12 3z"/>
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.6"/>
</svg>
<svg class="theme-ico theme-ico-to-light" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="12" r="4" fill="currentColor"/>
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.1 5.1l1.6 1.6M17.3 17.3l1.6 1.6M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6"/>
</svg>
</button>
<button type="button" class="btn present-btn" id="btn-present" title="Customer presentation · mid or fullscreen slides">Present</button>
<button type="button" class="btn primary" id="btn-chat">Cockpit Copilot</button>
</div>
</header>
@@ -46,6 +77,7 @@
<button type="button" class="viz-card active" data-layout="orbit"><span class="viz-ico"></span><span class="viz-t">Orbit</span><span class="viz-s">Hub &amp; rings</span></button>
<button type="button" class="viz-card" data-layout="galaxy"><span class="viz-ico"></span><span class="viz-t">Galaxy</span><span class="viz-s">Spiral arms</span></button>
<button type="button" class="viz-card" data-layout="clusters"><span class="viz-ico"></span><span class="viz-t">Clusters</span><span class="viz-s">Subnet islands</span></button>
<button type="button" class="viz-card" data-layout="radar"><span class="viz-ico"></span><span class="viz-t">Radar</span><span class="viz-s">Sector sweep</span></button>
<button type="button" class="viz-card" data-layout="lanes"><span class="viz-ico"></span><span class="viz-t">Lanes</span><span class="viz-s">Fiber highways</span></button>
<button type="button" class="viz-card" data-layout="helix"><span class="viz-ico"></span><span class="viz-t">Helix</span><span class="viz-s">3D depth coil</span></button>
</div>
@@ -66,7 +98,46 @@
<label class="toggle"><input type="checkbox" id="f-powered" /> Powered on</label>
<label class="toggle"><input type="checkbox" id="f-has-power" /> Has power reading</label>
<label class="toggle"><input type="checkbox" id="f-ghost-links" /> Ghost offline links</label>
<label class="toggle"><input type="checkbox" id="f-pulse-links" checked /> Animate link pulse</label>
<div class="pulse-row">
<label class="toggle"><input type="checkbox" id="f-pulse-links" checked /> Animate link pulse</label>
<button type="button" class="btn ghost compact pulse-gear" id="btn-pulse-settings" title="Pulse speed settings" aria-haspopup="dialog" aria-expanded="false"></button>
<div class="pulse-pop" id="pulse-pop" hidden role="dialog" aria-label="Link pulse settings">
<strong>Link pulse</strong>
<label class="range pulse-speed-range">
<span>Speed <em id="pulse-speed-label">0.45×</em></span>
<input type="range" id="f-pulse-speed" min="0.15" max="1.5" step="0.05" value="0.45" />
</label>
<div class="pulse-presets">
<button type="button" class="chip" data-pulse-speed="0.25">Slow</button>
<button type="button" class="chip" data-pulse-speed="0.45">Normal</button>
<button type="button" class="chip" data-pulse-speed="0.8">Fast</button>
<button type="button" class="chip" data-pulse-speed="1.2">Max</button>
</div>
<div class="pulse-colors">
<label class="pulse-color-field">
<span>Line</span>
<input type="color" id="f-pulse-line-color" value="#3dffe0" title="Link line color" />
</label>
<label class="pulse-color-field">
<span>Glow</span>
<input type="color" id="f-pulse-glow-color" value="#00a8e8" title="Link glow color" />
</label>
<label class="pulse-color-field">
<span>Packet</span>
<input type="color" id="f-pulse-packet-color" value="#ffffff" title="Moving packet color" />
</label>
</div>
<div class="pulse-color-presets" id="pulse-color-presets">
<button type="button" class="pulse-swatch" data-pulse-theme="cyan" style="--sw:#3dffe0" title="Cyan"></button>
<button type="button" class="pulse-swatch" data-pulse-theme="orange" style="--sw:#ff9a3c" title="Orange"></button>
<button type="button" class="pulse-swatch" data-pulse-theme="green" style="--sw:#3dffa0" title="Green"></button>
<button type="button" class="pulse-swatch" data-pulse-theme="purple" style="--sw:#c4a7ff" title="Purple"></button>
<button type="button" class="pulse-swatch" data-pulse-theme="pink" style="--sw:#f472b6" title="Pink"></button>
<button type="button" class="pulse-swatch" data-pulse-theme="gold" style="--sw:#f0e68c" title="Gold"></button>
</div>
<p class="hint">Lower speed = calmer packets on the main map.</p>
</div>
</div>
<label class="range">
<span>Min watts <em id="min-w-label">0</em></span>
<input type="range" id="f-min-watts" min="0" max="800" step="10" value="0" />
@@ -96,18 +167,6 @@
</div>
</section>
<section class="rail-block open" data-module="gpu">
<button type="button" class="rail-toggle" aria-expanded="true">
<span>V100 GPU matrix</span>
<span class="chev" aria-hidden="true"></span>
</button>
<div class="rail-panel">
<p class="hint">Live atc-gpu-prod · llama3-70b-gptq</p>
<div id="gpu-summary" class="gpu-summary">Waiting for GPU telemetry…</div>
<div id="gpu-matrix" class="gpu-matrix"></div>
</div>
</section>
<section class="rail-block open" data-module="live">
<button type="button" class="rail-toggle" aria-expanded="true">
<span>Realtime context</span>
@@ -189,20 +248,29 @@
<div class="inspector-body hidden" id="inspector-body"></div>
</aside>
<footer class="ticker" id="ticker"></footer>
<footer class="app-footer" id="app-footer">
<div class="app-credit" title="Not an official Dell product">
<span>OME Cockpit · Data FDEs <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong></span>
<span class="app-credit-sep" aria-hidden="true">·</span>
<span>Data Forward Deployed Engineers</span>
<span class="app-credit-sep" aria-hidden="true">·</span>
<span>Not an official Dell Technologies product</span>
</div>
<div class="ticker" id="ticker"></div>
</footer>
</div>
<!-- Cockpit chat -->
<div class="drawer" id="chat-drawer" aria-hidden="true">
<!-- Cockpit chat / Copilot -->
<div class="drawer chat-copilot" id="chat-drawer" aria-hidden="true">
<div class="drawer-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<span>Cockpit chat</span>
<span>Cockpit Copilot</span>
</div>
<button type="button" class="btn ghost" id="btn-chat-close">Close</button>
</div>
<div class="chat-context" id="chat-context">
<label class="chat-model-wrap" title="Select model">
<label class="chat-model-wrap" title="Select model (Llama / OpenManage AI)">
<span class="chat-model-label">Model</span>
<select id="chat-model" class="chat-model-select" aria-label="Chat model">
<option value="llama3-70b-gptq">llama3-70b-gptq</option>
@@ -211,60 +279,125 @@
<span class="chat-ctx-pill" id="chat-ctx-fleet">fleet …</span>
<span class="chat-ctx-pill" id="chat-ctx-focus">focus: none</span>
</div>
<section class="chat-gpu" aria-label="Live GPU matrix">
<div class="chat-gpu-head">
<strong>V100 GPU matrix</strong>
<span class="chat-gpu-host">atc-gpu-prod · live</span>
</div>
<div id="gpu-summary" class="gpu-summary">Waiting for GPU telemetry…</div>
<div id="gpu-matrix" class="gpu-matrix chat-gpu-matrix"></div>
</section>
<div class="chat-quick" id="chat-quick">
<button type="button" data-q="List every device with its Service Tag, model, and IP.">All service tags</button>
<button type="button" data-q="How much memory (RAM GB) does each server currently have? List Service Tag, name, model, and installed GB.">Memory per server</button>
<button type="button" data-q="What is the current fleet health? Include Service Tags for critical systems.">Fleet health</button>
<button type="button" data-q="List all critical alerts with system name, Service Tag, and IP.">Critical alerts</button>
<button type="button" data-q="Which systems are firmware outdated vs the Dell catalog? Include Service Tags and BIOS/iDRAC versions if known.">Outdated firmware</button>
<button type="button" data-q="Summarize V100 GPU utilization on atc-gpu-prod.">GPU status</button>
<button type="button" data-q="Summarize V100 GPU utilization on atc-gpu-prod from the live matrix.">GPU status</button>
</div>
<div class="chat-body" id="chat-body"></div>
<form class="chat-form" id="chat-form">
<input type="text" id="chat-input" placeholder="Ask about live OME fleet, alerts, power, iDRACs, GPUs…" autocomplete="off" />
<input type="text" id="chat-input" placeholder="Ask about fleet memory, alerts, power, iDRACs, GPUs…" autocomplete="off" />
<button type="submit" class="btn primary">Send</button>
</form>
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
</div>
<!-- Ops desk / tickets -->
<div class="drawer wide" id="ops-drawer" aria-hidden="true">
<div class="drawer wide ops-drawer" id="ops-drawer" aria-hidden="true">
<div class="drawer-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<span>ATC Ops desk</span>
</div>
<button type="button" class="btn ghost" id="btn-ops-close">Close</button>
<div class="ops-head-actions">
<button type="button" class="btn team-btn compact" id="btn-ops-open-team" title="Open team chat">Team chat</button>
<button type="button" class="btn ghost" id="btn-ops-close">Close</button>
</div>
</div>
<div class="ops-layout">
<div class="ops-side">
<label class="ops-identity">Acting as
<select id="ops-actor">
<option value="jody">Jody van Dongen</option>
<option value="laurens">Laurens Rammers</option>
<optgroup label="Datacenter Engineers">
<option value="jody">Jody van Dongen · DC Engineer</option>
<option value="laurens">Laurens Rammers · DC Engineer</option>
</optgroup>
<optgroup label="Data FDE team">
<option value="mo">Mohamed El Kadi · Data FDE · "Data Plumbers" 😉</option>
<option value="bart">Bart Sjerps · Data FDE · "Data Plumbers" 😉</option>
</optgroup>
<optgroup label="Other">
<option value="guest">Guest · Visitor</option>
</optgroup>
</select>
</label>
<button type="button" class="btn primary" id="btn-new-ticket">New ticket</button>
<div class="ops-side-actions">
<button type="button" class="btn primary" id="btn-new-ticket">New ticket</button>
<button type="button" class="btn compact" id="btn-ops-refresh" title="Reload ticket list">Refresh</button>
</div>
<div class="ops-filters">
<select id="ops-filter">
<option value="openish">Active</option>
<option value="all">All</option>
<select id="ops-filter" title="Ticket history filter">
<option value="all" selected>All history</option>
<option value="openish">Active only</option>
<option value="open">Open</option>
<option value="accepted">Accepted</option>
<option value="in_progress">In progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
<option value="mine">Assigned to me</option>
<option value="created">Created by me</option>
<option value="fde">FDE involved</option>
<option value="admin">Admin handoff</option>
</select>
<span class="ops-count" id="ops-count">0 tickets</span>
</div>
<div class="list" id="ticket-list"></div>
</div>
<div class="ops-main" id="ops-main">
<div class="ops-empty" id="ops-empty">
<h3>Admin collaboration</h3>
<p>Jody van Dongen and Laurens Rammers can exchange notes and tickets for ATC datacenter operations.</p>
<h3>ATC Ops desk</h3>
<p class="ops-intro">Select a ticket or create one to collaborate across Datacenter Engineers and Data Forward Deployed Engineers.</p>
<div class="ops-team-grid" id="ops-team-grid">
<section class="ops-team-card admin">
<h4>Datacenter Engineers</h4>
<ul>
<li><strong>Jody van Dongen</strong><span>Datacenter Engineer · storage · servers · network · rack &amp; stack · installs</span></li>
<li><strong>Laurens Rammers</strong><span>Datacenter Engineer · storage · servers · network · rack &amp; stack · installs</span></li>
</ul>
</section>
<section class="ops-team-card fde">
<h4>Data Forward Deployed Engineers</h4>
<ul>
<li><strong>Mohamed El Kadi</strong><span>Data FDE · &quot;Data Plumbers&quot; 😉 · Cockpit · OpenManage AI</span></li>
<li><strong>Bart Sjerps</strong><span>Data FDE · &quot;Data Plumbers&quot; 😉 · FDE cluster · AI workloads</span></li>
</ul>
</section>
</div>
<div class="ops-quick" id="ops-quick">
<h4>Quick actions</h4>
<div class="ops-quick-row">
<button type="button" class="btn primary" data-ops-quick="ticket">New ticket</button>
<button type="button" class="btn" data-ops-quick="to-admin">Handoff to DC engineer</button>
<button type="button" class="btn" data-ops-quick="to-fde">Ask FDE / AI cluster</button>
<button type="button" class="btn" data-ops-quick="active">Show active tickets</button>
<button type="button" class="btn" data-ops-quick="team">Team chat</button>
<button type="button" class="btn" data-ops-quick="chat">Open Copilot</button>
<button type="button" class="btn" data-ops-quick="reports">Fleet reports</button>
</div>
</div>
<div class="ops-stats" id="ops-stats"></div>
</div>
<div class="ops-thread hidden" id="ops-thread"></div>
</div>
</div>
<footer class="ops-footer">
<span>OME Cockpit · Data FDEs <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong></span>
<span class="ops-footer-sep" aria-hidden="true">·</span>
<span>Data Forward Deployed Engineers</span>
<span class="ops-footer-sep" aria-hidden="true">·</span>
<span>Not an official Dell Technologies product</span>
</footer>
</div>
<div class="drawer" id="ai-drawer" aria-hidden="true">
@@ -278,6 +411,88 @@
<iframe id="ai-frame" title="OpenManage AI" src="about:blank"></iframe>
</div>
<!-- Network: fabric + ATC racks -->
<div class="drawer wide network-drawer" id="network-drawer" aria-hidden="true">
<div class="drawer-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<span>Network · ATC1 / ATC2</span>
</div>
<div class="net-head-meta">
<span id="network-status" class="hint"></span>
<button type="button" class="btn ghost" id="btn-network-close">Close</button>
</div>
</div>
<div class="chip-grid" id="network-tabs">
<button type="button" class="chip active" data-ntab="fabric">Fabric</button>
<button type="button" class="chip" data-ntab="racks">Racks 42U</button>
<button type="button" class="chip" data-ntab="vlans">VLANs</button>
</div>
<div class="network-body">
<div id="network-fabric" class="network-panel"></div>
<div id="network-racks" class="network-panel hidden"></div>
<div id="network-vlans" class="network-panel hidden"></div>
</div>
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
</div>
<!-- Live iDRAC Console wall -->
<div class="drawer wide console-drawer" id="console-drawer" aria-hidden="true">
<div class="console-aurora" aria-hidden="true"></div>
<div class="drawer-head console-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<div>
<span class="console-title">Console · iDRAC wall</span>
<p class="console-sub">4 live embeds · drag from fleet · network-aware</p>
</div>
</div>
<div class="console-head-actions">
<div id="console-stats" class="console-stats"></div>
<div class="console-density" role="group" aria-label="Wall size">
<button type="button" class="chip active" data-console-slots="4" title="4 consoles">4 screens</button>
<button type="button" class="chip" data-console-slots="8" title="8 consoles">8 screens</button>
</div>
<div class="console-density" role="group" aria-label="Tile density">
<button type="button" class="chip" data-console-density="small" title="Compact tiles">Small</button>
<button type="button" class="chip active" data-console-density="medium" title="Larger tiles">Medium</button>
</div>
<button type="button" class="btn primary" id="btn-console-autofill" title="Fill wall with live iDRACs">Auto-fill live</button>
<button type="button" class="btn ghost" id="btn-console-clear">Clear wall</button>
<button type="button" class="btn ghost" id="btn-console-close">Close</button>
</div>
</div>
<div class="console-body">
<aside class="console-fleet">
<div class="console-fleet-tools">
<input type="search" id="console-search" placeholder="Search name, IP, tag, subnet…" autocomplete="off" />
<div class="console-subnet-chips" id="console-subnet-chips"></div>
<p class="hint">Only real iDRAC/BMC IPs · drag onto a slot or click <strong>+ Wall</strong> · Fullscreen = popup</p>
</div>
<div class="console-fleet-list" id="console-fleet-list"></div>
</aside>
<section class="console-stage density-medium" id="console-stage" aria-label="iDRAC console wall"></section>
</div>
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
</div>
<div class="modal hidden console-fs-modal" id="console-fs-modal" aria-hidden="true" role="dialog" aria-labelledby="console-fs-title">
<div class="modal-card console-fs-card">
<button type="button" class="modal-close" id="btn-console-fs-close" aria-label="Close">×</button>
<div class="console-fs-head">
<div>
<p class="modal-kicker">Fullscreen iDRAC</p>
<h2 id="console-fs-title">Console</h2>
<p class="modal-sub" id="console-fs-sub"></p>
<div id="console-fs-net"></div>
</div>
</div>
<div class="console-fs-frame-wrap">
<iframe id="console-fs-frame" title="iDRAC fullscreen" allow="fullscreen; clipboard-read; clipboard-write"></iframe>
</div>
</div>
</div>
<!-- Reports / compliance / export -->
<div class="drawer wide reports-drawer" id="reports-drawer" aria-hidden="true">
<div class="drawer-head">
@@ -288,15 +503,84 @@
<button type="button" class="btn ghost" id="btn-reports-close">Close</button>
</div>
<div class="chip-grid reports-tabs" id="reports-tabs">
<button type="button" class="chip active" data-tab="fleet">Fleet inventory</button>
<button type="button" class="chip" data-tab="firmware">Firmware / BIOS</button>
<button type="button" class="chip" data-tab="warranty">Warranty</button>
<button type="button" class="chip" data-tab="ome">OME reports</button>
<button type="button" class="chip" data-tab="brief">Customer brief</button>
<button type="button" class="chip active" data-tab="analytics" title="Dashboard KPIs — click tiles to drill into filtered views">Analytics</button>
<button type="button" class="chip" data-tab="hardware" title="DIMM slot map, disk/PCIe expansion, Dell best practices">Hardware / Expansion</button>
<button type="button" class="chip" data-tab="fleet" title="Full fleet table with Service Tags — filter & export">Fleet inventory</button>
<button type="button" class="chip" data-tab="firmware" title="BIOS/iDRAC vs Dell Online Baseline — outdated filter">Firmware / BIOS</button>
<button type="button" class="chip" data-tab="warranty" title="Warranty end dates — expired / expiring filters">Warranty</button>
<button type="button" class="chip" data-tab="ome" title="OME ReportDefs, catalogs, baselines, jobs">OME reports</button>
<button type="button" class="chip" data-tab="brief" title="Printable customer brief with Service Tag appendix">Customer brief</button>
</div>
<div class="reports-body" id="reports-mount">
<p class="hint">Select a report tab to load live OME data.</p>
</div>
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
</div>
<!-- ATC Team Chat -->
<div class="drawer wide team-drawer" id="team-drawer" aria-hidden="true">
<div class="drawer-head team-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<div>
<span class="team-title">Team chat</span>
<p class="team-sub">Jody · Laurens · Mo · Bart — messages &amp; files</p>
</div>
</div>
<div class="team-head-actions">
<div id="team-presence" class="team-presence"></div>
<button type="button" class="btn ghost" id="btn-team-switch">Switch identity</button>
<button type="button" class="btn ghost" id="btn-team-close">Close</button>
</div>
</div>
<div class="team-body">
<aside class="team-rail">
<div class="team-room-list" id="team-room-list"></div>
</aside>
<section class="team-chat-main" id="team-chat-main">
<header class="team-chat-head">
<div>
<h2 id="team-chat-title">ATC Team</h2>
<p class="modal-sub" id="team-chat-sub"></p>
</div>
<span id="team-typing" class="team-typing"></span>
</header>
<div class="team-messages" id="team-messages"></div>
<form class="team-composer" id="team-form">
<label class="team-attach btn ghost compact" title="Attach file">
📎
<input type="file" id="team-file" multiple hidden />
</label>
<input type="text" id="team-input" placeholder="Message the team… (drop files anywhere)" autocomplete="off" maxlength="8000" />
<button type="submit" class="btn primary">Send</button>
</form>
</section>
</div>
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
</div>
<div class="identity-gate hidden" id="identity-gate" aria-hidden="true" role="dialog" aria-labelledby="identity-gate-title">
<div class="identity-gate-card">
<img src="/dell.png" alt="Dell" width="48" height="48" />
<p class="modal-kicker">ATC Cockpit</p>
<h2 id="identity-gate-title">Who are you?</h2>
<p class="modal-sub">Select yourself to continue — Team chat, Ops desk, and handoffs use this identity. Guests welcome.</p>
<div class="identity-gate-grid" id="identity-gate-grid"></div>
<div class="identity-avatar-row" id="identity-avatar-row">
<div class="id-avatar-preview" id="identity-avatar-preview" aria-hidden="true">
<img id="identity-avatar-img" alt="" hidden />
<span id="identity-avatar-initials">?</span>
</div>
<div class="identity-avatar-copy">
<strong id="identity-avatar-label">Profile photo</strong>
<p class="modal-sub">JPG, PNG, WebP or GIF · max 5 MB</p>
</div>
<label class="btn ghost compact" id="identity-avatar-btn">
Upload photo
<input type="file" id="identity-avatar-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden />
</label>
</div>
</div>
</div>
<div class="scrim" id="scrim"></div>
@@ -310,7 +594,7 @@
<div>
<p class="modal-kicker">ATC Ops desk</p>
<h2 id="ticket-modal-title">Create ticket</h2>
<p class="modal-sub">Professional handoff between Jody van Dongen and Laurens Rammers</p>
<p class="modal-sub">Handoff between Datacenter Engineers (Jody / Laurens) and Data FDEs (Mo / Bart)</p>
</div>
</div>
<form id="ticket-form" class="ticket-form">
@@ -328,8 +612,17 @@
</label>
<label>Assign to
<select id="ticket-assignee">
<option value="laurens">Laurens Rammers</option>
<option value="jody">Jody van Dongen</option>
<optgroup label="Datacenter Engineers">
<option value="jody">Jody van Dongen</option>
<option value="laurens">Laurens Rammers</option>
</optgroup>
<optgroup label="Data FDE team">
<option value="mo">Mohamed El Kadi</option>
<option value="bart">Bart Sjerps</option>
</optgroup>
<optgroup label="Other">
<option value="guest">Guest</option>
</optgroup>
</select>
</label>
</div>
@@ -345,6 +638,23 @@
</div>
<div class="modal hidden" id="an-context-modal" aria-hidden="true" role="dialog" aria-labelledby="an-ctx-title">
<div class="modal-card an-ctx-card">
<button type="button" class="modal-close" id="an-ctx-close" aria-label="Close">×</button>
<div class="modal-brand">
<img src="/dell.png" alt="Dell" width="32" height="32" />
<div>
<p class="modal-kicker">Analytics context</p>
<h2 id="an-ctx-title">Context</h2>
<p class="modal-sub" id="an-ctx-sub"></p>
</div>
</div>
<div class="an-ctx-pills" id="an-ctx-pills"></div>
<div class="an-ctx-list" id="an-ctx-list"></div>
<div class="an-ctx-actions" id="an-ctx-actions"></div>
</div>
</div>
<div class="modal hidden" id="kpi-modal" aria-hidden="true" role="dialog" aria-labelledby="kpi-title">
<div class="modal-card kpi-card">
<button type="button" class="modal-close" id="btn-kpi-close" aria-label="Close">×</button>
@@ -421,12 +731,35 @@
<div class="modal-badges" id="connect-badges"></div>
<div class="modal-context" id="connect-context"></div>
<div class="connect-grid" id="connect-grid"></div>
<p class="modal-foot">iDRAC opens in a new tab · SSH opens an in-browser terminal — enter your own username</p>
<p class="modal-foot">iDRAC opens in a new tab · SSH = in-browser terminal · RDP = popout for Windows (local client)</p>
</div>
</div>
<div class="modal hidden" id="idrac-console-modal" aria-hidden="true" role="dialog" aria-labelledby="idrac-console-title">
<div class="modal-card idrac-console-card">
<button type="button" class="modal-close" id="btn-idrac-console-close" aria-label="Close">×</button>
<div class="modal-brand">
<img src="/dell.png" alt="Dell" width="36" height="36" />
<div>
<p class="modal-kicker">iDRAC console</p>
<h2 id="idrac-console-title">Console</h2>
<p class="modal-sub" id="idrac-console-sub"></p>
</div>
</div>
<div class="idrac-console-toolbar">
<button type="button" class="btn primary" id="btn-idrac-popout">Open popout</button>
<button type="button" class="btn ghost" id="btn-idrac-web">iDRAC Web</button>
<button type="button" class="btn ghost" id="btn-idrac-reload">Reload embed</button>
</div>
<p class="hint" id="idrac-console-note"></p>
<div class="idrac-console-frame-wrap">
<iframe id="idrac-console-frame" title="iDRAC console" allow="fullscreen; clipboard-read; clipboard-write" referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
</div>
</div>
<div class="modal hidden" id="ssh-modal" aria-hidden="true" role="dialog" aria-labelledby="ssh-device">
<div class="modal-card ssh-card">
<button type="button" class="modal-close" id="btn-ssh-close" aria-label="Close">×</button>
<div class="modal-brand">
@@ -466,9 +799,14 @@
<script src="/vendor/xterm/xterm.min.js?v=home1"></script>
<script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script>
<script src="/app.js?v=reports1"></script>
<script src="/ops.js?v=reports1"></script>
<script src="/ssh.js?v=reports1"></script>
<script src="/reports.js?v=reports1"></script>
<script src="/app.js?v=power5"></script>
<script src="/ops.js?v=opsfde6"></script>
<script src="/ssh.js?v=fabric4"></script>
<script src="/rdp.js?v=fabric4"></script>
<script src="/reports.js?v=anctx1"></script>
<script src="/network.js?v=vlanall1"></script>
<script src="/console.js?v=console2"></script>
<script src="/team.js?v=team6"></script>
<script src="/present.js?v=present20"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#1e293b"/>
<rect x="10" y="14" width="44" height="36" rx="4" fill="#f8fafc"/>
<rect x="10" y="14" width="44" height="8" rx="4" fill="#cbd5e1"/>
<circle cx="16" cy="18" r="1.8" fill="#ef4444"/>
<circle cx="22" cy="18" r="1.8" fill="#f59e0b"/>
<circle cx="28" cy="18" r="1.8" fill="#22c55e"/>
<rect x="16" y="28" width="20" height="3" rx="1.5" fill="#94a3b8"/>
<rect x="16" y="34" width="28" height="3" rx="1.5" fill="#cbd5e1"/>
<rect x="16" y="40" width="16" height="3" rx="1.5" fill="#cbd5e1"/>
</svg>

After

Width:  |  Height:  |  Size: 636 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#2496ED"/>
<g fill="#fff">
<rect x="14" y="28" width="7" height="6" rx="1"/>
<rect x="23" y="28" width="7" height="6" rx="1"/>
<rect x="32" y="28" width="7" height="6" rx="1"/>
<rect x="23" y="20" width="7" height="6" rx="1"/>
<rect x="32" y="20" width="7" height="6" rx="1"/>
<rect x="32" y="12" width="7" height="6" rx="1"/>
<rect x="41" y="28" width="7" height="6" rx="1"/>
</g>
<path d="M12 38c2 6 8 10 20 10 14 0 22-5 24-12H12z" fill="#fff" opacity=".95"/>
<circle cx="48" cy="18" r="3" fill="#fff" opacity=".85"/>
</svg>

After

Width:  |  Height:  |  Size: 681 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#009688"/>
<path d="M34 10L18 36h12l-2 18 18-28H34l2-16z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 199 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#0076CE"/>
<text x="32" y="38" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#fff">OME</text>
</svg>

After

Width:  |  Height:  |  Size: 266 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#0f172a"/>
<circle cx="32" cy="28" r="14" fill="#38bdf8"/>
<circle cx="26" cy="26" r="2.5" fill="#0f172a"/>
<circle cx="38" cy="26" r="2.5" fill="#0f172a"/>
<path d="M24 34c2.5 3 5.5 4.5 8 4.5S37.5 37 40 34" stroke="#0f172a" stroke-width="2.5" stroke-linecap="round"/>
<path d="M18 48c4-6 10-9 14-9s10 3 14 9" fill="#7dd3fc"/>
</svg>

After

Width:  |  Height:  |  Size: 462 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#0f766e"/>
<ellipse cx="32" cy="20" rx="16" ry="6" fill="#99f6e4"/>
<path d="M16 20v10c0 3.3 7.2 6 16 6s16-2.7 16-6V20" fill="#2dd4bf"/>
<path d="M16 30v10c0 3.3 7.2 6 16 6s16-2.7 16-6V30" fill="#14b8a6"/>
<ellipse cx="32" cy="40" rx="16" ry="6" fill="#99f6e4" opacity=".5"/>
</svg>

After

Width:  |  Height:  |  Size: 409 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#7c3aed"/>
<rect x="14" y="16" width="36" height="32" rx="4" fill="#ede9fe"/>
<rect x="20" y="22" width="10" height="8" rx="1.5" fill="#7c3aed"/>
<rect x="34" y="22" width="10" height="8" rx="1.5" fill="#a78bfa"/>
<rect x="20" y="34" width="24" height="6" rx="1.5" fill="#7c3aed"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

+1809
View File
File diff suppressed because it is too large Load Diff
+240 -31
View File
@@ -39,9 +39,17 @@
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: "Datacenter Engineer" },
{ id: "laurens", name: "Laurens Rammers", team: "admin", role: "Datacenter Engineer" },
{ id: "mo", name: "Mohamed El Kadi", team: "fde", role: "Data Forward Deployed Engineer · \"Data Plumbers\" 😉" },
{ id: "bart", name: "Bart Sjerps", team: "fde", role: "Data Forward Deployed Engineer · \"Data Plumbers\" 😉" },
{ id: "guest", name: "Guest", team: "guest", role: "Visitor" },
];
function selectedChatModel() {
const sel = $("#chat-model");
@@ -78,7 +86,47 @@
}
function adminName(id) {
return id === "laurens" ? "Laurens Rammers" : "Jody van Dongen";
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";
if (me === "guest") return "jody";
return "jody";
}
function assigneeOptionsHtml(selected) {
const admins = opsUsers.filter((u) => u.team === "admin");
const fde = opsUsers.filter((u) => u.team === "fde");
const guests = opsUsers.filter((u) => u.team === "guest");
const opt = (u) =>
`<option value="${u.id}" ${u.id === selected ? "selected" : ""}>${escape(u.name)}</option>`;
return `<optgroup label="Datacenter Engineers">${admins.map(opt).join("")}</optgroup>
<optgroup label="FDE team">${fde.map(opt).join("")}</optgroup>
${guests.length ? `<optgroup label="Other">${guests.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) {
@@ -92,14 +140,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 +181,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 +223,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 +232,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,37 +258,95 @@
}
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();
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;
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();
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 = filtered
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">${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)} · <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, "&amp;")
@@ -234,6 +368,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">
@@ -253,8 +388,7 @@
<option value="closed">Closed</option>
</select>
<select id="ops-assign">
<option value="jody">Assign Jody</option>
<option value="laurens">Assign Laurens</option>
${assigneeOptionsHtml(tkt.assignee || "jody")}
</select>
<select id="ops-priority">
<option value="low">Low</option>
@@ -268,6 +402,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(
@@ -278,7 +416,7 @@
.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>
<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>`;
@@ -286,6 +424,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,
@@ -324,11 +470,13 @@
function openTicketModal(draft = null) {
ticketDraft = draft;
const me = actor();
const other = me === "jody" ? "laurens" : "jody";
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 = draft?.assignee || other;
$("#ticket-assignee").value = other;
const modal = $("#ticket-modal");
const scrim = $("#scrim");
modal?.classList.remove("hidden");
@@ -712,7 +860,7 @@
((node && node.ip) || (a && a.ip) || "no-ip") +
"): " +
it.message +
". Suggest next steps for ATC admins Jody and Laurens."
". Suggest next steps for Datacenter Engineers Jody and Laurens."
);
});
$("#triage-inspect")?.addEventListener("click", () => {
@@ -723,7 +871,7 @@
async function createTicketFromTriage(it, node) {
const me = actor();
const other = me === "jody" ? "laurens" : "jody";
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 = [
@@ -756,6 +904,7 @@
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;
@@ -786,7 +935,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."
);
}
});
@@ -800,11 +949,71 @@
$("#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-open-team")?.addEventListener("click", () => {
window.cockpitTeam?.open?.();
});
$("#btn-ops-refresh")?.addEventListener("click", () => loadTickets().catch((e) => alert(e.message)));
$("#ops-filter")?.addEventListener("change", () => loadTickets());
$("#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 === "team") {
window.cockpitTeam?.open?.();
return;
}
if (q === "to-admin") {
openTicketModal({
title: "Handoff to Datacenter Engineer",
body: "Context for Jody / Laurens (storage · servers · network · rack & stack · installs):\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 FDEs · \"Data Plumbers\" 😉 · 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) => {
@@ -855,7 +1064,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();
+1303
View File
File diff suppressed because it is too large Load Diff
+353
View File
@@ -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>
+53
View File
@@ -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,
};
})();
+1425 -70
View File
File diff suppressed because it is too large Load Diff
+7594 -68
View File
File diff suppressed because it is too large Load Diff
+713
View File
@@ -0,0 +1,713 @@
/**
* ATC Team Chat identity gate, team room + DMs, file share, profile photos.
* Soft identity via localStorage.atc_actor (shared with Ops desk).
*/
(() => {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const ACTOR_KEY = "atc_actor";
const USERS = [
{ id: "jody", name: "Jody van Dongen", short: "Jody", team: "admin", role: "Datacenter Engineer", focus: "Storage · servers · network · rack & stack · installs" },
{ id: "laurens", name: "Laurens Rammers", short: "Laurens", team: "admin", role: "Datacenter Engineer", focus: "Storage · servers · network · rack & stack · installs" },
{ id: "mo", name: "Mohamed El Kadi", short: "Mo", team: "fde", role: "Data FDE", focus: "\"Data Plumbers\" 😉 · OME Cockpit · OpenManage AI" },
{ id: "bart", name: "Bart Sjerps", short: "Bart", team: "fde", role: "Data FDE", focus: "\"Data Plumbers\" 😉 · FDE cluster · AI workloads" },
{ id: "guest", name: "Guest", short: "Guest", team: "guest", role: "Visitor", focus: "Temporary access — select yourself next time" },
];
const state = {
me: null,
rooms: [],
presence: [],
activeRoomId: null,
messages: [],
ws: null,
typing: null,
avatarBust: {},
unread: {},
notifyAsked: false,
origTitle: document.title,
};
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function shortName(u) {
if (!u) return "?";
return u.short || (u.name || u.id || "?").split(" ")[0];
}
function avatarUrl(userId, fallbackUrl) {
if (!userId) return "";
const base = fallbackUrl || `/api/team/avatar/${userId}`;
const bust = state.avatarBust[userId];
if (!bust) return base;
return base.includes("?") ? `${base}&t=${bust}` : `${base}?t=${bust}`;
}
function userById(id) {
return USERS.find((u) => u.id === id) || state.presence.find((u) => u.id === id) || null;
}
function getActor() {
const v = localStorage.getItem(ACTOR_KEY);
return USERS.some((u) => u.id === v) ? v : null;
}
function setActor(id) {
if (!USERS.some((u) => u.id === id)) return;
localStorage.setItem(ACTOR_KEY, id);
state.me = id;
const sel = $("#ops-actor");
if (sel && sel.value !== id) sel.value = id;
updateIdentityBadge();
refreshAvatarPreview();
ensureNotifyPermission();
connectWs(true);
}
function unreadTotal() {
return Object.values(state.unread).reduce((a, b) => a + (b || 0), 0);
}
function updateUnreadBadge() {
const el = $("#team-unread");
const combo = document.querySelector(".ops-team-combo");
const n = unreadTotal();
if (el) {
if (n > 0) {
el.classList.remove("hidden");
el.textContent = n > 99 ? "99+" : String(n);
} else {
el.classList.add("hidden");
el.textContent = "0";
}
}
if (combo) {
combo.classList.toggle("has-unread", n > 0);
if (n > 0) {
combo.classList.remove("pulse");
void combo.offsetWidth;
combo.classList.add("pulse");
}
}
document.title = n > 0 ? `(${n}) Team · ${state.origTitle}` : state.origTitle;
}
function clearUnread(roomId) {
if (roomId == null) return;
delete state.unread[String(roomId)];
updateUnreadBadge();
}
function bumpUnread(roomId) {
const key = String(roomId);
state.unread[key] = (state.unread[key] || 0) + 1;
updateUnreadBadge();
}
function ensureNotifyPermission() {
if (!("Notification" in window) || state.notifyAsked) return;
state.notifyAsked = true;
if (Notification.permission === "default") {
Notification.requestPermission().catch(() => {});
}
}
function notifyNewMessage(msg) {
if (!msg || msg.author_id === state.me) return;
const who = msg.author_short || shortName({ name: msg.author_name, id: msg.author_id });
const body = (msg.body || (msg.file ? "Shared a file" : "New message")).slice(0, 120);
const room = state.rooms.find((r) => Number(r.id) === Number(msg.room_id));
const roomLabel =
room?.kind === "team"
? "ATC Team"
: room?.peer_short || shortName(userById(room?.peer_id)) || "Team chat";
// soft soundless flash via badge already; browser notification when allowed
if ("Notification" in window && Notification.permission === "granted" && document.hidden) {
try {
const n = new Notification(`${who} · ${roomLabel}`, {
body,
tag: `team-${msg.room_id}`,
renotify: true,
});
n.onclick = () => {
window.focus();
openTeam();
selectRoom(Number(msg.room_id)).catch(() => {});
n.close();
};
} catch (_) {}
}
}
function onIncomingMessage(msg) {
if (!msg) return;
const teamOpen = $("#team-drawer")?.classList.contains("open");
const viewing = teamOpen && Number(msg.room_id) === Number(state.activeRoomId);
if (viewing) {
if (!state.messages.some((m) => m.id === msg.id)) {
state.messages.push(msg);
renderMessages(true);
}
clearUnread(msg.room_id);
} else if (msg.author_id !== state.me) {
bumpUnread(msg.room_id);
notifyNewMessage(msg);
}
const room = state.rooms.find((r) => Number(r.id) === Number(msg.room_id));
if (room) room.last_message = msg;
renderRoomList();
}
function updateIdentityBadge() {
const badge = $("#team-identity-badge");
const btn = $("#btn-team");
const u = userById(state.me);
if (!badge) return;
if (!u) {
badge.classList.add("hidden");
badge.innerHTML = "";
if (btn) btn.title = "ATC team chat · files";
return;
}
badge.classList.remove("hidden");
const url = avatarUrl(u.id, u.avatar_url);
badge.innerHTML = `<span class="tib-av"><img src="${esc(url)}" alt="" onerror="this.remove()"/></span><span class="tib-dot ${esc(u.team)}"></span><strong>${esc(shortName(u))}</strong>`;
badge.title = `Acting as ${u.name} — click to switch`;
if (btn) btn.title = `Team chat · as ${u.name}`;
}
function refreshAvatarPreview() {
const u = userById(state.me || getActor());
const img = $("#identity-avatar-img");
const initials = $("#identity-avatar-initials");
const label = $("#identity-avatar-label");
const row = $("#identity-avatar-row");
if (!row) return;
if (!u) {
row.classList.add("dim");
if (label) label.textContent = "Profile photo";
if (img) {
img.hidden = true;
img.removeAttribute("src");
}
if (initials) {
initials.hidden = false;
initials.textContent = "?";
}
return;
}
row.classList.remove("dim");
if (label) label.textContent = `${u.name} · profile photo`;
const url = avatarUrl(u.id, u.avatar_url);
if (img) {
img.hidden = false;
img.onerror = () => {
img.hidden = true;
if (initials) {
initials.hidden = false;
initials.textContent = (u.name || "?").slice(0, 1).toUpperCase();
}
};
img.onload = () => {
if (initials) initials.hidden = true;
};
img.src = url;
}
if (initials) {
initials.hidden = false;
initials.textContent = (u.name || "?").slice(0, 1).toUpperCase();
}
}
function needsIdentityGate() {
return !getActor();
}
function openIdentityGate(force = false, { mustPick = false } = {}) {
const gate = $("#identity-gate");
if (!gate) return;
if (!force && !needsIdentityGate() && !mustPick) {
gate.classList.add("hidden");
gate.setAttribute("aria-hidden", "true");
return;
}
state._identityMustPick = mustPick || needsIdentityGate();
const grid = $("#identity-gate-grid");
if (grid) {
grid.innerHTML = USERS.map((u) => {
const url = avatarUrl(u.id, u.avatar_url);
const selected = (state.me || getActor()) === u.id ? "selected" : "";
const guest = u.team === "guest" ? "guest" : "";
return `<button type="button" class="id-card team-${esc(u.team)} ${selected} ${guest}" data-actor="${esc(u.id)}">
<span class="id-av"><img src="${esc(url)}" alt="" onerror="this.parentElement.classList.add('fallback')"/><span class="id-av-fallback">${esc((u.short || u.name || "?").slice(0, 1))}</span></span>
<span class="id-kicker">${u.team === "admin" ? "DC Engineer" : u.team === "guest" ? "Visitor" : "Data FDE · \"Data Plumbers\" 😉"}</span>
<strong>${esc(u.name)}</strong>
<span class="id-role">${esc(u.role)}</span>
<span class="id-focus">${esc(u.focus)}</span>
</button>`;
}).join("");
}
refreshAvatarPreview();
gate.classList.remove("hidden");
gate.setAttribute("aria-hidden", "false");
document.body.classList.add("identity-locked");
}
function closeIdentityGate() {
if (state._identityMustPick && needsIdentityGate()) return;
const gate = $("#identity-gate");
gate?.classList.add("hidden");
gate?.setAttribute("aria-hidden", "true");
state._identityMustPick = false;
document.body.classList.remove("identity-locked");
}
function confirmIdentity(id) {
setActor(id);
state._identityMustPick = false;
refreshAvatarPreview();
closeIdentityGate();
if (state._openTeamAfterId) {
state._openTeamAfterId = false;
openTeam();
}
}
async function uploadAvatar(file) {
const me = state.me || getActor();
if (!me || !file) return;
const fd = new FormData();
fd.append("user_id", me);
fd.append("file", file, file.name);
const res = await fetch("/api/team/avatar", { method: "POST", body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || res.statusText || "Avatar upload failed");
return;
}
state.avatarBust[me] = Date.now();
if (data.avatar_url) {
const u = userById(me);
if (u) u.avatar_url = data.avatar_url;
}
updateIdentityBadge();
refreshAvatarPreview();
openIdentityGate(true);
renderPresence();
renderMessages(false);
}
function closeOtherDrawers() {
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#network-drawer", "#console-drawer"].forEach((id) => {
const el = $(id);
if (el) {
el.classList.remove("open");
el.setAttribute("aria-hidden", "true");
}
});
}
function openTeam() {
if (needsIdentityGate()) {
openIdentityGate(true);
return;
}
state.me = getActor();
const drawer = $("#team-drawer");
const scrim = $("#scrim");
if (!drawer) return;
closeOtherDrawers();
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
if (scrim) {
scrim.classList.add("open");
scrim.dataset.mode = "team-drawer";
}
connectWs();
refreshRooms().then(() => {
const team = state.rooms.find((r) => r.kind === "team");
if (team) selectRoom(team.id);
else if (state.rooms[0]) selectRoom(state.rooms[0].id);
});
}
function closeTeam() {
const drawer = $("#team-drawer");
const scrim = $("#scrim");
drawer?.classList.remove("open");
drawer?.setAttribute("aria-hidden", "true");
if (scrim?.dataset.mode === "team-drawer") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function connectWs(force = false) {
if (!state.me) return;
if (!force && state.ws && state.ws.readyState <= 1) return;
if (force && state.ws) {
try {
state.ws.close();
} catch (_) {}
state.ws = null;
}
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws/team-chat?user=${encodeURIComponent(state.me)}`);
state.ws = ws;
ws.addEventListener("message", (ev) => {
let data;
try {
data = JSON.parse(ev.data);
} catch {
return;
}
if (data.type === "presence" || data.type === "hello" || data.type === "pong") {
if (data.presence) {
state.presence = data.presence;
renderRoomList();
renderPresence();
}
} else if (data.type === "avatar" && data.user_id) {
state.avatarBust[data.user_id] = Date.now();
const u = userById(data.user_id);
if (u && data.avatar_url) u.avatar_url = data.avatar_url;
updateIdentityBadge();
refreshAvatarPreview();
renderPresence();
renderMessages(false);
renderRoomList();
} else if (data.type === "message" && data.message) {
onIncomingMessage(data.message);
} else if (data.type === "typing") {
if (Number(data.room_id) === Number(state.activeRoomId) && data.user_id !== state.me) {
const el = $("#team-typing");
if (el) {
el.textContent = `${data.name || "Someone"} is typing…`;
clearTimeout(state.typing);
state.typing = setTimeout(() => {
el.textContent = "";
}, 2500);
}
}
}
});
ws.addEventListener("close", () => {
state.ws = null;
setTimeout(() => {
if (state.me) connectWs();
}, 2500);
});
}
// heartbeat once
if (!window.__teamChatHeartbeat) {
window.__teamChatHeartbeat = setInterval(() => {
if (state.ws?.readyState === 1) state.ws.send(JSON.stringify({ type: "ping" }));
}, 25000);
}
async function refreshRooms() {
const res = await fetch(`/api/team/rooms?me=${encodeURIComponent(state.me)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
state.rooms = data.rooms || [];
state.presence = data.presence || [];
if (data.users?.length) {
data.users.forEach((nu) => {
const local = USERS.find((u) => u.id === nu.id);
if (local) {
if (nu.name) local.name = nu.name;
if (nu.short) local.short = nu.short;
if (nu.avatar_url) local.avatar_url = nu.avatar_url;
}
});
updateIdentityBadge();
}
renderRoomList();
renderPresence();
}
function renderPresence() {
const host = $("#team-presence");
if (!host) return;
host.innerHTML = (state.presence || [])
.map((u) => {
const on = u.online ? "on" : "off";
const url = avatarUrl(u.id, u.avatar_url);
return `<span class="tp-pill ${on}" title="${esc(u.name)}"><img class="tp-av" src="${esc(url)}" alt="" onerror="this.remove()"/><i></i>${esc(shortName(u))}</span>`;
})
.join("");
}
function renderRoomList() {
const host = $("#team-room-list");
if (!host) return;
const team = state.rooms.filter((r) => r.kind === "team");
const dms = state.rooms.filter((r) => r.kind === "dm");
const item = (r) => {
const active = Number(r.id) === Number(state.activeRoomId) ? "active" : "";
const title =
r.kind === "team"
? "ATC Team"
: r.peer_short || shortName(userById(r.peer_id)) || r.peer_name || r.title;
const preview = r.last_message
? `${r.last_message.author_short || shortName({ name: r.last_message.author_name })}: ${r.last_message.body || "file"}`
: "No messages yet";
const peer = r.peer_id ? state.presence.find((p) => p.id === r.peer_id) : null;
const live = r.kind === "team" ? "" : peer?.online ? "live" : "";
const av =
r.kind === "dm" && r.peer_id
? `<img class="tr-av" src="${esc(avatarUrl(r.peer_id, r.peer_avatar))}" alt="" onerror="this.classList.add('hidden')"/>`
: "";
const unread = state.unread[String(r.id)] || 0;
const unreadHtml = unread
? `<span class="tr-unread">${unread > 99 ? "99+" : unread}</span>`
: "";
return `<button type="button" class="team-room ${active} ${live} ${unread ? "has-unread" : ""}" data-room-id="${r.id}">
${av}
<span class="tr-text"><span class="tr-title">${esc(title)}${unreadHtml}</span>
<span class="tr-preview">${esc(preview.slice(0, 64))}</span></span>
</button>`;
};
host.innerHTML = `
<div class="team-room-group"><span>Team</span></div>
${team.map(item).join("")}
<div class="team-room-group"><span>Direct</span></div>
${dms.map(item).join("") || `<p class="hint">No DMs yet</p>`}`;
}
async function selectRoom(roomId) {
state.activeRoomId = roomId;
clearUnread(roomId);
renderRoomList();
const res = await fetch(`/api/team/rooms/${roomId}/messages?limit=300`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
state.messages = data.messages || [];
const title = $("#team-chat-title");
const sub = $("#team-chat-sub");
const room = data.room || state.rooms.find((r) => Number(r.id) === Number(roomId));
if (title) {
title.textContent =
room?.kind === "team"
? "ATC Team"
: room?.peer_short || shortName(userById(room?.peer_id)) || room?.title || "Chat";
}
if (sub) {
if (room?.kind === "dm") {
const peer = room.peer_id || (room.key || "").split(":").filter((x) => x !== "dm" && x !== state.me)[0];
const p = userById(peer);
sub.textContent = p ? `${p.role} · ${p.focus || ""}` : "Direct message";
} else {
sub.textContent = "Jody · Laurens · Mo · Bart — shared room";
}
}
renderMessages(false);
}
function fmtTime(ts) {
if (!ts) return "";
try {
return new Date(ts * 1000).toLocaleString();
} catch {
return "";
}
}
function formatBytes(n) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function renderMessages(stickBottom) {
const host = $("#team-messages");
if (!host) return;
const nearBottom = host.scrollHeight - host.scrollTop - host.clientHeight < 80;
host.innerHTML = state.messages
.map((m) => {
const mine = m.author_id === state.me;
const name = m.author_short || shortName({ name: m.author_name, id: m.author_id });
const url = avatarUrl(m.author_id, m.author_avatar);
const file = m.file
? `<a class="team-file" href="${esc(m.file.url)}" target="_blank" rel="noopener">
<span class="tf-name">${esc(m.file.filename)}</span>
<span class="tf-meta">${esc(formatBytes(m.file.size || 0))} · ${esc(m.file.mime || "file")}</span>
</a>`
: "";
return `<div class="team-bubble ${mine ? "mine" : "theirs"} team-${esc(m.author_team || "")}">
<img class="tb-av" src="${esc(url)}" alt="" onerror="this.classList.add('hidden')"/>
<div class="tb-content">
<div class="tb-meta"><strong>${esc(name)}</strong><span>${esc(fmtTime(m.created_at))}</span></div>
${m.body ? `<div class="tb-body">${esc(m.body)}</div>` : ""}
${file}
</div>
</div>`;
})
.join("");
if (stickBottom || nearBottom) host.scrollTop = host.scrollHeight;
}
async function sendMessage() {
const input = $("#team-input");
const body = (input?.value || "").trim();
if (!body || !state.activeRoomId) return;
input.value = "";
const res = await fetch(`/api/team/rooms/${state.activeRoomId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ author_id: state.me, body }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || res.statusText);
return;
}
if (data.message && !state.messages.some((m) => m.id === data.message.id)) {
state.messages.push(data.message);
renderMessages(true);
}
}
async function uploadFiles(fileList) {
if (!state.activeRoomId || !fileList?.length) return;
for (const file of fileList) {
const fd = new FormData();
fd.append("uploader_id", state.me);
fd.append("body", "");
fd.append("file", file, file.name);
const res = await fetch(`/api/team/rooms/${state.activeRoomId}/files`, { method: "POST", body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || `Upload failed: ${file.name}`);
continue;
}
if (data.message && !state.messages.some((m) => m.id === data.message.id)) {
state.messages.push(data.message);
renderMessages(true);
}
}
}
function notifyTyping() {
if (state.ws?.readyState === 1 && state.activeRoomId) {
state.ws.send(JSON.stringify({ type: "typing", room_id: state.activeRoomId }));
}
}
function bind() {
state.me = getActor();
updateIdentityBadge();
updateUnreadBadge();
// Always first popup on UI open so everyone can pick themselves immediately
openIdentityGate(true, { mustPick: needsIdentityGate() });
if (state.me) {
ensureNotifyPermission();
connectWs();
refreshRooms().catch(() => {});
}
$("#identity-gate-grid")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-actor]");
if (!btn) return;
confirmIdentity(btn.dataset.actor);
});
$("#identity-avatar-file")?.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (!getActor()) {
alert("Pick who you are first, then upload a photo.");
e.target.value = "";
return;
}
uploadAvatar(file).catch((err) => alert(err.message));
e.target.value = "";
});
$("#team-identity-badge")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
openIdentityGate(true);
});
$("#btn-team")?.addEventListener("click", (e) => {
if (e.target.closest("#team-identity-badge")) return;
if (needsIdentityGate()) {
state._openTeamAfterId = true;
openIdentityGate(true, { mustPick: true });
return;
}
openTeam();
});
$("#btn-team-close")?.addEventListener("click", closeTeam);
$("#btn-team-switch")?.addEventListener("click", () => openIdentityGate(true));
$("#scrim")?.addEventListener("click", () => {
if ($("#scrim")?.dataset.mode === "team-drawer") closeTeam();
});
$("#team-room-list")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-room-id]");
if (!btn) return;
selectRoom(Number(btn.dataset.roomId)).catch((err) => alert(err.message));
});
$("#team-form")?.addEventListener("submit", (e) => {
e.preventDefault();
sendMessage().catch((err) => alert(err.message));
});
$("#team-input")?.addEventListener("input", () => notifyTyping());
$("#team-file")?.addEventListener("change", (e) => {
const files = e.target.files;
uploadFiles(files).catch((err) => alert(err.message));
e.target.value = "";
});
const drop = $("#team-chat-main");
drop?.addEventListener("dragover", (e) => {
e.preventDefault();
drop.classList.add("drag-files");
});
drop?.addEventListener("dragleave", () => drop.classList.remove("drag-files"));
drop?.addEventListener("drop", (e) => {
e.preventDefault();
drop.classList.remove("drag-files");
if (e.dataTransfer?.files?.length) uploadFiles(e.dataTransfer.files).catch((err) => alert(err.message));
});
$("#ops-actor")?.addEventListener("change", (e) => {
if (e.target.value) setActor(e.target.value);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (!$("#identity-gate")?.classList.contains("hidden")) {
if (!state._identityMustPick && !needsIdentityGate()) closeIdentityGate();
} else if ($("#team-drawer")?.classList.contains("open")) {
closeTeam();
}
}
});
$("#identity-gate")?.addEventListener("click", (e) => {
if (e.target.id === "identity-gate" && !state._identityMustPick && !needsIdentityGate()) {
closeIdentityGate();
}
});
}
bind();
window.cockpitTeam = {
open: openTeam,
close: closeTeam,
setActor,
getActor,
openIdentityGate,
uploadAvatar,
};
})();