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