From f706c83cc15bd35a7222938c580189f710098e2c Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Mon, 4 May 2026 11:09:28 -0500 Subject: [PATCH] feat: support multiple Honcho instances (closes #2) Replace single localStorage config with a named-instance store ({ instances: Instance[], activeId }). Adds an instances manager on the settings page (list / add / edit / delete / activate) and a sidebar switcher for quick swaps. Existing single-config users are migrated transparently on first load. Switching or deleting an instance clears the TanStack Query cache so data from another deployment never bleeds into the active view. --- .../web/src/components/layout/Sidebar.tsx | 103 +++++++++- .../components/settings/InstancesManager.tsx | 178 ++++++++++++++++++ .../src/components/settings/SettingsForm.tsx | 119 +++++++++--- packages/web/src/hooks/useInstances.ts | 90 +++++++++ packages/web/src/lib/config.ts | 121 +++++++++++- packages/web/src/routes/settings.tsx | 11 +- 6 files changed, 575 insertions(+), 47 deletions(-) create mode 100644 packages/web/src/components/settings/InstancesManager.tsx create mode 100644 packages/web/src/hooks/useInstances.ts diff --git a/packages/web/src/components/layout/Sidebar.tsx b/packages/web/src/components/layout/Sidebar.tsx index 84af122..9305546 100644 --- a/packages/web/src/components/layout/Sidebar.tsx +++ b/packages/web/src/components/layout/Sidebar.tsx @@ -2,7 +2,9 @@ import { Link, useMatchRoute } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { Boxes, + Check, ChevronRight, + ChevronsUpDown, Eye, EyeOff, LayoutDashboard, @@ -10,9 +12,10 @@ import { Settings, Sun, } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { useDemo } from "@/hooks/useDemo"; +import { useInstances } from "@/hooks/useInstances"; import { useTheme } from "@/hooks/useTheme"; -import { loadConfig } from "@/lib/config"; import { COLOR } from "@/lib/constants"; const navItems = [ @@ -23,9 +26,22 @@ const navItems = [ export function Sidebar() { const matchRoute = useMatchRoute(); - const config = loadConfig(); + const { instances, active, activate } = useInstances(); const { theme, toggle } = useTheme(); const { demo, toggle: toggleDemo, mask } = useDemo(); + const [switcherOpen, setSwitcherOpen] = useState(false); + const switcherRef = useRef(null); + + useEffect(() => { + if (!switcherOpen) return; + function onClick(e: MouseEvent) { + if (!switcherRef.current?.contains(e.target as Node)) { + setSwitcherOpen(false); + } + } + window.addEventListener("mousedown", onClick); + return () => window.removeEventListener("mousedown", onClick); + }, [switcherOpen]); return ( - {config && ( -

- {mask(config.baseUrl.replace(/^https?:\/\//, ""))} -

+ {active && ( +
+ + {switcherOpen && instances.length > 1 && ( +
+ {instances.map((inst) => ( + + ))} +
+ )} +
)} diff --git a/packages/web/src/components/settings/InstancesManager.tsx b/packages/web/src/components/settings/InstancesManager.tsx new file mode 100644 index 0000000..4ff3af8 --- /dev/null +++ b/packages/web/src/components/settings/InstancesManager.tsx @@ -0,0 +1,178 @@ +import { motion } from "framer-motion"; +import { Check, Pencil, Plus, Server, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { SettingsForm } from "@/components/settings/SettingsForm"; +import { Button } from "@/components/ui/button"; +import { Muted } from "@/components/ui/typography"; +import { useInstances } from "@/hooks/useInstances"; +import type { Instance } from "@/lib/config"; +import { COLOR } from "@/lib/constants"; + +type Mode = { kind: "list" } | { kind: "create" } | { kind: "edit"; id: string }; + +interface InstancesManagerProps { + onActivated?: () => void; +} + +export function InstancesManager({ onActivated }: InstancesManagerProps) { + const { instances, activeId, activate, remove } = useInstances(); + const [mode, setMode] = useState({ kind: "list" }); + + if (mode.kind === "create") { + return ( + { + setMode({ kind: "list" }); + onActivated?.(); + }} + onCancel={instances.length > 0 ? () => setMode({ kind: "list" }) : undefined} + hideCancel={instances.length === 0} + /> + ); + } + + if (mode.kind === "edit") { + const target = instances.find((i) => i.id === mode.id); + if (!target) return null; + return ( + setMode({ kind: "list" })} + onCancel={() => setMode({ kind: "list" })} + /> + ); + } + + if (instances.length === 0) { + return ( + onActivated?.()} + hideCancel + submitLabel="Save Connection" + /> + ); + } + + return ( +
+
+ {instances.map((inst) => ( + { + activate(inst.id); + onActivated?.(); + }} + onEdit={() => setMode({ kind: "edit", id: inst.id })} + onDelete={() => remove(inst.id)} + /> + ))} +
+ + +
+ ); +} + +interface InstanceRowProps { + instance: Instance; + active: boolean; + onActivate: () => void; + onEdit: () => void; + onDelete: () => void; +} + +function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: InstanceRowProps) { + const [confirmingDelete, setConfirmingDelete] = useState(false); + + return ( + + + +
+ + {confirmingDelete ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/packages/web/src/components/settings/SettingsForm.tsx b/packages/web/src/components/settings/SettingsForm.tsx index 82908dc..236bdf0 100644 --- a/packages/web/src/components/settings/SettingsForm.tsx +++ b/packages/web/src/components/settings/SettingsForm.tsx @@ -5,18 +5,21 @@ import { Button } from "@/components/ui/button"; import { Input, Textarea } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Muted } from "@/components/ui/typography"; -import { - type Config, - checkConnection, - configSchema, - type HealthStatus, - loadConfig, - saveConfig, -} from "@/lib/config"; +import { useInstances } from "@/hooks/useInstances"; +import { checkConnection, type HealthStatus, type Instance, instanceSchema } from "@/lib/config"; import { COLOR } from "@/lib/constants"; interface SettingsFormProps { - onSaved?: () => void; + /** Instance to edit; pass `null` to create a new one. */ + instance: Instance | null; + /** Called after a successful save. Receives the saved instance id. */ + onSaved?: (id: string) => void; + /** Called when the user cancels (only meaningful when there's something to cancel back to). */ + onCancel?: () => void; + /** Hide the cancel button. */ + hideCancel?: boolean; + /** Override the submit button label. */ + submitLabel?: string; } const statusConfig = { @@ -26,15 +29,25 @@ const statusConfig = { checking: { icon: Loader, color: COLOR.accentText, label: "Checking..." }, }; -export function SettingsForm({ onSaved }: SettingsFormProps) { - const existing = loadConfig(); - const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? "http://localhost:8000"); - const [token, setToken] = useState(existing?.token ?? ""); - const [errors, setErrors] = useState>>({}); +export function SettingsForm({ + instance, + onSaved, + onCancel, + hideCancel, + submitLabel, +}: SettingsFormProps) { + const { add, update, activate } = useInstances(); + + const [name, setName] = useState(instance?.name ?? ""); + const [baseUrl, setBaseUrl] = useState(instance?.baseUrl ?? "http://localhost:8000"); + const [token, setToken] = useState(instance?.token ?? ""); + const [errors, setErrors] = useState>>({}); const [saved, setSaved] = useState(false); const [health, setHealth] = useState<{ status: HealthStatus; message: string } | null>(null); const [checking, setChecking] = useState(false); + const isCreate = instance === null; + async function handleTest() { setChecking(true); setHealth({ status: "checking", message: "Connecting..." }); @@ -49,22 +62,46 @@ export function SettingsForm({ onSaved }: SettingsFormProps) { function handleSubmit(e: React.SyntheticEvent) { e.preventDefault(); - const result = configSchema.safeParse({ baseUrl, token }); + const candidate = { + id: instance?.id ?? "placeholder", + name: name.trim() || "Default", + baseUrl, + token, + }; + const result = instanceSchema.safeParse(candidate); if (!result.success) { const fieldErrors: typeof errors = {}; for (const issue of result.error.issues) { - const key = issue.path[0] as keyof Config; + const key = issue.path[0] as keyof Instance; fieldErrors[key] = issue.message; } setErrors(fieldErrors); return; } setErrors({}); - saveConfig(result.data); + + let id: string; + if (isCreate) { + const created = add({ + name: result.data.name, + baseUrl: result.data.baseUrl, + token: result.data.token, + }); + activate(created.id); + id = created.id; + } else { + update(instance.id, { + name: result.data.name, + baseUrl: result.data.baseUrl, + token: result.data.token, + }); + id = instance.id; + } + setSaved(true); setTimeout(() => { setSaved(false); - onSaved?.(); + onSaved?.(id); }, 600); } @@ -79,6 +116,24 @@ export function SettingsForm({ onSaved }: SettingsFormProps) { border: "1px solid var(--border)", }} > + {/* Name */} +
+ + setName(e.target.value)} + placeholder="e.g. Local, Staging, Production" + className="rounded-xl" + /> + {errors.name && ( +

+ {errors.name} +

+ )} + A short label to identify this connection +
+ {/* Base URL */}
@@ -202,14 +257,26 @@ export function SettingsForm({ onSaved }: SettingsFormProps) { )}
- +
+ {!hideCancel && onCancel && ( + + )} + +
); } diff --git a/packages/web/src/hooks/useInstances.ts b/packages/web/src/hooks/useInstances.ts new file mode 100644 index 0000000..71dd43d --- /dev/null +++ b/packages/web/src/hooks/useInstances.ts @@ -0,0 +1,90 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useSyncExternalStore } from "react"; +import { + addInstance as addInstanceCore, + deleteInstance as deleteInstanceCore, + type Instance, + type InstanceStore, + loadStore, + setActiveInstance as setActiveInstanceCore, + updateInstance as updateInstanceCore, +} from "@/lib/config"; + +const EVENT = "openconcho:instances-changed"; + +function emit() { + window.dispatchEvent(new Event(EVENT)); +} + +function subscribe(cb: () => void): () => void { + window.addEventListener(EVENT, cb); + window.addEventListener("storage", cb); + return () => { + window.removeEventListener(EVENT, cb); + window.removeEventListener("storage", cb); + }; +} + +let cachedKey = ""; +let cachedSnapshot: InstanceStore = { instances: [], activeId: null }; + +function getSnapshot(): InstanceStore { + const next = loadStore(); + const key = JSON.stringify(next); + if (key !== cachedKey) { + cachedKey = key; + cachedSnapshot = next; + } + return cachedSnapshot; +} + +function getServerSnapshot(): InstanceStore { + return cachedSnapshot; +} + +export function useInstances() { + const qc = useQueryClient(); + + const store = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const add = useCallback((input: Omit) => { + const inst = addInstanceCore(input); + emit(); + return inst; + }, []); + + const update = useCallback((id: string, patch: Partial>) => { + updateInstanceCore(id, patch); + emit(); + }, []); + + const remove = useCallback( + (id: string) => { + deleteInstanceCore(id); + qc.clear(); + emit(); + }, + [qc], + ); + + const activate = useCallback( + (id: string) => { + setActiveInstanceCore(id); + qc.clear(); + emit(); + }, + [qc], + ); + + const active = store.instances.find((i) => i.id === store.activeId) ?? null; + + return { + instances: store.instances, + activeId: store.activeId, + active, + add, + update, + remove, + activate, + }; +} diff --git a/packages/web/src/lib/config.ts b/packages/web/src/lib/config.ts index 9c63fca..faca089 100644 --- a/packages/web/src/lib/config.ts +++ b/packages/web/src/lib/config.ts @@ -1,7 +1,8 @@ import { z } from "zod"; import { httpFetch } from "@/lib/http"; -const CONFIG_KEY = "openconcho:config"; +const LEGACY_KEY = "openconcho:config"; +const STORE_KEY = "openconcho:instances"; export const configSchema = z.object({ baseUrl: z.string().url({ message: "Must be a valid URL" }), @@ -10,23 +11,127 @@ export const configSchema = z.object({ export type Config = z.infer; -export function loadConfig(): Config | null { +export const instanceSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1, { message: "Name is required" }), + baseUrl: z.string().url({ message: "Must be a valid URL" }), + token: z.string().optional().default(""), +}); + +export type Instance = z.infer; + +const storeSchema = z.object({ + instances: z.array(instanceSchema), + activeId: z.string().nullable(), +}); + +export type InstanceStore = z.infer; + +function newId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `inst_${Math.random().toString(36).slice(2)}_${Date.now()}`; +} + +function migrateLegacy(): InstanceStore | null { + const raw = localStorage.getItem(LEGACY_KEY); + if (!raw) return null; try { - const raw = localStorage.getItem(CONFIG_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw); - return configSchema.parse(parsed); + const parsed = configSchema.parse(JSON.parse(raw)); + const inst: Instance = { + id: newId(), + name: "Default", + baseUrl: parsed.baseUrl, + token: parsed.token, + }; + const store: InstanceStore = { instances: [inst], activeId: inst.id }; + localStorage.setItem(STORE_KEY, JSON.stringify(store)); + localStorage.removeItem(LEGACY_KEY); + return store; } catch { return null; } } +export function loadStore(): InstanceStore { + try { + const raw = localStorage.getItem(STORE_KEY); + if (raw) return storeSchema.parse(JSON.parse(raw)); + } catch { + // fall through + } + const migrated = migrateLegacy(); + if (migrated) return migrated; + return { instances: [], activeId: null }; +} + +export function saveStore(store: InstanceStore): void { + localStorage.setItem(STORE_KEY, JSON.stringify(store)); +} + +export function getActiveInstance(): Instance | null { + const store = loadStore(); + if (!store.activeId) return null; + return store.instances.find((i) => i.id === store.activeId) ?? null; +} + +export function loadConfig(): Config | null { + const active = getActiveInstance(); + if (!active) return null; + return { baseUrl: active.baseUrl, token: active.token ?? "" }; +} + +/** Backwards-compatible single-instance save: replaces or creates a "Default" instance. */ export function saveConfig(config: Config): void { - localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + const store = loadStore(); + if (store.activeId) { + const idx = store.instances.findIndex((i) => i.id === store.activeId); + if (idx >= 0) { + store.instances[idx] = { ...store.instances[idx], ...config }; + saveStore(store); + return; + } + } + const inst: Instance = { id: newId(), name: "Default", ...config }; + saveStore({ instances: [...store.instances, inst], activeId: inst.id }); } export function clearConfig(): void { - localStorage.removeItem(CONFIG_KEY); + localStorage.removeItem(STORE_KEY); + localStorage.removeItem(LEGACY_KEY); +} + +export function addInstance(input: Omit): Instance { + const store = loadStore(); + const inst: Instance = { id: newId(), ...input }; + const next: InstanceStore = { + instances: [...store.instances, inst], + activeId: store.activeId ?? inst.id, + }; + saveStore(next); + return inst; +} + +export function updateInstance(id: string, patch: Partial>): void { + const store = loadStore(); + const idx = store.instances.findIndex((i) => i.id === id); + if (idx < 0) return; + store.instances[idx] = { ...store.instances[idx], ...patch }; + saveStore(store); +} + +export function deleteInstance(id: string): void { + const store = loadStore(); + const remaining = store.instances.filter((i) => i.id !== id); + const activeId = store.activeId === id ? (remaining[0]?.id ?? null) : store.activeId; + saveStore({ instances: remaining, activeId }); +} + +export function setActiveInstance(id: string): void { + const store = loadStore(); + if (!store.instances.some((i) => i.id === id)) return; + saveStore({ ...store, activeId: id }); } export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking"; diff --git a/packages/web/src/routes/settings.tsx b/packages/web/src/routes/settings.tsx index 775c53c..7a05b81 100644 --- a/packages/web/src/routes/settings.tsx +++ b/packages/web/src/routes/settings.tsx @@ -1,6 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { SettingsForm } from "@/components/settings/SettingsForm"; +import { InstancesManager } from "@/components/settings/InstancesManager"; +import { useInstances } from "@/hooks/useInstances"; export const Route = createFileRoute("/settings")({ component: SettingsPage, @@ -8,6 +9,8 @@ export const Route = createFileRoute("/settings")({ function SettingsPage() { const navigate = useNavigate(); + const { instances } = useInstances(); + const isFirstRun = instances.length === 0; return (

- Connect to your self-hosted Honcho instance + {isFirstRun + ? "Connect to your self-hosted Honcho instance" + : "Manage your Honcho connections"}

- navigate({ to: "/" as never })} /> + navigate({ to: "/" as never })} />

Connection details are stored locally on this device only