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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 01:42:00 +02:00

714 lines
24 KiB
JavaScript

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