- 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
162 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|