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 [tab, setTab] = useState<"config" | "admin">("config");
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 {
if (agentId && agentId !== ownAgentId) {
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) {
addToast("Failed to load: " + e.message, "error");
if (e.message.includes("401") || e.message.includes("403")) onLogout();
} finally {
setLoading(false);
}
}, []);
}, [ownAgentId, agentList.length]);
useEffect(() => { load(); }, [load]);
@ -45,7 +65,7 @@ export default function Dashboard({ onLogout, addToast }: Props) {
await api.putKey(key, scope, { value, env, secret });
addToast(`Key "${key}" saved`, "success");
setModal(null);
load();
load(selectedAgent);
} catch (e: any) {
addToast("Failed: " + e.message, "error");
}
@ -56,13 +76,20 @@ export default function Dashboard({ onLogout, addToast }: Props) {
try {
await api.deleteKey(key, scope);
addToast(`Key "${key}" deleted`, "success");
load();
load(selectedAgent);
} catch (e: any) {
addToast("Failed: " + e.message, "error");
}
};
const handleAgentSwitch = (agentId: string) => {
setSelectedAgent(agentId);
setLoading(true);
load(agentId);
};
const isAdmin = data?.role === "admin";
const isViewingOther = selectedAgent && selectedAgent !== ownAgentId;
if (loading) {
return (
@ -88,6 +115,18 @@ export default function Dashboard({ onLogout, addToast }: Props) {
</div>
<div className="flex items-center gap-4">
<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>}
<button onClick={onLogout} className="text-sm text-[#64748b] hover:text-white transition-colors">Logout</button>
</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 ────────────────────────────────────────────────────
@ -232,9 +232,22 @@ async function handleAdminRevokeTokens(auth: AuthInfo, kv: KVNamespace, agentId:
async function handleAdminListAgents(auth: AuthInfo, kv: KVNamespace): Promise<Response> {
if (auth.role !== "admin") return err("admin required", 403);
const list = await kv.list({ prefix: "agent_tokens:" });
const agents = list.keys.map((k) => k.name.replace("agent_tokens:", ""));
return json({ agents });
// Collect agents from agent_tokens: prefix
const tokenList = await kv.list({ prefix: "agent_tokens:" });
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> {

File diff suppressed because one or more lines are too long