feat: one-click join + live pod presence
Join UX: click a member's name to join directly, or type a name and "Join" (adds to roster + joins) — removes the redundant dropdown step. "Add" still adds to the roster without joining. Presence: new GET /api/presence (LiveKit RoomServiceClient) reports who's connected per pod. The pod list polls it and shows a "N in room" badge plus a green dot on members currently connected, so people can see active pods and jump in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Record<string, string[]>> {
|
||||||
|
if (!isConfigured()) return {};
|
||||||
|
const out: Record<string, string[]> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
removeMember,
|
removeMember,
|
||||||
seedDefaultPods,
|
seedDefaultPods,
|
||||||
} from './pods/store.js';
|
} from './pods/store.js';
|
||||||
|
import { getPresence } from './livekit/rooms.js';
|
||||||
import type { InterventionOutcome } from '@podman/shared';
|
import type { InterventionOutcome } from '@podman/shared';
|
||||||
|
|
||||||
const app = express();
|
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) ---
|
// --- Pods CRUD (Mongo-backed) ---
|
||||||
app.get('/api/pods', async (_req, res) => {
|
app.get('/api/pods', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default function App() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pending, setPending] = useState<Set<string>>(new Set());
|
const [pending, setPending] = useState<Set<string>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [presence, setPresence] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
// join state — keep the id and derive the pod so it never goes stale
|
// join state — keep the id and derive the pod so it never goes stale
|
||||||
const [joinedPodId, setJoinedPodId] = useState<string | null>(null);
|
const [joinedPodId, setJoinedPodId] = useState<string | null>(null);
|
||||||
@@ -59,6 +60,27 @@ export default function App() {
|
|||||||
void refresh();
|
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).
|
// Resume a joined session across a page refresh (auto-reconnect, no re-prompt).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
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() {
|
function handleLeave() {
|
||||||
room?.disconnect();
|
room?.disconnect();
|
||||||
setRoom(null);
|
setRoom(null);
|
||||||
@@ -203,7 +239,9 @@ export default function App() {
|
|||||||
key={pod.id}
|
key={pod.id}
|
||||||
pod={pod}
|
pod={pod}
|
||||||
busy={pending.has(pod.id)}
|
busy={pending.has(pod.id)}
|
||||||
|
presence={presence[pod.id] ?? []}
|
||||||
onJoin={handleJoin}
|
onJoin={handleJoin}
|
||||||
|
onAddAndJoin={handleAddAndJoin}
|
||||||
onAddMember={handleAddMember}
|
onAddMember={handleAddMember}
|
||||||
onRemoveMember={handleRemoveMember}
|
onRemoveMember={handleRemoveMember}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { Avatar } from './Avatar.js';
|
|||||||
export function PodCard({
|
export function PodCard({
|
||||||
pod,
|
pod,
|
||||||
busy,
|
busy,
|
||||||
|
presence,
|
||||||
onJoin,
|
onJoin,
|
||||||
|
onAddAndJoin,
|
||||||
onAddMember,
|
onAddMember,
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
@@ -13,14 +15,15 @@ export function PodCard({
|
|||||||
}: {
|
}: {
|
||||||
pod: Pod;
|
pod: Pod;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
presence: string[];
|
||||||
onJoin: (pod: Pod, member: string) => void;
|
onJoin: (pod: Pod, member: string) => void;
|
||||||
|
onAddAndJoin: (pod: Pod, name: string) => void;
|
||||||
onAddMember: (id: string, name: string) => void;
|
onAddMember: (id: string, name: string) => void;
|
||||||
onRemoveMember: (id: string, name: string) => void;
|
onRemoveMember: (id: string, name: string) => void;
|
||||||
onUpdate: (id: string, patch: PodInput) => void;
|
onUpdate: (id: string, patch: PodInput) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [newMember, setNewMember] = useState('');
|
const [newMember, setNewMember] = useState('');
|
||||||
const [joinAs, setJoinAs] = useState(pod.members[0] ?? '');
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState<PodInput>({
|
const [draft, setDraft] = useState<PodInput>({
|
||||||
name: pod.name,
|
name: pod.name,
|
||||||
@@ -28,15 +31,7 @@ export function PodCard({
|
|||||||
description: pod.description ?? '',
|
description: pod.description ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// keep joinAs valid as members change
|
const inRoom = (name: string) => presence.some((p) => p.toLowerCase() === name.toLowerCase());
|
||||||
const joinMember = pod.members.includes(joinAs) ? joinAs : (pod.members[0] ?? '');
|
|
||||||
|
|
||||||
function add() {
|
|
||||||
const name = newMember.trim();
|
|
||||||
if (!name) return;
|
|
||||||
onAddMember(pod.id, name);
|
|
||||||
setNewMember('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveEdit() {
|
function saveEdit() {
|
||||||
onUpdate(pod.id, {
|
onUpdate(pod.id, {
|
||||||
@@ -88,13 +83,20 @@ export function PodCard({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="font-semibold text-slate-100">{pod.name}</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-slate-100">{pod.name}</h3>
|
||||||
|
{presence.length > 0 && (
|
||||||
|
<span className="flex items-center gap-1 rounded-full bg-emerald-950/60 px-2 py-0.5 text-xs text-emerald-400">
|
||||||
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||||
|
{presence.length} in room
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-xs text-slate-500">{pod.repo || 'no repo set'}</p>
|
<p className="text-xs text-slate-500">{pod.repo || 'no repo set'}</p>
|
||||||
{pod.description && <p className="mt-1 text-sm text-slate-300">{pod.description}</p>}
|
{pod.description && <p className="mt-1 text-sm text-slate-300">{pod.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
<button
|
<button
|
||||||
title="Edit pod"
|
|
||||||
className="rounded-md border border-slate-700 px-2 py-1 text-xs text-slate-300 hover:bg-slate-800"
|
className="rounded-md border border-slate-700 px-2 py-1 text-xs text-slate-300 hover:bg-slate-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDraft({ name: pod.name, repo: pod.repo, description: pod.description ?? '' });
|
setDraft({ name: pod.name, repo: pod.repo, description: pod.description ?? '' });
|
||||||
@@ -104,7 +106,6 @@ export function PodCard({
|
|||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Delete pod"
|
|
||||||
className="rounded-md border border-red-900/60 px-2 py-1 text-xs text-red-400 hover:bg-red-950/40 disabled:opacity-50"
|
className="rounded-md border border-red-900/60 px-2 py-1 text-xs text-red-400 hover:bg-red-950/40 disabled:opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Delete pod “${pod.name}”?`)) onDelete(pod.id);
|
if (confirm(`Delete pod “${pod.name}”?`)) onDelete(pod.id);
|
||||||
@@ -117,74 +118,78 @@ export function PodCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Members */}
|
{/* Members — click your name to join */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-xs text-slate-500">Click your name to join:</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{pod.members.length === 0 && (
|
{pod.members.length === 0 && (
|
||||||
<span className="text-xs text-slate-600">No members yet.</span>
|
<span className="text-xs text-slate-600">No members yet — add your name below.</span>
|
||||||
)}
|
)}
|
||||||
{pod.members.map((m) => (
|
{pod.members.map((m) => (
|
||||||
<span
|
<span
|
||||||
key={m}
|
key={m}
|
||||||
className="flex items-center gap-1.5 rounded-full bg-slate-800 py-0.5 pl-0.5 pr-2 text-sm text-slate-200"
|
className="flex items-center overflow-hidden rounded-full border border-slate-700 bg-slate-800"
|
||||||
>
|
>
|
||||||
<Avatar name={m} size={22} />
|
|
||||||
{m}
|
|
||||||
<button
|
<button
|
||||||
title={`Remove ${m}`}
|
className="flex items-center gap-1.5 py-0.5 pl-0.5 pr-2 text-sm text-slate-100 hover:bg-emerald-900/40 disabled:opacity-50"
|
||||||
className="ml-0.5 text-slate-500 hover:text-red-400"
|
onClick={() => onJoin(pod, m)}
|
||||||
|
disabled={busy}
|
||||||
|
title={`Join as ${m}`}
|
||||||
|
>
|
||||||
|
<Avatar name={m} size={22} />
|
||||||
|
{m}
|
||||||
|
{inRoom(m) && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-1.5 text-slate-500 hover:bg-red-950/40 hover:text-red-400 disabled:opacity-50"
|
||||||
onClick={() => onRemoveMember(pod.id, m)}
|
onClick={() => onRemoveMember(pod.id, m)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
title={`Remove ${m}`}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add your name (and join) */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
className="flex-1 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm"
|
className="flex-1 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm"
|
||||||
placeholder="Add your name…"
|
placeholder="Add your name…"
|
||||||
value={newMember}
|
value={newMember}
|
||||||
onChange={(e) => setNewMember(e.target.value)}
|
onChange={(e) => setNewMember(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && add()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && newMember.trim()) {
|
||||||
|
onAddAndJoin(pod, newMember.trim());
|
||||||
|
setNewMember('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="rounded-md border border-slate-600 px-3 py-1.5 text-sm hover:bg-slate-800 disabled:opacity-50"
|
className="rounded-md border border-slate-600 px-3 py-1.5 text-sm hover:bg-slate-800 disabled:opacity-50"
|
||||||
onClick={add}
|
onClick={() => {
|
||||||
|
onAddMember(pod.id, newMember.trim());
|
||||||
|
setNewMember('');
|
||||||
|
}}
|
||||||
disabled={busy || !newMember.trim()}
|
disabled={busy || !newMember.trim()}
|
||||||
|
title="Add to roster without joining"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
onAddAndJoin(pod, newMember.trim());
|
||||||
|
setNewMember('');
|
||||||
|
}}
|
||||||
|
disabled={busy || !newMember.trim()}
|
||||||
|
title="Add your name and join the room"
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Join */}
|
|
||||||
<div className="flex gap-2 border-t border-slate-800 pt-3">
|
|
||||||
<select
|
|
||||||
className="flex-1 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm text-slate-100 disabled:opacity-50"
|
|
||||||
value={joinMember}
|
|
||||||
onChange={(e) => setJoinAs(e.target.value)}
|
|
||||||
disabled={pod.members.length === 0}
|
|
||||||
>
|
|
||||||
{pod.members.length === 0 ? (
|
|
||||||
<option>add a member first</option>
|
|
||||||
) : (
|
|
||||||
pod.members.map((m) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{m}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
className="rounded-md bg-emerald-600 px-4 py-1.5 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50"
|
|
||||||
onClick={() => onJoin(pod, joinMember)}
|
|
||||||
disabled={busy || pod.members.length === 0}
|
|
||||||
>
|
|
||||||
Join
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ export async function listPods(): Promise<Pod[]> {
|
|||||||
return json(await fetch(`${BACKEND_URL}/api/pods`));
|
return json(await fetch(`${BACKEND_URL}/api/pods`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Display names currently connected per pod id (= LiveKit room name). */
|
||||||
|
export async function getPresence(): Promise<Record<string, string[]>> {
|
||||||
|
return json(await fetch(`${BACKEND_URL}/api/presence`));
|
||||||
|
}
|
||||||
|
|
||||||
export async function createPod(input: PodInput): Promise<Pod> {
|
export async function createPod(input: PodInput): Promise<Pod> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(`${BACKEND_URL}/api/pods`, {
|
await fetch(`${BACKEND_URL}/api/pods`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user