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>
This commit is contained in:
root
2026-07-19 01:42:00 +02:00
parent 03e207b46d
commit b4714c70d1
11 changed files with 1953 additions and 139 deletions
+519 -13
View File
@@ -9,13 +9,15 @@ import sqlite3
import ipaddress import ipaddress
import logging import logging
import time import time
import uuid
import shutil
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
import asyncssh 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.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response, StreamingResponse from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -2669,34 +2671,47 @@ OPS_USERS = [
{ {
"id": "jody", "id": "jody",
"name": "Jody van Dongen", "name": "Jody van Dongen",
"role": "ATC Datacenter Admin", "short": "Jody",
"role": "Datacenter Engineer",
"team": "admin", "team": "admin",
"email": "jody.van.dongen@dell.com", "email": "jody.van.dongen@dell.com",
"focus": "OME fleet · racks · warranty / compliance", "focus": "Storage · servers · network · rack & stack · installs",
}, },
{ {
"id": "laurens", "id": "laurens",
"name": "Laurens Rammers", "name": "Laurens Rammers",
"role": "ATC Datacenter Admin", "short": "Laurens",
"role": "Datacenter Engineer",
"team": "admin", "team": "admin",
"email": "laurens.rammers@dell.com", "email": "laurens.rammers@dell.com",
"focus": "Datacenter ops · handoffs · escalation", "focus": "Storage · servers · network · rack & stack · installs",
}, },
{ {
"id": "mo", "id": "mo",
"name": "Mohamed El Kadi", "name": "Mohamed El Kadi",
"short": "Mo",
"role": "Data Forward Deployed Engineer", "role": "Data Forward Deployed Engineer",
"team": "fde", "team": "fde",
"email": "mohamed.el.kadi@dell.com", "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", "id": "bart",
"name": "Bart Sjerps", "name": "Bart Sjerps",
"short": "Bart",
"role": "Data Forward Deployed Engineer", "role": "Data Forward Deployed Engineer",
"team": "fde", "team": "fde",
"email": "bart.sjerps@dell.com", "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 # Back-compat alias used by older ticket endpoints / UI
@@ -2891,12 +2906,43 @@ def init_db():
created_at REAL NOT NULL, created_at REAL NOT NULL,
updated_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() _restore_ops_db_if_empty()
_seed_atc_racks() _seed_atc_racks()
_seed_atc_vlans() _seed_atc_vlans()
_seed_network_endpoints() _seed_network_endpoints()
_ensure_team_chat_seed()
n = 0 n = 0
try: try:
with _db() as conn: with _db() as conn:
@@ -2906,6 +2952,131 @@ def init_db():
log.info("Ops tickets DB ready at %s (%s tickets)", DB_PATH, n) 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: async def fetch_gpu() -> dict:
url = settings.gpu_metrics_url.rstrip("/") + "/api/gpu" url = settings.gpu_metrics_url.rstrip("/") + "/api/gpu"
try: try:
@@ -3939,9 +4110,9 @@ def build_fleet_context(focus_device_id: int | None = None, max_chars: int | Non
lines = [ lines = [
"You are OpenManage Cockpit Copilot for Dell ATC. Be concise and operational.", "You are OpenManage Cockpit Copilot for Dell ATC. Be concise and operational.",
"ATC Datacenter Admins (escalate here when facts are missing):", "ATC Datacenter Engineers (escalate here when facts are missing):",
" - Jody van Dongen <jody.van.dongen@dell.com>", " - Jody van Dongen <jody.van.dongen@dell.com> — storage, servers, network, rack & stack, installs",
" - Laurens Rammers <laurens.rammers@dell.com>", " - Laurens Rammers <laurens.rammers@dell.com> — storage, servers, network, rack & stack, installs",
"ACCURACY RULES (mandatory — never break these):", "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.", "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.", "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, "users": OPS_USERS,
"admins": [u for u in OPS_USERS if u.get("team") == "admin"], "admins": [u for u in OPS_USERS if u.get("team") == "admin"],
"fde": [u for u in OPS_USERS if u.get("team") == "fde"], "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": { "context": {
"cockpit": "OME Cockpit by Data Forward Deployed Engineers Mohamed El Kadi & Bart Sjerps", "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", "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", "admins": "Jody van Dongen and Laurens Rammers — ATC Datacenter Engineers (storage, servers, network, rack & stack, installs)",
"fde": "Mo and Bart — both Data Forward Deployed Engineers deploying AI workloads", "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] user_msg = payload.message[:2500]
escalate = ( escalate = (
"If any needed fact is absent, say it is unknown and instruct the user to overleggen met " "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." "Never invent."
) )
if tool_results: if tool_results:
@@ -4848,6 +5020,335 @@ def _admin_name(aid: str) -> str:
return u["name"] if u else aid 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 <img> 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") @app.get("/api/tickets")
async def list_tickets(): async def list_tickets():
with _db() as conn: with _db() as conn:
@@ -6463,6 +6964,11 @@ async def console_js():
return FileResponse(STATIC_DIR / "console.js", media_type="application/javascript") 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") @app.get("/ssh.js")
async def ssh_js(): async def ssh_js():
return FileResponse(STATIC_DIR / "ssh.js", media_type="application/javascript") return FileResponse(STATIC_DIR / "ssh.js", media_type="application/javascript")
+1
View File
@@ -2,6 +2,7 @@ fastapi==0.115.6
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
httpx==0.28.1 httpx==0.28.1
websockets==14.1 websockets==14.1
python-multipart==0.0.20
pydantic==2.10.4 pydantic==2.10.4
pydantic-settings==2.7.0 pydantic-settings==2.7.0
asyncssh==2.18.0 asyncssh==2.18.0
+4 -3
View File
@@ -2785,7 +2785,7 @@
setTimeout(() => { setTimeout(() => {
const input = document.getElementById("chat-input"); const input = document.getElementById("chat-input");
if (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(); input.focus();
} }
}, 150); }, 150);
@@ -3113,9 +3113,10 @@
mode === "reports-drawer" || mode === "reports-drawer" ||
mode === "an-context" || mode === "an-context" ||
mode === "network-drawer" || 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(); } else closeAi();
}); });
$("#btn-ome-console").addEventListener("click", () => { $("#btn-ome-console").addEventListener("click", () => {
+1 -1
View File
@@ -127,7 +127,7 @@
} }
function closeOtherDrawers() { 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); const el = $(id);
if (el) { if (el) {
el.classList.remove("open"); el.classList.remove("open");
+115 -29
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css?v=console2" /> <link rel="stylesheet" href="/styles.css?v=team6" />
<link rel="icon" href="/dell.png" type="image/png" /> <link rel="icon" href="/dell.png" type="image/png" />
<link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" /> <link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" />
<script> <script>
@@ -41,7 +41,25 @@
<button type="button" class="btn ghost" id="btn-network" title="Fabric map &amp; ATC racks">Network</button> <button type="button" class="btn ghost" id="btn-network" title="Fabric map &amp; ATC racks">Network</button>
<button type="button" class="btn console-btn" id="btn-console" title="Live iDRAC console wall">Console</button> <button type="button" class="btn console-btn" id="btn-console" title="Live iDRAC console wall">Console</button>
<button type="button" class="btn ghost" id="btn-reports" title="Fleet reports &amp; Dell compliance">Reports</button> <button type="button" class="btn ghost" id="btn-reports" title="Fleet reports &amp; Dell compliance">Reports</button>
<button type="button" class="btn ghost" id="btn-ops" title="ATC admin tickets">Ops desk</button> <div class="ops-team-combo" title="Ops desk &amp; team chat">
<button type="button" class="btn ghost" id="btn-ops" title="ATC admin tickets">Ops desk</button>
<button type="button" class="btn team-btn" id="btn-team" title="ATC team chat · files">
<span class="team-btn-label">Team</span>
<span class="team-unread hidden" id="team-unread" aria-label="Unread messages">0</span>
<span class="team-btn-who hidden" id="team-identity-badge" title="Switch identity"></span>
</button>
</div>
<button type="button" class="btn theme-toggle" id="btn-theme" title="Switch theme" aria-label="Switch light or dark theme">
<svg class="theme-ico theme-ico-to-dark" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M12 3a9 9 0 1 0 9 9c0-.3 0-.6-.05-.9A7 7 0 0 1 12 3z"/>
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.6"/>
</svg>
<svg class="theme-ico theme-ico-to-light" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="12" r="4" fill="currentColor"/>
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.1 5.1l1.6 1.6M17.3 17.3l1.6 1.6M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6"/>
</svg>
</button>
<button type="button" class="btn present-btn" id="btn-present" title="Customer presentation · mid or fullscreen slides">Present</button> <button type="button" class="btn present-btn" id="btn-present" title="Customer presentation · mid or fullscreen slides">Present</button>
<button type="button" class="btn primary" id="btn-chat">Cockpit Copilot</button> <button type="button" class="btn primary" id="btn-chat">Cockpit Copilot</button>
</div> </div>
@@ -242,15 +260,6 @@
</footer> </footer>
</div> </div>
<button type="button" class="theme-fab" id="btn-theme" title="Switch theme" aria-label="Switch light or dark theme">
<svg class="theme-ico theme-ico-moon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M21 14.3A8.5 8.5 0 0 1 9.7 3 7 7 0 1 0 21 14.3z"/>
</svg>
<svg class="theme-ico theme-ico-sun" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M12 4.5a1 1 0 0 1 1 1V7a1 1 0 1 1-2 0V5.5a1 1 0 0 1 1-1zm0 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM5.5 11a1 1 0 0 1-1 1H3a1 1 0 1 1 0-2h1.5a1 1 0 0 1 1 1zm15.5 0a1 1 0 0 1-1 1H18.5a1 1 0 1 1 0-2H20a1 1 0 0 1 1 1zM6.7 6.7a1 1 0 0 1 0 1.4L5.6 9.2A1 1 0 1 1 4.2 7.8l1.1-1.1a1 1 0 0 1 1.4 0zm11.6 0a1 1 0 0 1 1.4 0l1.1 1.1a1 1 0 1 1-1.4 1.4l-1.1-1.1a1 1 0 0 1 0-1.4zM12 17a1 1 0 0 1 1 1v1.5a1 1 0 1 1-2 0V18a1 1 0 0 1 1-1zm-5.3.3a1 1 0 0 1 1.4 0l1.1 1.1a1 1 0 1 1-1.4 1.4L6.7 18.7a1 1 0 0 1 0-1.4zm11.6 0a1 1 0 0 1 0 1.4l-1.1 1.1a1 1 0 1 1-1.4-1.4l1.1-1.1a1 1 0 0 1 1.4 0z"/>
</svg>
</button>
<!-- Cockpit chat / Copilot --> <!-- Cockpit chat / Copilot -->
<div class="drawer chat-copilot" id="chat-drawer" aria-hidden="true"> <div class="drawer chat-copilot" id="chat-drawer" aria-hidden="true">
<div class="drawer-head"> <div class="drawer-head">
@@ -301,19 +310,25 @@
<img src="/dell.png" alt="Dell" height="22" /> <img src="/dell.png" alt="Dell" height="22" />
<span>ATC Ops desk</span> <span>ATC Ops desk</span>
</div> </div>
<button type="button" class="btn ghost" id="btn-ops-close">Close</button> <div class="ops-head-actions">
<button type="button" class="btn team-btn compact" id="btn-ops-open-team" title="Open team chat">Team chat</button>
<button type="button" class="btn ghost" id="btn-ops-close">Close</button>
</div>
</div> </div>
<div class="ops-layout"> <div class="ops-layout">
<div class="ops-side"> <div class="ops-side">
<label class="ops-identity">Acting as <label class="ops-identity">Acting as
<select id="ops-actor"> <select id="ops-actor">
<optgroup label="ATC Admins"> <optgroup label="Datacenter Engineers">
<option value="jody">Jody van Dongen · Admin</option> <option value="jody">Jody van Dongen · DC Engineer</option>
<option value="laurens">Laurens Rammers · Admin</option> <option value="laurens">Laurens Rammers · DC Engineer</option>
</optgroup> </optgroup>
<optgroup label="Data FDE team"> <optgroup label="Data FDE team">
<option value="mo">Mohamed El Kadi · Data FDE</option> <option value="mo">Mohamed El Kadi · Data FDE · "Data Plumbers" 😉</option>
<option value="bart">Bart Sjerps · Data FDE</option> <option value="bart">Bart Sjerps · Data FDE · "Data Plumbers" 😉</option>
</optgroup>
<optgroup label="Other">
<option value="guest">Guest · Visitor</option>
</optgroup> </optgroup>
</select> </select>
</label> </label>
@@ -342,20 +357,20 @@
<div class="ops-main" id="ops-main"> <div class="ops-main" id="ops-main">
<div class="ops-empty" id="ops-empty"> <div class="ops-empty" id="ops-empty">
<h3>ATC Ops desk</h3> <h3>ATC Ops desk</h3>
<p class="ops-intro">Select a ticket or create one to collaborate across ATC admins and Data Forward Deployed Engineers.</p> <p class="ops-intro">Select a ticket or create one to collaborate across Datacenter Engineers and Data Forward Deployed Engineers.</p>
<div class="ops-team-grid" id="ops-team-grid"> <div class="ops-team-grid" id="ops-team-grid">
<section class="ops-team-card admin"> <section class="ops-team-card admin">
<h4>ATC Admins</h4> <h4>Datacenter Engineers</h4>
<ul> <ul>
<li><strong>Jody van Dongen</strong><span>OME fleet · racks · warranty / compliance</span></li> <li><strong>Jody van Dongen</strong><span>Datacenter Engineer · storage · servers · network · rack &amp; stack · installs</span></li>
<li><strong>Laurens Rammers</strong><span>Datacenter ops · handoffs · escalation</span></li> <li><strong>Laurens Rammers</strong><span>Datacenter Engineer · storage · servers · network · rack &amp; stack · installs</span></li>
</ul> </ul>
</section> </section>
<section class="ops-team-card fde"> <section class="ops-team-card fde">
<h4>Data Forward Deployed Engineers</h4> <h4>Data Forward Deployed Engineers</h4>
<ul> <ul>
<li><strong>Mohamed El Kadi</strong><span>Data Forward Deployed Engineer · Cockpit · OpenManage AI</span></li> <li><strong>Mohamed El Kadi</strong><span>Data FDE · &quot;Data Plumbers&quot; 😉 · Cockpit · OpenManage AI</span></li>
<li><strong>Bart Sjerps</strong><span>Data Forward Deployed Engineer · FDE cluster · AI workloads</span></li> <li><strong>Bart Sjerps</strong><span>Data FDE · &quot;Data Plumbers&quot; 😉 · FDE cluster · AI workloads</span></li>
</ul> </ul>
</section> </section>
</div> </div>
@@ -363,9 +378,10 @@
<h4>Quick actions</h4> <h4>Quick actions</h4>
<div class="ops-quick-row"> <div class="ops-quick-row">
<button type="button" class="btn primary" data-ops-quick="ticket">New ticket</button> <button type="button" class="btn primary" data-ops-quick="ticket">New ticket</button>
<button type="button" class="btn" data-ops-quick="to-admin">Handoff to admin</button> <button type="button" class="btn" data-ops-quick="to-admin">Handoff to DC engineer</button>
<button type="button" class="btn" data-ops-quick="to-fde">Ask FDE / AI cluster</button> <button type="button" class="btn" data-ops-quick="to-fde">Ask FDE / AI cluster</button>
<button type="button" class="btn" data-ops-quick="active">Show active tickets</button> <button type="button" class="btn" data-ops-quick="active">Show active tickets</button>
<button type="button" class="btn" data-ops-quick="team">Team chat</button>
<button type="button" class="btn" data-ops-quick="chat">Open Copilot</button> <button type="button" class="btn" data-ops-quick="chat">Open Copilot</button>
<button type="button" class="btn" data-ops-quick="reports">Fleet reports</button> <button type="button" class="btn" data-ops-quick="reports">Fleet reports</button>
</div> </div>
@@ -501,6 +517,72 @@
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer> <footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
</div> </div>
<!-- ATC Team Chat -->
<div class="drawer wide team-drawer" id="team-drawer" aria-hidden="true">
<div class="drawer-head team-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<div>
<span class="team-title">Team chat</span>
<p class="team-sub">Jody · Laurens · Mo · Bart — messages &amp; files</p>
</div>
</div>
<div class="team-head-actions">
<div id="team-presence" class="team-presence"></div>
<button type="button" class="btn ghost" id="btn-team-switch">Switch identity</button>
<button type="button" class="btn ghost" id="btn-team-close">Close</button>
</div>
</div>
<div class="team-body">
<aside class="team-rail">
<div class="team-room-list" id="team-room-list"></div>
</aside>
<section class="team-chat-main" id="team-chat-main">
<header class="team-chat-head">
<div>
<h2 id="team-chat-title">ATC Team</h2>
<p class="modal-sub" id="team-chat-sub"></p>
</div>
<span id="team-typing" class="team-typing"></span>
</header>
<div class="team-messages" id="team-messages"></div>
<form class="team-composer" id="team-form">
<label class="team-attach btn ghost compact" title="Attach file">
📎
<input type="file" id="team-file" multiple hidden />
</label>
<input type="text" id="team-input" placeholder="Message the team… (drop files anywhere)" autocomplete="off" maxlength="8000" />
<button type="submit" class="btn primary">Send</button>
</form>
</section>
</div>
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
</div>
<div class="identity-gate hidden" id="identity-gate" aria-hidden="true" role="dialog" aria-labelledby="identity-gate-title">
<div class="identity-gate-card">
<img src="/dell.png" alt="Dell" width="48" height="48" />
<p class="modal-kicker">ATC Cockpit</p>
<h2 id="identity-gate-title">Who are you?</h2>
<p class="modal-sub">Select yourself to continue — Team chat, Ops desk, and handoffs use this identity. Guests welcome.</p>
<div class="identity-gate-grid" id="identity-gate-grid"></div>
<div class="identity-avatar-row" id="identity-avatar-row">
<div class="id-avatar-preview" id="identity-avatar-preview" aria-hidden="true">
<img id="identity-avatar-img" alt="" hidden />
<span id="identity-avatar-initials">?</span>
</div>
<div class="identity-avatar-copy">
<strong id="identity-avatar-label">Profile photo</strong>
<p class="modal-sub">JPG, PNG, WebP or GIF · max 5 MB</p>
</div>
<label class="btn ghost compact" id="identity-avatar-btn">
Upload photo
<input type="file" id="identity-avatar-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden />
</label>
</div>
</div>
</div>
<div class="scrim" id="scrim"></div> <div class="scrim" id="scrim"></div>
<div class="node-tip hidden" id="node-tip" role="tooltip"></div> <div class="node-tip hidden" id="node-tip" role="tooltip"></div>
@@ -512,7 +594,7 @@
<div> <div>
<p class="modal-kicker">ATC Ops desk</p> <p class="modal-kicker">ATC Ops desk</p>
<h2 id="ticket-modal-title">Create ticket</h2> <h2 id="ticket-modal-title">Create ticket</h2>
<p class="modal-sub">Handoff between ATC admins (Jody / Laurens) and Data FDEs (Mo / Bart)</p> <p class="modal-sub">Handoff between Datacenter Engineers (Jody / Laurens) and Data FDEs (Mo / Bart)</p>
</div> </div>
</div> </div>
<form id="ticket-form" class="ticket-form"> <form id="ticket-form" class="ticket-form">
@@ -530,7 +612,7 @@
</label> </label>
<label>Assign to <label>Assign to
<select id="ticket-assignee"> <select id="ticket-assignee">
<optgroup label="ATC Admins"> <optgroup label="Datacenter Engineers">
<option value="jody">Jody van Dongen</option> <option value="jody">Jody van Dongen</option>
<option value="laurens">Laurens Rammers</option> <option value="laurens">Laurens Rammers</option>
</optgroup> </optgroup>
@@ -538,6 +620,9 @@
<option value="mo">Mohamed El Kadi</option> <option value="mo">Mohamed El Kadi</option>
<option value="bart">Bart Sjerps</option> <option value="bart">Bart Sjerps</option>
</optgroup> </optgroup>
<optgroup label="Other">
<option value="guest">Guest</option>
</optgroup>
</select> </select>
</label> </label>
</div> </div>
@@ -714,13 +799,14 @@
<script src="/vendor/xterm/xterm.min.js?v=home1"></script> <script src="/vendor/xterm/xterm.min.js?v=home1"></script>
<script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script> <script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script>
<script src="/app.js?v=power4"></script> <script src="/app.js?v=power5"></script>
<script src="/ops.js?v=opsfde2"></script> <script src="/ops.js?v=opsfde6"></script>
<script src="/ssh.js?v=fabric4"></script> <script src="/ssh.js?v=fabric4"></script>
<script src="/rdp.js?v=fabric4"></script> <script src="/rdp.js?v=fabric4"></script>
<script src="/reports.js?v=anctx1"></script> <script src="/reports.js?v=anctx1"></script>
<script src="/network.js?v=vlanall1"></script> <script src="/network.js?v=vlanall1"></script>
<script src="/console.js?v=console2"></script> <script src="/console.js?v=console2"></script>
<script src="/present.js?v=present17"></script> <script src="/team.js?v=team6"></script>
<script src="/present.js?v=present20"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -95,7 +95,7 @@
const drawer = $("#network-drawer"); const drawer = $("#network-drawer");
const scrim = $("#scrim"); const scrim = $("#scrim");
if (!drawer) return; 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); const el = $(id);
if (el) { if (el) {
el.classList.remove("open"); el.classList.remove("open");
+21 -10
View File
@@ -44,10 +44,11 @@
let gpuData = null; let gpuData = null;
let chatModelsLoaded = false; let chatModelsLoaded = false;
let opsUsers = [ let opsUsers = [
{ id: "jody", name: "Jody van Dongen", team: "admin", role: "ATC Datacenter Admin" }, { id: "jody", name: "Jody van Dongen", team: "admin", role: "Datacenter Engineer" },
{ id: "laurens", name: "Laurens Rammers", team: "admin", role: "ATC Datacenter Admin" }, { id: "laurens", name: "Laurens Rammers", team: "admin", role: "Datacenter Engineer" },
{ id: "mo", name: "Mohamed El Kadi", team: "fde", role: "Data Forward Deployed 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" }, { id: "bart", name: "Bart Sjerps", team: "fde", role: "Data Forward Deployed Engineer · \"Data Plumbers\" 😉" },
{ id: "guest", name: "Guest", team: "guest", role: "Visitor" },
]; ];
function selectedChatModel() { function selectedChatModel() {
@@ -98,16 +99,19 @@
if (me === "laurens") return "jody"; if (me === "laurens") return "jody";
if (me === "mo") return "bart"; if (me === "mo") return "bart";
if (me === "bart") return "mo"; if (me === "bart") return "mo";
if (me === "guest") return "jody";
return "jody"; return "jody";
} }
function assigneeOptionsHtml(selected) { function assigneeOptionsHtml(selected) {
const admins = opsUsers.filter((u) => u.team === "admin"); const admins = opsUsers.filter((u) => u.team === "admin");
const fde = opsUsers.filter((u) => u.team === "fde"); const fde = opsUsers.filter((u) => u.team === "fde");
const guests = opsUsers.filter((u) => u.team === "guest");
const opt = (u) => const opt = (u) =>
`<option value="${u.id}" ${u.id === selected ? "selected" : ""}>${escape(u.name)}</option>`; `<option value="${u.id}" ${u.id === selected ? "selected" : ""}>${escape(u.name)}</option>`;
return `<optgroup label="ATC Admins">${admins.map(opt).join("")}</optgroup> return `<optgroup label="Datacenter Engineers">${admins.map(opt).join("")}</optgroup>
<optgroup label="FDE team">${fde.map(opt).join("")}</optgroup>`; <optgroup label="FDE team">${fde.map(opt).join("")}</optgroup>
${guests.length ? `<optgroup label="Other">${guests.map(opt).join("")}</optgroup>` : ""}`;
} }
async function loadOpsUsers() { async function loadOpsUsers() {
@@ -856,7 +860,7 @@
((node && node.ip) || (a && a.ip) || "no-ip") + ((node && node.ip) || (a && a.ip) || "no-ip") +
"): " + "): " +
it.message + it.message +
". Suggest next steps for ATC admins Jody and Laurens." ". Suggest next steps for Datacenter Engineers Jody and Laurens."
); );
}); });
$("#triage-inspect")?.addEventListener("click", () => { $("#triage-inspect")?.addEventListener("click", () => {
@@ -952,6 +956,9 @@
activeTicketId = null; activeTicketId = null;
}); });
$("#btn-ops-close")?.addEventListener("click", closeDrawers); $("#btn-ops-close")?.addEventListener("click", closeDrawers);
$("#btn-ops-open-team")?.addEventListener("click", () => {
window.cockpitTeam?.open?.();
});
$("#btn-ops-refresh")?.addEventListener("click", () => loadTickets().catch((e) => alert(e.message))); $("#btn-ops-refresh")?.addEventListener("click", () => loadTickets().catch((e) => alert(e.message)));
$("#ops-quick")?.addEventListener("click", (e) => { $("#ops-quick")?.addEventListener("click", (e) => {
@@ -962,10 +969,14 @@
openTicketModal(); openTicketModal();
return; return;
} }
if (q === "team") {
window.cockpitTeam?.open?.();
return;
}
if (q === "to-admin") { if (q === "to-admin") {
openTicketModal({ openTicketModal({
title: "Handoff to ATC admin", title: "Handoff to Datacenter Engineer",
body: "Context for Jody / Laurens:\n\n", body: "Context for Jody / Laurens (storage · servers · network · rack & stack · installs):\n\n",
assignee: "jody", assignee: "jody",
priority: "normal", priority: "normal",
}); });
@@ -974,7 +985,7 @@
if (q === "to-fde") { if (q === "to-fde") {
openTicketModal({ openTicketModal({
title: "Data FDE / AI cluster request", 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", assignee: "mo",
priority: "normal", priority: "normal",
}); });
+47 -26
View File
@@ -28,16 +28,17 @@
title: "What we will cover", title: "What we will cover",
anim: "rise", anim: "rise",
html: ` html: `
<p class="ps-lead">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.</p> <p class="ps-lead">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.</p>
<ol class="ps-steps ps-stagger"> <ol class="ps-steps ps-stagger">
<li><strong>Why</strong> — the gap around OME and why AI alone is not enough.</li> <li><strong>Why</strong> — the gap around OME and why AI alone is not enough.</li>
<li><strong>Architecture</strong> — one design: Browser → Cockpit BFF → OME / AI (with logos).</li> <li><strong>Architecture</strong> — one design: Browser → Cockpit BFF → OME / AI (with logos).</li>
<li><strong>Apps</strong> — map, network, reports, <em>Console wall</em>, ops desk, copilots.</li> <li><strong>Apps</strong> — map, network, reports, Console wall, Ops + Team chat, copilots.</li>
<li><strong>Remote ops</strong> — power via OME→iDRAC and live consoles in-panel.</li> <li><strong>Remote ops</strong> — power via OME→iDRAC and live consoles in-panel.</li>
<li><strong>People</strong> — who asked, who built, who runs it.</li> <li><strong>Team</strong> — soft identity, chat, files, avatars, live unread alerts.</li>
<li><strong>People</strong> — Datacenter Engineers &amp; Data FDEs (<em>"Data Plumbers"</em> 😉).</li>
<li><strong>Value</strong> — what customers can take away.</li> <li><strong>Value</strong> — what customers can take away.</li>
</ol> </ol>
<p class="ps-sub">Requested by <strong>Jody van Dongen</strong> &amp; <strong>Laurens Rammers</strong> · delivered by the FDE team (<strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong>).</p>`, <p class="ps-sub">Requested by <strong>Jody van Dongen</strong> &amp; <strong>Laurens Rammers</strong> · delivered by the FDE team (<strong>Mo</strong> &amp; <strong>Bart</strong> — <em>"Data Plumbers"</em> 😉).</p>`,
}, },
{ {
id: "problem", id: "problem",
@@ -51,7 +52,7 @@
<div class="ps-card"><h4>Context loss</h4><p>Warranty, firmware, and offline state are related — rarely one story.</p></div> <div class="ps-card"><h4>Context loss</h4><p>Warranty, firmware, and offline state are related — rarely one story.</p></div>
<div class="ps-card"><h4>Ungrounded AI</h4><p>Generic chatbots guess. Useful AI must start from OME.</p></div> <div class="ps-card"><h4>Ungrounded AI</h4><p>Generic chatbots guess. Useful AI must start from OME.</p></div>
</div> </div>
<p class="ps-sub">Jody and Laurens asked the FDE team to assist — build a practical ops lens on top of OME, not replace OME.</p>`, <p class="ps-sub">Jody and Laurens (Datacenter Engineers) asked the FDE team to assist — build a practical ops lens on top of OME, not replace OME.</p>`,
}, },
{ {
id: "value", id: "value",
@@ -63,8 +64,8 @@
<div class="ps-card"><h4>One Service Tag story</h4><p>Map, inventory, compliance, warranty, fabric, and tickets around the same ST.</p></div> <div class="ps-card"><h4>One Service Tag story</h4><p>Map, inventory, compliance, warranty, fabric, and tickets around the same ST.</p></div>
<div class="ps-card"><h4>Remote ops in UI</h4><p>Power via OME jobs · live iDRAC Console wall (4/8) without tab sprawl.</p></div> <div class="ps-card"><h4>Remote ops in UI</h4><p>Power via OME jobs · live iDRAC Console wall (4/8) without tab sprawl.</p></div>
<div class="ps-card"><h4>AI that cites the fleet</h4><p>Copilot answers from live OME facts — or says it does not know.</p></div> <div class="ps-card"><h4>AI that cites the fleet</h4><p>Copilot answers from live OME facts — or says it does not know.</p></div>
<div class="ps-card"><h4>Faster briefings</h4><p>Click a chart → named systems → hand off in Ops desk.</p></div> <div class="ps-card"><h4>Ops + Team chat</h4><p>Tickets and live team / DM chat with files, avatars, and unread alerts.</p></div>
<div class="ps-card"><h4>Admin ↔ Data FDE</h4><p>Clear roles: ATC admins own the estate; Data FDEs deliver the AI/ops surface.</p></div> <div class="ps-card"><h4>DC Engineer ↔ Data FDE</h4><p>Jody &amp; Laurens own the estate; Mo &amp; Bart (<em>"Data Plumbers"</em> 😉) deliver the surface.</p></div>
<div class="ps-card"><h4>Copyable pattern</h4><p>OME API + BFF + grounded AI — a blueprint, not a Dell SKU.</p></div> <div class="ps-card"><h4>Copyable pattern</h4><p>OME API + BFF + grounded AI — a blueprint, not a Dell SKU.</p></div>
</div>`, </div>`,
}, },
@@ -117,7 +118,7 @@
<div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div> <div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div>
<div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div> <div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div>
<div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div> <div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div>
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div> <div class="ps-arch-node app"><strong>Ops / Team</strong><span>Tickets · chat</span></div>
</div> </div>
<div class="ps-arch-layer mid"> <div class="ps-arch-layer mid">
<div class="ps-arch-node bff"> <div class="ps-arch-node bff">
@@ -163,10 +164,10 @@
<div class="ps-app"><i></i><strong>Console wall</strong><span>4 / 8 live iDRAC embeds · drag</span></div> <div class="ps-app"><i></i><strong>Console wall</strong><span>4 / 8 live iDRAC embeds · drag</span></div>
<div class="ps-app"><i></i><strong>Network</strong><span>Fabric · 42U racks · VLANs</span></div> <div class="ps-app"><i></i><strong>Network</strong><span>Fabric · 42U racks · VLANs</span></div>
<div class="ps-app"><i></i><strong>Reports</strong><span>Compliance · warranty analytics</span></div> <div class="ps-app"><i></i><strong>Reports</strong><span>Compliance · warranty analytics</span></div>
<div class="ps-app"><i></i><strong>Ops desk</strong><span>Admin ↔ Data FDE tickets</span></div> <div class="ps-app"><i></i><strong>Ops + Team</strong><span>Tickets · chat · files · avatars</span></div>
<div class="ps-app"><i></i><strong>Copilot</strong><span>Grounded chat on Service Tags</span></div> <div class="ps-app"><i></i><strong>Copilot</strong><span>Grounded chat on Service Tags</span></div>
</div> </div>
<p class="ps-sub">One join key everywhere: <strong>Service Tag</strong>. Power and console actions ride OME → iDRAC — not a second inventory.</p>`, <p class="ps-sub">One join key everywhere: <strong>Service Tag</strong>. Soft identity on open so Jody, Laurens, Mo, Bart (or Guest) act as themselves.</p>`,
}, },
{ {
id: "console-wall", id: "console-wall",
@@ -200,6 +201,22 @@
</div> </div>
<p class="ps-sub">Demo power with care — production change windows still belong in official OME / iDRAC process.</p>`, <p class="ps-sub">Demo power with care — production change windows still belong in official OME / iDRAC process.</p>`,
}, },
{
id: "team-chat",
kicker: "Collaboration",
title: "Team chat · who you are, what you share",
anim: "rise",
html: `
<p class="ps-lead">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.</p>
<div class="ps-grid3">
<div class="ps-card"><h4>Identity gate</h4><p>Jody · Laurens · Mo · Bart · Guest — profile photo upload optional.</p></div>
<div class="ps-card"><h4>Team + DMs</h4><p>Shared ATC room and 1:1 chats · WebSocket live presence.</p></div>
<div class="ps-card"><h4>Files &amp; avatars</h4><p>Drop files into chat · avatars on bubbles, presence, and the Team chip.</p></div>
<div class="ps-card"><h4>Unread alerts</h4><p>Badge on Ops|Team · room counters · browser notify when the tab is in the background.</p></div>
<div class="ps-card"><h4>Ops|Team combo</h4><p>One topbar control: tickets left, Team + who-you-are right — KPIs keep a single row.</p></div>
<div class="ps-card"><h4>Roles that match reality</h4><p>Datacenter Engineers own rack &amp; stack; FDEs are <em>"Data Plumbers"</em> 😉.</p></div>
</div>`,
},
{ {
id: "ai", id: "ai",
kicker: "AI + OME", kicker: "AI + OME",
@@ -219,24 +236,24 @@
{ {
id: "origin", id: "origin",
kicker: "How this started", 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", anim: "rise",
html: ` html: `
<p class="ps-lead"><strong>Jody van Dongen</strong> and <strong>Laurens Rammers</strong> 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.</p> <p class="ps-lead"><strong>Jody van Dongen</strong> and <strong>Laurens Rammers</strong> (Datacenter Engineers — storage, servers, network, rack &amp; 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.</p>
<div class="ps-cols"> <div class="ps-cols">
<div> <div>
<h4>ATC Admins · the ask</h4> <h4>Datacenter Engineers · the ask</h4>
<ul class="ps-bullets ps-stagger"> <ul class="ps-bullets ps-stagger">
<li><strong>Jody van Dongen</strong> — fleet, racks, warranty / compliance</li> <li><strong>Jody van Dongen</strong> — storage, servers, network, rack &amp; stack, installs</li>
<li><strong>Laurens Rammers</strong> — datacenter ops &amp; escalation</li> <li><strong>Laurens Rammers</strong> — storage, servers, network, rack &amp; stack, installs</li>
<li>Better visibility and faster OME storytelling</li> <li>Better visibility and faster OME storytelling</li>
</ul> </ul>
</div> </div>
<div> <div>
<h4>Data FDEs · the delivery</h4> <h4>Data FDEs · the delivery</h4>
<ul class="ps-bullets ps-stagger"> <ul class="ps-bullets ps-stagger">
<li><strong>Mohamed El Kadi</strong> — cockpit UX/API, OME integration, Copilot, Present</li> <li><strong>Mohamed El Kadi</strong> — cockpit UX/API, OME integration, Copilot, Present · <em>"Data Plumbers"</em> 😉</li>
<li><strong>Bart Sjerps</strong> — FDE cluster, Open WebUI / AI workloads, hosting</li> <li><strong>Bart Sjerps</strong> — FDE cluster, Open WebUI / AI workloads, hosting · <em>"Data Plumbers"</em> 😉</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -252,7 +269,7 @@
<div class="ps-card"><h4>Clarity</h4><p>One place for connectivity, compliance, warranty, and hardware depth.</p></div> <div class="ps-card"><h4>Clarity</h4><p>One place for connectivity, compliance, warranty, and hardware depth.</p></div>
<div class="ps-card"><h4>Credibility</h4><p>Claims trace to a Service Tag from OME.</p></div> <div class="ps-card"><h4>Credibility</h4><p>Claims trace to a Service Tag from OME.</p></div>
<div class="ps-card"><h4>Speed</h4><p>AI drafts explanations; humans own change windows.</p></div> <div class="ps-card"><h4>Speed</h4><p>AI drafts explanations; humans own change windows.</p></div>
<div class="ps-card"><h4>Collaboration</h4><p>Admins and Data FDEs share one Ops language.</p></div> <div class="ps-card"><h4>Collaboration</h4><p>Datacenter Engineers and Data FDEs (<em>"Data Plumbers"</em> 😉) share Ops + Team chat.</p></div>
<div class="ps-card"><h4>Pattern</h4><p>OME + Docker BFF + grounded AI — repeatable.</p></div> <div class="ps-card"><h4>Pattern</h4><p>OME + Docker BFF + grounded AI — repeatable.</p></div>
<div class="ps-card"><h4>Honesty</h4><p>Demo power with an explicit non-product disclaimer.</p></div> <div class="ps-card"><h4>Honesty</h4><p>Demo power with an explicit non-product disclaimer.</p></div>
</div>`, </div>`,
@@ -264,10 +281,11 @@
anim: "zoom", anim: "zoom",
html: ` html: `
<ol class="ps-steps ps-stagger"> <ol class="ps-steps ps-stagger">
<li>Pick yourself on the identity gate (or Guest) when the UI opens.</li>
<li>Pick a Service Tag on the map and open the inspector.</li> <li>Pick a Service Tag on the map and open the inspector.</li>
<li>Open <strong>Console</strong> — Auto-fill live iDRACs (4 or 8 screens).</li> <li>Open <strong>Console</strong> — Auto-fill live iDRACs (4 or 8 screens).</li>
<li>From Servers KPI: Power on a cold node · open iDRAC console in-panel.</li> <li>From Servers KPI: Power on a cold node · open iDRAC console in-panel.</li>
<li>Open Reports · Analytics and click a colored segment.</li> <li>Open <strong>Ops|Team</strong> — create a ticket, then send a Team or DM message with a file.</li>
<li>Ask Copilot a question that must cite Service Tags.</li> <li>Ask Copilot a question that must cite Service Tags.</li>
<li>Switch to <strong>Technical architecture</strong> for API and trust-boundary depth.</li> <li>Switch to <strong>Technical architecture</strong> for API and trust-boundary depth.</li>
</ol> </ol>
@@ -308,8 +326,9 @@
<li>AI completion path</li> <li>AI completion path</li>
<li>Portal containers &amp; key <code>/api/*</code> contracts</li> <li>Portal containers &amp; key <code>/api/*</code> contracts</li>
<li>iDRAC proxy · power jobs · Console wall</li> <li>iDRAC proxy · power jobs · Console wall</li>
<li>Team chat · soft identity · WebSocket · file / avatar store</li>
</ol> </ol>
<p class="ps-sub">Delivered for ATC admins Jody &amp; Laurens by Data FDEs Mo &amp; Bart.</p>`, <p class="ps-sub">Delivered for Datacenter Engineers Jody &amp; Laurens by Data FDEs Mo &amp; Bart (<em>"Data Plumbers"</em> 😉).</p>`,
}, },
{ {
id: "arch-design", id: "arch-design",
@@ -360,7 +379,7 @@
<div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div> <div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div>
<div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div> <div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div>
<div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div> <div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div>
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div> <div class="ps-arch-node app"><strong>Ops / Team</strong><span>Tickets · chat</span></div>
</div> </div>
<div class="ps-arch-layer mid"> <div class="ps-arch-layer mid">
<div class="ps-arch-node bff"> <div class="ps-arch-node bff">
@@ -496,7 +515,7 @@
<div class="ps-card tech"><h4>/api/fleet</h4><p>Cached device graph for canvas &amp; KPIs.</p></div> <div class="ps-card tech"><h4>/api/fleet</h4><p>Cached device graph for canvas &amp; KPIs.</p></div>
<div class="ps-card tech"><h4>/api/devices/…/power</h4><p>OME JobService POWER_CONTROL · on / off / cycle.</p></div> <div class="ps-card tech"><h4>/api/devices/…/power</h4><p>OME JobService POWER_CONTROL · on / off / cycle.</p></div>
<div class="ps-card tech"><h4>/api/idrac-proxy/…</h4><p>Same-origin HTML + WebSocket bridge · strips XFO.</p></div> <div class="ps-card tech"><h4>/api/idrac-proxy/…</h4><p>Same-origin HTML + WebSocket bridge · strips XFO.</p></div>
<div class="ps-card tech"><h4>/api/reports/*</h4><p>Analytics, firmware, warranty, brief.</p></div> <div class="ps-card tech"><h4>/api/team/* · /ws/team-chat</h4><p>Rooms, DMs, files, avatars · live presence &amp; messages.</p></div>
<div class="ps-card tech"><h4>/api/chat · /api/models</h4><p>Grounded completions · model list.</p></div> <div class="ps-card tech"><h4>/api/chat · /api/models</h4><p>Grounded completions · model list.</p></div>
<div class="ps-card tech"><h4>/api/network/*</h4><p>Fabric ports · racks · VLANs.</p></div> <div class="ps-card tech"><h4>/api/network/*</h4><p>Fabric ports · racks · VLANs.</p></div>
</div>`, </div>`,
@@ -531,12 +550,14 @@
<h4 style="margin-top:0.85rem">Cockpit-local (write)</h4> <h4 style="margin-top:0.85rem">Cockpit-local (write)</h4>
<ul class="ps-bullets ps-stagger"> <ul class="ps-bullets ps-stagger">
<li>Fabric / racks / tickets / presentation decks</li> <li>Fabric / racks / tickets / presentation decks</li>
<li>Team chat messages, files, and profile avatars (<code>/data/team-chat</code>)</li>
</ul> </ul>
</div> </div>
<div> <div>
<h4>Security notes</h4> <h4>Security notes</h4>
<ul class="ps-bullets ps-stagger"> <ul class="ps-bullets ps-stagger">
<li>Secrets only in portal environment variables</li> <li>Secrets only in portal environment variables</li>
<li>Soft identity is localStorage (ops convenience — not SSO)</li>
<li>iDRAC proxy is an ops convenience — credentials stay with the user</li> <li>iDRAC proxy is an ops convenience — credentials stay with the user</li>
<li>Production change windows still belong in official OME / iDRAC process</li> <li>Production change windows still belong in official OME / iDRAC process</li>
</ul> </ul>
@@ -552,7 +573,7 @@
<ol class="ps-steps ps-stagger"> <ol class="ps-steps ps-stagger">
<li>Return to the map and pick a Service Tag.</li> <li>Return to the map and pick a Service Tag.</li>
<li>Open <strong>Console</strong> and drop two iDRACs onto the wall.</li> <li>Open <strong>Console</strong> and drop two iDRACs onto the wall.</li>
<li>Open Reports and click a compliance segment.</li> <li>Open <strong>Ops|Team</strong> — ticket + a chat message with unread badge.</li>
<li>Ask Copilot a question that must cite that ST.</li> <li>Ask Copilot a question that must cite that ST.</li>
<li>Use <strong>Customer story</strong> for business / ops audiences.</li> <li>Use <strong>Customer story</strong> for business / ops audiences.</li>
</ol> </ol>
@@ -560,20 +581,20 @@
}, },
]; ];
const BUILTIN_VERSION = 9; const BUILTIN_VERSION = 10;
const BUILTIN_DECKS = [ const BUILTIN_DECKS = [
{ {
id: "story", id: "story",
name: "Customer story", name: "Customer story",
description: "Customer briefing — ask, Console wall, power, architecture, value", description: "Customer briefing — Console, power, Team chat, roles, value",
builtin: true, builtin: true,
slides: structuredClone(STORY_SLIDES), slides: structuredClone(STORY_SLIDES),
}, },
{ {
id: "technical", id: "technical",
name: "Technical architecture", name: "Technical architecture",
description: "Engineer deep dive — design, BFF, APIs, trust", description: "Engineer deep dive — design, BFF, APIs, Team chat, trust",
builtin: true, builtin: true,
slides: structuredClone(TECH_SLIDES), slides: structuredClone(TECH_SLIDES),
}, },
+1 -1
View File
@@ -77,7 +77,7 @@
const drawer = $("#reports-drawer"); const drawer = $("#reports-drawer");
const scrim = $("#scrim"); const scrim = $("#scrim");
if (!drawer) return; 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); const el = $(id);
if (el) { if (el) {
el.classList.remove("open"); el.classList.remove("open");
+530 -55
View File
@@ -146,22 +146,24 @@ button { cursor: pointer; }
.kpi-strip { .kpi-strip {
flex: 1; flex: 1;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 0.3rem; gap: 0.25rem;
overflow: visible; overflow-x: auto;
padding: 0.35rem 0; overflow-y: hidden;
padding: 0.2rem 0;
min-width: 0; min-width: 0;
justify-content: flex-start; justify-content: flex-start;
align-content: center; align-content: center;
scrollbar-width: thin;
} }
.kpi { .kpi {
appearance: none; appearance: none;
border: 1px solid transparent; border: 1px solid transparent;
background: rgba(0, 118, 206, 0.12); background: rgba(0, 118, 206, 0.12);
border-radius: 6px; border-radius: 6px;
padding: 0.28rem 0.55rem; padding: 0.22rem 0.42rem;
min-width: 0; min-width: 0;
flex: 0 1 auto; flex: 0 0 auto;
text-align: left; text-align: left;
transition: border-color 0.15s, background 0.15s, transform 0.15s; 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-prod-meta em,
html[data-theme="light"] .rs-lib-hint { color: #4a5d72; } html[data-theme="light"] .rs-lib-hint { color: #4a5d72; }
/* Theme FAB — parks left while Full Inventory is open (inspector is right) */ /* Theme toggle — in topbar (FAB removed so it no longer blocks the map) */
.theme-fab { .btn.theme-toggle {
position: fixed; width: 2.15rem;
right: 14px; min-width: 2.15rem;
left: auto; height: 2.15rem;
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;
padding: 0; 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 { .btn.theme-toggle .theme-ico { display: none; }
right: auto; html[data-theme="light"] .btn.theme-toggle .theme-ico-to-dark { display: block; }
left: 14px; html[data-theme="dark"] .btn.theme-toggle .theme-ico-to-light,
bottom: 56px; html:not([data-theme]) .btn.theme-toggle .theme-ico-to-light { display: block; }
opacity: 0.92; .theme-fab { display: none !important; }
box-shadow:
0 4px 14px rgba(255, 154, 60, 0.35), .team-unread {
0 0 0 3px rgba(255, 154, 60, 0.18); display: inline-grid;
border-color: rgba(255, 184, 77, 0.55); place-items: center;
} min-width: 1.05rem;
.theme-fab:hover { height: 1.05rem;
border-color: #fff; padding: 0 0.28rem;
border-radius: 999px;
background: #e53935;
color: #fff; color: #fff;
transform: translateY(-2px) scale(1.05); font-size: 0.62rem;
filter: brightness(1.08); font-weight: 700;
box-shadow: font-family: var(--mono);
0 8px 20px rgba(0, 118, 206, 0.55), line-height: 1;
0 0 0 4px rgba(0, 168, 232, 0.28);
} }
.theme-fab .theme-ico { display: none; } .team-unread.hidden { display: none !important; }
html[data-theme="light"] .theme-fab .theme-ico-moon { display: block; } .ops-team-combo.has-unread {
html[data-theme="dark"] .theme-fab .theme-ico-sun, box-shadow: 0 0 0 1px rgba(229, 57, 53, 0.55);
html:not([data-theme]) .theme-fab .theme-ico-sun { display: block; } }
html[data-theme="light"] .theme-fab { @keyframes team-pulse {
background: linear-gradient(145deg, #1aa3e8, #0062b0); 0%, 100% { box-shadow: 0 0 0 0 rgba(229, 57, 53, 0.55); }
border-color: rgba(255, 255, 255, 0.7); 50% { box-shadow: 0 0 0 4px rgba(229, 57, 53, 0.15); }
color: #fff; }
box-shadow: .ops-team-combo.has-unread.pulse {
0 4px 14px rgba(0, 98, 176, 0.35), animation: team-pulse 1.2s ease 2;
0 0 0 3px rgba(0, 118, 206, 0.15);
} }
/* Present button + deck */ /* Present button + deck */
@@ -5553,7 +5544,7 @@ html[data-theme="light"] .theme-fab {
border-top: 1px solid rgba(255,255,255,0.06); border-top: 1px solid rgba(255,255,255,0.06);
background: rgba(0,0,0,0.35); 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, .network-drawer.drawer,
.reports-drawer.drawer { .reports-drawer.drawer {
display: flex; display: flex;
@@ -8812,3 +8803,487 @@ html[data-theme="light"] .ctc-name { color: #102033; }
min-height: 0; min-height: 0;
} }
.console-fleet-card { cursor: grab; } .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); }
}
+713
View File
@@ -0,0 +1,713 @@
/**
* ATC Team Chat identity gate, team room + DMs, file share, profile photos.
* Soft identity via localStorage.atc_actor (shared with Ops desk).
*/
(() => {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const ACTOR_KEY = "atc_actor";
const USERS = [
{ id: "jody", name: "Jody van Dongen", short: "Jody", team: "admin", role: "Datacenter Engineer", focus: "Storage · servers · network · rack & stack · installs" },
{ id: "laurens", name: "Laurens Rammers", short: "Laurens", team: "admin", role: "Datacenter Engineer", focus: "Storage · servers · network · rack & stack · installs" },
{ id: "mo", name: "Mohamed El Kadi", short: "Mo", team: "fde", role: "Data FDE", focus: "\"Data Plumbers\" 😉 · OME Cockpit · OpenManage AI" },
{ id: "bart", name: "Bart Sjerps", short: "Bart", team: "fde", role: "Data FDE", focus: "\"Data Plumbers\" 😉 · FDE cluster · AI workloads" },
{ id: "guest", name: "Guest", short: "Guest", team: "guest", role: "Visitor", focus: "Temporary access — select yourself next time" },
];
const state = {
me: null,
rooms: [],
presence: [],
activeRoomId: null,
messages: [],
ws: null,
typing: null,
avatarBust: {},
unread: {},
notifyAsked: false,
origTitle: document.title,
};
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function shortName(u) {
if (!u) return "?";
return u.short || (u.name || u.id || "?").split(" ")[0];
}
function avatarUrl(userId, fallbackUrl) {
if (!userId) return "";
const base = fallbackUrl || `/api/team/avatar/${userId}`;
const bust = state.avatarBust[userId];
if (!bust) return base;
return base.includes("?") ? `${base}&t=${bust}` : `${base}?t=${bust}`;
}
function userById(id) {
return USERS.find((u) => u.id === id) || state.presence.find((u) => u.id === id) || null;
}
function getActor() {
const v = localStorage.getItem(ACTOR_KEY);
return USERS.some((u) => u.id === v) ? v : null;
}
function setActor(id) {
if (!USERS.some((u) => u.id === id)) return;
localStorage.setItem(ACTOR_KEY, id);
state.me = id;
const sel = $("#ops-actor");
if (sel && sel.value !== id) sel.value = id;
updateIdentityBadge();
refreshAvatarPreview();
ensureNotifyPermission();
connectWs(true);
}
function unreadTotal() {
return Object.values(state.unread).reduce((a, b) => a + (b || 0), 0);
}
function updateUnreadBadge() {
const el = $("#team-unread");
const combo = document.querySelector(".ops-team-combo");
const n = unreadTotal();
if (el) {
if (n > 0) {
el.classList.remove("hidden");
el.textContent = n > 99 ? "99+" : String(n);
} else {
el.classList.add("hidden");
el.textContent = "0";
}
}
if (combo) {
combo.classList.toggle("has-unread", n > 0);
if (n > 0) {
combo.classList.remove("pulse");
void combo.offsetWidth;
combo.classList.add("pulse");
}
}
document.title = n > 0 ? `(${n}) Team · ${state.origTitle}` : state.origTitle;
}
function clearUnread(roomId) {
if (roomId == null) return;
delete state.unread[String(roomId)];
updateUnreadBadge();
}
function bumpUnread(roomId) {
const key = String(roomId);
state.unread[key] = (state.unread[key] || 0) + 1;
updateUnreadBadge();
}
function ensureNotifyPermission() {
if (!("Notification" in window) || state.notifyAsked) return;
state.notifyAsked = true;
if (Notification.permission === "default") {
Notification.requestPermission().catch(() => {});
}
}
function notifyNewMessage(msg) {
if (!msg || msg.author_id === state.me) return;
const who = msg.author_short || shortName({ name: msg.author_name, id: msg.author_id });
const body = (msg.body || (msg.file ? "Shared a file" : "New message")).slice(0, 120);
const room = state.rooms.find((r) => Number(r.id) === Number(msg.room_id));
const roomLabel =
room?.kind === "team"
? "ATC Team"
: room?.peer_short || shortName(userById(room?.peer_id)) || "Team chat";
// soft soundless flash via badge already; browser notification when allowed
if ("Notification" in window && Notification.permission === "granted" && document.hidden) {
try {
const n = new Notification(`${who} · ${roomLabel}`, {
body,
tag: `team-${msg.room_id}`,
renotify: true,
});
n.onclick = () => {
window.focus();
openTeam();
selectRoom(Number(msg.room_id)).catch(() => {});
n.close();
};
} catch (_) {}
}
}
function onIncomingMessage(msg) {
if (!msg) return;
const teamOpen = $("#team-drawer")?.classList.contains("open");
const viewing = teamOpen && Number(msg.room_id) === Number(state.activeRoomId);
if (viewing) {
if (!state.messages.some((m) => m.id === msg.id)) {
state.messages.push(msg);
renderMessages(true);
}
clearUnread(msg.room_id);
} else if (msg.author_id !== state.me) {
bumpUnread(msg.room_id);
notifyNewMessage(msg);
}
const room = state.rooms.find((r) => Number(r.id) === Number(msg.room_id));
if (room) room.last_message = msg;
renderRoomList();
}
function updateIdentityBadge() {
const badge = $("#team-identity-badge");
const btn = $("#btn-team");
const u = userById(state.me);
if (!badge) return;
if (!u) {
badge.classList.add("hidden");
badge.innerHTML = "";
if (btn) btn.title = "ATC team chat · files";
return;
}
badge.classList.remove("hidden");
const url = avatarUrl(u.id, u.avatar_url);
badge.innerHTML = `<span class="tib-av"><img src="${esc(url)}" alt="" onerror="this.remove()"/></span><span class="tib-dot ${esc(u.team)}"></span><strong>${esc(shortName(u))}</strong>`;
badge.title = `Acting as ${u.name} — click to switch`;
if (btn) btn.title = `Team chat · as ${u.name}`;
}
function refreshAvatarPreview() {
const u = userById(state.me || getActor());
const img = $("#identity-avatar-img");
const initials = $("#identity-avatar-initials");
const label = $("#identity-avatar-label");
const row = $("#identity-avatar-row");
if (!row) return;
if (!u) {
row.classList.add("dim");
if (label) label.textContent = "Profile photo";
if (img) {
img.hidden = true;
img.removeAttribute("src");
}
if (initials) {
initials.hidden = false;
initials.textContent = "?";
}
return;
}
row.classList.remove("dim");
if (label) label.textContent = `${u.name} · profile photo`;
const url = avatarUrl(u.id, u.avatar_url);
if (img) {
img.hidden = false;
img.onerror = () => {
img.hidden = true;
if (initials) {
initials.hidden = false;
initials.textContent = (u.name || "?").slice(0, 1).toUpperCase();
}
};
img.onload = () => {
if (initials) initials.hidden = true;
};
img.src = url;
}
if (initials) {
initials.hidden = false;
initials.textContent = (u.name || "?").slice(0, 1).toUpperCase();
}
}
function needsIdentityGate() {
return !getActor();
}
function openIdentityGate(force = false, { mustPick = false } = {}) {
const gate = $("#identity-gate");
if (!gate) return;
if (!force && !needsIdentityGate() && !mustPick) {
gate.classList.add("hidden");
gate.setAttribute("aria-hidden", "true");
return;
}
state._identityMustPick = mustPick || needsIdentityGate();
const grid = $("#identity-gate-grid");
if (grid) {
grid.innerHTML = USERS.map((u) => {
const url = avatarUrl(u.id, u.avatar_url);
const selected = (state.me || getActor()) === u.id ? "selected" : "";
const guest = u.team === "guest" ? "guest" : "";
return `<button type="button" class="id-card team-${esc(u.team)} ${selected} ${guest}" data-actor="${esc(u.id)}">
<span class="id-av"><img src="${esc(url)}" alt="" onerror="this.parentElement.classList.add('fallback')"/><span class="id-av-fallback">${esc((u.short || u.name || "?").slice(0, 1))}</span></span>
<span class="id-kicker">${u.team === "admin" ? "DC Engineer" : u.team === "guest" ? "Visitor" : "Data FDE · \"Data Plumbers\" 😉"}</span>
<strong>${esc(u.name)}</strong>
<span class="id-role">${esc(u.role)}</span>
<span class="id-focus">${esc(u.focus)}</span>
</button>`;
}).join("");
}
refreshAvatarPreview();
gate.classList.remove("hidden");
gate.setAttribute("aria-hidden", "false");
document.body.classList.add("identity-locked");
}
function closeIdentityGate() {
if (state._identityMustPick && needsIdentityGate()) return;
const gate = $("#identity-gate");
gate?.classList.add("hidden");
gate?.setAttribute("aria-hidden", "true");
state._identityMustPick = false;
document.body.classList.remove("identity-locked");
}
function confirmIdentity(id) {
setActor(id);
state._identityMustPick = false;
refreshAvatarPreview();
closeIdentityGate();
if (state._openTeamAfterId) {
state._openTeamAfterId = false;
openTeam();
}
}
async function uploadAvatar(file) {
const me = state.me || getActor();
if (!me || !file) return;
const fd = new FormData();
fd.append("user_id", me);
fd.append("file", file, file.name);
const res = await fetch("/api/team/avatar", { method: "POST", body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || res.statusText || "Avatar upload failed");
return;
}
state.avatarBust[me] = Date.now();
if (data.avatar_url) {
const u = userById(me);
if (u) u.avatar_url = data.avatar_url;
}
updateIdentityBadge();
refreshAvatarPreview();
openIdentityGate(true);
renderPresence();
renderMessages(false);
}
function closeOtherDrawers() {
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#network-drawer", "#console-drawer"].forEach((id) => {
const el = $(id);
if (el) {
el.classList.remove("open");
el.setAttribute("aria-hidden", "true");
}
});
}
function openTeam() {
if (needsIdentityGate()) {
openIdentityGate(true);
return;
}
state.me = getActor();
const drawer = $("#team-drawer");
const scrim = $("#scrim");
if (!drawer) return;
closeOtherDrawers();
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
if (scrim) {
scrim.classList.add("open");
scrim.dataset.mode = "team-drawer";
}
connectWs();
refreshRooms().then(() => {
const team = state.rooms.find((r) => r.kind === "team");
if (team) selectRoom(team.id);
else if (state.rooms[0]) selectRoom(state.rooms[0].id);
});
}
function closeTeam() {
const drawer = $("#team-drawer");
const scrim = $("#scrim");
drawer?.classList.remove("open");
drawer?.setAttribute("aria-hidden", "true");
if (scrim?.dataset.mode === "team-drawer") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function connectWs(force = false) {
if (!state.me) return;
if (!force && state.ws && state.ws.readyState <= 1) return;
if (force && state.ws) {
try {
state.ws.close();
} catch (_) {}
state.ws = null;
}
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws/team-chat?user=${encodeURIComponent(state.me)}`);
state.ws = ws;
ws.addEventListener("message", (ev) => {
let data;
try {
data = JSON.parse(ev.data);
} catch {
return;
}
if (data.type === "presence" || data.type === "hello" || data.type === "pong") {
if (data.presence) {
state.presence = data.presence;
renderRoomList();
renderPresence();
}
} else if (data.type === "avatar" && data.user_id) {
state.avatarBust[data.user_id] = Date.now();
const u = userById(data.user_id);
if (u && data.avatar_url) u.avatar_url = data.avatar_url;
updateIdentityBadge();
refreshAvatarPreview();
renderPresence();
renderMessages(false);
renderRoomList();
} else if (data.type === "message" && data.message) {
onIncomingMessage(data.message);
} else if (data.type === "typing") {
if (Number(data.room_id) === Number(state.activeRoomId) && data.user_id !== state.me) {
const el = $("#team-typing");
if (el) {
el.textContent = `${data.name || "Someone"} is typing…`;
clearTimeout(state.typing);
state.typing = setTimeout(() => {
el.textContent = "";
}, 2500);
}
}
}
});
ws.addEventListener("close", () => {
state.ws = null;
setTimeout(() => {
if (state.me) connectWs();
}, 2500);
});
}
// heartbeat once
if (!window.__teamChatHeartbeat) {
window.__teamChatHeartbeat = setInterval(() => {
if (state.ws?.readyState === 1) state.ws.send(JSON.stringify({ type: "ping" }));
}, 25000);
}
async function refreshRooms() {
const res = await fetch(`/api/team/rooms?me=${encodeURIComponent(state.me)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
state.rooms = data.rooms || [];
state.presence = data.presence || [];
if (data.users?.length) {
data.users.forEach((nu) => {
const local = USERS.find((u) => u.id === nu.id);
if (local) {
if (nu.name) local.name = nu.name;
if (nu.short) local.short = nu.short;
if (nu.avatar_url) local.avatar_url = nu.avatar_url;
}
});
updateIdentityBadge();
}
renderRoomList();
renderPresence();
}
function renderPresence() {
const host = $("#team-presence");
if (!host) return;
host.innerHTML = (state.presence || [])
.map((u) => {
const on = u.online ? "on" : "off";
const url = avatarUrl(u.id, u.avatar_url);
return `<span class="tp-pill ${on}" title="${esc(u.name)}"><img class="tp-av" src="${esc(url)}" alt="" onerror="this.remove()"/><i></i>${esc(shortName(u))}</span>`;
})
.join("");
}
function renderRoomList() {
const host = $("#team-room-list");
if (!host) return;
const team = state.rooms.filter((r) => r.kind === "team");
const dms = state.rooms.filter((r) => r.kind === "dm");
const item = (r) => {
const active = Number(r.id) === Number(state.activeRoomId) ? "active" : "";
const title =
r.kind === "team"
? "ATC Team"
: r.peer_short || shortName(userById(r.peer_id)) || r.peer_name || r.title;
const preview = r.last_message
? `${r.last_message.author_short || shortName({ name: r.last_message.author_name })}: ${r.last_message.body || "file"}`
: "No messages yet";
const peer = r.peer_id ? state.presence.find((p) => p.id === r.peer_id) : null;
const live = r.kind === "team" ? "" : peer?.online ? "live" : "";
const av =
r.kind === "dm" && r.peer_id
? `<img class="tr-av" src="${esc(avatarUrl(r.peer_id, r.peer_avatar))}" alt="" onerror="this.classList.add('hidden')"/>`
: "";
const unread = state.unread[String(r.id)] || 0;
const unreadHtml = unread
? `<span class="tr-unread">${unread > 99 ? "99+" : unread}</span>`
: "";
return `<button type="button" class="team-room ${active} ${live} ${unread ? "has-unread" : ""}" data-room-id="${r.id}">
${av}
<span class="tr-text"><span class="tr-title">${esc(title)}${unreadHtml}</span>
<span class="tr-preview">${esc(preview.slice(0, 64))}</span></span>
</button>`;
};
host.innerHTML = `
<div class="team-room-group"><span>Team</span></div>
${team.map(item).join("")}
<div class="team-room-group"><span>Direct</span></div>
${dms.map(item).join("") || `<p class="hint">No DMs yet</p>`}`;
}
async function selectRoom(roomId) {
state.activeRoomId = roomId;
clearUnread(roomId);
renderRoomList();
const res = await fetch(`/api/team/rooms/${roomId}/messages?limit=300`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
state.messages = data.messages || [];
const title = $("#team-chat-title");
const sub = $("#team-chat-sub");
const room = data.room || state.rooms.find((r) => Number(r.id) === Number(roomId));
if (title) {
title.textContent =
room?.kind === "team"
? "ATC Team"
: room?.peer_short || shortName(userById(room?.peer_id)) || room?.title || "Chat";
}
if (sub) {
if (room?.kind === "dm") {
const peer = room.peer_id || (room.key || "").split(":").filter((x) => x !== "dm" && x !== state.me)[0];
const p = userById(peer);
sub.textContent = p ? `${p.role} · ${p.focus || ""}` : "Direct message";
} else {
sub.textContent = "Jody · Laurens · Mo · Bart — shared room";
}
}
renderMessages(false);
}
function fmtTime(ts) {
if (!ts) return "";
try {
return new Date(ts * 1000).toLocaleString();
} catch {
return "";
}
}
function formatBytes(n) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function renderMessages(stickBottom) {
const host = $("#team-messages");
if (!host) return;
const nearBottom = host.scrollHeight - host.scrollTop - host.clientHeight < 80;
host.innerHTML = state.messages
.map((m) => {
const mine = m.author_id === state.me;
const name = m.author_short || shortName({ name: m.author_name, id: m.author_id });
const url = avatarUrl(m.author_id, m.author_avatar);
const file = m.file
? `<a class="team-file" href="${esc(m.file.url)}" target="_blank" rel="noopener">
<span class="tf-name">${esc(m.file.filename)}</span>
<span class="tf-meta">${esc(formatBytes(m.file.size || 0))} · ${esc(m.file.mime || "file")}</span>
</a>`
: "";
return `<div class="team-bubble ${mine ? "mine" : "theirs"} team-${esc(m.author_team || "")}">
<img class="tb-av" src="${esc(url)}" alt="" onerror="this.classList.add('hidden')"/>
<div class="tb-content">
<div class="tb-meta"><strong>${esc(name)}</strong><span>${esc(fmtTime(m.created_at))}</span></div>
${m.body ? `<div class="tb-body">${esc(m.body)}</div>` : ""}
${file}
</div>
</div>`;
})
.join("");
if (stickBottom || nearBottom) host.scrollTop = host.scrollHeight;
}
async function sendMessage() {
const input = $("#team-input");
const body = (input?.value || "").trim();
if (!body || !state.activeRoomId) return;
input.value = "";
const res = await fetch(`/api/team/rooms/${state.activeRoomId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ author_id: state.me, body }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || res.statusText);
return;
}
if (data.message && !state.messages.some((m) => m.id === data.message.id)) {
state.messages.push(data.message);
renderMessages(true);
}
}
async function uploadFiles(fileList) {
if (!state.activeRoomId || !fileList?.length) return;
for (const file of fileList) {
const fd = new FormData();
fd.append("uploader_id", state.me);
fd.append("body", "");
fd.append("file", file, file.name);
const res = await fetch(`/api/team/rooms/${state.activeRoomId}/files`, { method: "POST", body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || `Upload failed: ${file.name}`);
continue;
}
if (data.message && !state.messages.some((m) => m.id === data.message.id)) {
state.messages.push(data.message);
renderMessages(true);
}
}
}
function notifyTyping() {
if (state.ws?.readyState === 1 && state.activeRoomId) {
state.ws.send(JSON.stringify({ type: "typing", room_id: state.activeRoomId }));
}
}
function bind() {
state.me = getActor();
updateIdentityBadge();
updateUnreadBadge();
// Always first popup on UI open so everyone can pick themselves immediately
openIdentityGate(true, { mustPick: needsIdentityGate() });
if (state.me) {
ensureNotifyPermission();
connectWs();
refreshRooms().catch(() => {});
}
$("#identity-gate-grid")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-actor]");
if (!btn) return;
confirmIdentity(btn.dataset.actor);
});
$("#identity-avatar-file")?.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (!getActor()) {
alert("Pick who you are first, then upload a photo.");
e.target.value = "";
return;
}
uploadAvatar(file).catch((err) => alert(err.message));
e.target.value = "";
});
$("#team-identity-badge")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
openIdentityGate(true);
});
$("#btn-team")?.addEventListener("click", (e) => {
if (e.target.closest("#team-identity-badge")) return;
if (needsIdentityGate()) {
state._openTeamAfterId = true;
openIdentityGate(true, { mustPick: true });
return;
}
openTeam();
});
$("#btn-team-close")?.addEventListener("click", closeTeam);
$("#btn-team-switch")?.addEventListener("click", () => openIdentityGate(true));
$("#scrim")?.addEventListener("click", () => {
if ($("#scrim")?.dataset.mode === "team-drawer") closeTeam();
});
$("#team-room-list")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-room-id]");
if (!btn) return;
selectRoom(Number(btn.dataset.roomId)).catch((err) => alert(err.message));
});
$("#team-form")?.addEventListener("submit", (e) => {
e.preventDefault();
sendMessage().catch((err) => alert(err.message));
});
$("#team-input")?.addEventListener("input", () => notifyTyping());
$("#team-file")?.addEventListener("change", (e) => {
const files = e.target.files;
uploadFiles(files).catch((err) => alert(err.message));
e.target.value = "";
});
const drop = $("#team-chat-main");
drop?.addEventListener("dragover", (e) => {
e.preventDefault();
drop.classList.add("drag-files");
});
drop?.addEventListener("dragleave", () => drop.classList.remove("drag-files"));
drop?.addEventListener("drop", (e) => {
e.preventDefault();
drop.classList.remove("drag-files");
if (e.dataTransfer?.files?.length) uploadFiles(e.dataTransfer.files).catch((err) => alert(err.message));
});
$("#ops-actor")?.addEventListener("change", (e) => {
if (e.target.value) setActor(e.target.value);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (!$("#identity-gate")?.classList.contains("hidden")) {
if (!state._identityMustPick && !needsIdentityGate()) closeIdentityGate();
} else if ($("#team-drawer")?.classList.contains("open")) {
closeTeam();
}
}
});
$("#identity-gate")?.addEventListener("click", (e) => {
if (e.target.id === "identity-gate" && !state._identityMustPick && !needsIdentityGate()) {
closeIdentityGate();
}
});
}
bind();
window.cockpitTeam = {
open: openTeam,
close: closeTeam,
setActor,
getActor,
openIdentityGate,
uploadAvatar,
};
})();