diff --git a/.gitignore b/.gitignore
index 1fa7219..19ace18 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,6 @@
+node_modules/
+dist/
.wrangler/
.npmrc
+bun.lock
+package-lock.json
diff --git a/packages/webui/.gitignore b/packages/webui/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/packages/webui/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/packages/webui/README.md b/packages/webui/README.md
new file mode 100644
index 0000000..7dbf7eb
--- /dev/null
+++ b/packages/webui/README.md
@@ -0,0 +1,73 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+
+```js
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+
+ // Remove tseslint.configs.recommended and replace with this
+ tseslint.configs.recommendedTypeChecked,
+ // Alternatively, use this for stricter rules
+ tseslint.configs.strictTypeChecked,
+ // Optionally, add this for stylistic rules
+ tseslint.configs.stylisticTypeChecked,
+
+ // Other configs...
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
+
+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+
+```js
+// eslint.config.js
+import reactX from 'eslint-plugin-react-x'
+import reactDom from 'eslint-plugin-react-dom'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+ // Enable lint rules for React
+ reactX.configs['recommended-typescript'],
+ // Enable lint rules for React DOM
+ reactDom.configs.recommended,
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
diff --git a/packages/webui/eslint.config.js b/packages/webui/eslint.config.js
new file mode 100644
index 0000000..5e6b472
--- /dev/null
+++ b/packages/webui/eslint.config.js
@@ -0,0 +1,23 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ },
+])
diff --git a/packages/webui/index.html b/packages/webui/index.html
new file mode 100644
index 0000000..bf9c117
--- /dev/null
+++ b/packages/webui/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Config Service
+
+
+
+
+
+
diff --git a/packages/webui/package.json b/packages/webui/package.json
new file mode 100644
index 0000000..bb06bab
--- /dev/null
+++ b/packages/webui/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "webui",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "lucide-react": "^1.8.0",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.4",
+ "@tailwindcss/vite": "^4.2.3",
+ "@types/node": "^24.12.2",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "eslint": "^9.39.4",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.5.0",
+ "tailwindcss": "^4.2.3",
+ "terser": "^5.46.1",
+ "typescript": "~6.0.2",
+ "typescript-eslint": "^8.58.2",
+ "vite": "^8.0.9",
+ "vite-plugin-singlefile": "^2.3.3"
+ }
+}
diff --git a/packages/webui/src/App.tsx b/packages/webui/src/App.tsx
new file mode 100644
index 0000000..bc12875
--- /dev/null
+++ b/packages/webui/src/App.tsx
@@ -0,0 +1,34 @@
+import { useState, useCallback } from "react";
+import LoginPage from "./components/LoginPage";
+import Dashboard from "./components/Dashboard";
+import Toast from "./components/Toast";
+import type { Toast as ToastType } from "./types";
+
+export default function App() {
+ const [authed, setAuthed] = useState(!!localStorage.getItem("config-token"));
+ const [toasts, setToasts] = useState([]);
+
+ const addToast = useCallback((message: string, type: "success" | "error") => {
+ setToasts((prev) => [...prev, { id: Date.now() + Math.random(), message, type }]);
+ }, []);
+
+ const removeToast = useCallback((id: number) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, []);
+
+ const logout = () => {
+ localStorage.removeItem("config-token");
+ setAuthed(false);
+ };
+
+ return (
+ <>
+
+ {authed ? (
+
+ ) : (
+ setAuthed(true)} />
+ )}
+ >
+ );
+}
diff --git a/packages/webui/src/components/AddEditModal.tsx b/packages/webui/src/components/AddEditModal.tsx
new file mode 100644
index 0000000..a7036d9
--- /dev/null
+++ b/packages/webui/src/components/AddEditModal.tsx
@@ -0,0 +1,90 @@
+import { useState } from "react";
+import type { ConfigEntry } from "../types";
+
+interface Props {
+ editKey?: string;
+ editEntry?: ConfigEntry;
+ onSave: (key: string, value: string, scope: string, env: boolean, secret: boolean) => void;
+ onClose: () => void;
+}
+
+export default function AddEditModal({ editKey, editEntry, onSave, onClose }: Props) {
+ const [key, setKey] = useState(editKey || "");
+ const [value, setValue] = useState(editEntry?.value || "");
+ const [scope, setScope] = useState(editEntry?.scope || "personal");
+ const [excludeEnv, setExcludeEnv] = useState(editEntry ? !editEntry.env : false);
+ const [secret, setSecret] = useState(editEntry?.secret || false);
+
+ const isEdit = !!editKey;
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSave(key, value, scope, !excludeEnv, secret);
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/webui/src/components/AdminPanel.tsx b/packages/webui/src/components/AdminPanel.tsx
new file mode 100644
index 0000000..bf849f9
--- /dev/null
+++ b/packages/webui/src/components/AdminPanel.tsx
@@ -0,0 +1,114 @@
+import { useEffect, useState } from "react";
+import { useApi } from "../hooks/useApi";
+import type { Agent } from "../types";
+
+interface Props {
+ addToast: (msg: string, type: "success" | "error") => void;
+}
+
+export default function AdminPanel({ addToast }: Props) {
+ const api = useApi();
+ const [agents, setAgents] = useState([]);
+ const [newAgentId, setNewAgentId] = useState("");
+ const [newRole, setNewRole] = useState("agent");
+ const [generatedToken, setGeneratedToken] = useState("");
+ const [loading, setLoading] = useState(true);
+
+ const loadAgents = async () => {
+ try {
+ const data = await api.getAgents();
+ setAgents(Array.isArray(data) ? data : data.agents || []);
+ } catch (e: any) {
+ addToast("Failed to load agents: " + e.message, "error");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { loadAgents(); }, []);
+
+ const handleCreateToken = async () => {
+ if (!newAgentId.trim()) return;
+ try {
+ const data = await api.createToken(newAgentId.trim(), newRole);
+ setGeneratedToken(data.token || JSON.stringify(data));
+ addToast("Token created", "success");
+ setNewAgentId("");
+ loadAgents();
+ } catch (e: any) {
+ addToast("Failed: " + e.message, "error");
+ }
+ };
+
+ const handleRevoke = async (tokenId: string) => {
+ try {
+ await api.revokeToken(tokenId);
+ addToast("Token revoked", "success");
+ loadAgents();
+ } catch (e: any) {
+ addToast("Failed: " + e.message, "error");
+ }
+ };
+
+ if (loading) return Loading agents...
;
+
+ return (
+
+
+
Create Agent Token
+
+
+
+ setNewAgentId(e.target.value)} placeholder="agent-name"
+ className="px-3 py-2 bg-[#0a0a0c] border border-[#1e2030] rounded-lg text-white text-sm focus:outline-none focus:border-[#3b82f6]" style={{ fontFamily: "var(--font-mono)" }} />
+
+
+
+
+
+
+
+ {generatedToken && (
+
+
Generated token (copy now, shown once):
+
{generatedToken}
+
+
+ )}
+
+
+
+
Agents
+ {agents.length === 0 ? (
+
No agents found
+ ) : (
+
+ {agents.map((agent) => (
+
+
+ {agent.id}
+ {agent.role}
+
+ {agent.tokens?.map((t) => (
+
+ ))}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/packages/webui/src/components/ConfigTable.tsx b/packages/webui/src/components/ConfigTable.tsx
new file mode 100644
index 0000000..ab1ba3e
--- /dev/null
+++ b/packages/webui/src/components/ConfigTable.tsx
@@ -0,0 +1,116 @@
+import { useState } from "react";
+import type { ConfigEntry } from "../types";
+
+interface Props {
+ entries: [string, ConfigEntry][];
+ onEdit: (key: string, entry: ConfigEntry) => void;
+ onDelete: (key: string, scope: string) => void;
+ addToast: (msg: string, type: "success" | "error") => void;
+}
+
+export default function ConfigTable({ entries, onEdit, onDelete, addToast }: Props) {
+ const [revealed, setRevealed] = useState>(new Set());
+
+ const toggle = (key: string) => {
+ setRevealed((prev) => {
+ const next = new Set(prev);
+ next.has(key) ? next.delete(key) : next.add(key);
+ return next;
+ });
+ };
+
+ const copy = (value: string) => {
+ navigator.clipboard.writeText(value);
+ addToast("Copied to clipboard", "success");
+ };
+
+ const fmtTime = (iso: string) => {
+ try {
+ const d = new Date(iso);
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
+ } catch { return iso; }
+ };
+
+ if (entries.length === 0) {
+ return (
+
+
No configuration keys found
+
Add your first key to get started
+
+ );
+ }
+
+ return (
+
+
+
+
+ | Key |
+ Value |
+ Scope |
+ Flags |
+ Updated |
+ Actions |
+
+
+
+ {entries.map(([key, entry]) => {
+ const show = revealed.has(key);
+ const displayVal = show ? entry.value : "•".repeat(Math.min(entry.value.length || 8, 24));
+ return (
+
+ |
+ {key}
+ |
+
+
+
+ {displayVal}
+
+
+
+
+ |
+
+
+ {entry.scope}
+
+ |
+
+
+ {entry.secret && (
+ secret
+ )}
+ {entry.env === false && (
+ no-env
+ )}
+
+ |
+ {fmtTime(entry.updated_at)} |
+
+
+
+
+
+ |
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/packages/webui/src/components/Dashboard.tsx b/packages/webui/src/components/Dashboard.tsx
new file mode 100644
index 0000000..90de239
--- /dev/null
+++ b/packages/webui/src/components/Dashboard.tsx
@@ -0,0 +1,161 @@
+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(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 (
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {tab === "config" ? (
+ <>
+ {/* Toolbar */}
+
+ 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"
+ />
+
+
+
+
+ {/* Stats */}
+
+ {[
+ { 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) => (
+
+
{s.value}
+
{s.label}
+
+ ))}
+
+
+ {/* Table */}
+
+ setModal({ key, entry })}
+ onDelete={handleDelete}
+ addToast={addToast}
+ />
+
+ >
+ ) : (
+
+ )}
+
+
+ {modal && (
+
setModal(null)}
+ />
+ )}
+
+ );
+}
diff --git a/packages/webui/src/components/LoginPage.tsx b/packages/webui/src/components/LoginPage.tsx
new file mode 100644
index 0000000..f6cbac9
--- /dev/null
+++ b/packages/webui/src/components/LoginPage.tsx
@@ -0,0 +1,54 @@
+import { useState } from "react";
+
+export default function LoginPage({ onLogin }: { onLogin: () => void }) {
+ const [token, setToken] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError("");
+ try {
+ const res = await fetch("/config", { headers: { Authorization: `Bearer ${token.trim()}` } });
+ if (!res.ok) throw new Error("Invalid token");
+ localStorage.setItem("config-token", token.trim());
+ onLogin();
+ } catch {
+ setError("Authentication failed. Check your token.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/webui/src/components/Toast.tsx b/packages/webui/src/components/Toast.tsx
new file mode 100644
index 0000000..e78d280
--- /dev/null
+++ b/packages/webui/src/components/Toast.tsx
@@ -0,0 +1,31 @@
+import { useEffect, useState } from "react";
+import type { Toast as ToastType } from "../types";
+
+export default function Toast({ toasts, remove }: { toasts: ToastType[]; remove: (id: number) => void }) {
+ return (
+
+ {toasts.map((t) => (
+
+ ))}
+
+ );
+}
+
+function ToastItem({ toast, remove }: { toast: ToastType; remove: (id: number) => void }) {
+ const [show, setShow] = useState(false);
+ useEffect(() => {
+ requestAnimationFrame(() => setShow(true));
+ const timer = setTimeout(() => { setShow(false); setTimeout(() => remove(toast.id), 300); }, 3000);
+ return () => clearTimeout(timer);
+ }, [toast.id, remove]);
+
+ return (
+
+ {toast.message}
+
+ );
+}
diff --git a/packages/webui/src/hooks/useApi.ts b/packages/webui/src/hooks/useApi.ts
new file mode 100644
index 0000000..5b6a4d4
--- /dev/null
+++ b/packages/webui/src/hooks/useApi.ts
@@ -0,0 +1,39 @@
+import { useCallback } from "react";
+
+const getToken = () => localStorage.getItem("config-token") || "";
+
+export function useApi() {
+ const request = useCallback(async (path: string, options: RequestInit = {}) => {
+ const res = await fetch(path, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${getToken()}`,
+ ...(options.headers || {}),
+ },
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || `${res.status} ${res.statusText}`);
+ }
+ if (res.status === 204) return null;
+ return res.json();
+ }, []);
+
+ const getConfig = () => request("/config");
+ const getKey = (key: string) => request(`/config/${encodeURIComponent(key)}`);
+ const putKey = (key: string, scope: string, body: { value: string; env?: boolean; secret?: boolean }) =>
+ request(`/config/${encodeURIComponent(key)}?scope=${scope}`, { method: "PUT", body: JSON.stringify(body) });
+ const patchKey = (key: string, scope: string, body: { env?: boolean; secret?: boolean }) =>
+ request(`/config/${encodeURIComponent(key)}?scope=${scope}`, { method: "PATCH", body: JSON.stringify(body) });
+ const deleteKey = (key: string, scope: string) =>
+ request(`/config/${encodeURIComponent(key)}?scope=${scope}`, { method: "DELETE" });
+ const getAgents = () => request("/admin/agents");
+ const getAgent = (id: string) => request(`/admin/agent/${encodeURIComponent(id)}`);
+ const createToken = (agent_id: string, role?: string) =>
+ request("/admin/token", { method: "POST", body: JSON.stringify({ agent_id, role }) });
+ const revokeToken = (id: string) =>
+ request(`/admin/token/${encodeURIComponent(id)}`, { method: "DELETE" });
+
+ return { getConfig, getKey, putKey, patchKey, deleteKey, getAgents, getAgent, createToken, revokeToken };
+}
diff --git a/packages/webui/src/index.css b/packages/webui/src/index.css
new file mode 100644
index 0000000..68aa3e2
--- /dev/null
+++ b/packages/webui/src/index.css
@@ -0,0 +1,31 @@
+@import "tailwindcss";
+@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
+
+@theme {
+ --color-bg: #0a0a0c;
+ --color-surface: #12141a;
+ --color-border: #1e2030;
+ --color-accent: #3b82f6;
+ --color-accent-hover: #2563eb;
+ --color-text: #e2e8f0;
+ --color-text-dim: #64748b;
+ --color-badge-shared: #3b82f6;
+ --color-badge-personal: #22c55e;
+ --color-badge-secret: #ef4444;
+ --color-badge-noenv: #eab308;
+ --font-heading: 'Space Grotesk', sans-serif;
+ --font-body: 'Plus Jakarta Sans', sans-serif;
+ --font-mono: 'JetBrains Mono', monospace;
+}
+
+body {
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-family: var(--font-body);
+ margin: 0;
+}
+
+* {
+ scrollbar-width: thin;
+ scrollbar-color: #1e2030 transparent;
+}
diff --git a/packages/webui/src/main.tsx b/packages/webui/src/main.tsx
new file mode 100644
index 0000000..12fa35b
--- /dev/null
+++ b/packages/webui/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import App from "./App";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/packages/webui/src/types.ts b/packages/webui/src/types.ts
new file mode 100644
index 0000000..7928725
--- /dev/null
+++ b/packages/webui/src/types.ts
@@ -0,0 +1,25 @@
+export interface ConfigEntry {
+ value: string;
+ scope: "personal" | "shared";
+ env: boolean;
+ secret: boolean;
+ updated_at: string;
+}
+
+export interface ConfigResponse {
+ agent_id: string;
+ role?: string;
+ secrets: Record;
+}
+
+export interface Agent {
+ id: string;
+ role: string;
+ tokens?: { id: string; created_at: string }[];
+}
+
+export interface Toast {
+ id: number;
+ message: string;
+ type: "success" | "error";
+}
diff --git a/packages/webui/tsconfig.app.json b/packages/webui/tsconfig.app.json
new file mode 100644
index 0000000..7f42e5f
--- /dev/null
+++ b/packages/webui/tsconfig.app.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/webui/tsconfig.json b/packages/webui/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/packages/webui/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/packages/webui/tsconfig.node.json b/packages/webui/tsconfig.node.json
new file mode 100644
index 0000000..d3c52ea
--- /dev/null
+++ b/packages/webui/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "module": "esnext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/packages/webui/vite.config.ts b/packages/webui/vite.config.ts
new file mode 100644
index 0000000..136cf93
--- /dev/null
+++ b/packages/webui/vite.config.ts
@@ -0,0 +1,12 @@
+import tailwindcss from "@tailwindcss/vite";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { viteSingleFile } from "vite-plugin-singlefile";
+
+export default defineConfig({
+ plugins: [react(), tailwindcss(), viteSingleFile()],
+ build: {
+ target: "esnext",
+ minify: "terser",
+ },
+});
diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts
index 3c7a8f6..0b157e3 100644
--- a/packages/worker/src/index.ts
+++ b/packages/worker/src/index.ts
@@ -292,6 +292,12 @@ export default {
// Health check
if (path === "/health") return json({ status: "ok" });
+ // Serve UI
+ if (path === "/" && method === "GET") {
+ const { renderUI } = await import("./ui.js");
+ return new Response(renderUI(), { headers: { "Content-Type": "text/html" } });
+ }
+
// Auth required for everything else
const auth = await authenticate(request, env.CONFIG_KV);
if (!auth) return err("unauthorized", 401);
diff --git a/packages/worker/src/ui.ts b/packages/worker/src/ui.ts
index 9b6ee98..13333d4 100644
--- a/packages/worker/src/ui.ts
+++ b/packages/worker/src/ui.ts
@@ -1,381 +1,3 @@
export function renderUI(): string {
- return `
-
-
-
-
-Config Service
-
-
-
-
-
-
-
🔑 Config Service
-
-
-
-
-
-
-
-
-
-
-
-
-
- | Key | Value | Scope | Updated | Actions |
-
-
-
-
-
-
-
-
Add Key
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
+ return "\n\n \n \n \n Config Service\n \n \n \n \n \n \n\n";
}
diff --git a/scripts/build-ui.sh b/scripts/build-ui.sh
new file mode 100755
index 0000000..c021574
--- /dev/null
+++ b/scripts/build-ui.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+export PATH="$HOME/.bun/bin:$PATH"
+
+echo "Building webui..."
+cd packages/webui
+bun run build
+
+HTML=$(cat dist/index.html)
+
+echo "Generating ui.ts..."
+cat > ../worker/src/ui.ts << 'TSEOF'
+export function renderUI(): string {
+ return UI_HTML;
+}
+TSEOF
+
+# Use node to safely embed the HTML as a JS string
+node -e "
+const fs = require('fs');
+const html = fs.readFileSync('dist/index.html', 'utf8');
+const src = 'export function renderUI(): string {\n return ' + JSON.stringify(html) + ';\n}\n';
+fs.writeFileSync('../worker/src/ui.ts', src);
+"
+
+echo "Done. Generated packages/worker/src/ui.ts"