diff --git a/backend/src/livekit/rooms.ts b/backend/src/livekit/rooms.ts new file mode 100644 index 0000000..52fa10e --- /dev/null +++ b/backend/src/livekit/rooms.ts @@ -0,0 +1,39 @@ +import { RoomServiceClient } from 'livekit-server-sdk'; +import { env } from '../env.js'; + +function isConfigured(): boolean { + return !!env.LIVEKIT_URL && !env.LIVEKIT_URL.includes('REPLACE_ME'); +} + +let client: RoomServiceClient | null = null; +function svc(): RoomServiceClient { + if (!client) { + const httpUrl = env.LIVEKIT_URL.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:'); + client = new RoomServiceClient(httpUrl, env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET); + } + return client; +} + +/** + * Who is currently connected in each pod's LiveKit room, as display names, + * keyed by pod id (= room name). Empty rooms are omitted. Returns {} if + * LiveKit isn't configured or the API call fails (presence is best-effort). + */ +export async function getPresence(): Promise> { + if (!isConfigured()) return {}; + const out: Record = {}; + const rooms = await svc().listRooms(); + await Promise.all( + rooms + .filter((r) => r.numParticipants > 0) + .map(async (r) => { + try { + const ps = await svc().listParticipants(r.name); + out[r.name] = ps.map((p) => p.name || p.identity); + } catch { + out[r.name] = []; + } + }), + ); + return out; +} diff --git a/backend/src/server.ts b/backend/src/server.ts index dd91021..a594b6a 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -17,6 +17,7 @@ import { removeMember, seedDefaultPods, } from './pods/store.js'; +import { getPresence } from './livekit/rooms.js'; import type { InterventionOutcome } from '@podman/shared'; const app = express(); @@ -72,6 +73,15 @@ app.get('/api/memory/stats', async (_req, res) => { } }); +// Live presence: who is currently connected in each pod's LiveKit room. +app.get('/api/presence', async (_req, res) => { + try { + res.json(await getPresence()); + } catch (e) { + res.status(500).json({ error: (e as Error).message }); + } +}); + // --- Pods CRUD (Mongo-backed) --- app.get('/api/pods', async (_req, res) => { try { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ca3c0a..3ca1614 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ export default function App() { const [loading, setLoading] = useState(true); const [pending, setPending] = useState>(new Set()); const [error, setError] = useState(null); + const [presence, setPresence] = useState>({}); // join state — keep the id and derive the pod so it never goes stale const [joinedPodId, setJoinedPodId] = useState(null); @@ -59,6 +60,27 @@ export default function App() { void refresh(); }, []); + // Poll live presence while browsing the pod list (not while joined — PodView + // tracks the room live itself). + useEffect(() => { + if (joinedPodId) return; + let alive = true; + const tick = async () => { + try { + const p = await api.getPresence(); + if (alive) setPresence(p); + } catch { + /* presence is best-effort */ + } + }; + void tick(); + const id = window.setInterval(() => void tick(), 5000); + return () => { + alive = false; + window.clearInterval(id); + }; + }, [joinedPodId]); + // Resume a joined session across a page refresh (auto-reconnect, no re-prompt). useEffect(() => { const raw = sessionStorage.getItem(SESSION_KEY); @@ -134,6 +156,20 @@ export default function App() { } } + // Add your name to the roster (if new) and join in one step. + async function handleAddAndJoin(pod: Pod, name: string) { + startPending(pod.id); + setError(null); + try { + upsert(await api.addMember(pod.id, name)); + await connectToPod(pod.id, name); + } catch (e) { + setError((e as Error).message); + } finally { + endPending(pod.id); + } + } + function handleLeave() { room?.disconnect(); setRoom(null); @@ -203,7 +239,9 @@ export default function App() { key={pod.id} pod={pod} busy={pending.has(pod.id)} + presence={presence[pod.id] ?? []} onJoin={handleJoin} + onAddAndJoin={handleAddAndJoin} onAddMember={handleAddMember} onRemoveMember={handleRemoveMember} onUpdate={handleUpdate} diff --git a/frontend/src/components/PodCard.tsx b/frontend/src/components/PodCard.tsx index 8aba2bd..191f8b2 100644 --- a/frontend/src/components/PodCard.tsx +++ b/frontend/src/components/PodCard.tsx @@ -5,7 +5,9 @@ import { Avatar } from './Avatar.js'; export function PodCard({ pod, busy, + presence, onJoin, + onAddAndJoin, onAddMember, onRemoveMember, onUpdate, @@ -13,14 +15,15 @@ export function PodCard({ }: { pod: Pod; busy: boolean; + presence: string[]; onJoin: (pod: Pod, member: string) => void; + onAddAndJoin: (pod: Pod, name: string) => void; onAddMember: (id: string, name: string) => void; onRemoveMember: (id: string, name: string) => void; onUpdate: (id: string, patch: PodInput) => void; onDelete: (id: string) => void; }) { const [newMember, setNewMember] = useState(''); - const [joinAs, setJoinAs] = useState(pod.members[0] ?? ''); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState({ name: pod.name, @@ -28,15 +31,7 @@ export function PodCard({ description: pod.description ?? '', }); - // keep joinAs valid as members change - const joinMember = pod.members.includes(joinAs) ? joinAs : (pod.members[0] ?? ''); - - function add() { - const name = newMember.trim(); - if (!name) return; - onAddMember(pod.id, name); - setNewMember(''); - } + const inRoom = (name: string) => presence.some((p) => p.toLowerCase() === name.toLowerCase()); function saveEdit() { onUpdate(pod.id, { @@ -88,13 +83,20 @@ export function PodCard({ ) : (
-

{pod.name}

+
+

{pod.name}

+ {presence.length > 0 && ( + + + {presence.length} in room + + )} +

{pod.repo || 'no repo set'}

{pod.description &&

{pod.description}

}
)} - {/* Members */} + {/* Members — click your name to join */}
+

Click your name to join:

{pod.members.length === 0 && ( - No members yet. + No members yet — add your name below. )} {pod.members.map((m) => ( - - {m} + ))}
+ + {/* Add your name (and join) */}
setNewMember(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && add()} + onKeyDown={(e) => { + if (e.key === 'Enter' && newMember.trim()) { + onAddAndJoin(pod, newMember.trim()); + setNewMember(''); + } + }} /> +
- - {/* Join */} -
- - -
); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 50f4af0..bff49d3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -41,6 +41,11 @@ export async function listPods(): Promise { return json(await fetch(`${BACKEND_URL}/api/pods`)); } +/** Display names currently connected per pod id (= LiveKit room name). */ +export async function getPresence(): Promise> { + return json(await fetch(`${BACKEND_URL}/api/presence`)); +} + export async function createPod(input: PodInput): Promise { return json( await fetch(`${BACKEND_URL}/api/pods`, {