diff --git a/api/main.py b/api/main.py index 8d9bdb1..6a519dd 100644 --- a/api/main.py +++ b/api/main.py @@ -9,13 +9,15 @@ import sqlite3 import ipaddress import logging import time +import uuid +import shutil from collections import defaultdict from pathlib import Path from typing import Any import httpx import asyncssh -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, Response, StreamingResponse from pydantic import BaseModel, Field @@ -2669,34 +2671,47 @@ OPS_USERS = [ { "id": "jody", "name": "Jody van Dongen", - "role": "ATC Datacenter Admin", + "short": "Jody", + "role": "Datacenter Engineer", "team": "admin", "email": "jody.van.dongen@dell.com", - "focus": "OME fleet · racks · warranty / compliance", + "focus": "Storage · servers · network · rack & stack · installs", }, { "id": "laurens", "name": "Laurens Rammers", - "role": "ATC Datacenter Admin", + "short": "Laurens", + "role": "Datacenter Engineer", "team": "admin", "email": "laurens.rammers@dell.com", - "focus": "Datacenter ops · handoffs · escalation", + "focus": "Storage · servers · network · rack & stack · installs", }, { "id": "mo", "name": "Mohamed El Kadi", + "short": "Mo", "role": "Data Forward Deployed Engineer", "team": "fde", "email": "mohamed.el.kadi@dell.com", - "focus": "OME Cockpit · OpenManage AI · AI workloads on FDE cluster", + "focus": "\"Data Plumbers\" 😉 · OME Cockpit · OpenManage AI · AI workloads on FDE cluster", }, { "id": "bart", "name": "Bart Sjerps", + "short": "Bart", "role": "Data Forward Deployed Engineer", "team": "fde", "email": "bart.sjerps@dell.com", - "focus": "FDE cluster · AI workload deployment with Mo", + "focus": "\"Data Plumbers\" 😉 · FDE cluster · AI workload deployment with Mo", + }, + { + "id": "guest", + "name": "Guest", + "short": "Guest", + "role": "Visitor", + "team": "guest", + "email": "", + "focus": "Temporary access · pick your name next time", }, ] # Back-compat alias used by older ticket endpoints / UI @@ -2891,12 +2906,43 @@ def init_db(): created_at REAL NOT NULL, updated_at REAL NOT NULL ); + CREATE TABLE IF NOT EXISTS chat_rooms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + key TEXT NOT NULL UNIQUE, + title TEXT, + created_at REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS chat_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE, + uploader_id TEXT NOT NULL, + filename TEXT NOT NULL, + mime TEXT, + size INTEGER NOT NULL DEFAULT 0, + stored_path TEXT NOT NULL, + created_at REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE, + author_id TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + file_id INTEGER REFERENCES chat_files(id) ON DELETE SET NULL, + created_at REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS chat_presence ( + user_id TEXT PRIMARY KEY, + last_seen REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_chat_messages_room ON chat_messages(room_id, created_at); """ ) _restore_ops_db_if_empty() _seed_atc_racks() _seed_atc_vlans() _seed_network_endpoints() + _ensure_team_chat_seed() n = 0 try: with _db() as conn: @@ -2906,6 +2952,131 @@ def init_db(): log.info("Ops tickets DB ready at %s (%s tickets)", DB_PATH, n) +TEAM_CHAT_DIR = DATA_DIR / "team-chat" +TEAM_AVATAR_DIR = DATA_DIR / "team-chat" / "avatars" +TEAM_CHAT_MAX_BYTES = 25 * 1024 * 1024 +TEAM_AVATAR_MAX_BYTES = 5 * 1024 * 1024 +TEAM_CHAT_CLIENTS: dict[str, set[WebSocket]] = defaultdict(set) +TEAM_AVATAR_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} + + +def _ensure_team_chat_seed() -> None: + TEAM_CHAT_DIR.mkdir(parents=True, exist_ok=True) + TEAM_AVATAR_DIR.mkdir(parents=True, exist_ok=True) + now = time.time() + with _db() as conn: + row = conn.execute("SELECT id FROM chat_rooms WHERE key='team'").fetchone() + if not row: + conn.execute( + "INSERT INTO chat_rooms(kind, key, title, created_at) VALUES (?,?,?,?)", + ("team", "team", "ATC Team", now), + ) + conn.commit() + log.info("Seeded ATC Team chat room") + + +def _require_ops_user_id(uid: str) -> str: + uid = (uid or "").strip().lower() + if not _ops_user(uid): + raise HTTPException(400, f"Unknown ATC user: {uid}") + return uid + + +def _dm_room_key(a: str, b: str) -> str: + x, y = sorted([a, b]) + return f"dm:{x}:{y}" + + +def _ensure_dm_room(user_a: str, user_b: str) -> dict: + a = _require_ops_user_id(user_a) + b = _require_ops_user_id(user_b) + if a == b: + raise HTTPException(400, "Cannot DM yourself") + key = _dm_room_key(a, b) + now = time.time() + with _db() as conn: + row = conn.execute("SELECT * FROM chat_rooms WHERE key=?", (key,)).fetchone() + if row: + return dict(row) + ua, ub = _ops_user(a), _ops_user(b) + title = f"{ua.get('short') or ua['name'].split()[0]} ↔ {ub.get('short') or ub['name'].split()[0]}" + cur = conn.execute( + "INSERT INTO chat_rooms(kind, key, title, created_at) VALUES (?,?,?,?)", + ("dm", key, title, now), + ) + conn.commit() + rid = cur.lastrowid + row = conn.execute("SELECT * FROM chat_rooms WHERE id=?", (rid,)).fetchone() + return dict(row) + + +def _message_dict(conn, row) -> dict: + d = dict(row) + file_meta = None + if d.get("file_id"): + fr = conn.execute("SELECT * FROM chat_files WHERE id=?", (d["file_id"],)).fetchone() + if fr: + file_meta = { + "id": fr["id"], + "filename": fr["filename"], + "mime": fr["mime"], + "size": fr["size"], + "url": f"/api/team/files/{fr['id']}", + } + author = _ops_user(d["author_id"]) or {"id": d["author_id"], "name": d["author_id"], "short": d["author_id"]} + return { + "id": d["id"], + "room_id": d["room_id"], + "author_id": d["author_id"], + "author_name": author.get("name"), + "author_short": author.get("short") or (author.get("name") or "").split()[0], + "author_team": author.get("team"), + "author_avatar": f"/api/team/avatar/{d['author_id']}", + "body": d["body"] or "", + "file": file_meta, + "created_at": d["created_at"], + } + + +async def _team_broadcast(payload: dict, exclude: WebSocket | None = None) -> None: + dead: list[tuple[str, WebSocket]] = [] + for uid, socks in list(TEAM_CHAT_CLIENTS.items()): + for ws in list(socks): + if ws is exclude: + continue + try: + await ws.send_json(payload) + except Exception: + dead.append((uid, ws)) + for uid, ws in dead: + TEAM_CHAT_CLIENTS[uid].discard(ws) + + +def _presence_snapshot() -> list[dict]: + now = time.time() + online = {uid for uid, socks in TEAM_CHAT_CLIENTS.items() if socks} + with _db() as conn: + rows = conn.execute("SELECT user_id, last_seen FROM chat_presence").fetchall() + out = [] + for u in OPS_USERS: + last = next((r["last_seen"] for r in rows if r["user_id"] == u["id"]), None) + out.append( + { + "id": u["id"], + "name": u["name"], + "short": u.get("short") or u["name"].split()[0], + "team": u["team"], + "role": u.get("role"), + "focus": u.get("focus"), + "avatar_url": f"/api/team/avatar/{u['id']}", + "online": u["id"] in online, + "last_seen": last, + "recent": bool(last and (now - last) < 120), + } + ) + return out + + async def fetch_gpu() -> dict: url = settings.gpu_metrics_url.rstrip("/") + "/api/gpu" try: @@ -3939,9 +4110,9 @@ def build_fleet_context(focus_device_id: int | None = None, max_chars: int | Non lines = [ "You are OpenManage Cockpit Copilot for Dell ATC. Be concise and operational.", - "ATC Datacenter Admins (escalate here when facts are missing):", - " - Jody van Dongen ", - " - Laurens Rammers ", + "ATC Datacenter Engineers (escalate here when facts are missing):", + " - Jody van Dongen — storage, servers, network, rack & stack, installs", + " - Laurens Rammers — storage, servers, network, rack & stack, installs", "ACCURACY RULES (mandatory — never break these):", "1) Use ONLY facts from this snapshot and any TOOL FACTS block. Never invent Service Tags, IPs, DIMM counts, firmware versions, RDP targets, port maps, VLAN members, or rack placements.", "2) If a requested fact is not present in the snapshot/TOOL FACTS, or tools failed/returned empty: say exactly what is unknown, then tell the user to overleggen met Jody van Dongen and Laurens Rammers (emails above). Do not guess.", @@ -4516,11 +4687,12 @@ async def api_ops_users(): "users": OPS_USERS, "admins": [u for u in OPS_USERS if u.get("team") == "admin"], "fde": [u for u in OPS_USERS if u.get("team") == "fde"], + "guests": [u for u in OPS_USERS if u.get("team") == "guest"], "context": { "cockpit": "OME Cockpit by Data Forward Deployed Engineers Mohamed El Kadi & Bart Sjerps", "cluster": "Runs on the FDE cluster operated by Data Forward Deployed Engineers Mohamed El Kadi and Bart Sjerps", - "admins": "Jody van Dongen and Laurens Rammers — ATC datacenter administrators", - "fde": "Mo and Bart — both Data Forward Deployed Engineers deploying AI workloads", + "admins": "Jody van Dongen and Laurens Rammers — ATC Datacenter Engineers (storage, servers, network, rack & stack, installs)", + "fde": "Mo and Bart — Data Forward Deployed Engineers, aka \"Data Plumbers\" 😉", }, } @@ -4734,7 +4906,7 @@ async def api_chat(payload: ChatIn): user_msg = payload.message[:2500] escalate = ( "If any needed fact is absent, say it is unknown and instruct the user to overleggen met " - "Jody van Dongen (jody.van.dongen@dell.com) and Laurens Rammers (laurens.rammers@dell.com). " + "Jody van Dongen (jody.van.dongen@dell.com) and Laurens Rammers (laurens.rammers@dell.com) — Datacenter Engineers. " "Never invent." ) if tool_results: @@ -4848,6 +5020,335 @@ def _admin_name(aid: str) -> str: return u["name"] if u else aid +class TeamMessageIn(BaseModel): + author_id: str + body: str = "" + + +class TeamDmIn(BaseModel): + me: str + peer: str + + +def _avatar_path(user_id: str) -> Path | None: + """Return existing avatar file for user, if any.""" + TEAM_AVATAR_DIR.mkdir(parents=True, exist_ok=True) + for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif"): + p = TEAM_AVATAR_DIR / f"{user_id}{ext}" + if p.is_file(): + return p + return None + + +def _ops_users_public() -> list[dict]: + out = [] + for u in OPS_USERS: + d = dict(u) + d["short"] = u.get("short") or u["name"].split()[0] + d["avatar_url"] = f"/api/team/avatar/{u['id']}" + d["has_avatar"] = _avatar_path(u["id"]) is not None + out.append(d) + return out + + +@app.get("/api/team/users") +async def team_users(): + return {"users": _ops_users_public(), "presence": _presence_snapshot()} + + +@app.get("/api/team/avatar/{user_id}") +async def team_get_avatar(user_id: str): + uid = (user_id or "").strip().lower() + if not _ops_user(uid): + raise HTTPException(404, "Unknown user") + path = _avatar_path(uid) + if not path: + # 1x1 transparent PNG so doesn't break + raise HTTPException(404, "No avatar") + media = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".gif": "image/gif", + }.get(path.suffix.lower(), "application/octet-stream") + resp = FileResponse(path, media_type=media) + resp.headers["Cache-Control"] = "public, max-age=60" + return resp + + +@app.post("/api/team/avatar") +async def team_upload_avatar( + user_id: str = Form(...), + file: UploadFile = File(...), +): + uid = _require_ops_user_id(user_id) + raw_name = Path(file.filename or "avatar.jpg").name + ext = Path(raw_name).suffix.lower() + if ext not in TEAM_AVATAR_EXTS: + raise HTTPException(400, "Avatar must be jpg, png, webp, or gif") + data = await file.read() + if not data: + raise HTTPException(400, "Empty file") + if len(data) > TEAM_AVATAR_MAX_BYTES: + raise HTTPException(400, "Avatar too large (max 5 MB)") + # basic sniff + head = data[:16] + ok = ( + head.startswith(b"\xff\xd8\xff") + or head.startswith(b"\x89PNG\r\n\x1a\n") + or head.startswith(b"GIF87a") + or head.startswith(b"GIF89a") + or head.startswith(b"RIFF") + ) + if not ok: + raise HTTPException(400, "File does not look like an image") + TEAM_AVATAR_DIR.mkdir(parents=True, exist_ok=True) + # remove previous variants + for old in TEAM_AVATAR_DIR.glob(f"{uid}.*"): + try: + old.unlink() + except Exception: + pass + dest = TEAM_AVATAR_DIR / f"{uid}{ext}" + dest.write_bytes(data) + await _team_broadcast( + { + "type": "avatar", + "user_id": uid, + "avatar_url": f"/api/team/avatar/{uid}?t={int(time.time())}", + } + ) + return { + "ok": True, + "user_id": uid, + "avatar_url": f"/api/team/avatar/{uid}?t={int(time.time())}", + } + + +@app.get("/api/team/rooms") +async def team_rooms(me: str = "mo"): + me = _require_ops_user_id(me) + # ensure team room + all DM rooms exist for this user + for u in OPS_USERS: + if u["id"] != me: + _ensure_dm_room(me, u["id"]) + with _db() as conn: + team = conn.execute("SELECT * FROM chat_rooms WHERE key='team'").fetchone() + all_dms = conn.execute("SELECT * FROM chat_rooms WHERE kind='dm' ORDER BY title").fetchall() + dms = [] + for r in all_dms: + parts = (r["key"] or "").split(":") + if len(parts) == 3 and me in (parts[1], parts[2]): + dms.append(r) + rooms = [] + if team: + rooms.append(dict(team)) + rooms.extend(dict(r) for r in dms) + # last message preview + for r in rooms: + last = conn.execute( + "SELECT * FROM chat_messages WHERE room_id=? ORDER BY created_at DESC LIMIT 1", + (r["id"],), + ).fetchone() + r["last_message"] = _message_dict(conn, last) if last else None + # peer for DMs + if r["kind"] == "dm": + parts = r["key"].split(":") + peer = parts[1] if parts[2] == me else parts[2] + pu = _ops_user(peer) + r["peer_id"] = peer + r["peer_name"] = pu["name"] if pu else peer + r["peer_short"] = (pu.get("short") if pu else None) or (pu["name"].split()[0] if pu else peer) + r["peer_team"] = pu.get("team") if pu else None + r["peer_avatar"] = f"/api/team/avatar/{peer}" + return {"me": me, "rooms": rooms, "presence": _presence_snapshot(), "users": _ops_users_public()} + + +@app.post("/api/team/rooms/dm") +async def team_open_dm(payload: TeamDmIn): + room = _ensure_dm_room(payload.me, payload.peer) + return {"room": room} + + +@app.get("/api/team/rooms/{room_id}/messages") +async def team_list_messages(room_id: int, limit: int = 200): + limit = max(1, min(limit, 500)) + with _db() as conn: + room = conn.execute("SELECT * FROM chat_rooms WHERE id=?", (room_id,)).fetchone() + if not room: + raise HTTPException(404, "Room not found") + rows = conn.execute( + "SELECT * FROM chat_messages WHERE room_id=? ORDER BY created_at ASC LIMIT ?", + (room_id, limit), + ).fetchall() + return { + "room": dict(room), + "messages": [_message_dict(conn, r) for r in rows], + } + + +@app.post("/api/team/rooms/{room_id}/messages") +async def team_post_message(room_id: int, payload: TeamMessageIn): + author = _require_ops_user_id(payload.author_id) + body = (payload.body or "").strip() + if not body: + raise HTTPException(400, "Empty message") + if len(body) > 8000: + raise HTTPException(400, "Message too long") + now = time.time() + with _db() as conn: + room = conn.execute("SELECT * FROM chat_rooms WHERE id=?", (room_id,)).fetchone() + if not room: + raise HTTPException(404, "Room not found") + cur = conn.execute( + "INSERT INTO chat_messages(room_id, author_id, body, file_id, created_at) VALUES (?,?,?,?,?)", + (room_id, author, body, None, now), + ) + conn.execute( + "INSERT INTO chat_presence(user_id, last_seen) VALUES (?,?) " + "ON CONFLICT(user_id) DO UPDATE SET last_seen=excluded.last_seen", + (author, now), + ) + conn.commit() + mid = cur.lastrowid + row = conn.execute("SELECT * FROM chat_messages WHERE id=?", (mid,)).fetchone() + msg = _message_dict(conn, row) + await _team_broadcast({"type": "message", "message": msg}) + return {"message": msg} + + +@app.post("/api/team/rooms/{room_id}/files") +async def team_upload_file( + room_id: int, + uploader_id: str = Form(...), + body: str = Form(""), + file: UploadFile = File(...), +): + author = _require_ops_user_id(uploader_id) + with _db() as conn: + room = conn.execute("SELECT * FROM chat_rooms WHERE id=?", (room_id,)).fetchone() + if not room: + raise HTTPException(404, "Room not found") + raw_name = Path(file.filename or "upload.bin").name + safe = re.sub(r"[^\w.\- ()\[\]]+", "_", raw_name)[:180] or "upload.bin" + data = await file.read() + if not data: + raise HTTPException(400, "Empty file") + if len(data) > TEAM_CHAT_MAX_BYTES: + raise HTTPException(400, f"File too large (max {TEAM_CHAT_MAX_BYTES // (1024*1024)} MB)") + room_dir = TEAM_CHAT_DIR / str(room_id) + room_dir.mkdir(parents=True, exist_ok=True) + stored_name = f"{uuid.uuid4().hex}_{safe}" + dest = room_dir / stored_name + dest.write_bytes(data) + now = time.time() + caption = (body or "").strip()[:2000] + with _db() as conn: + cur = conn.execute( + """ + INSERT INTO chat_files(room_id, uploader_id, filename, mime, size, stored_path, created_at) + VALUES (?,?,?,?,?,?,?) + """, + (room_id, author, safe, file.content_type or "application/octet-stream", len(data), str(dest), now), + ) + fid = cur.lastrowid + cur2 = conn.execute( + "INSERT INTO chat_messages(room_id, author_id, body, file_id, created_at) VALUES (?,?,?,?,?)", + (room_id, author, caption or f"Shared {safe}", fid, now), + ) + conn.execute( + "INSERT INTO chat_presence(user_id, last_seen) VALUES (?,?) " + "ON CONFLICT(user_id) DO UPDATE SET last_seen=excluded.last_seen", + (author, now), + ) + conn.commit() + mid = cur2.lastrowid + row = conn.execute("SELECT * FROM chat_messages WHERE id=?", (mid,)).fetchone() + msg = _message_dict(conn, row) + await _team_broadcast({"type": "message", "message": msg}) + return {"message": msg} + + +@app.get("/api/team/files/{file_id}") +async def team_download_file(file_id: int): + with _db() as conn: + fr = conn.execute("SELECT * FROM chat_files WHERE id=?", (file_id,)).fetchone() + if not fr: + raise HTTPException(404, "File not found") + path = Path(fr["stored_path"]) + if not path.is_file(): + raise HTTPException(404, "File missing on disk") + return FileResponse( + path, + filename=fr["filename"], + media_type=fr["mime"] or "application/octet-stream", + ) + + +@app.websocket("/ws/team-chat") +async def ws_team_chat(ws: WebSocket, user: str = "mo"): + try: + uid = _require_ops_user_id(user) + except HTTPException: + await ws.close(code=4400) + return + await ws.accept() + TEAM_CHAT_CLIENTS[uid].add(ws) + now = time.time() + with _db() as conn: + conn.execute( + "INSERT INTO chat_presence(user_id, last_seen) VALUES (?,?) " + "ON CONFLICT(user_id) DO UPDATE SET last_seen=excluded.last_seen", + (uid, now), + ) + conn.commit() + await _team_broadcast({"type": "presence", "presence": _presence_snapshot()}) + try: + await ws.send_json({"type": "hello", "user": uid, "presence": _presence_snapshot()}) + while True: + raw = await ws.receive_text() + try: + msg = json.loads(raw) + except Exception: + continue + mtype = msg.get("type") + if mtype == "ping": + with _db() as conn: + conn.execute( + "INSERT INTO chat_presence(user_id, last_seen) VALUES (?,?) " + "ON CONFLICT(user_id) DO UPDATE SET last_seen=excluded.last_seen", + (uid, time.time()), + ) + conn.commit() + await ws.send_json({"type": "pong", "presence": _presence_snapshot()}) + elif mtype == "typing": + await _team_broadcast( + { + "type": "typing", + "user_id": uid, + "room_id": msg.get("room_id"), + "name": (_ops_user(uid) or {}).get("name"), + }, + exclude=ws, + ) + elif mtype == "message": + # allow send via WS as convenience + room_id = int(msg.get("room_id") or 0) + body = str(msg.get("body") or "").strip() + if room_id and body: + posted = await team_post_message(room_id, TeamMessageIn(author_id=uid, body=body)) + # already broadcast inside team_post_message + _ = posted + except WebSocketDisconnect: + pass + except Exception as e: + log.debug("team chat ws end: %s", e) + finally: + TEAM_CHAT_CLIENTS[uid].discard(ws) + await _team_broadcast({"type": "presence", "presence": _presence_snapshot()}) + + @app.get("/api/tickets") async def list_tickets(): with _db() as conn: @@ -6463,6 +6964,11 @@ async def console_js(): return FileResponse(STATIC_DIR / "console.js", media_type="application/javascript") +@app.get("/team.js") +async def team_js(): + return FileResponse(STATIC_DIR / "team.js", media_type="application/javascript") + + @app.get("/ssh.js") async def ssh_js(): return FileResponse(STATIC_DIR / "ssh.js", media_type="application/javascript") diff --git a/api/requirements.txt b/api/requirements.txt index 4803cce..4a8702b 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -2,6 +2,7 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 httpx==0.28.1 websockets==14.1 +python-multipart==0.0.20 pydantic==2.10.4 pydantic-settings==2.7.0 asyncssh==2.18.0 diff --git a/ui/app.js b/ui/app.js index a271ee2..5613e89 100644 --- a/ui/app.js +++ b/ui/app.js @@ -2785,7 +2785,7 @@ setTimeout(() => { const input = document.getElementById("chat-input"); if (input) { - input.value = `Give operational context for ${node.name} (${node.ip || "no IP"}). Status ${node.status}, connected=${node.connected}, watts=${node.watts ?? "n/a"}. Suggest next actions for ATC admins.`; + input.value = `Give operational context for ${node.name} (${node.ip || "no IP"}). Status ${node.status}, connected=${node.connected}, watts=${node.watts ?? "n/a"}. Suggest next actions for Datacenter Engineers.`; input.focus(); } }, 150); @@ -3113,9 +3113,10 @@ mode === "reports-drawer" || mode === "an-context" || mode === "network-drawer" || - mode === "console-drawer" + mode === "console-drawer" || + mode === "team-drawer" ) { - /* reports.js / ops.js / network.js / console.js also listen */ + /* reports.js / ops.js / network.js / console.js / team.js also listen */ } else closeAi(); }); $("#btn-ome-console").addEventListener("click", () => { diff --git a/ui/console.js b/ui/console.js index af56d9c..427f7df 100644 --- a/ui/console.js +++ b/ui/console.js @@ -127,7 +127,7 @@ } function closeOtherDrawers() { - ["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#network-drawer"].forEach((id) => { + ["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#network-drawer", "#team-drawer"].forEach((id) => { const el = $(id); if (el) { el.classList.remove("open"); diff --git a/ui/index.html b/ui/index.html index 33ddd6c..3fc7528 100644 --- a/ui/index.html +++ b/ui/index.html @@ -7,7 +7,7 @@ - + - - + + - + + diff --git a/ui/network.js b/ui/network.js index 1da34b0..f3b04ca 100644 --- a/ui/network.js +++ b/ui/network.js @@ -95,7 +95,7 @@ const drawer = $("#network-drawer"); const scrim = $("#scrim"); if (!drawer) return; - ["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#console-drawer"].forEach((id) => { + ["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#console-drawer", "#team-drawer"].forEach((id) => { const el = $(id); if (el) { el.classList.remove("open"); diff --git a/ui/ops.js b/ui/ops.js index 604c723..2c407d2 100644 --- a/ui/ops.js +++ b/ui/ops.js @@ -44,10 +44,11 @@ let gpuData = null; let chatModelsLoaded = false; let opsUsers = [ - { id: "jody", name: "Jody van Dongen", team: "admin", role: "ATC Datacenter Admin" }, - { id: "laurens", name: "Laurens Rammers", team: "admin", role: "ATC Datacenter Admin" }, - { id: "mo", name: "Mohamed El Kadi", team: "fde", role: "Data Forward Deployed Engineer" }, - { id: "bart", name: "Bart Sjerps", team: "fde", role: "Data Forward Deployed Engineer" }, + { 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() { @@ -98,16 +99,19 @@ 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) => ``; - return `${admins.map(opt).join("")} - ${fde.map(opt).join("")}`; + return `${admins.map(opt).join("")} + ${fde.map(opt).join("")} + ${guests.length ? `${guests.map(opt).join("")}` : ""}`; } async function loadOpsUsers() { @@ -856,7 +860,7 @@ ((node && node.ip) || (a && a.ip) || "no-ip") + "): " + it.message + - ". Suggest next steps for ATC admins Jody and Laurens." + ". Suggest next steps for Datacenter Engineers Jody and Laurens." ); }); $("#triage-inspect")?.addEventListener("click", () => { @@ -952,6 +956,9 @@ activeTicketId = null; }); $("#btn-ops-close")?.addEventListener("click", closeDrawers); + $("#btn-ops-open-team")?.addEventListener("click", () => { + window.cockpitTeam?.open?.(); + }); $("#btn-ops-refresh")?.addEventListener("click", () => loadTickets().catch((e) => alert(e.message))); $("#ops-quick")?.addEventListener("click", (e) => { @@ -962,10 +969,14 @@ openTicketModal(); return; } + if (q === "team") { + window.cockpitTeam?.open?.(); + return; + } if (q === "to-admin") { openTicketModal({ - title: "Handoff to ATC admin", - body: "Context for Jody / Laurens:\n\n", + title: "Handoff to Datacenter Engineer", + body: "Context for Jody / Laurens (storage · servers · network · rack & stack · installs):\n\n", assignee: "jody", priority: "normal", }); @@ -974,7 +985,7 @@ if (q === "to-fde") { openTicketModal({ title: "Data FDE / AI cluster request", - body: "Request for Mohamed El Kadi / Bart Sjerps (Data Forward Deployed Engineers · AI workloads):\n\n", + body: "Request for Mohamed El Kadi / Bart Sjerps (Data FDEs · \"Data Plumbers\" 😉 · AI workloads):\n\n", assignee: "mo", priority: "normal", }); diff --git a/ui/present.js b/ui/present.js index 7a04e88..38927fd 100644 --- a/ui/present.js +++ b/ui/present.js @@ -28,16 +28,17 @@ title: "What we will cover", anim: "rise", html: ` -

ATC admins asked us to help them see OME data faster and explain it with AI. This briefing shows the result — and why a cockpit like this adds value.

+

Datacenter Engineers asked us to help them see OME data faster and explain it with AI. This briefing shows the result — and why a cockpit like this adds value.

  1. Why — the gap around OME and why AI alone is not enough.
  2. Architecture — one design: Browser → Cockpit BFF → OME / AI (with logos).
  3. -
  4. Apps — map, network, reports, Console wall, ops desk, copilots.
  5. +
  6. Apps — map, network, reports, Console wall, Ops + Team chat, copilots.
  7. Remote ops — power via OME→iDRAC and live consoles in-panel.
  8. -
  9. People — who asked, who built, who runs it.
  10. +
  11. Team — soft identity, chat, files, avatars, live unread alerts.
  12. +
  13. People — Datacenter Engineers & Data FDEs ("Data Plumbers" 😉).
  14. Value — what customers can take away.
-

Requested by Jody van Dongen & Laurens Rammers · delivered by the FDE team (Mohamed El Kadi & Bart Sjerps).

`, +

Requested by Jody van Dongen & Laurens Rammers · delivered by the FDE team (Mo & Bart"Data Plumbers" 😉).

`, }, { id: "problem", @@ -51,7 +52,7 @@

Context loss

Warranty, firmware, and offline state are related — rarely one story.

Ungrounded AI

Generic chatbots guess. Useful AI must start from OME.

-

Jody and Laurens asked the FDE team to assist — build a practical ops lens on top of OME, not replace OME.

`, +

Jody and Laurens (Datacenter Engineers) asked the FDE team to assist — build a practical ops lens on top of OME, not replace OME.

`, }, { id: "value", @@ -63,8 +64,8 @@

One Service Tag story

Map, inventory, compliance, warranty, fabric, and tickets around the same ST.

Remote ops in UI

Power via OME jobs · live iDRAC Console wall (4/8) without tab sprawl.

AI that cites the fleet

Copilot answers from live OME facts — or says it does not know.

-

Faster briefings

Click a chart → named systems → hand off in Ops desk.

-

Admin ↔ Data FDE

Clear roles: ATC admins own the estate; Data FDEs deliver the AI/ops surface.

+

Ops + Team chat

Tickets and live team / DM chat with files, avatars, and unread alerts.

+

DC Engineer ↔ Data FDE

Jody & Laurens own the estate; Mo & Bart ("Data Plumbers" 😉) deliver the surface.

Copyable pattern

OME API + BFF + grounded AI — a blueprint, not a Dell SKU.

`, }, @@ -117,7 +118,7 @@
ConsoleiDRAC wall
Cockpit UIDrawers · KPIs
NetworkFabric · Racks
-
Ops / AITickets · Copilot
+
Ops / TeamTickets · chat
@@ -163,10 +164,10 @@
Console wall4 / 8 live iDRAC embeds · drag
NetworkFabric · 42U racks · VLANs
ReportsCompliance · warranty analytics
-
Ops deskAdmin ↔ Data FDE tickets
+
Ops + TeamTickets · chat · files · avatars
CopilotGrounded chat on Service Tags
-

One join key everywhere: Service Tag. Power and console actions ride OME → iDRAC — not a second inventory.

`, +

One join key everywhere: Service Tag. Soft identity on open so Jody, Laurens, Mo, Bart (or Guest) act as themselves.

`, }, { id: "console-wall", @@ -200,6 +201,22 @@

Demo power with care — production change windows still belong in official OME / iDRAC process.

`, }, + { + id: "team-chat", + kicker: "Collaboration", + title: "Team chat · who you are, what you share", + anim: "rise", + html: ` +

First popup on open: pick yourself (or Guest). Then Ops desk and Team chat share one soft identity — tickets, DMs, and files stay attributable without SSO.

+
+

Identity gate

Jody · Laurens · Mo · Bart · Guest — profile photo upload optional.

+

Team + DMs

Shared ATC room and 1:1 chats · WebSocket live presence.

+

Files & avatars

Drop files into chat · avatars on bubbles, presence, and the Team chip.

+

Unread alerts

Badge on Ops|Team · room counters · browser notify when the tab is in the background.

+

Ops|Team combo

One topbar control: tickets left, Team + who-you-are right — KPIs keep a single row.

+

Roles that match reality

Datacenter Engineers own rack & stack; FDEs are "Data Plumbers" 😉.

+
`, + }, { id: "ai", kicker: "AI + OME", @@ -219,24 +236,24 @@ { id: "origin", kicker: "How this started", - title: "Admins set the need · Data FDEs delivered the surface", + title: "Datacenter Engineers set the need · Data FDEs delivered the surface", anim: "rise", html: ` -

Jody van Dongen and Laurens Rammers defined the operational need and asked the FDE team to help. Together they produced a cockpit that sits on OME: topology, reports, network context, tickets, and AI — for ATC use and customer conversations, explicitly not a product SKU.

+

Jody van Dongen and Laurens Rammers (Datacenter Engineers — storage, servers, network, rack & stack, installs) defined the operational need and asked the FDE team to help. Together they produced a cockpit that sits on OME: topology, reports, network context, tickets, and AI — for ATC use and customer conversations, explicitly not a product SKU.

-

ATC Admins · the ask

+

Datacenter Engineers · the ask

    -
  • Jody van Dongen — fleet, racks, warranty / compliance
  • -
  • Laurens Rammers — datacenter ops & escalation
  • +
  • Jody van Dongen — storage, servers, network, rack & stack, installs
  • +
  • Laurens Rammers — storage, servers, network, rack & stack, installs
  • Better visibility and faster OME storytelling

Data FDEs · the delivery

    -
  • Mohamed El Kadi — cockpit UX/API, OME integration, Copilot, Present
  • -
  • Bart Sjerps — FDE cluster, Open WebUI / AI workloads, hosting
  • +
  • Mohamed El Kadi — cockpit UX/API, OME integration, Copilot, Present · "Data Plumbers" 😉
  • +
  • Bart Sjerps — FDE cluster, Open WebUI / AI workloads, hosting · "Data Plumbers" 😉
@@ -252,7 +269,7 @@

Clarity

One place for connectivity, compliance, warranty, and hardware depth.

Credibility

Claims trace to a Service Tag from OME.

Speed

AI drafts explanations; humans own change windows.

-

Collaboration

Admins and Data FDEs share one Ops language.

+

Collaboration

Datacenter Engineers and Data FDEs ("Data Plumbers" 😉) share Ops + Team chat.

Pattern

OME + Docker BFF + grounded AI — repeatable.

Honesty

Demo power with an explicit non-product disclaimer.

`, @@ -264,10 +281,11 @@ anim: "zoom", html: `
    +
  1. Pick yourself on the identity gate (or Guest) when the UI opens.
  2. Pick a Service Tag on the map and open the inspector.
  3. Open Console — Auto-fill live iDRACs (4 or 8 screens).
  4. From Servers KPI: Power on a cold node · open iDRAC console in-panel.
  5. -
  6. Open Reports · Analytics and click a colored segment.
  7. +
  8. Open Ops|Team — create a ticket, then send a Team or DM message with a file.
  9. Ask Copilot a question that must cite Service Tags.
  10. Switch to Technical architecture for API and trust-boundary depth.
@@ -308,8 +326,9 @@
  • AI completion path
  • Portal containers & key /api/* contracts
  • iDRAC proxy · power jobs · Console wall
  • +
  • Team chat · soft identity · WebSocket · file / avatar store
  • -

    Delivered for ATC admins Jody & Laurens by Data FDEs Mo & Bart.

    `, +

    Delivered for Datacenter Engineers Jody & Laurens by Data FDEs Mo & Bart ("Data Plumbers" 😉).

    `, }, { id: "arch-design", @@ -360,7 +379,7 @@
    ConsoleiDRAC wall
    Cockpit UIDrawers · KPIs
    NetworkFabric · Racks
    -
    Ops / AITickets · Copilot
    +
    Ops / TeamTickets · chat
    @@ -496,7 +515,7 @@

    /api/fleet

    Cached device graph for canvas & KPIs.

    /api/devices/…/power

    OME JobService POWER_CONTROL · on / off / cycle.

    /api/idrac-proxy/…

    Same-origin HTML + WebSocket bridge · strips XFO.

    -

    /api/reports/*

    Analytics, firmware, warranty, brief.

    +

    /api/team/* · /ws/team-chat

    Rooms, DMs, files, avatars · live presence & messages.

    /api/chat · /api/models

    Grounded completions · model list.

    /api/network/*

    Fabric ports · racks · VLANs.

    `, @@ -531,12 +550,14 @@

    Cockpit-local (write)

    • Fabric / racks / tickets / presentation decks
    • +
    • Team chat messages, files, and profile avatars (/data/team-chat)

    Security notes

    • Secrets only in portal environment variables
    • +
    • Soft identity is localStorage (ops convenience — not SSO)
    • iDRAC proxy is an ops convenience — credentials stay with the user
    • Production change windows still belong in official OME / iDRAC process
    @@ -552,7 +573,7 @@
    1. Return to the map and pick a Service Tag.
    2. Open Console and drop two iDRACs onto the wall.
    3. -
    4. Open Reports and click a compliance segment.
    5. +
    6. Open Ops|Team — ticket + a chat message with unread badge.
    7. Ask Copilot a question that must cite that ST.
    8. Use Customer story for business / ops audiences.
    @@ -560,20 +581,20 @@ }, ]; - const BUILTIN_VERSION = 9; + const BUILTIN_VERSION = 10; const BUILTIN_DECKS = [ { id: "story", name: "Customer story", - description: "Customer briefing — ask, Console wall, power, architecture, value", + description: "Customer briefing — Console, power, Team chat, roles, value", builtin: true, slides: structuredClone(STORY_SLIDES), }, { id: "technical", name: "Technical architecture", - description: "Engineer deep dive — design, BFF, APIs, trust", + description: "Engineer deep dive — design, BFF, APIs, Team chat, trust", builtin: true, slides: structuredClone(TECH_SLIDES), }, diff --git a/ui/reports.js b/ui/reports.js index 1ebf1c7..6e58e45 100644 --- a/ui/reports.js +++ b/ui/reports.js @@ -77,7 +77,7 @@ const drawer = $("#reports-drawer"); const scrim = $("#scrim"); if (!drawer) return; - ["#chat-drawer", "#ops-drawer", "#ai-drawer", "#network-drawer", "#console-drawer"].forEach((id) => { + ["#chat-drawer", "#ops-drawer", "#ai-drawer", "#network-drawer", "#console-drawer", "#team-drawer"].forEach((id) => { const el = $(id); if (el) { el.classList.remove("open"); diff --git a/ui/styles.css b/ui/styles.css index fedd25c..f276ca5 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -146,22 +146,24 @@ button { cursor: pointer; } .kpi-strip { flex: 1; display: flex; - flex-wrap: wrap; - gap: 0.3rem; - overflow: visible; - padding: 0.35rem 0; + flex-wrap: nowrap; + gap: 0.25rem; + overflow-x: auto; + overflow-y: hidden; + padding: 0.2rem 0; min-width: 0; justify-content: flex-start; align-content: center; + scrollbar-width: thin; } .kpi { appearance: none; border: 1px solid transparent; background: rgba(0, 118, 206, 0.12); border-radius: 6px; - padding: 0.28rem 0.55rem; + padding: 0.22rem 0.42rem; min-width: 0; - flex: 0 1 auto; + flex: 0 0 auto; text-align: left; transition: border-color 0.15s, background 0.15s, transform 0.15s; } @@ -5233,58 +5235,47 @@ html[data-theme="light"] .rs-prod-meta strong { color: #122033; } html[data-theme="light"] .rs-prod-meta em, html[data-theme="light"] .rs-lib-hint { color: #4a5d72; } -/* Theme FAB — parks left while Full Inventory is open (inspector is right) */ -.theme-fab { - position: fixed; - right: 14px; - left: auto; - bottom: 48px; - z-index: 9000; - width: 42px; - height: 42px; - border-radius: 999px; - border: 2px solid rgba(255, 255, 255, 0.35); - background: linear-gradient(145deg, #00a8e8, #0076ce); - color: #fff; - display: grid; - place-items: center; - box-shadow: - 0 4px 14px rgba(0, 118, 206, 0.45), - 0 0 0 3px rgba(0, 168, 232, 0.2); - cursor: pointer; +/* Theme toggle — in topbar (FAB removed so it no longer blocks the map) */ +.btn.theme-toggle { + width: 2.15rem; + min-width: 2.15rem; + height: 2.15rem; padding: 0; - transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease, left 0.2s ease, right 0.2s ease, opacity 0.15s ease; + display: inline-grid; + place-items: center; + border-radius: 8px; + flex-shrink: 0; } -body.inv-open .theme-fab { - right: auto; - left: 14px; - bottom: 56px; - opacity: 0.92; - box-shadow: - 0 4px 14px rgba(255, 154, 60, 0.35), - 0 0 0 3px rgba(255, 154, 60, 0.18); - border-color: rgba(255, 184, 77, 0.55); -} -.theme-fab:hover { - border-color: #fff; +.btn.theme-toggle .theme-ico { display: none; } +html[data-theme="light"] .btn.theme-toggle .theme-ico-to-dark { display: block; } +html[data-theme="dark"] .btn.theme-toggle .theme-ico-to-light, +html:not([data-theme]) .btn.theme-toggle .theme-ico-to-light { display: block; } +.theme-fab { display: none !important; } + +.team-unread { + display: inline-grid; + place-items: center; + min-width: 1.05rem; + height: 1.05rem; + padding: 0 0.28rem; + border-radius: 999px; + background: #e53935; color: #fff; - transform: translateY(-2px) scale(1.05); - filter: brightness(1.08); - box-shadow: - 0 8px 20px rgba(0, 118, 206, 0.55), - 0 0 0 4px rgba(0, 168, 232, 0.28); + font-size: 0.62rem; + font-weight: 700; + font-family: var(--mono); + line-height: 1; } -.theme-fab .theme-ico { display: none; } -html[data-theme="light"] .theme-fab .theme-ico-moon { display: block; } -html[data-theme="dark"] .theme-fab .theme-ico-sun, -html:not([data-theme]) .theme-fab .theme-ico-sun { display: block; } -html[data-theme="light"] .theme-fab { - background: linear-gradient(145deg, #1aa3e8, #0062b0); - border-color: rgba(255, 255, 255, 0.7); - color: #fff; - box-shadow: - 0 4px 14px rgba(0, 98, 176, 0.35), - 0 0 0 3px rgba(0, 118, 206, 0.15); +.team-unread.hidden { display: none !important; } +.ops-team-combo.has-unread { + box-shadow: 0 0 0 1px rgba(229, 57, 53, 0.55); +} +@keyframes team-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(229, 57, 53, 0.55); } + 50% { box-shadow: 0 0 0 4px rgba(229, 57, 53, 0.15); } +} +.ops-team-combo.has-unread.pulse { + animation: team-pulse 1.2s ease 2; } /* Present button + deck */ @@ -5553,7 +5544,7 @@ html[data-theme="light"] .theme-fab { border-top: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.35); } -body.present-open .theme-fab { opacity: 0; pointer-events: none; } +body.present-open .theme-fab { display: none !important; } .network-drawer.drawer, .reports-drawer.drawer { display: flex; @@ -8812,3 +8803,487 @@ html[data-theme="light"] .ctc-name { color: #102033; } min-height: 0; } .console-fleet-card { cursor: grab; } + +/* ===== ATC Team chat + identity gate ===== */ +.ops-head-actions { + display: flex; + align-items: center; + gap: 0.4rem; + margin-left: auto; +} +.ops-team-combo { + display: inline-flex; + align-items: stretch; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(45, 212, 191, 0.45); + background: rgba(14, 116, 144, 0.12); +} +html[data-theme="light"] .ops-team-combo { + border-color: rgba(0, 118, 206, 0.35); + background: rgba(0, 118, 206, 0.06); +} +html[data-theme="light"] .ops-team-combo #btn-ops { + border-right-color: rgba(0, 118, 206, 0.25); + color: var(--text); +} +.ops-team-combo .btn { + border: none; + border-radius: 0; + margin: 0; +} +.ops-team-combo #btn-ops { + border-right: 1px solid rgba(45, 212, 191, 0.35); + background: transparent; + padding-inline: 0.65rem; +} +.ops-team-combo #btn-ops:hover { + background: rgba(45, 212, 191, 0.12); +} +.ops-team-combo .btn.team-btn { + background: linear-gradient(135deg, rgba(14, 116, 144, 0.35), rgba(45, 212, 191, 0.45)); + border-color: transparent; + color: #d7fffa; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding-right: 0.35rem; +} +.team-btn-label { padding-left: 0.15rem; } +.team-btn-who { + display: inline-flex; + align-items: center; + gap: 0.28rem; + font-size: 0.68rem; + font-weight: 500; + padding: 0.12rem 0.4rem 0.12rem 0.25rem; + border-radius: 999px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.12); + max-width: 6.5rem; + cursor: pointer; +} +.team-btn-who.hidden { display: none !important; } +.team-btn-who:hover { + border-color: rgba(45, 212, 191, 0.55); + background: rgba(0, 0, 0, 0.4); +} +.team-btn-who strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #e8fffb; +} +.tib-av { + width: 1.05rem; + height: 1.05rem; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + background: rgba(255,255,255,0.08); +} +.tib-av img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.tib-dot { + width: 0.35rem; + height: 0.35rem; + border-radius: 50%; + background: #3dffa0; + box-shadow: 0 0 6px #3dffa0; + flex-shrink: 0; +} +.tib-dot.admin { background: #ffb06a; box-shadow: 0 0 6px #ffb06a; } +.tib-dot.guest { background: #9eb4c8; box-shadow: none; } + +.btn.team-btn { + background: linear-gradient(135deg, rgba(14, 116, 144, 0.35), rgba(45, 212, 191, 0.45)); + border-color: rgba(45, 212, 191, 0.65); + color: #d7fffa; + font-weight: 600; +} + +.identity-gate { + position: fixed; + inset: 0; + z-index: 120; + display: grid; + place-items: center; + padding: 1.5rem; + background: + radial-gradient(ellipse 60% 40% at 20% 10%, rgba(0, 168, 232, 0.2), transparent 55%), + radial-gradient(ellipse 50% 40% at 80% 80%, rgba(45, 212, 191, 0.12), transparent 50%), + rgba(4, 10, 18, 0.92); + backdrop-filter: blur(8px); +} +.identity-gate.hidden { display: none !important; } +.identity-gate-card { + width: min(820px, 96vw); + background: rgba(8, 16, 28, 0.95); + border: 1px solid rgba(45, 212, 191, 0.35); + border-radius: 18px; + padding: 1.5rem 1.6rem 1.75rem; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45); + text-align: center; +} +.identity-gate-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + margin-top: 1.1rem; + text-align: left; +} +@media (max-width: 700px) { + .identity-gate-grid { grid-template-columns: 1fr; } +} +.id-card.guest { + grid-column: 1 / -1; + background: rgba(255, 255, 255, 0.04); + border-style: dashed; +} +.id-card.team-guest .id-kicker { color: #a8b8c8; } +body.identity-locked { + overflow: hidden; +} +.id-card { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.28); + color: inherit; + border-radius: 14px; + padding: 0.9rem 1rem 0.9rem 4.1rem; + display: grid; + gap: 0.2rem; + cursor: pointer; + transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s; + position: relative; +} +.id-card.selected { + border-color: rgba(45, 212, 191, 0.7); + box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.35); +} +.id-card:hover { + transform: translateY(-2px); + border-color: rgba(45, 212, 191, 0.55); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35); +} +.id-card.team-admin:hover { border-color: rgba(255, 154, 60, 0.65); } +.id-av { + position: absolute; + left: 0.85rem; + top: 50%; + transform: translateY(-50%); + width: 2.6rem; + height: 2.6rem; + border-radius: 50%; + overflow: hidden; + background: rgba(45, 212, 191, 0.15); + border: 1px solid rgba(255,255,255,0.12); +} +.id-av img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.id-av.fallback img { display: none; } +.id-av-fallback { + display: none; + width: 100%; + height: 100%; + place-items: center; + font-weight: 700; + color: #7ee8d8; + font-size: 1rem; +} +.id-av.fallback .id-av-fallback { display: grid; } +.id-kicker { + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #7ee8d8; +} +.id-card.team-admin .id-kicker { color: #ffb06a; } +.id-card strong { font-size: 1rem; color: #fff; } +.id-role, .id-focus { font-size: 0.72rem; color: #9eb4c8; } + +.identity-avatar-row { + margin-top: 1.15rem; + padding-top: 1rem; + border-top: 1px solid rgba(255,255,255,0.08); + display: flex; + align-items: center; + gap: 0.75rem; + text-align: left; +} +.identity-avatar-row.dim { opacity: 0.55; } +.id-avatar-preview { + width: 3.2rem; + height: 3.2rem; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + background: rgba(45, 212, 191, 0.12); + border: 1px solid rgba(255,255,255,0.12); + display: grid; + place-items: center; + position: relative; +} +.id-avatar-preview img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} +.id-avatar-preview span { + font-weight: 700; + color: #7ee8d8; + font-size: 1.1rem; +} +.identity-avatar-copy { flex: 1; min-width: 0; } +.identity-avatar-copy strong { display: block; color: #e8f0f8; font-size: 0.9rem; } +.identity-avatar-copy .modal-sub { margin: 0.15rem 0 0; font-size: 0.72rem; } + +.team-drawer.drawer, +.drawer.team-drawer { + top: 0; left: 0; right: 0; bottom: 0; + width: 100vw !important; + max-width: 100vw !important; + height: 100vh !important; + max-height: 100vh !important; + border: none; + transform: translateY(110%); + overflow: hidden; + display: flex; + flex-direction: column; + z-index: 80; + background: + radial-gradient(ellipse 50% 35% at 10% 0%, rgba(45, 212, 191, 0.14), transparent 55%), + radial-gradient(ellipse 40% 40% at 90% 20%, rgba(0, 118, 206, 0.12), transparent 50%), + #050b12; +} +.team-drawer.drawer.open { transform: translateY(0); } +.team-head { flex-wrap: wrap; gap: 0.6rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(45, 212, 191, 0.2); } +.team-title { display: block; font-weight: 700; color: #7ee8d8; } +.team-sub { margin: 0.1rem 0 0; font-size: 0.72rem; color: #9eb4c8; } +.team-head-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-left: auto; } +.team-presence { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.tp-pill { + font-size: 0.66rem; + font-family: var(--mono); + padding: 0.15rem 0.45rem 0.15rem 0.2rem; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.1); + display: inline-flex; + align-items: center; + gap: 0.3rem; + color: #c5d6e6; +} +.tp-av { + width: 1.05rem; + height: 1.05rem; + border-radius: 50%; + object-fit: cover; + background: rgba(255,255,255,0.08); +} +.tp-pill i { width: 0.4rem; height: 0.4rem; border-radius: 50%; background: #667788; } +.tp-pill.on { border-color: rgba(61,255,160,0.4); } +.tp-pill.on i { background: #3dffa0; box-shadow: 0 0 8px #3dffa0; } + +.team-body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: min(280px, 34vw) 1fr; +} +.team-rail { + border-right: 1px solid rgba(45, 212, 191, 0.15); + overflow: auto; + background: rgba(0, 12, 20, 0.4); + padding: 0.65rem; +} +.team-room-group { + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #7ee8d8; + margin: 0.55rem 0 0.3rem; +} +.team-room { + width: 100%; + text-align: left; + appearance: none; + border: 1px solid transparent; + background: transparent; + color: inherit; + border-radius: 10px; + padding: 0.55rem 0.6rem; + display: flex; + align-items: center; + gap: 0.55rem; + cursor: pointer; + margin-bottom: 0.25rem; +} +.tr-av { + width: 2rem; + height: 2rem; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + background: rgba(255,255,255,0.06); +} +.tr-av.hidden { display: none; } +.tr-text { min-width: 0; display: grid; gap: 0.15rem; flex: 1; } +.team-room:hover { background: rgba(45, 212, 191, 0.08); } +.team-room.active { + border-color: rgba(45, 212, 191, 0.4); + background: rgba(45, 212, 191, 0.12); +} +.team-room.live .tr-title::after { + content: ""; + display: inline-block; + width: 0.35rem; + height: 0.35rem; + margin-left: 0.35rem; + border-radius: 50%; + background: #3dffa0; + box-shadow: 0 0 6px #3dffa0; + vertical-align: middle; +} +.tr-unread { + display: inline-grid; + place-items: center; + min-width: 1rem; + height: 1rem; + margin-left: 0.35rem; + padding: 0 0.25rem; + border-radius: 999px; + background: #e53935; + color: #fff; + font-size: 0.6rem; + font-weight: 700; + vertical-align: middle; +} +.tr-title { font-weight: 600; color: #fff; font-size: 0.84rem; } +.tr-preview { font-size: 0.68rem; color: #8aa0b4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.team-room.has-unread .tr-title { color: #fff; } +.team-room.has-unread .tr-preview { color: #c5d6e6; } + +.team-chat-main { + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +} +.team-chat-main.drag-files::after { + content: "Drop files to share"; + position: absolute; + inset: 0.75rem; + border: 2px dashed rgba(45, 212, 191, 0.55); + border-radius: 12px; + display: grid; + place-items: center; + background: rgba(4, 20, 28, 0.72); + color: #7ee8d8; + font-weight: 600; + z-index: 5; + pointer-events: none; +} +.team-chat-head { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 0.75rem; + padding: 0.75rem 1rem 0.4rem; + border-bottom: 1px solid rgba(255,255,255,0.06); +} +.team-chat-head h2 { margin: 0; font-size: 1.05rem; color: #e8f0f8; } +.team-typing { font-size: 0.72rem; color: #7ee8d8; min-height: 1em; } +.team-messages { + flex: 1; + min-height: 0; + overflow: auto; + padding: 0.85rem 1rem; + display: flex; + flex-direction: column; + gap: 0.55rem; +} +.team-bubble { + max-width: min(560px, 88%); + border-radius: 14px; + padding: 0.55rem 0.75rem; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(12, 22, 34, 0.9); + display: flex; + gap: 0.55rem; + align-items: flex-start; +} +.team-bubble.mine { + align-self: flex-end; + flex-direction: row-reverse; + background: linear-gradient(135deg, rgba(0, 118, 206, 0.28), rgba(45, 212, 191, 0.18)); + border-color: rgba(45, 212, 191, 0.35); +} +.team-bubble.theirs.team-admin { + border-color: rgba(255, 154, 60, 0.28); +} +.tb-av { + width: 2rem; + height: 2rem; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + background: rgba(255,255,255,0.06); + margin-top: 0.1rem; +} +.tb-av.hidden { display: none; } +.tb-content { min-width: 0; flex: 1; } +.tb-meta { + display: flex; + justify-content: space-between; + gap: 0.75rem; + font-size: 0.66rem; + color: #8aa0b4; + margin-bottom: 0.25rem; +} +.tb-meta strong { color: #d7e6f4; } +.tb-body { font-size: 0.88rem; color: #f0f4f8; white-space: pre-wrap; word-break: break-word; } +.team-file { + display: block; + margin-top: 0.4rem; + padding: 0.45rem 0.55rem; + border-radius: 8px; + border: 1px solid rgba(45, 212, 191, 0.3); + background: rgba(0, 0, 0, 0.25); + text-decoration: none; + color: #7ee8d8; +} +.team-file:hover { border-color: #7ee8d8; } +.tf-name { display: block; font-weight: 600; font-size: 0.8rem; } +.tf-meta { font-size: 0.66rem; color: #9eb4c8; font-family: var(--mono); } +.team-composer { + display: flex; + gap: 0.4rem; + padding: 0.65rem 1rem 0.85rem; + border-top: 1px solid rgba(255,255,255,0.06); + align-items: center; +} +.team-composer input[type="text"] { + flex: 1; + background: rgba(0,0,0,0.35); + border: 1px solid rgba(45, 212, 191, 0.28); + border-radius: 10px; + color: #fff; + padding: 0.55rem 0.7rem; +} +.team-attach { cursor: pointer; } + +@media (max-width: 900px) { + .team-body { grid-template-columns: 1fr; } + .team-rail { max-height: 28vh; border-right: none; border-bottom: 1px solid rgba(45, 212, 191, 0.15); } +} diff --git a/ui/team.js b/ui/team.js new file mode 100644 index 0000000..8d59fd8 --- /dev/null +++ b/ui/team.js @@ -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, """); + } + + 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 = `${esc(shortName(u))}`; + 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 ``; + }).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 `${esc(shortName(u))}`; + }) + .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 + ? `` + : ""; + const unread = state.unread[String(r.id)] || 0; + const unreadHtml = unread + ? `${unread > 99 ? "99+" : unread}` + : ""; + return ``; + }; + host.innerHTML = ` +
    Team
    + ${team.map(item).join("")} +
    Direct
    + ${dms.map(item).join("") || `

    No DMs yet

    `}`; + } + + 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 + ? ` + ${esc(m.file.filename)} + ${esc(formatBytes(m.file.size || 0))} · ${esc(m.file.mime || "file")} + ` + : ""; + return `
    + +
    +
    ${esc(name)}${esc(fmtTime(m.created_at))}
    + ${m.body ? `
    ${esc(m.body)}
    ` : ""} + ${file} +
    +
    `; + }) + .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, + }; +})();