团子 58494b8fca feat: React webui with flags support, build-ui pipeline
- packages/webui: Vite + React + Tailwind v4, single-file bundle
- Dark industrial theme, Space Grotesk + JetBrains Mono
- Config table with scope/flag badges, masked values, search/filter
- Admin panel for agent management
- scripts/build-ui.sh generates worker/src/ui.ts from webui build
- Worker serves UI at GET / before auth check
2026-04-21 04:01:35 +00:00

162 lines
6.6 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { useApi } from "../hooks/useApi";
import type { ConfigEntry, ConfigResponse } from "../types";
import ConfigTable from "./ConfigTable";
import AddEditModal from "./AddEditModal";
import AdminPanel from "./AdminPanel";
interface Props {
onLogout: () => void;
addToast: (msg: string, type: "success" | "error") => void;
}
export default function Dashboard({ onLogout, addToast }: Props) {
const api = useApi();
const [data, setData] = useState<ConfigResponse | null>(null);
const [search, setSearch] = useState("");
const [scopeFilter, setScopeFilter] = useState<"all" | "personal" | "shared">("all");
const [modal, setModal] = useState<{ key?: string; entry?: ConfigEntry } | null>(null);
const [tab, setTab] = useState<"config" | "admin">("config");
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const d = await api.getConfig();
setData(d);
} catch (e: any) {
addToast("Failed to load: " + e.message, "error");
if (e.message.includes("401") || e.message.includes("403")) onLogout();
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const entries = data ? Object.entries(data.secrets || {}) : [];
const filtered = entries.filter(([key, entry]) => {
if (search && !key.toLowerCase().includes(search.toLowerCase())) return false;
if (scopeFilter !== "all" && entry.scope !== scopeFilter) return false;
return true;
});
const handleSave = async (key: string, value: string, scope: string, env: boolean, secret: boolean) => {
try {
await api.putKey(key, scope, { value, env, secret });
addToast(`Key "${key}" saved`, "success");
setModal(null);
load();
} catch (e: any) {
addToast("Failed: " + e.message, "error");
}
};
const handleDelete = async (key: string, scope: string) => {
if (!confirm(`Delete "${key}"?`)) return;
try {
await api.deleteKey(key, scope);
addToast(`Key "${key}" deleted`, "success");
load();
} catch (e: any) {
addToast("Failed: " + e.message, "error");
}
};
const isAdmin = data?.role === "admin";
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-[#64748b]">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0a0c]">
{/* Header */}
<header className="border-b border-[#1e2030] bg-[#12141a]/80 backdrop-blur-sm sticky top-0 z-30">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-xl font-bold text-white" style={{ fontFamily: "var(--font-heading)" }}>Config Service</h1>
{isAdmin && (
<div className="flex bg-[#0a0a0c] rounded-lg p-0.5 border border-[#1e2030]">
<button onClick={() => setTab("config")} className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${tab === "config" ? "bg-[#3b82f6] text-white" : "text-[#64748b] hover:text-white"}`}>Config</button>
<button onClick={() => setTab("admin")} className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${tab === "admin" ? "bg-[#3b82f6] text-white" : "text-[#64748b] hover:text-white"}`}>Admin</button>
</div>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-[#64748b]" style={{ fontFamily: "var(--font-mono)" }}>{data?.agent_id}</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>
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-6">
{tab === "config" ? (
<>
{/* Toolbar */}
<div className="flex items-center gap-3 mb-6 flex-wrap">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search keys..."
className="flex-1 min-w-[200px] px-4 py-2.5 bg-[#12141a] border border-[#1e2030] rounded-lg text-white text-sm placeholder-[#334155] focus:outline-none focus:border-[#3b82f6] transition-colors"
/>
<select
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value as any)}
className="px-3 py-2.5 bg-[#12141a] border border-[#1e2030] rounded-lg text-white text-sm focus:outline-none focus:border-[#3b82f6]"
>
<option value="all">All scopes</option>
<option value="personal">Personal</option>
<option value="shared">Shared</option>
</select>
<button onClick={() => setModal({})} className="px-4 py-2.5 bg-[#3b82f6] hover:bg-[#2563eb] text-white text-sm font-semibold rounded-lg transition-colors whitespace-nowrap">
+ Add Key
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
{[
{ label: "Total Keys", value: entries.length },
{ label: "Shared", value: entries.filter(([, e]) => e.scope === "shared").length },
{ label: "Secrets", value: entries.filter(([, e]) => e.secret).length },
].map((s) => (
<div key={s.label} className="bg-[#12141a] border border-[#1e2030] rounded-xl px-5 py-4">
<div className="text-2xl font-bold text-white" style={{ fontFamily: "var(--font-heading)" }}>{s.value}</div>
<div className="text-xs text-[#64748b] mt-1">{s.label}</div>
</div>
))}
</div>
{/* Table */}
<div className="bg-[#12141a] border border-[#1e2030] rounded-xl p-5">
<ConfigTable
entries={filtered}
onEdit={(key, entry) => setModal({ key, entry })}
onDelete={handleDelete}
addToast={addToast}
/>
</div>
</>
) : (
<AdminPanel addToast={addToast} />
)}
</main>
{modal && (
<AddEditModal
editKey={modal.key}
editEntry={modal.entry}
onSave={handleSave}
onClose={() => setModal(null)}
/>
)}
</div>
);
}