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 logging
import time
import uuid
import shutil
from collections import defaultdict
from pathlib import Path
from typing import Any
import httpx
import asyncssh
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
@@ -2669,34 +2671,47 @@ OPS_USERS = [
{
"id": "jody",
"name": "Jody van Dongen",
"role": "ATC Datacenter Admin",
"short": "Jody",
"role": "Datacenter Engineer",
"team": "admin",
"email": "jody.van.dongen@dell.com",
"focus": "OME fleet · racks · warranty / compliance",
"focus": "Storage · servers · network · rack & stack · installs",
},
{
"id": "laurens",
"name": "Laurens Rammers",
"role": "ATC Datacenter Admin",
"short": "Laurens",
"role": "Datacenter Engineer",
"team": "admin",
"email": "laurens.rammers@dell.com",
"focus": "Datacenter ops · handoffs · escalation",
"focus": "Storage · servers · network · rack & stack · installs",
},
{
"id": "mo",
"name": "Mohamed El Kadi",
"short": "Mo",
"role": "Data Forward Deployed Engineer",
"team": "fde",
"email": "mohamed.el.kadi@dell.com",
"focus": "OME Cockpit · OpenManage AI · AI workloads on FDE cluster",
"focus": "\"Data Plumbers\" 😉 · OME Cockpit · OpenManage AI · AI workloads on FDE cluster",
},
{
"id": "bart",
"name": "Bart Sjerps",
"short": "Bart",
"role": "Data Forward Deployed Engineer",
"team": "fde",
"email": "bart.sjerps@dell.com",
"focus": "FDE cluster · AI workload deployment with Mo",
"focus": "\"Data Plumbers\" 😉 · FDE cluster · AI workload deployment with Mo",
},
{
"id": "guest",
"name": "Guest",
"short": "Guest",
"role": "Visitor",
"team": "guest",
"email": "",
"focus": "Temporary access · pick your name next time",
},
]
# Back-compat alias used by older ticket endpoints / UI
@@ -2891,12 +2906,43 @@ def init_db():
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
key TEXT NOT NULL UNIQUE,
title TEXT,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
uploader_id TEXT NOT NULL,
filename TEXT NOT NULL,
mime TEXT,
size INTEGER NOT NULL DEFAULT 0,
stored_path TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
author_id TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
file_id INTEGER REFERENCES chat_files(id) ON DELETE SET NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_presence (
user_id TEXT PRIMARY KEY,
last_seen REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_room ON chat_messages(room_id, created_at);
"""
)
_restore_ops_db_if_empty()
_seed_atc_racks()
_seed_atc_vlans()
_seed_network_endpoints()
_ensure_team_chat_seed()
n = 0
try:
with _db() as conn:
@@ -2906,6 +2952,131 @@ def init_db():
log.info("Ops tickets DB ready at %s (%s tickets)", DB_PATH, n)
TEAM_CHAT_DIR = DATA_DIR / "team-chat"
TEAM_AVATAR_DIR = DATA_DIR / "team-chat" / "avatars"
TEAM_CHAT_MAX_BYTES = 25 * 1024 * 1024
TEAM_AVATAR_MAX_BYTES = 5 * 1024 * 1024
TEAM_CHAT_CLIENTS: dict[str, set[WebSocket]] = defaultdict(set)
TEAM_AVATAR_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
def _ensure_team_chat_seed() -> None:
TEAM_CHAT_DIR.mkdir(parents=True, exist_ok=True)
TEAM_AVATAR_DIR.mkdir(parents=True, exist_ok=True)
now = time.time()
with _db() as conn:
row = conn.execute("SELECT id FROM chat_rooms WHERE key='team'").fetchone()
if not row:
conn.execute(
"INSERT INTO chat_rooms(kind, key, title, created_at) VALUES (?,?,?,?)",
("team", "team", "ATC Team", now),
)
conn.commit()
log.info("Seeded ATC Team chat room")
def _require_ops_user_id(uid: str) -> str:
uid = (uid or "").strip().lower()
if not _ops_user(uid):
raise HTTPException(400, f"Unknown ATC user: {uid}")
return uid
def _dm_room_key(a: str, b: str) -> str:
x, y = sorted([a, b])
return f"dm:{x}:{y}"
def _ensure_dm_room(user_a: str, user_b: str) -> dict:
a = _require_ops_user_id(user_a)
b = _require_ops_user_id(user_b)
if a == b:
raise HTTPException(400, "Cannot DM yourself")
key = _dm_room_key(a, b)
now = time.time()
with _db() as conn:
row = conn.execute("SELECT * FROM chat_rooms WHERE key=?", (key,)).fetchone()
if row:
return dict(row)
ua, ub = _ops_user(a), _ops_user(b)
title = f"{ua.get('short') or ua['name'].split()[0]}{ub.get('short') or ub['name'].split()[0]}"
cur = conn.execute(
"INSERT INTO chat_rooms(kind, key, title, created_at) VALUES (?,?,?,?)",
("dm", key, title, now),
)
conn.commit()
rid = cur.lastrowid
row = conn.execute("SELECT * FROM chat_rooms WHERE id=?", (rid,)).fetchone()
return dict(row)
def _message_dict(conn, row) -> dict:
d = dict(row)
file_meta = None
if d.get("file_id"):
fr = conn.execute("SELECT * FROM chat_files WHERE id=?", (d["file_id"],)).fetchone()
if fr:
file_meta = {
"id": fr["id"],
"filename": fr["filename"],
"mime": fr["mime"],
"size": fr["size"],
"url": f"/api/team/files/{fr['id']}",
}
author = _ops_user(d["author_id"]) or {"id": d["author_id"], "name": d["author_id"], "short": d["author_id"]}
return {
"id": d["id"],
"room_id": d["room_id"],
"author_id": d["author_id"],
"author_name": author.get("name"),
"author_short": author.get("short") or (author.get("name") or "").split()[0],
"author_team": author.get("team"),
"author_avatar": f"/api/team/avatar/{d['author_id']}",
"body": d["body"] or "",
"file": file_meta,
"created_at": d["created_at"],
}
async def _team_broadcast(payload: dict, exclude: WebSocket | None = None) -> None:
dead: list[tuple[str, WebSocket]] = []
for uid, socks in list(TEAM_CHAT_CLIENTS.items()):
for ws in list(socks):
if ws is exclude:
continue
try:
await ws.send_json(payload)
except Exception:
dead.append((uid, ws))
for uid, ws in dead:
TEAM_CHAT_CLIENTS[uid].discard(ws)
def _presence_snapshot() -> list[dict]:
now = time.time()
online = {uid for uid, socks in TEAM_CHAT_CLIENTS.items() if socks}
with _db() as conn:
rows = conn.execute("SELECT user_id, last_seen FROM chat_presence").fetchall()
out = []
for u in OPS_USERS:
last = next((r["last_seen"] for r in rows if r["user_id"] == u["id"]), None)
out.append(
{
"id": u["id"],
"name": u["name"],
"short": u.get("short") or u["name"].split()[0],
"team": u["team"],
"role": u.get("role"),
"focus": u.get("focus"),
"avatar_url": f"/api/team/avatar/{u['id']}",
"online": u["id"] in online,
"last_seen": last,
"recent": bool(last and (now - last) < 120),
}
)
return out
async def fetch_gpu() -> dict:
url = settings.gpu_metrics_url.rstrip("/") + "/api/gpu"
try:
@@ -3939,9 +4110,9 @@ def build_fleet_context(focus_device_id: int | None = None, max_chars: int | Non
lines = [
"You are OpenManage Cockpit Copilot for Dell ATC. Be concise and operational.",
"ATC Datacenter Admins (escalate here when facts are missing):",
" - Jody van Dongen <jody.van.dongen@dell.com>",
" - Laurens Rammers <laurens.rammers@dell.com>",
"ATC Datacenter Engineers (escalate here when facts are missing):",
" - Jody van Dongen <jody.van.dongen@dell.com> — storage, servers, network, rack & stack, installs",
" - Laurens Rammers <laurens.rammers@dell.com> — storage, servers, network, rack & stack, installs",
"ACCURACY RULES (mandatory — never break these):",
"1) Use ONLY facts from this snapshot and any TOOL FACTS block. Never invent Service Tags, IPs, DIMM counts, firmware versions, RDP targets, port maps, VLAN members, or rack placements.",
"2) If a requested fact is not present in the snapshot/TOOL FACTS, or tools failed/returned empty: say exactly what is unknown, then tell the user to overleggen met Jody van Dongen and Laurens Rammers (emails above). Do not guess.",
@@ -4516,11 +4687,12 @@ async def api_ops_users():
"users": OPS_USERS,
"admins": [u for u in OPS_USERS if u.get("team") == "admin"],
"fde": [u for u in OPS_USERS if u.get("team") == "fde"],
"guests": [u for u in OPS_USERS if u.get("team") == "guest"],
"context": {
"cockpit": "OME Cockpit by Data Forward Deployed Engineers Mohamed El Kadi & Bart Sjerps",
"cluster": "Runs on the FDE cluster operated by Data Forward Deployed Engineers Mohamed El Kadi and Bart Sjerps",
"admins": "Jody van Dongen and Laurens Rammers — ATC datacenter administrators",
"fde": "Mo and Bart — both Data Forward Deployed Engineers deploying AI workloads",
"admins": "Jody van Dongen and Laurens Rammers — ATC Datacenter Engineers (storage, servers, network, rack & stack, installs)",
"fde": "Mo and Bart — Data Forward Deployed Engineers, aka \"Data Plumbers\" 😉",
},
}
@@ -4734,7 +4906,7 @@ async def api_chat(payload: ChatIn):
user_msg = payload.message[:2500]
escalate = (
"If any needed fact is absent, say it is unknown and instruct the user to overleggen met "
"Jody van Dongen (jody.van.dongen@dell.com) and Laurens Rammers (laurens.rammers@dell.com). "
"Jody van Dongen (jody.van.dongen@dell.com) and Laurens Rammers (laurens.rammers@dell.com) — Datacenter Engineers. "
"Never invent."
)
if tool_results:
@@ -4848,6 +5020,335 @@ def _admin_name(aid: str) -> str:
return u["name"] if u else aid
class TeamMessageIn(BaseModel):
author_id: str
body: str = ""
class TeamDmIn(BaseModel):
me: str
peer: str
def _avatar_path(user_id: str) -> Path | None:
"""Return existing avatar file for user, if any."""
TEAM_AVATAR_DIR.mkdir(parents=True, exist_ok=True)
for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
p = TEAM_AVATAR_DIR / f"{user_id}{ext}"
if p.is_file():
return p
return None
def _ops_users_public() -> list[dict]:
out = []
for u in OPS_USERS:
d = dict(u)
d["short"] = u.get("short") or u["name"].split()[0]
d["avatar_url"] = f"/api/team/avatar/{u['id']}"
d["has_avatar"] = _avatar_path(u["id"]) is not None
out.append(d)
return out
@app.get("/api/team/users")
async def team_users():
return {"users": _ops_users_public(), "presence": _presence_snapshot()}
@app.get("/api/team/avatar/{user_id}")
async def team_get_avatar(user_id: str):
uid = (user_id or "").strip().lower()
if not _ops_user(uid):
raise HTTPException(404, "Unknown user")
path = _avatar_path(uid)
if not path:
# 1x1 transparent PNG so <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")
async def list_tickets():
with _db() as conn:
@@ -6463,6 +6964,11 @@ async def console_js():
return FileResponse(STATIC_DIR / "console.js", media_type="application/javascript")
@app.get("/team.js")
async def team_js():
return FileResponse(STATIC_DIR / "team.js", media_type="application/javascript")
@app.get("/ssh.js")
async def ssh_js():
return FileResponse(STATIC_DIR / "ssh.js", media_type="application/javascript")
+1
View File
@@ -2,6 +2,7 @@ fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
websockets==14.1
python-multipart==0.0.20
pydantic==2.10.4
pydantic-settings==2.7.0
asyncssh==2.18.0