feat: modernize PodMan frontend command center
This commit is contained in:
+195
-22
@@ -9,12 +9,15 @@ import { PodView } from './components/PodView.js';
|
|||||||
|
|
||||||
const SESSION_KEY = 'podman.session';
|
const SESSION_KEY = 'podman.session';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('en', { notation: 'compact' });
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [pods, setPods] = useState<Pod[]>([]);
|
const [pods, setPods] = useState<Pod[]>([]);
|
||||||
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[]>>({});
|
const [presence, setPresence] = useState<Record<string, string[]>>({});
|
||||||
|
const [memory, setMemory] = useState<api.MemoryStats | null>(null);
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -28,7 +31,14 @@ export default function App() {
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setPods(await api.listPods());
|
const [nextPods, nextPresence, nextMemory] = await Promise.all([
|
||||||
|
api.listPods(),
|
||||||
|
api.getPresence().catch(() => presence),
|
||||||
|
api.getMemoryStats().catch(() => memory),
|
||||||
|
]);
|
||||||
|
setPods(nextPods);
|
||||||
|
setPresence(nextPresence);
|
||||||
|
setMemory(nextMemory);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
@@ -69,8 +79,14 @@ export default function App() {
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
try {
|
try {
|
||||||
const p = await api.getPresence();
|
const [p, m] = await Promise.all([
|
||||||
if (alive) setPresence(p);
|
api.getPresence(),
|
||||||
|
api.getMemoryStats().catch(() => memory),
|
||||||
|
]);
|
||||||
|
if (alive) {
|
||||||
|
setPresence(p);
|
||||||
|
setMemory(m);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* presence is best-effort */
|
/* presence is best-effort */
|
||||||
}
|
}
|
||||||
@@ -181,53 +197,93 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showReconnecting = restoring || (joinedPodId !== null && joinedPod === null);
|
const showReconnecting = restoring || (joinedPodId !== null && joinedPod === null);
|
||||||
|
const liveNames = Array.from(new Set(Object.values(presence).flat()));
|
||||||
|
const liveTotal = liveNames.length;
|
||||||
|
const totalMembers = pods.reduce((sum, p) => sum + p.members.length, 0);
|
||||||
|
const activeRooms = Object.values(presence).filter((names) => names.length > 0).length;
|
||||||
|
const podManOnline = liveNames.some((name) => name.toLowerCase() === 'podman');
|
||||||
|
const latestActivity = memory
|
||||||
|
? memory.observations + memory.collisions + memory.interventions + memory.outcomes
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto min-h-screen w-full max-w-5xl px-4 py-8 sm:px-6">
|
<div className="min-h-screen bg-[#f5f5f7] text-slate-950">
|
||||||
<header className="mb-8 flex items-center gap-3">
|
<div className="mx-auto flex min-h-screen w-full max-w-7xl flex-col px-4 py-5 sm:px-6 lg:px-8">
|
||||||
<span className="text-3xl">🛰️</span>
|
<header className="flex flex-col gap-5 border-b border-slate-200 pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="grid h-10 w-10 place-items-center rounded-lg border border-slate-200 bg-white text-sm font-semibold text-slate-950 shadow-sm">
|
||||||
|
PM
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-100">PodMan</h1>
|
<p className="text-xs font-medium text-slate-500">Live team coordination</p>
|
||||||
<p className="text-sm text-slate-400">
|
<h1 className="text-3xl font-semibold text-slate-950">PodMan</h1>
|
||||||
The teammate that sees what git can't — collisions caught before you push.
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-600">
|
||||||
|
Active work, team memory, and coordination signals in one quiet workspace.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:min-w-[520px]">
|
||||||
|
<Metric label="Live now" value={fmt.format(liveTotal)} tone="green" />
|
||||||
|
<Metric label="Live rooms" value={fmt.format(activeRooms)} tone="blue" />
|
||||||
|
<Metric label="Roster" value={fmt.format(totalMembers)} tone="slate" />
|
||||||
|
<Metric label="Memory" value={fmt.format(latestActivity)} tone="amber" />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="grid flex-1 gap-5 py-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<section className="min-w-0">
|
||||||
{joinedPod ? (
|
{joinedPod ? (
|
||||||
<PodView team={joinedPod} me={member} room={room} devMode={devMode} onLeave={handleLeave} />
|
<PodView
|
||||||
|
team={joinedPod}
|
||||||
|
me={member}
|
||||||
|
room={room}
|
||||||
|
devMode={devMode}
|
||||||
|
onLeave={handleLeave}
|
||||||
|
/>
|
||||||
) : showReconnecting ? (
|
) : showReconnecting ? (
|
||||||
<div className="flex flex-col items-start gap-3">
|
<div className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
<p className="text-sm text-slate-400">Reconnecting to your pod…</p>
|
<p className="text-sm text-slate-600">Reconnecting to your pod...</p>
|
||||||
<button className="text-xs text-slate-500 hover:text-slate-300" onClick={handleLeave}>
|
<button
|
||||||
|
className="mt-3 rounded-md border border-slate-200 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-50"
|
||||||
|
onClick={handleLeave}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<main className="flex flex-col gap-4">
|
<main className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-sm font-medium text-slate-400">
|
<div>
|
||||||
Pods {loading ? '…' : `(${pods.length})`}
|
<h2 className="text-lg font-semibold text-slate-950">Team workspaces</h2>
|
||||||
</h2>
|
<p className="text-sm text-slate-500">
|
||||||
|
Pods, presence, and intervention state.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-xs text-slate-400 hover:text-slate-200 disabled:opacity-50"
|
className="inline-flex items-center justify-center rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:opacity-50"
|
||||||
onClick={() => void refresh()}
|
onClick={() => void refresh()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
↻ Refresh
|
<span>Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="rounded-md border border-red-900/60 bg-red-950/40 px-3 py-2 text-sm text-red-400">
|
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-slate-500">Loading pods…</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
{pods.map((pod) => (
|
{pods.map((pod) => (
|
||||||
<PodCard
|
<PodCard
|
||||||
key={pod.id}
|
key={pod.id}
|
||||||
@@ -247,6 +303,123 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="flex flex-col gap-4">
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-slate-500">Agent state</p>
|
||||||
|
<h2 className="mt-1 text-base font-semibold text-slate-950">PodMan signal</h2>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
|
podManOnline
|
||||||
|
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
|
||||||
|
: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{podManOnline ? 'online' : 'standby'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<SignalRow
|
||||||
|
label="Screen/IDE stream"
|
||||||
|
state={podManOnline ? 'watching' : 'waiting'}
|
||||||
|
/>
|
||||||
|
<SignalRow label="Local git truth" state="scheduled" />
|
||||||
|
<SignalRow
|
||||||
|
label="Team memory"
|
||||||
|
state={memory && latestActivity > 0 ? 'learning' : 'ready'}
|
||||||
|
/>
|
||||||
|
<SignalRow label="Urgency routing" state="card first" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<p className="text-xs font-medium text-slate-500">Intervention model</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<TimelineItem
|
||||||
|
title="Observe"
|
||||||
|
text="Live room presence plus screen and git signals."
|
||||||
|
/>
|
||||||
|
<TimelineItem
|
||||||
|
title="Compare"
|
||||||
|
text="Detect same-file, ownership, and stale path risk."
|
||||||
|
/>
|
||||||
|
<TimelineItem
|
||||||
|
title="Route"
|
||||||
|
text="Small card first, Hermes/voice only when urgent."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<p className="text-xs font-medium text-slate-500">Live roster</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{liveNames.length ? (
|
||||||
|
liveNames.map((name) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs text-slate-700"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-slate-500">No live participants yet.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value, tone }: { label: string; value: string; tone: string }) {
|
||||||
|
const toneClass =
|
||||||
|
tone === 'green'
|
||||||
|
? 'text-emerald-700 bg-emerald-50 border-emerald-200'
|
||||||
|
: tone === 'blue'
|
||||||
|
? 'text-blue-700 bg-blue-50 border-blue-200'
|
||||||
|
: tone === 'amber'
|
||||||
|
? 'text-amber-700 bg-amber-50 border-amber-200'
|
||||||
|
: 'text-slate-700 bg-white border-slate-200';
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border px-3 py-2 shadow-sm ${toneClass}`}>
|
||||||
|
<p className="text-[11px] font-medium opacity-70">{label}</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignalRow({ label, state }: { label: string; state: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
|
||||||
|
<span className="text-sm text-slate-700">{label}</span>
|
||||||
|
<span className="text-xs font-medium text-blue-700">{state}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineItem({ title, text }: { title: string; text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="border-l border-blue-200 pl-3">
|
||||||
|
<p className="text-sm font-medium text-slate-900">{title}</p>
|
||||||
|
<p className="mt-0.5 text-xs leading-5 text-slate-500">{text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<div className="h-56 animate-pulse rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="h-5 w-1/2 rounded bg-slate-100" />
|
||||||
|
<div className="mt-3 h-3 w-2/3 rounded bg-slate-100" />
|
||||||
|
<div className="mt-8 h-24 rounded bg-slate-50" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,52 +36,61 @@ export function CreatePodForm({
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="flex min-h-[140px] items-center justify-center rounded-xl border border-dashed border-slate-700 text-slate-400 hover:border-emerald-600 hover:text-emerald-400"
|
className="group flex min-h-[280px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-300 bg-white p-6 text-center text-slate-600 shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
+ New Pod
|
<span className="grid h-11 w-11 place-items-center rounded-lg border border-slate-200 bg-slate-50 text-2xl font-light text-slate-900 transition group-hover:border-blue-200 group-hover:bg-blue-50">
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-semibold text-slate-950">New pod</span>
|
||||||
|
<span className="max-w-[18rem] text-sm leading-5 text-slate-500">
|
||||||
|
Add a workspace for a feature team, spike, or demo flow.
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 rounded-xl border border-emerald-700/50 bg-slate-900/50 p-4">
|
<div className="flex min-h-[280px] flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
<h3 className="font-semibold text-slate-100">New Pod</h3>
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-950">New pod</h3>
|
||||||
|
<p className="text-xs text-slate-500">Name, repository, and first member.</p>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
className="rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm"
|
className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-blue-400"
|
||||||
placeholder="Pod name (e.g. Mobile Pod)"
|
placeholder="Pod name (e.g. Mobile Pod)"
|
||||||
value={name}
|
value={name}
|
||||||
autoFocus
|
autoFocus
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm"
|
className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-blue-400"
|
||||||
placeholder="owner/repo"
|
placeholder="owner/repo"
|
||||||
value={repo}
|
value={repo}
|
||||||
onChange={(e) => setRepo(e.target.value)}
|
onChange={(e) => setRepo(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm"
|
className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-blue-400"
|
||||||
placeholder="Description (optional)"
|
placeholder="Description (optional)"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm"
|
className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-blue-400"
|
||||||
placeholder="Your name (optional first member)"
|
placeholder="Your name (optional first member)"
|
||||||
value={firstMember}
|
value={firstMember}
|
||||||
onChange={(e) => setFirstMember(e.target.value)}
|
onChange={(e) => setFirstMember(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="mt-auto flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="rounded-md bg-emerald-600 px-3 py-1 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50"
|
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={busy || !name.trim()}
|
disabled={busy || !name.trim()}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded-md border border-slate-700 px-3 py-1 text-sm hover:bg-slate-800"
|
className="rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export function PodCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const inRoom = (name: string) => presence.some((p) => p.toLowerCase() === name.toLowerCase());
|
const inRoom = (name: string) => presence.some((p) => p.toLowerCase() === name.toLowerCase());
|
||||||
|
const active = presence.length > 0;
|
||||||
|
const memberCount = pod.members.length;
|
||||||
|
|
||||||
function saveEdit() {
|
function saveEdit() {
|
||||||
onUpdate(pod.id, {
|
onUpdate(pod.id, {
|
||||||
@@ -43,37 +45,44 @@ export function PodCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 rounded-xl border border-slate-700 bg-slate-900/50 p-4">
|
<div className="group relative flex min-h-[280px] flex-col overflow-hidden rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition hover:border-slate-300 hover:shadow-md">
|
||||||
|
<div
|
||||||
|
className={`absolute inset-x-0 top-0 h-1 ${active ? 'bg-emerald-500' : 'bg-slate-200'}`}
|
||||||
|
/>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-slate-950">Edit pod</h3>
|
||||||
|
<p className="text-xs text-slate-500">Name, repository, and summary.</p>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
className="rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm"
|
className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-blue-400"
|
||||||
value={draft.name ?? ''}
|
value={draft.name ?? ''}
|
||||||
placeholder="Pod name"
|
placeholder="Pod name"
|
||||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm"
|
className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-blue-400"
|
||||||
value={draft.repo ?? ''}
|
value={draft.repo ?? ''}
|
||||||
placeholder="owner/repo"
|
placeholder="owner/repo"
|
||||||
onChange={(e) => setDraft({ ...draft, repo: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, repo: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-sm"
|
className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-blue-400"
|
||||||
value={draft.description ?? ''}
|
value={draft.description ?? ''}
|
||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="rounded-md bg-emerald-600 px-3 py-1 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50"
|
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
|
||||||
onClick={saveEdit}
|
onClick={saveEdit}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded-md border border-slate-700 px-3 py-1 text-sm hover:bg-slate-800"
|
className="rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
|
||||||
onClick={() => setEditing(false)}
|
onClick={() => setEditing(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -81,23 +90,27 @@ export function PodCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="font-semibold text-slate-100">{pod.name}</h3>
|
<h3 className="truncate text-lg font-semibold text-slate-950">{pod.name}</h3>
|
||||||
{presence.length > 0 && (
|
{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="flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 ring-1 ring-emerald-200">
|
||||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||||
{presence.length} in room
|
{presence.length} live
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-500">{pod.repo || 'no repo set'}</p>
|
<p className="mt-1 truncate 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-3 line-clamp-2 min-h-[2.5rem] text-sm leading-5 text-slate-700">
|
||||||
|
{pod.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
<button
|
<button
|
||||||
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-200 px-2.5 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDraft({ name: pod.name, repo: pod.repo, description: pod.description ?? '' });
|
setDraft({ name: pod.name, repo: pod.repo, description: pod.description ?? '' });
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
@@ -106,7 +119,7 @@ export function PodCard({
|
|||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
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-200 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Delete pod “${pod.name}”?`)) onDelete(pod.id);
|
if (confirm(`Delete pod “${pod.name}”?`)) onDelete(pod.id);
|
||||||
}}
|
}}
|
||||||
@@ -118,30 +131,38 @@ export function PodCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Members — click your name to join */}
|
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<Stat label="Members" value={memberCount} />
|
||||||
<p className="text-xs text-slate-500">Click your name to join:</p>
|
<Stat label="Live" value={presence.length} />
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<Stat label="Mode" value={active ? 'hot' : 'idle'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-1 flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-medium text-slate-500">Roster</p>
|
||||||
|
<p className="text-xs text-slate-500">Members</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
{pod.members.length === 0 && (
|
{pod.members.length === 0 && (
|
||||||
<span className="text-xs text-slate-600">No members yet — add your name below.</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 overflow-hidden rounded-full border border-slate-700 bg-slate-800"
|
className="flex items-center overflow-hidden rounded-full border border-slate-200 bg-slate-50"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
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="flex min-h-8 items-center gap-1.5 py-0.5 pl-0.5 pr-2 text-sm text-slate-800 hover:bg-emerald-50 disabled:opacity-50"
|
||||||
onClick={() => onJoin(pod, m)}
|
onClick={() => onJoin(pod, m)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
title={`Join as ${m}`}
|
title={`Join as ${m}`}
|
||||||
>
|
>
|
||||||
<Avatar name={m} size={22} />
|
<Avatar name={m} size={22} />
|
||||||
{m}
|
{m}
|
||||||
{inRoom(m) && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />}
|
{inRoom(m) && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="px-1.5 text-slate-500 hover:bg-red-950/40 hover:text-red-400 disabled:opacity-50"
|
className="min-h-8 px-2 text-slate-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||||
onClick={() => onRemoveMember(pod.id, m)}
|
onClick={() => onRemoveMember(pod.id, m)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
title={`Remove ${m}`}
|
title={`Remove ${m}`}
|
||||||
@@ -152,11 +173,10 @@ export function PodCard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add your name (and join) */}
|
<div className="mt-auto grid gap-2 sm:grid-cols-[1fr_auto_auto]">
|
||||||
<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="min-w-0 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-blue-400"
|
||||||
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) => {
|
onKeyDown={(e) => {
|
||||||
@@ -167,7 +187,7 @@ export function PodCard({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<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-200 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAddMember(pod.id, newMember.trim());
|
onAddMember(pod.id, newMember.trim());
|
||||||
setNewMember('');
|
setNewMember('');
|
||||||
@@ -178,7 +198,7 @@ export function PodCard({
|
|||||||
Add
|
Add
|
||||||
</button>
|
</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"
|
className="rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAddAndJoin(pod, newMember.trim());
|
onAddAndJoin(pod, newMember.trim());
|
||||||
setNewMember('');
|
setNewMember('');
|
||||||
@@ -193,3 +213,12 @@ export function PodCard({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: number | string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-slate-200 bg-slate-50 px-2.5 py-2">
|
||||||
|
<p className="text-[10px] font-medium text-slate-500">{label}</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-slate-950">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,112 +150,200 @@ export function PodView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const liveCount = participants.length;
|
const liveCount = participants.length;
|
||||||
|
const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex flex-col gap-4 rounded-lg border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-slate-100">{team.name}</h2>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="text-xs text-slate-500">{team.repo}</p>
|
<h2 className="text-2xl font-semibold text-slate-950">{team.name}</h2>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
|
room
|
||||||
|
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
|
||||||
|
: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{room ? 'live room' : 'local'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{team.repo}</p>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-600">
|
||||||
|
Presence, media controls, and intervention state for this pod.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onLeave}
|
onClick={onLeave}
|
||||||
className="rounded-md border border-slate-700 px-3 py-1.5 text-sm text-slate-300 hover:bg-slate-800"
|
className="rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
Leave
|
Leave
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{devMode && (
|
{devMode && (
|
||||||
<p className="rounded-md border border-amber-700/50 bg-amber-950/40 px-3 py-2 text-xs text-amber-300">
|
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||||
DEV MODE — LiveKit not configured, so this is a local-only mock (no real room).
|
Dev mode: LiveKit is not configured, so this is a local-only mock.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connectivity test controls */}
|
<section className="grid gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm md:grid-cols-[1fr_auto] md:items-center">
|
||||||
<section className="flex flex-wrap items-center gap-3 rounded-xl border border-slate-800 bg-slate-900/40 p-4">
|
<div>
|
||||||
|
<p className="text-xs font-medium text-slate-500">Broadcast controls</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">Audio, screen, and room signal.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={toggleBeat}
|
onClick={toggleBeat}
|
||||||
disabled={!room}
|
disabled={!room}
|
||||||
className={`rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50 ${
|
className={`rounded-md px-4 py-2 text-sm font-semibold disabled:opacity-50 ${
|
||||||
playingBeat ? 'bg-red-600 hover:bg-red-500' : 'bg-emerald-600 hover:bg-emerald-500'
|
playingBeat
|
||||||
|
? 'bg-red-600 text-white hover:bg-red-500'
|
||||||
|
: 'bg-emerald-600 text-white hover:bg-emerald-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{playingBeat ? '⏹ Stop beat' : '▶ Play beat'}
|
{playingBeat ? 'Stop beat' : 'Play beat'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleScreen}
|
onClick={toggleScreen}
|
||||||
disabled={!room}
|
disabled={!room}
|
||||||
className="rounded-md border border-slate-600 px-4 py-2 text-sm hover:bg-slate-800 disabled:opacity-50"
|
className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{sharing ? '🛑 Stop sharing' : '📺 Share my screen'}
|
{sharing ? 'Stop sharing' : 'Share screen'}
|
||||||
</button>
|
</button>
|
||||||
<span className="ml-auto text-xs text-slate-400">
|
</div>
|
||||||
{playingBeat
|
|
||||||
? '🔊 broadcasting beat to the pod'
|
|
||||||
: 'press “Play beat” — everyone should hear it'}
|
|
||||||
</span>
|
|
||||||
</section>
|
</section>
|
||||||
{note && <p className="text-sm text-amber-400">{note}</p>}
|
{note && (
|
||||||
|
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||||
|
{note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-[1fr_300px]">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
{/* Live participants */}
|
<RoomMetric label="Participants" value={liveCount} />
|
||||||
<section>
|
<RoomMetric label="Screen share" value={sharing ? 'on' : 'off'} />
|
||||||
<h3 className="mb-3 text-sm font-medium text-slate-400">In the room now ({liveCount})</h3>
|
<RoomMetric label="Audio test" value={playingBeat ? 'on' : 'idle'} />
|
||||||
|
<RoomMetric label="PodMan" value={podmanPresent ? 'online' : 'waiting'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-slate-950">Room presence</h3>
|
||||||
|
<p className="text-sm text-slate-500">Live participants and speaking state.</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-slate-50 px-2.5 py-1 text-xs text-slate-700 ring-1 ring-slate-200">
|
||||||
|
{liveCount} connected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{liveCount === 0 ? (
|
{liveCount === 0 ? (
|
||||||
<p className="text-sm text-slate-500">Connecting…</p>
|
<p className="text-sm text-slate-500">Connecting...</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{participants.map((p) => (
|
{participants.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className={`flex items-center gap-3 rounded-lg border p-3 transition ${
|
className={`flex min-h-20 items-center gap-3 rounded-lg border p-3 transition ${
|
||||||
p.speaking
|
p.speaking
|
||||||
? 'border-emerald-400 bg-emerald-950/30 ring-1 ring-emerald-400/50'
|
? 'border-emerald-200 bg-emerald-50 ring-1 ring-emerald-100'
|
||||||
: 'border-slate-800 bg-slate-900/40'
|
: 'border-slate-200 bg-slate-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Avatar name={p.name} size={36} ring={p.isLocal} />
|
<Avatar name={p.name} size={38} ring={p.isLocal} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-medium text-slate-200">{p.name}</p>
|
<p className="truncate text-sm font-semibold text-slate-950">{p.name}</p>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="mt-0.5 text-xs text-slate-500">
|
||||||
{p.isLocal ? 'you' : 'connected'}
|
{p.isLocal ? 'you' : 'connected'}
|
||||||
{p.speaking ? ' · 🔊' : ''}
|
{p.speaking ? ' - speaking' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-3 text-xs text-slate-600">
|
<p className="mt-4 text-xs leading-5 text-slate-600">
|
||||||
Pod roster: {team.members.join(', ') || '—'}
|
Pod roster: {team.members.join(', ') || '-'}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* PodMan panel */}
|
<aside className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
<aside className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<span className="text-lg">🛰️</span>
|
<p className="text-xs font-medium text-slate-500">Intervention rail</p>
|
||||||
<h3 className="font-semibold text-slate-100">PodMan</h3>
|
<h3 className="mt-1 text-base font-semibold text-slate-950">PodMan</h3>
|
||||||
<span className="ml-auto flex items-center gap-1 text-xs text-emerald-400">
|
</div>
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-400" /> watching
|
<span
|
||||||
|
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
|
podmanPresent
|
||||||
|
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
|
||||||
|
: 'bg-slate-100 text-slate-500 ring-1 ring-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{podmanPresent ? 'watching' : 'waiting'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
|
||||||
{liveCount} participant{liveCount === 1 ? '' : 's'} connected. Watching for collisions
|
<div className="mt-5 space-y-3">
|
||||||
before push.
|
<InterventionCard
|
||||||
</p>
|
state="ready"
|
||||||
<div className="mt-4 border-t border-slate-800 pt-4">
|
title="Live inference"
|
||||||
<h4 className="mb-2 text-xs font-medium text-slate-400">Interventions</h4>
|
text={
|
||||||
<div className="rounded-lg border border-dashed border-slate-800 px-3 py-6 text-center text-xs text-slate-600">
|
sharing ? 'Screen frames available to the agent.' : 'Waiting for screen signal.'
|
||||||
No collisions detected.
|
}
|
||||||
</div>
|
/>
|
||||||
|
<InterventionCard
|
||||||
|
state="quiet"
|
||||||
|
title="Collision detector"
|
||||||
|
text="No same-file collision has been detected in this room."
|
||||||
|
/>
|
||||||
|
<InterventionCard
|
||||||
|
state="quiet"
|
||||||
|
title="Escalation policy"
|
||||||
|
text="Cards first; voice only when urgency crosses the threshold."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* hidden sink for remote audio elements */}
|
|
||||||
<div ref={audioRef} className="hidden" />
|
<div ref={audioRef} className="hidden" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RoomMetric({ label, value }: { label: string; value: number | string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white px-3 py-3 shadow-sm">
|
||||||
|
<p className="text-[11px] font-medium text-slate-500">{label}</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-slate-950">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InterventionCard({
|
||||||
|
state,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
state: 'ready' | 'quiet';
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-semibold text-slate-950">{title}</p>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-medium uppercase ${
|
||||||
|
state === 'ready'
|
||||||
|
? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200'
|
||||||
|
: 'bg-white text-slate-500 ring-1 ring-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-slate-500">{text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+47
-3
@@ -1,16 +1,60 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #0b0f17;
|
background: #f5f5f7;
|
||||||
color: #e7eaf0;
|
color: #0f172a;
|
||||||
font-family:
|
font-family:
|
||||||
ui-sans-serif,
|
ui-sans-serif,
|
||||||
system-ui,
|
system-ui,
|
||||||
-apple-system,
|
-apple-system,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible {
|
||||||
|
outline: 2px solid rgb(37 99 235 / 0.7);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgb(37 99 235 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f5f5f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border: 2px solid #f5f5f7;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import type { InterventionOutcome, Pod, PodInput } from '@podman/shared';
|
|||||||
|
|
||||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8787';
|
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8787';
|
||||||
|
|
||||||
|
export interface MemoryStats {
|
||||||
|
observations: number;
|
||||||
|
collisions: number;
|
||||||
|
interventions: number;
|
||||||
|
outcomes: number;
|
||||||
|
}
|
||||||
|
|
||||||
async function json<T>(res: Response): Promise<T> {
|
async function json<T>(res: Response): Promise<T> {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
@@ -46,6 +53,10 @@ export async function getPresence(): Promise<Record<string, string[]>> {
|
|||||||
return json(await fetch(`${BACKEND_URL}/api/presence`));
|
return json(await fetch(`${BACKEND_URL}/api/presence`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMemoryStats(): Promise<MemoryStats> {
|
||||||
|
return json(await fetch(`${BACKEND_URL}/api/memory/stats`));
|
||||||
|
}
|
||||||
|
|
||||||
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