Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4714c70d1 | |||
| 03e207b46d | |||
| 795707e47b | |||
| 64208eb344 | |||
| ae2a7163ef | |||
| 01837b19f2 | |||
| 126e34093e | |||
| cd347e6e4c | |||
| 1e87f22006 | |||
| 91222faffe |
@@ -1,6 +1,8 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
websockets==14.1
|
||||||
|
python-multipart==0.0.20
|
||||||
pydantic==2.10.4
|
pydantic==2.10.4
|
||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
asyncssh==2.18.0
|
asyncssh==2.18.0
|
||||||
|
|||||||
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
@@ -7,9 +7,19 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="/styles.css?v=reports1" />
|
<link rel="stylesheet" href="/styles.css?v=team6" />
|
||||||
<link rel="icon" href="/dell.png" type="image/png" />
|
<link rel="icon" href="/dell.png" type="image/png" />
|
||||||
<link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" />
|
<link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem("cockpit_theme") || "light";
|
||||||
|
document.documentElement.setAttribute("data-theme", t === "dark" ? "dark" : "light");
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.setAttribute("data-theme", "light");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="notify-stack" aria-live="polite" aria-atomic="false"></div>
|
<div id="notify-stack" aria-live="polite" aria-atomic="false"></div>
|
||||||
@@ -28,9 +38,30 @@
|
|||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<button type="button" class="btn ghost" id="btn-reset-view" title="Fit fleet in view">Reset view</button>
|
<button type="button" class="btn ghost" id="btn-reset-view" title="Fit fleet in view">Reset view</button>
|
||||||
<button type="button" class="btn ghost" id="btn-ome-console" title="Open OME console">OME console</button>
|
<button type="button" class="btn ghost" id="btn-ome-console" title="Open OME console">OME console</button>
|
||||||
|
<button type="button" class="btn ghost" id="btn-network" title="Fabric map & ATC racks">Network</button>
|
||||||
|
<button type="button" class="btn console-btn" id="btn-console" title="Live iDRAC console wall">Console</button>
|
||||||
<button type="button" class="btn ghost" id="btn-reports" title="Fleet reports & Dell compliance">Reports</button>
|
<button type="button" class="btn ghost" id="btn-reports" title="Fleet reports & Dell compliance">Reports</button>
|
||||||
<button type="button" class="btn ghost" id="btn-ops" title="ATC admin tickets">Ops desk</button>
|
<div class="ops-team-combo" title="Ops desk & team chat">
|
||||||
<button type="button" class="btn primary" id="btn-chat">Cockpit chat</button>
|
<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>
|
</div>
|
||||||
</header>
|
</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 & rings</span></button>
|
<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 & 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="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="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="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>
|
<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>
|
</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-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-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-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">
|
<label class="range">
|
||||||
<span>Min watts <em id="min-w-label">0</em></span>
|
<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" />
|
<input type="range" id="f-min-watts" min="0" max="800" step="10" value="0" />
|
||||||
@@ -96,18 +167,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rail-block open" data-module="gpu">
|
|
||||||
<button type="button" class="rail-toggle" aria-expanded="true">
|
|
||||||
<span>V100 GPU matrix</span>
|
|
||||||
<span class="chev" aria-hidden="true"></span>
|
|
||||||
</button>
|
|
||||||
<div class="rail-panel">
|
|
||||||
<p class="hint">Live atc-gpu-prod · llama3-70b-gptq</p>
|
|
||||||
<div id="gpu-summary" class="gpu-summary">Waiting for GPU telemetry…</div>
|
|
||||||
<div id="gpu-matrix" class="gpu-matrix"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rail-block open" data-module="live">
|
<section class="rail-block open" data-module="live">
|
||||||
<button type="button" class="rail-toggle" aria-expanded="true">
|
<button type="button" class="rail-toggle" aria-expanded="true">
|
||||||
<span>Realtime context</span>
|
<span>Realtime context</span>
|
||||||
@@ -189,20 +248,29 @@
|
|||||||
<div class="inspector-body hidden" id="inspector-body"></div>
|
<div class="inspector-body hidden" id="inspector-body"></div>
|
||||||
</aside>
|
</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> & <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>
|
</div>
|
||||||
|
|
||||||
<!-- Cockpit chat -->
|
<!-- Cockpit chat / Copilot -->
|
||||||
<div class="drawer" id="chat-drawer" aria-hidden="true">
|
<div class="drawer chat-copilot" id="chat-drawer" aria-hidden="true">
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
<div class="drawer-brand">
|
<div class="drawer-brand">
|
||||||
<img src="/dell.png" alt="Dell" height="22" />
|
<img src="/dell.png" alt="Dell" height="22" />
|
||||||
<span>Cockpit chat</span>
|
<span>Cockpit Copilot</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn ghost" id="btn-chat-close">Close</button>
|
<button type="button" class="btn ghost" id="btn-chat-close">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-context" id="chat-context">
|
<div class="chat-context" id="chat-context">
|
||||||
<label class="chat-model-wrap" title="Select model">
|
<label class="chat-model-wrap" title="Select model (Llama / OpenManage AI)">
|
||||||
<span class="chat-model-label">Model</span>
|
<span class="chat-model-label">Model</span>
|
||||||
<select id="chat-model" class="chat-model-select" aria-label="Chat model">
|
<select id="chat-model" class="chat-model-select" aria-label="Chat model">
|
||||||
<option value="llama3-70b-gptq">llama3-70b-gptq</option>
|
<option value="llama3-70b-gptq">llama3-70b-gptq</option>
|
||||||
@@ -211,60 +279,125 @@
|
|||||||
<span class="chat-ctx-pill" id="chat-ctx-fleet">fleet …</span>
|
<span class="chat-ctx-pill" id="chat-ctx-fleet">fleet …</span>
|
||||||
<span class="chat-ctx-pill" id="chat-ctx-focus">focus: none</span>
|
<span class="chat-ctx-pill" id="chat-ctx-focus">focus: none</span>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="chat-gpu" aria-label="Live GPU matrix">
|
||||||
|
<div class="chat-gpu-head">
|
||||||
|
<strong>V100 GPU matrix</strong>
|
||||||
|
<span class="chat-gpu-host">atc-gpu-prod · live</span>
|
||||||
|
</div>
|
||||||
|
<div id="gpu-summary" class="gpu-summary">Waiting for GPU telemetry…</div>
|
||||||
|
<div id="gpu-matrix" class="gpu-matrix chat-gpu-matrix"></div>
|
||||||
|
</section>
|
||||||
<div class="chat-quick" id="chat-quick">
|
<div class="chat-quick" id="chat-quick">
|
||||||
<button type="button" data-q="List every device with its Service Tag, model, and IP.">All service tags</button>
|
<button type="button" data-q="List every device with its Service Tag, model, and IP.">All service tags</button>
|
||||||
|
<button type="button" data-q="How much memory (RAM GB) does each server currently have? List Service Tag, name, model, and installed GB.">Memory per server</button>
|
||||||
<button type="button" data-q="What is the current fleet health? Include Service Tags for critical systems.">Fleet health</button>
|
<button type="button" data-q="What is the current fleet health? Include Service Tags for critical systems.">Fleet health</button>
|
||||||
<button type="button" data-q="List all critical alerts with system name, Service Tag, and IP.">Critical alerts</button>
|
<button type="button" data-q="List all critical alerts with system name, Service Tag, and IP.">Critical alerts</button>
|
||||||
<button type="button" data-q="Which systems are firmware outdated vs the Dell catalog? Include Service Tags and BIOS/iDRAC versions if known.">Outdated firmware</button>
|
<button type="button" data-q="Which systems are firmware outdated vs the Dell catalog? Include Service Tags and BIOS/iDRAC versions if known.">Outdated firmware</button>
|
||||||
<button type="button" data-q="Summarize V100 GPU utilization on atc-gpu-prod.">GPU status</button>
|
<button type="button" data-q="Summarize V100 GPU utilization on atc-gpu-prod from the live matrix.">GPU status</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-body" id="chat-body"></div>
|
<div class="chat-body" id="chat-body"></div>
|
||||||
<form class="chat-form" id="chat-form">
|
<form class="chat-form" id="chat-form">
|
||||||
<input type="text" id="chat-input" placeholder="Ask about live OME fleet, alerts, power, iDRACs, GPUs…" autocomplete="off" />
|
<input type="text" id="chat-input" placeholder="Ask about fleet memory, alerts, power, iDRACs, GPUs…" autocomplete="off" />
|
||||||
<button type="submit" class="btn primary">Send</button>
|
<button type="submit" class="btn primary">Send</button>
|
||||||
</form>
|
</form>
|
||||||
|
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> & <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ops desk / tickets -->
|
<!-- 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-head">
|
||||||
<div class="drawer-brand">
|
<div class="drawer-brand">
|
||||||
<img src="/dell.png" alt="Dell" height="22" />
|
<img src="/dell.png" alt="Dell" height="22" />
|
||||||
<span>ATC Ops desk</span>
|
<span>ATC Ops desk</span>
|
||||||
</div>
|
</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>
|
||||||
<div class="ops-layout">
|
<div class="ops-layout">
|
||||||
<div class="ops-side">
|
<div class="ops-side">
|
||||||
<label class="ops-identity">Acting as
|
<label class="ops-identity">Acting as
|
||||||
<select id="ops-actor">
|
<select id="ops-actor">
|
||||||
<option value="jody">Jody van Dongen</option>
|
<optgroup label="Datacenter Engineers">
|
||||||
<option value="laurens">Laurens Rammers</option>
|
<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>
|
</select>
|
||||||
</label>
|
</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">
|
<div class="ops-filters">
|
||||||
<select id="ops-filter">
|
<select id="ops-filter" title="Ticket history filter">
|
||||||
<option value="openish">Active</option>
|
<option value="all" selected>All history</option>
|
||||||
<option value="all">All</option>
|
<option value="openish">Active only</option>
|
||||||
<option value="open">Open</option>
|
<option value="open">Open</option>
|
||||||
<option value="accepted">Accepted</option>
|
<option value="accepted">Accepted</option>
|
||||||
<option value="in_progress">In progress</option>
|
<option value="in_progress">In progress</option>
|
||||||
<option value="resolved">Resolved</option>
|
<option value="resolved">Resolved</option>
|
||||||
<option value="closed">Closed</option>
|
<option value="closed">Closed</option>
|
||||||
<option value="mine">Assigned to me</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>
|
</select>
|
||||||
|
<span class="ops-count" id="ops-count">0 tickets</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="list" id="ticket-list"></div>
|
<div class="list" id="ticket-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ops-main" id="ops-main">
|
<div class="ops-main" id="ops-main">
|
||||||
<div class="ops-empty" id="ops-empty">
|
<div class="ops-empty" id="ops-empty">
|
||||||
<h3>Admin collaboration</h3>
|
<h3>ATC Ops desk</h3>
|
||||||
<p>Jody van Dongen and Laurens Rammers can exchange notes and tickets for ATC datacenter operations.</p>
|
<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 & stack · installs</span></li>
|
||||||
|
<li><strong>Laurens Rammers</strong><span>Datacenter Engineer · storage · servers · network · rack & 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 · "Data Plumbers" 😉 · Cockpit · OpenManage AI</span></li>
|
||||||
|
<li><strong>Bart Sjerps</strong><span>Data FDE · "Data Plumbers" 😉 · 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>
|
||||||
<div class="ops-thread hidden" id="ops-thread"></div>
|
<div class="ops-thread hidden" id="ops-thread"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<footer class="ops-footer">
|
||||||
|
<span>OME Cockpit · Data FDEs <strong>Mohamed El Kadi</strong> & <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>
|
||||||
|
|
||||||
<div class="drawer" id="ai-drawer" aria-hidden="true">
|
<div class="drawer" id="ai-drawer" aria-hidden="true">
|
||||||
@@ -278,6 +411,88 @@
|
|||||||
<iframe id="ai-frame" title="OpenManage AI" src="about:blank"></iframe>
|
<iframe id="ai-frame" title="OpenManage AI" src="about:blank"></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Network: fabric + ATC racks -->
|
||||||
|
<div class="drawer wide network-drawer" id="network-drawer" aria-hidden="true">
|
||||||
|
<div class="drawer-head">
|
||||||
|
<div class="drawer-brand">
|
||||||
|
<img src="/dell.png" alt="Dell" height="22" />
|
||||||
|
<span>Network · ATC1 / ATC2</span>
|
||||||
|
</div>
|
||||||
|
<div class="net-head-meta">
|
||||||
|
<span id="network-status" class="hint">—</span>
|
||||||
|
<button type="button" class="btn ghost" id="btn-network-close">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chip-grid" id="network-tabs">
|
||||||
|
<button type="button" class="chip active" data-ntab="fabric">Fabric</button>
|
||||||
|
<button type="button" class="chip" data-ntab="racks">Racks 42U</button>
|
||||||
|
<button type="button" class="chip" data-ntab="vlans">VLANs</button>
|
||||||
|
</div>
|
||||||
|
<div class="network-body">
|
||||||
|
<div id="network-fabric" class="network-panel"></div>
|
||||||
|
<div id="network-racks" class="network-panel hidden"></div>
|
||||||
|
<div id="network-vlans" class="network-panel hidden"></div>
|
||||||
|
</div>
|
||||||
|
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> & <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> & <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 -->
|
<!-- Reports / compliance / export -->
|
||||||
<div class="drawer wide reports-drawer" id="reports-drawer" aria-hidden="true">
|
<div class="drawer wide reports-drawer" id="reports-drawer" aria-hidden="true">
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
@@ -288,15 +503,84 @@
|
|||||||
<button type="button" class="btn ghost" id="btn-reports-close">Close</button>
|
<button type="button" class="btn ghost" id="btn-reports-close">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="chip-grid reports-tabs" id="reports-tabs">
|
<div class="chip-grid reports-tabs" id="reports-tabs">
|
||||||
<button type="button" class="chip active" data-tab="fleet">Fleet inventory</button>
|
<button type="button" class="chip active" data-tab="analytics" title="Dashboard KPIs — click tiles to drill into filtered views">Analytics</button>
|
||||||
<button type="button" class="chip" data-tab="firmware">Firmware / BIOS</button>
|
<button type="button" class="chip" data-tab="hardware" title="DIMM slot map, disk/PCIe expansion, Dell best practices">Hardware / Expansion</button>
|
||||||
<button type="button" class="chip" data-tab="warranty">Warranty</button>
|
<button type="button" class="chip" data-tab="fleet" title="Full fleet table with Service Tags — filter & export">Fleet inventory</button>
|
||||||
<button type="button" class="chip" data-tab="ome">OME reports</button>
|
<button type="button" class="chip" data-tab="firmware" title="BIOS/iDRAC vs Dell Online Baseline — outdated filter">Firmware / BIOS</button>
|
||||||
<button type="button" class="chip" data-tab="brief">Customer brief</button>
|
<button type="button" class="chip" data-tab="warranty" title="Warranty end dates — expired / expiring filters">Warranty</button>
|
||||||
|
<button type="button" class="chip" data-tab="ome" title="OME ReportDefs, catalogs, baselines, jobs">OME reports</button>
|
||||||
|
<button type="button" class="chip" data-tab="brief" title="Printable customer brief with Service Tag appendix">Customer brief</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="reports-body" id="reports-mount">
|
<div class="reports-body" id="reports-mount">
|
||||||
<p class="hint">Select a report tab to load live OME data.</p>
|
<p class="hint">Select a report tab to load live OME data.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> & <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 & 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> & <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>
|
||||||
|
|
||||||
<div class="scrim" id="scrim"></div>
|
<div class="scrim" id="scrim"></div>
|
||||||
@@ -310,7 +594,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="modal-kicker">ATC Ops desk</p>
|
<p class="modal-kicker">ATC Ops desk</p>
|
||||||
<h2 id="ticket-modal-title">Create ticket</h2>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<form id="ticket-form" class="ticket-form">
|
<form id="ticket-form" class="ticket-form">
|
||||||
@@ -328,8 +612,17 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>Assign to
|
<label>Assign to
|
||||||
<select id="ticket-assignee">
|
<select id="ticket-assignee">
|
||||||
<option value="laurens">Laurens Rammers</option>
|
<optgroup label="Datacenter Engineers">
|
||||||
<option value="jody">Jody van Dongen</option>
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,6 +638,23 @@
|
|||||||
</div>
|
</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 hidden" id="kpi-modal" aria-hidden="true" role="dialog" aria-labelledby="kpi-title">
|
||||||
<div class="modal-card kpi-card">
|
<div class="modal-card kpi-card">
|
||||||
<button type="button" class="modal-close" id="btn-kpi-close" aria-label="Close">×</button>
|
<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-badges" id="connect-badges"></div>
|
||||||
<div class="modal-context" id="connect-context"></div>
|
<div class="modal-context" id="connect-context"></div>
|
||||||
<div class="connect-grid" id="connect-grid"></div>
|
<div class="connect-grid" id="connect-grid"></div>
|
||||||
<p class="modal-foot">iDRAC opens in a new tab · SSH opens an in-browser terminal — enter your own username</p>
|
<p class="modal-foot">iDRAC opens in a new tab · SSH = in-browser terminal · RDP = popout for Windows (local client)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal hidden" id="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 hidden" id="ssh-modal" aria-hidden="true" role="dialog" aria-labelledby="ssh-device">
|
||||||
|
|
||||||
<div class="modal-card ssh-card">
|
<div class="modal-card ssh-card">
|
||||||
<button type="button" class="modal-close" id="btn-ssh-close" aria-label="Close">×</button>
|
<button type="button" class="modal-close" id="btn-ssh-close" aria-label="Close">×</button>
|
||||||
<div class="modal-brand">
|
<div class="modal-brand">
|
||||||
@@ -466,9 +799,14 @@
|
|||||||
|
|
||||||
<script src="/vendor/xterm/xterm.min.js?v=home1"></script>
|
<script src="/vendor/xterm/xterm.min.js?v=home1"></script>
|
||||||
<script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script>
|
<script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script>
|
||||||
<script src="/app.js?v=reports1"></script>
|
<script src="/app.js?v=power5"></script>
|
||||||
<script src="/ops.js?v=reports1"></script>
|
<script src="/ops.js?v=opsfde6"></script>
|
||||||
<script src="/ssh.js?v=reports1"></script>
|
<script src="/ssh.js?v=fabric4"></script>
|
||||||
<script src="/reports.js?v=reports1"></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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -39,9 +39,17 @@
|
|||||||
const api = ensureApi();
|
const api = ensureApi();
|
||||||
const chatHistory = [];
|
const chatHistory = [];
|
||||||
let tickets = [];
|
let tickets = [];
|
||||||
|
let filteredTickets = [];
|
||||||
let activeTicketId = null;
|
let activeTicketId = null;
|
||||||
let gpuData = null;
|
let gpuData = null;
|
||||||
let chatModelsLoaded = false;
|
let chatModelsLoaded = false;
|
||||||
|
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() {
|
function selectedChatModel() {
|
||||||
const sel = $("#chat-model");
|
const sel = $("#chat-model");
|
||||||
@@ -78,7 +86,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adminName(id) {
|
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) {
|
function openDrawer(id) {
|
||||||
@@ -92,14 +140,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeDrawers() {
|
function closeDrawers() {
|
||||||
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer"].forEach((id) => {
|
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#network-drawer"].forEach((id) => {
|
||||||
const el = $(id);
|
const el = $(id);
|
||||||
el?.classList.remove("open");
|
el?.classList.remove("open");
|
||||||
el?.setAttribute("aria-hidden", "true");
|
el?.setAttribute("aria-hidden", "true");
|
||||||
});
|
});
|
||||||
window.dispatchEvent(new CustomEvent("cockpit-close-drawers"));
|
window.dispatchEvent(new CustomEvent("cockpit-close-drawers"));
|
||||||
const scrim = $("#scrim");
|
const scrim = $("#scrim");
|
||||||
if (scrim && ["chat-drawer", "ops-drawer", "ai-drawer", "reports-drawer"].includes(scrim.dataset.mode)) {
|
if (scrim && ["chat-drawer", "ops-drawer", "ai-drawer", "reports-drawer", "network-drawer"].includes(scrim.dataset.mode)) {
|
||||||
scrim.classList.remove("open");
|
scrim.classList.remove("open");
|
||||||
delete scrim.dataset.mode;
|
delete scrim.dataset.mode;
|
||||||
}
|
}
|
||||||
@@ -133,14 +181,28 @@
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendChat(role, text) {
|
function appendChat(role, text, meta) {
|
||||||
const body = $("#chat-body");
|
const body = $("#chat-body");
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = `chat-bubble ${role}`;
|
div.className = `chat-bubble ${role}`;
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
|
if (meta?.tools?.length) {
|
||||||
|
const tools = document.createElement("div");
|
||||||
|
tools.className = "chat-tools";
|
||||||
|
tools.title = "Live OME lookups used for this answer";
|
||||||
|
meta.tools.forEach((t) => {
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
chip.className = "chat-tool-chip";
|
||||||
|
const q = t.args?.query || t.args?.service_tag || t.args?.device_id || "";
|
||||||
|
chip.textContent = q ? `${t.name}:${q}` : t.name;
|
||||||
|
tools.appendChild(chip);
|
||||||
|
});
|
||||||
|
div.appendChild(tools);
|
||||||
|
}
|
||||||
body.appendChild(div);
|
body.appendChild(div);
|
||||||
body.scrollTop = body.scrollHeight;
|
body.scrollTop = body.scrollHeight;
|
||||||
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshChatContext() {
|
function refreshChatContext() {
|
||||||
@@ -161,8 +223,7 @@
|
|||||||
appendChat("user", msg);
|
appendChat("user", msg);
|
||||||
chatHistory.push({ role: "user", content: msg });
|
chatHistory.push({ role: "user", content: msg });
|
||||||
const model = selectedChatModel();
|
const model = selectedChatModel();
|
||||||
appendChat("assistant", `Thinking with ${model}…`);
|
const thinking = appendChat("assistant", `Looking up live OME facts with ${model}…`);
|
||||||
const thinking = $("#chat-body")?.lastElementChild;
|
|
||||||
try {
|
try {
|
||||||
const focusId = window.cockpit?.getState?.()?.selectedId || null;
|
const focusId = window.cockpit?.getState?.()?.selectedId || null;
|
||||||
const res = await api.post("/api/chat", {
|
const res = await api.post("/api/chat", {
|
||||||
@@ -171,7 +232,22 @@
|
|||||||
focus_device_id: focusId,
|
focus_device_id: focusId,
|
||||||
model,
|
model,
|
||||||
});
|
});
|
||||||
if (thinking) thinking.textContent = res.reply || "(empty reply)";
|
if (thinking) {
|
||||||
|
thinking.textContent = res.reply || "(empty reply)";
|
||||||
|
if (res.tools_used?.length) {
|
||||||
|
const tools = document.createElement("div");
|
||||||
|
tools.className = "chat-tools";
|
||||||
|
tools.title = "Live OME lookups used for this answer";
|
||||||
|
res.tools_used.forEach((t) => {
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
chip.className = "chat-tool-chip";
|
||||||
|
const q = t.args?.query || t.args?.service_tag || t.args?.device_id || "";
|
||||||
|
chip.textContent = q ? `${t.name}:${q}` : t.name;
|
||||||
|
tools.appendChild(chip);
|
||||||
|
});
|
||||||
|
thinking.appendChild(tools);
|
||||||
|
}
|
||||||
|
}
|
||||||
chatHistory.push({ role: "assistant", content: res.reply || "" });
|
chatHistory.push({ role: "assistant", content: res.reply || "" });
|
||||||
const g = await api.get("/api/gpu");
|
const g = await api.get("/api/gpu");
|
||||||
renderGpu(g);
|
renderGpu(g);
|
||||||
@@ -182,37 +258,95 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ticketFilterValue() {
|
function ticketFilterValue() {
|
||||||
return $("#ops-filter")?.value || "openish";
|
const saved = localStorage.getItem("cockpit_ops_filter");
|
||||||
|
const sel = $("#ops-filter");
|
||||||
|
if (sel && !sel.dataset.ready) {
|
||||||
|
if (saved) sel.value = saved;
|
||||||
|
else sel.value = "all";
|
||||||
|
sel.dataset.ready = "1";
|
||||||
|
}
|
||||||
|
return sel?.value || saved || "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredTicketList() {
|
||||||
|
const f = ticketFilterValue();
|
||||||
|
const me = actor();
|
||||||
|
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() {
|
async function loadTickets() {
|
||||||
const data = await api.get("/api/tickets");
|
const data = await api.get("/api/tickets");
|
||||||
tickets = data.tickets || [];
|
tickets = data.tickets || [];
|
||||||
|
if (data.users?.length) opsUsers = data.users;
|
||||||
|
else if (data.admins?.length) opsUsers = data.admins;
|
||||||
const list = $("#ticket-list");
|
const list = $("#ticket-list");
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const f = ticketFilterValue();
|
filteredTickets = filteredTicketList();
|
||||||
const me = actor();
|
renderOpsStats();
|
||||||
const filtered = tickets.filter((t) => {
|
const count = $("#ops-count");
|
||||||
if (f === "all") return true;
|
if (count) {
|
||||||
if (f === "mine") return t.assignee === me;
|
count.textContent =
|
||||||
if (f === "openish") return ["open", "accepted", "in_progress"].includes(t.status);
|
filteredTickets.length === tickets.length
|
||||||
return t.status === f;
|
? `${tickets.length} ticket${tickets.length === 1 ? "" : "s"}`
|
||||||
});
|
: `${filteredTickets.length} / ${tickets.length} tickets`;
|
||||||
if (!filtered.length) {
|
}
|
||||||
list.innerHTML = `<p class="hint">No tickets in this filter.</p>`;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = filtered
|
list.innerHTML = filteredTickets
|
||||||
.map((tkt) => {
|
.map((tkt) => {
|
||||||
const active = tkt.id === activeTicketId ? " active" : "";
|
const active = tkt.id === activeTicketId ? " active" : "";
|
||||||
|
const when = tkt.updated_at
|
||||||
|
? new Date(tkt.updated_at * 1000).toLocaleString()
|
||||||
|
: "";
|
||||||
|
const team = userTeam(tkt.assignee);
|
||||||
return `<button type="button" class="card${active}" data-ticket="${tkt.id}">
|
return `<button type="button" class="card${active}" data-ticket="${tkt.id}">
|
||||||
<span class="t">${escape(tkt.title)}</span>
|
<span class="t">#${tkt.id} · ${escape(tkt.title)}</span>
|
||||||
<span class="s"><span class="st-${escape(tkt.status)}">${escape(tkt.status)}</span> · ${escape(tkt.priority)} · ${escape(adminName(tkt.created_by))} → ${escape(adminName(tkt.assignee))}${tkt.accepted_by ? " · accepted " + escape(adminName(tkt.accepted_by)) : ""}</span>
|
<span class="s"><span class="st-${escape(tkt.status)}">${escape(tkt.status)}</span> · ${escape(tkt.priority)} · <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>`;
|
</button>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ticketNavHtml(id) {
|
||||||
|
const idx = filteredTickets.findIndex((t) => t.id === id);
|
||||||
|
if (idx < 0) return "";
|
||||||
|
const prev = idx > 0 ? filteredTickets[idx - 1] : null;
|
||||||
|
const next = idx < filteredTickets.length - 1 ? filteredTickets[idx + 1] : null;
|
||||||
|
return `<div class="ops-nav">
|
||||||
|
<button type="button" class="btn" id="ops-prev" ${prev ? "" : "disabled"} title="Previous ticket">← Prev</button>
|
||||||
|
<span class="ops-nav-pos">${idx + 1} / ${filteredTickets.length}</span>
|
||||||
|
<button type="button" class="btn" id="ops-next" ${next ? "" : "disabled"} title="Next ticket">Next →</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function escape(s) {
|
function escape(s) {
|
||||||
return String(s ?? "")
|
return String(s ?? "")
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -234,6 +368,7 @@
|
|||||||
const me = actor();
|
const me = actor();
|
||||||
const canAccept = tkt.assignee === me && !tkt.accepted_by && tkt.status === "open";
|
const canAccept = tkt.assignee === me && !tkt.accepted_by && tkt.status === "open";
|
||||||
thread.innerHTML = `
|
thread.innerHTML = `
|
||||||
|
${ticketNavHtml(id)}
|
||||||
<div class="ops-ticket-head">
|
<div class="ops-ticket-head">
|
||||||
<h3>#${tkt.id} · ${escape(tkt.title)}</h3>
|
<h3>#${tkt.id} · ${escape(tkt.title)}</h3>
|
||||||
<div class="ops-meta-grid">
|
<div class="ops-meta-grid">
|
||||||
@@ -253,8 +388,7 @@
|
|||||||
<option value="closed">Closed</option>
|
<option value="closed">Closed</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ops-assign">
|
<select id="ops-assign">
|
||||||
<option value="jody">Assign Jody</option>
|
${assigneeOptionsHtml(tkt.assignee || "jody")}
|
||||||
<option value="laurens">Assign Laurens</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select id="ops-priority">
|
<select id="ops-priority">
|
||||||
<option value="low">Low</option>
|
<option value="low">Low</option>
|
||||||
@@ -268,6 +402,10 @@
|
|||||||
<button type="button" class="btn danger" id="ops-delete-ticket">Delete</button>
|
<button type="button" class="btn danger" id="ops-delete-ticket">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ops-body-box">
|
||||||
|
<h4>Original request</h4>
|
||||||
|
<p class="ops-body-text">${escape(tkt.body)}</p>
|
||||||
|
</div>
|
||||||
<div class="ops-msgs" id="ops-msgs">
|
<div class="ops-msgs" id="ops-msgs">
|
||||||
${msgs
|
${msgs
|
||||||
.map(
|
.map(
|
||||||
@@ -278,7 +416,7 @@
|
|||||||
.join("")}
|
.join("")}
|
||||||
</div>
|
</div>
|
||||||
<form class="ops-reply" id="ops-reply">
|
<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>
|
<button class="btn primary" type="submit">Send note</button>
|
||||||
</form>`;
|
</form>`;
|
||||||
|
|
||||||
@@ -286,6 +424,14 @@
|
|||||||
$("#ops-assign").value = tkt.assignee || "jody";
|
$("#ops-assign").value = tkt.assignee || "jody";
|
||||||
$("#ops-priority").value = tkt.priority || "normal";
|
$("#ops-priority").value = tkt.priority || "normal";
|
||||||
|
|
||||||
|
const idx = filteredTickets.findIndex((t) => t.id === id);
|
||||||
|
$("#ops-prev")?.addEventListener("click", () => {
|
||||||
|
if (idx > 0) openTicket(filteredTickets[idx - 1].id);
|
||||||
|
});
|
||||||
|
$("#ops-next")?.addEventListener("click", () => {
|
||||||
|
if (idx >= 0 && idx < filteredTickets.length - 1) openTicket(filteredTickets[idx + 1].id);
|
||||||
|
});
|
||||||
|
|
||||||
$("#ops-save-meta")?.addEventListener("click", async () => {
|
$("#ops-save-meta")?.addEventListener("click", async () => {
|
||||||
await api.patch(`/api/tickets/${id}`, {
|
await api.patch(`/api/tickets/${id}`, {
|
||||||
status: $("#ops-status").value,
|
status: $("#ops-status").value,
|
||||||
@@ -324,11 +470,13 @@
|
|||||||
function openTicketModal(draft = null) {
|
function openTicketModal(draft = null) {
|
||||||
ticketDraft = draft;
|
ticketDraft = draft;
|
||||||
const me = actor();
|
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-title").value = draft?.title || "";
|
||||||
$("#ticket-body").value = draft?.body || "";
|
$("#ticket-body").value = draft?.body || "";
|
||||||
$("#ticket-priority").value = draft?.priority || "normal";
|
$("#ticket-priority").value = draft?.priority || "normal";
|
||||||
$("#ticket-assignee").value = draft?.assignee || other;
|
$("#ticket-assignee").value = other;
|
||||||
const modal = $("#ticket-modal");
|
const modal = $("#ticket-modal");
|
||||||
const scrim = $("#scrim");
|
const scrim = $("#scrim");
|
||||||
modal?.classList.remove("hidden");
|
modal?.classList.remove("hidden");
|
||||||
@@ -712,7 +860,7 @@
|
|||||||
((node && node.ip) || (a && a.ip) || "no-ip") +
|
((node && node.ip) || (a && a.ip) || "no-ip") +
|
||||||
"): " +
|
"): " +
|
||||||
it.message +
|
it.message +
|
||||||
". Suggest next steps for ATC admins Jody and Laurens."
|
". Suggest next steps for Datacenter Engineers Jody and Laurens."
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
$("#triage-inspect")?.addEventListener("click", () => {
|
$("#triage-inspect")?.addEventListener("click", () => {
|
||||||
@@ -723,7 +871,7 @@
|
|||||||
|
|
||||||
async function createTicketFromTriage(it, node) {
|
async function createTicketFromTriage(it, node) {
|
||||||
const me = actor();
|
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 sev = it.severity || it.kind || "alert";
|
||||||
const title = ("[" + sev + "] " + it.title).slice(0, 180);
|
const title = ("[" + sev + "] " + it.title).slice(0, 180);
|
||||||
const body = [
|
const body = [
|
||||||
@@ -756,6 +904,7 @@
|
|||||||
function wire() {
|
function wire() {
|
||||||
$("#ops-actor")?.addEventListener("change", (e) => {
|
$("#ops-actor")?.addEventListener("change", (e) => {
|
||||||
localStorage.setItem("atc_actor", e.target.value);
|
localStorage.setItem("atc_actor", e.target.value);
|
||||||
|
loadTickets();
|
||||||
});
|
});
|
||||||
const saved = localStorage.getItem("atc_actor");
|
const saved = localStorage.getItem("atc_actor");
|
||||||
if (saved && $("#ops-actor")) $("#ops-actor").value = saved;
|
if (saved && $("#ops-actor")) $("#ops-actor").value = saved;
|
||||||
@@ -786,7 +935,7 @@
|
|||||||
if (!$("#chat-body")?.children.length) {
|
if (!$("#chat-body")?.children.length) {
|
||||||
appendChat(
|
appendChat(
|
||||||
"assistant",
|
"assistant",
|
||||||
"Cockpit Copilot online. Pick a model above (same catalog as OpenManage AI), then ask about fleet health, alerts, power, or GPUs."
|
"Cockpit Copilot online. Live V100 GPUs are shown above. Pick a model (Llama or OpenManage AI), then ask about fleet memory, alerts, power, or GPUs."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -800,11 +949,71 @@
|
|||||||
$("#btn-chat-close")?.addEventListener("click", closeDrawers);
|
$("#btn-chat-close")?.addEventListener("click", closeDrawers);
|
||||||
$("#btn-ops")?.addEventListener("click", async () => {
|
$("#btn-ops")?.addEventListener("click", async () => {
|
||||||
openDrawer("#ops-drawer");
|
openDrawer("#ops-drawer");
|
||||||
|
await loadOpsUsers();
|
||||||
await loadTickets();
|
await loadTickets();
|
||||||
|
$("#ops-empty")?.classList.remove("hidden");
|
||||||
|
$("#ops-thread")?.classList.add("hidden");
|
||||||
|
activeTicketId = null;
|
||||||
});
|
});
|
||||||
$("#btn-ops-close")?.addEventListener("click", closeDrawers);
|
$("#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-modal-close")?.addEventListener("click", closeTicketModal);
|
||||||
$("#btn-ticket-cancel")?.addEventListener("click", closeTicketModal);
|
$("#btn-ticket-cancel")?.addEventListener("click", closeTicketModal);
|
||||||
$("#ticket-form")?.addEventListener("submit", async (e) => {
|
$("#ticket-form")?.addEventListener("submit", async (e) => {
|
||||||
@@ -855,7 +1064,7 @@
|
|||||||
const scrim = $("#scrim");
|
const scrim = $("#scrim");
|
||||||
scrim?.addEventListener("click", () => {
|
scrim?.addEventListener("click", () => {
|
||||||
const mode = scrim.dataset.mode;
|
const mode = scrim.dataset.mode;
|
||||||
if (mode === "chat-drawer" || mode === "ops-drawer" || mode === "ai-drawer" || mode === "reports-drawer") closeDrawers();
|
if (mode === "chat-drawer" || mode === "ops-drawer" || mode === "ai-drawer" || mode === "reports-drawer" || mode === "network-drawer") closeDrawers();
|
||||||
});
|
});
|
||||||
|
|
||||||
initFeedDrag();
|
initFeedDrag();
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>OME Cockpit · RDP</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css?v=rdp1" />
|
||||||
|
<style>
|
||||||
|
body.rdp-body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 70% 50% at 50% 0%, rgba(0, 118, 206, 0.22), transparent 55%),
|
||||||
|
var(--bg-deep);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
}
|
||||||
|
.rdp-shell {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.25rem 1.35rem 2rem;
|
||||||
|
}
|
||||||
|
.rdp-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
.rdp-brand img { width: 40px; height: 40px; border-radius: 8px; }
|
||||||
|
.rdp-brand .kicker {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.rdp-brand h1 {
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #e8f4ff;
|
||||||
|
}
|
||||||
|
.rdp-card {
|
||||||
|
background: rgba(8, 18, 32, 0.94);
|
||||||
|
border: 1px solid rgba(0, 168, 232, 0.28);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.1rem 1.15rem 1.25rem;
|
||||||
|
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.rdp-meta {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
color: #c5d6e6;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.rdp-warn {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 176, 32, 0.4);
|
||||||
|
background: rgba(255, 176, 32, 0.1);
|
||||||
|
color: #ffe0a8;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.rdp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 7.5rem;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.rdp-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #9fd0c8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.rdp-grid label.wide { grid-column: 1 / -1; }
|
||||||
|
.rdp-grid input {
|
||||||
|
background: rgba(2, 8, 16, 0.9);
|
||||||
|
border: 1px solid rgba(0, 168, 232, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
color: #e8f4ff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.rdp-grid input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(61, 255, 224, 0.55);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 168, 232, 0.2);
|
||||||
|
}
|
||||||
|
.rdp-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.rdp-actions .btn { min-height: 2.4rem; }
|
||||||
|
.rdp-status {
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
min-height: 1.2em;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #9fd0c8;
|
||||||
|
}
|
||||||
|
.rdp-status.ok { color: var(--cyan); }
|
||||||
|
.rdp-status.error { color: #ff8a8a; }
|
||||||
|
.rdp-help {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #b7c9d9;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.rdp-help code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--cyan);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
.rdp-ip-picks { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 0.35rem; margin: -0.25rem 0 0.35rem; }
|
||||||
|
.rdp-ip-picks button {
|
||||||
|
background: rgba(0,118,206,0.18);
|
||||||
|
border: 1px solid rgba(61,255,224,0.3);
|
||||||
|
color: #e8f4ff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.rdp-ip-picks button.active { background: rgba(61,255,224,0.22); border-color: rgba(61,255,224,0.55); }
|
||||||
|
.rdp-meta code { color: var(--cyan); font-family: var(--mono); font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="rdp-body">
|
||||||
|
<div class="rdp-shell">
|
||||||
|
<header class="rdp-brand">
|
||||||
|
<img src="/dell.png" alt="Dell" />
|
||||||
|
<div>
|
||||||
|
<p class="kicker">Remote Desktop</p>
|
||||||
|
<h1 id="rdp-title">RDP session</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="rdp-card">
|
||||||
|
<p class="rdp-meta" id="rdp-meta">—</p>
|
||||||
|
<p class="rdp-warn hidden" id="rdp-warn">
|
||||||
|
This OME entry looks like an <strong>iDRAC</strong> address. RDP needs the <strong>Windows OS IP/hostname</strong> — change Host below if needed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="rdp-form" autocomplete="on">
|
||||||
|
<div class="rdp-grid">
|
||||||
|
<label class="wide">Host (Windows OS IP or DNS)
|
||||||
|
<input type="text" id="rdp-host" name="host" autocomplete="off" spellcheck="false" required list="rdp-ip-list" />
|
||||||
|
<datalist id="rdp-ip-list"></datalist>
|
||||||
|
</label>
|
||||||
|
<div class="rdp-ip-picks" id="rdp-ip-picks"></div>
|
||||||
|
<label>Port
|
||||||
|
<input type="number" id="rdp-port" name="port" value="3389" min="1" max="65535" />
|
||||||
|
</label>
|
||||||
|
<label class="wide">Username (optional — client will prompt if empty)
|
||||||
|
<input type="text" id="rdp-user" name="username" autocomplete="username" spellcheck="false" placeholder="DOMAIN\user or user@domain" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="rdp-actions">
|
||||||
|
<button type="submit" class="btn primary" id="rdp-launch">Open Remote Desktop</button>
|
||||||
|
<button type="button" class="btn" id="rdp-download">Download .rdp</button>
|
||||||
|
<button type="button" class="btn ghost" id="rdp-copy">Copy host:port</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="rdp-status" id="rdp-status">Ready — opens your local RDP client (mstsc / Windows App / Remmina).</p>
|
||||||
|
<div class="rdp-help">
|
||||||
|
<p>Cockpit launches a standard <code>.rdp</code> file so Windows nodes without SSH stay reachable from this popout.</p>
|
||||||
|
<p>Windows: opens Remote Desktop Connection. Linux: Remmina / FreeRDP if associated with <code>.rdp</code>. macOS: Microsoft Remote Desktop.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
const name = q.get("name") || "RDP session";
|
||||||
|
const model = q.get("model") || "";
|
||||||
|
const st = q.get("st") || "";
|
||||||
|
const osHost = (q.get("os_hostname") || "").trim();
|
||||||
|
const idracIp = (q.get("idrac_ip") || "").trim();
|
||||||
|
const hostIn = (q.get("host") || "").trim();
|
||||||
|
const rdpIps = (q.get("rdp_ips") || "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
const idracHint = /idrac|bmc|ilo/i.test(name) || (idracIp && hostIn === idracIp);
|
||||||
|
|
||||||
|
document.title = `RDP · ${name}`;
|
||||||
|
document.getElementById("rdp-title").textContent = name;
|
||||||
|
document.getElementById("rdp-meta").innerHTML = [
|
||||||
|
model,
|
||||||
|
st && `ST ${st}`,
|
||||||
|
osHost && `OS <code>${osHost}</code>`,
|
||||||
|
idracIp && `iDRAC <code>${idracIp}</code>`,
|
||||||
|
].filter(Boolean).join(" · ") || "—";
|
||||||
|
document.getElementById("rdp-host").value = hostIn || rdpIps[0] || osHost || "";
|
||||||
|
document.getElementById("rdp-user").value = localStorage.getItem("cockpit_rdp_user") || "";
|
||||||
|
|
||||||
|
const warn = document.getElementById("rdp-warn");
|
||||||
|
if (idracHint) warn.classList.remove("hidden");
|
||||||
|
|
||||||
|
const status = document.getElementById("rdp-status");
|
||||||
|
function setStatus(msg, kind) {
|
||||||
|
status.textContent = msg;
|
||||||
|
status.className = "rdp-status" + (kind ? " " + kind : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.getElementById("rdp-ip-list");
|
||||||
|
const picks = document.getElementById("rdp-ip-picks");
|
||||||
|
const candidates = [...new Set([...(rdpIps || []), hostIn, osHost].filter(Boolean))];
|
||||||
|
candidates.forEach((ip) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = ip;
|
||||||
|
list.appendChild(opt);
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.textContent = ip;
|
||||||
|
if (ip === (hostIn || rdpIps[0])) btn.classList.add("active");
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
document.getElementById("rdp-host").value = ip;
|
||||||
|
picks.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
|
||||||
|
setStatus(`Selected ${ip}`, "ok");
|
||||||
|
});
|
||||||
|
picks.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
function vals() {
|
||||||
|
const host = document.getElementById("rdp-host").value.trim();
|
||||||
|
const port = Number(document.getElementById("rdp-port").value || 3389);
|
||||||
|
const username = document.getElementById("rdp-user").value.trim();
|
||||||
|
return { host, port, username };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberUser(u) {
|
||||||
|
if (u) localStorage.setItem("cockpit_rdp_user", u);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRdp({ host, port, username }) {
|
||||||
|
const full = port && port !== 3389 ? `${host}:${port}` : host;
|
||||||
|
const lines = [
|
||||||
|
"screen mode id:i:2",
|
||||||
|
"use multimon:i:0",
|
||||||
|
"desktopwidth:i:1920",
|
||||||
|
"desktopheight:i:1080",
|
||||||
|
"session bpp:i:32",
|
||||||
|
"compression:i:1",
|
||||||
|
"keyboardhook:i:2",
|
||||||
|
"audiocapturemode:i:0",
|
||||||
|
"videoplaybackmode:i:1",
|
||||||
|
"connection type:i:7",
|
||||||
|
"networkautodetect:i:1",
|
||||||
|
"bandwidthautodetect:i:1",
|
||||||
|
"displayconnectionbar:i:1",
|
||||||
|
"enableworkspacereconnect:i:0",
|
||||||
|
"disable wallpaper:i:0",
|
||||||
|
"allow font smoothing:i:1",
|
||||||
|
"allow desktop composition:i:1",
|
||||||
|
"disable full window drag:i:0",
|
||||||
|
"disable menu anims:i:0",
|
||||||
|
"disable themes:i:0",
|
||||||
|
"disable cursor setting:i:0",
|
||||||
|
"bitmapcachepersistenable:i:1",
|
||||||
|
`full address:s:${full}`,
|
||||||
|
"audiomode:i:0",
|
||||||
|
"redirectprinters:i:0",
|
||||||
|
"redirectcomports:i:0",
|
||||||
|
"redirectsmartcards:i:0",
|
||||||
|
"redirectclipboard:i:1",
|
||||||
|
"redirectposdevices:i:0",
|
||||||
|
"autoreconnection enabled:i:1",
|
||||||
|
"authentication level:i:2",
|
||||||
|
"prompt for credentials:i:1",
|
||||||
|
"negotiate security layer:i:1",
|
||||||
|
"remoteapplicationmode:i:0",
|
||||||
|
"alternate shell:s:",
|
||||||
|
"shell working directory:s:",
|
||||||
|
"gatewayhostname:s:",
|
||||||
|
"gatewayusagemethod:i:4",
|
||||||
|
"gatewaycredentialssource:i:4",
|
||||||
|
"gatewayprofileusagemethod:i:0",
|
||||||
|
"promptcredentialonce:i:0",
|
||||||
|
"gatewaybrokeringtype:i:0",
|
||||||
|
"use redirection server name:i:0",
|
||||||
|
"rdgiskdcproxy:i:0",
|
||||||
|
"kdcproxyname:s:",
|
||||||
|
];
|
||||||
|
if (username) lines.splice(24, 0, `username:s:${username}`);
|
||||||
|
return lines.join("\r\n") + "\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadRdp(autoOpen) {
|
||||||
|
const v = vals();
|
||||||
|
if (!v.host) {
|
||||||
|
setStatus("Host is required", "error");
|
||||||
|
document.getElementById("rdp-host").focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rememberUser(v.username);
|
||||||
|
const body = buildRdp(v);
|
||||||
|
const blob = new Blob([body], { type: "application/x-rdp" });
|
||||||
|
const safe = v.host.replace(/[^\w.-]+/g, "_");
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${safe}.rdp`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||||
|
|
||||||
|
// Also hit server endpoint (same content) for clients that prefer navigation
|
||||||
|
if (autoOpen) {
|
||||||
|
const api = `/api/rdp.rdp?host=${encodeURIComponent(v.host)}&port=${v.port}&username=${encodeURIComponent(v.username || "")}`;
|
||||||
|
// secondary attempt via hidden iframe — helps some browsers associate .rdp
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
iframe.style.display = "none";
|
||||||
|
iframe.src = api;
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
setTimeout(() => iframe.remove(), 4000);
|
||||||
|
}
|
||||||
|
setStatus(`RDP file ready for ${v.host}:${v.port} — open it with your Remote Desktop client.`, "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("rdp-form").addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
downloadRdp(true);
|
||||||
|
});
|
||||||
|
document.getElementById("rdp-download").addEventListener("click", () => downloadRdp(false));
|
||||||
|
document.getElementById("rdp-copy").addEventListener("click", async () => {
|
||||||
|
const v = vals();
|
||||||
|
if (!v.host) return;
|
||||||
|
const text = `${v.host}:${v.port || 3389}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setStatus(`Copied ${text}`, "ok");
|
||||||
|
} catch (_) {
|
||||||
|
setStatus("Copy failed", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const u = document.getElementById("rdp-user");
|
||||||
|
if (u && !u.value) u.focus();
|
||||||
|
else document.getElementById("rdp-host")?.focus();
|
||||||
|
}, 40);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
(() => {
|
||||||
|
function looksLikeIdrac(node) {
|
||||||
|
const blob = `${node?.name || ""} ${node?.sub_type || ""} ${node?.ip || ""}`;
|
||||||
|
return !!node?.is_idrac || /idrac|bmc/i.test(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRdpPopout(node = {}) {
|
||||||
|
const rdpIps = Array.isArray(node.rdp_ips) ? node.rdp_ips.filter(Boolean) : [];
|
||||||
|
const host = (node.host || node.rdp_host || rdpIps[0] || node.ip || "").trim();
|
||||||
|
if (!host && !node.allowEmpty) {
|
||||||
|
alert("No RDP / OS IP resolved yet — enter the Windows OS address in the RDP popout.");
|
||||||
|
}
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
host: host || "",
|
||||||
|
name: node.name || host || "RDP session",
|
||||||
|
model: node.model || "",
|
||||||
|
st: node.service_tag || "",
|
||||||
|
id: node.id != null ? String(node.id) : "",
|
||||||
|
os_hostname: node.os_hostname || "",
|
||||||
|
idrac_ip: node.idrac_ip || (looksLikeIdrac(node) ? node.ip || "" : ""),
|
||||||
|
});
|
||||||
|
if (rdpIps.length) q.set("rdp_ips", rdpIps.join(","));
|
||||||
|
if (looksLikeIdrac(node) || node.is_windows) q.set("windows", "1");
|
||||||
|
|
||||||
|
const w = 780;
|
||||||
|
const h = 760;
|
||||||
|
const left = Math.max(0, Math.round((window.screen.width - w) / 2));
|
||||||
|
const top = Math.max(0, Math.round((window.screen.height - h) / 2));
|
||||||
|
const features = [
|
||||||
|
"popup=yes",
|
||||||
|
`width=${w}`,
|
||||||
|
`height=${h}`,
|
||||||
|
`left=${left}`,
|
||||||
|
`top=${top}`,
|
||||||
|
"resizable=yes",
|
||||||
|
"scrollbars=yes",
|
||||||
|
].join(",");
|
||||||
|
const win = window.open(`/rdp-popout.html?${q.toString()}`, `ome-rdp-${node.id || host || "x"}`, features);
|
||||||
|
if (!win) {
|
||||||
|
alert("Popout blocked — allow popups for this site, or open RDP from Quick Connect again.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
win.focus();
|
||||||
|
} catch (_) {}
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.cockpitRdp = {
|
||||||
|
open: openRdpPopout,
|
||||||
|
looksLikeIdrac,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
})();
|
||||||