feat: admin agent switcher - view any agent's config from WebUI

- Add agent selector dropdown in header (admin only)
- Return role in GET /config response
- Collect agents from both auth: and agent_tokens: KV prefixes
- Show 'viewing <agent>' badge when viewing another agent
- Non-admin users see only their own config (unchanged)
This commit is contained in:
团子 2026-04-22 16:13:51 +00:00
parent eadaa97caa
commit a3b3c598ab
3 changed files with 63 additions and 11 deletions

View File

@ -18,18 +18,38 @@ export default function Dashboard({ onLogout, addToast }: Props) {
const [modal, setModal] = useState<{ key?: string; entry?: ConfigEntry } | null>(null); const [modal, setModal] = useState<{ key?: string; entry?: ConfigEntry } | null>(null);
const [tab, setTab] = useState<"config" | "admin">("config"); const [tab, setTab] = useState<"config" | "admin">("config");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [agentList, setAgentList] = useState<string[]>([]);
const [selectedAgent, setSelectedAgent] = useState<string>("");
const [ownAgentId, setOwnAgentId] = useState<string>("");
const load = useCallback(async () => { const load = useCallback(async (agentId?: string) => {
try { try {
const d = await api.getConfig(); if (agentId && agentId !== ownAgentId) {
setData(d); const d = await api.getAgent(agentId);
setData(d);
} else {
const d = await api.getConfig();
setData(d);
if (!ownAgentId && d.agent_id) {
setOwnAgentId(d.agent_id);
setSelectedAgent(d.agent_id);
}
// Load agent list for admins
if (d.role === "admin" && agentList.length === 0) {
try {
const agents = await api.getAgents();
const list = Array.isArray(agents) ? agents : agents.agents || [];
setAgentList(list);
} catch {}
}
}
} catch (e: any) { } catch (e: any) {
addToast("Failed to load: " + e.message, "error"); addToast("Failed to load: " + e.message, "error");
if (e.message.includes("401") || e.message.includes("403")) onLogout(); if (e.message.includes("401") || e.message.includes("403")) onLogout();
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [ownAgentId, agentList.length]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@ -45,7 +65,7 @@ export default function Dashboard({ onLogout, addToast }: Props) {
await api.putKey(key, scope, { value, env, secret }); await api.putKey(key, scope, { value, env, secret });
addToast(`Key "${key}" saved`, "success"); addToast(`Key "${key}" saved`, "success");
setModal(null); setModal(null);
load(); load(selectedAgent);
} catch (e: any) { } catch (e: any) {
addToast("Failed: " + e.message, "error"); addToast("Failed: " + e.message, "error");
} }
@ -56,13 +76,20 @@ export default function Dashboard({ onLogout, addToast }: Props) {
try { try {
await api.deleteKey(key, scope); await api.deleteKey(key, scope);
addToast(`Key "${key}" deleted`, "success"); addToast(`Key "${key}" deleted`, "success");
load(); load(selectedAgent);
} catch (e: any) { } catch (e: any) {
addToast("Failed: " + e.message, "error"); addToast("Failed: " + e.message, "error");
} }
}; };
const handleAgentSwitch = (agentId: string) => {
setSelectedAgent(agentId);
setLoading(true);
load(agentId);
};
const isAdmin = data?.role === "admin"; const isAdmin = data?.role === "admin";
const isViewingOther = selectedAgent && selectedAgent !== ownAgentId;
if (loading) { if (loading) {
return ( return (
@ -88,6 +115,18 @@ export default function Dashboard({ onLogout, addToast }: Props) {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-[#64748b]" style={{ fontFamily: "var(--font-mono)" }}>{data?.agent_id}</span> <span className="text-sm text-[#64748b]" style={{ fontFamily: "var(--font-mono)" }}>{data?.agent_id}</span>
{isAdmin && agentList.length > 0 && (
<select
value={selectedAgent}
onChange={(e) => handleAgentSwitch(e.target.value)}
className="px-2 py-1 bg-[#0a0a0c] border border-[#1e2030] rounded-lg text-sm text-white focus:outline-none focus:border-[#3b82f6] transition-colors"
>
{agentList.map((id) => (
<option key={id} value={id}>{id}{id === ownAgentId ? " (me)" : ""}</option>
))}
</select>
)}
{isViewingOther && <span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-400 border border-amber-500/30 font-medium">viewing {selectedAgent}</span>}
{isAdmin && <span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 border border-purple-500/30 font-medium">admin</span>} {isAdmin && <span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 border border-purple-500/30 font-medium">admin</span>}
<button onClick={onLogout} className="text-sm text-[#64748b] hover:text-white transition-colors">Logout</button> <button onClick={onLogout} className="text-sm text-[#64748b] hover:text-white transition-colors">Logout</button>
</div> </div>

View File

@ -195,7 +195,7 @@ async function handleSync(auth: AuthInfo, kv: KVNamespace): Promise<Response> {
} }
} }
return json({ agent_id: auth.agent_id, secrets: resolved }); return json({ agent_id: auth.agent_id, role: auth.role, secrets: resolved });
} }
// ── Admin endpoints ──────────────────────────────────────────────────── // ── Admin endpoints ────────────────────────────────────────────────────
@ -232,9 +232,22 @@ async function handleAdminRevokeTokens(auth: AuthInfo, kv: KVNamespace, agentId:
async function handleAdminListAgents(auth: AuthInfo, kv: KVNamespace): Promise<Response> { async function handleAdminListAgents(auth: AuthInfo, kv: KVNamespace): Promise<Response> {
if (auth.role !== "admin") return err("admin required", 403); if (auth.role !== "admin") return err("admin required", 403);
const list = await kv.list({ prefix: "agent_tokens:" }); // Collect agents from agent_tokens: prefix
const agents = list.keys.map((k) => k.name.replace("agent_tokens:", "")); const tokenList = await kv.list({ prefix: "agent_tokens:" });
return json({ agents }); const fromTokens = tokenList.keys.map((k) => k.name.replace("agent_tokens:", ""));
// Also collect agents from auth: entries
const authList = await kv.list({ prefix: "auth:" });
const agentIds = new Set<string>(fromTokens);
for (const key of authList.keys) {
const val = await kv.get(key.name);
if (val) {
try {
const parsed = JSON.parse(val);
if (parsed.agent_id) agentIds.add(parsed.agent_id);
} catch {}
}
}
return json({ agents: [...agentIds].sort() });
} }
async function handleAdminInspect(auth: AuthInfo, kv: KVNamespace, agentId: string): Promise<Response> { async function handleAdminInspect(auth: AuthInfo, kv: KVNamespace, agentId: string): Promise<Response> {

File diff suppressed because one or more lines are too long