Files
openconcho/packages/web/src/components/settings/SettingsForm.tsx
Offending Commit f706c83cc1 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.
2026-05-04 11:09:28 -05:00

283 lines
7.8 KiB
TypeScript

import { AnimatePresence, motion } from "framer-motion";
import { AlertCircle, CheckCircle, Loader, Lock, LockOpen, Wifi, WifiOff } from "lucide-react";
import { useState } from "react";
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 { useInstances } from "@/hooks/useInstances";
import { checkConnection, type HealthStatus, type Instance, instanceSchema } from "@/lib/config";
import { COLOR } from "@/lib/constants";
interface SettingsFormProps {
/** 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 = {
ok: { icon: CheckCircle, color: COLOR.success, label: "Connected" },
"auth-required": { icon: AlertCircle, color: COLOR.warning, label: "Auth required" },
unreachable: { icon: WifiOff, color: COLOR.destructive, label: "Unreachable" },
checking: { icon: Loader, color: COLOR.accentText, label: "Checking..." },
};
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<Partial<Record<keyof Instance, string>>>({});
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..." });
const result = await checkConnection(baseUrl, token || undefined);
setHealth(result);
setChecking(false);
if (result.status === "auth-required" && !token) {
document.getElementById("honcho-token")?.focus();
}
}
function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
e.preventDefault();
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 Instance;
fieldErrors[key] = issue.message;
}
setErrors(fieldErrors);
return;
}
setErrors({});
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?.(id);
}, 600);
}
const StatusIcon = health ? statusConfig[health.status].icon : null;
return (
<form
onSubmit={handleSubmit}
className="rounded-2xl p-6 space-y-5"
style={{
background: "var(--bg-2)",
border: "1px solid var(--border)",
}}
>
{/* Name */}
<div>
<Label className="mb-1.5 text-sm">Instance Name</Label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Local, Staging, Production"
className="rounded-xl"
/>
{errors.name && (
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
{errors.name}
</p>
)}
<Muted className="text-xs mt-1.5">A short label to identify this connection</Muted>
</div>
{/* Base URL */}
<div>
<Label className="mb-1.5 text-sm">Honcho Base URL</Label>
<div className="flex gap-2">
<Input
type="url"
value={baseUrl}
onChange={(e) => {
setBaseUrl(e.target.value);
setHealth(null);
}}
placeholder="http://localhost:8000"
className="flex-1 font-mono rounded-xl"
/>
<Button
type="button"
variant="accent"
onClick={handleTest}
disabled={checking || !baseUrl}
className="rounded-xl"
>
{checking ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
>
<Loader className="w-4 h-4" strokeWidth={1.5} />
</motion.div>
) : (
<Wifi className="w-4 h-4" strokeWidth={1.5} />
)}
<span className="hidden sm:block">Test</span>
</Button>
</div>
{errors.baseUrl && (
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
{errors.baseUrl}
</p>
)}
<Muted className="text-xs mt-1.5">URL of your self-hosted Honcho instance</Muted>
</div>
{/* Health status */}
<AnimatePresence>
{health && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div
className="rounded-xl px-4 py-3 flex items-center gap-2.5"
style={{
background: "var(--surface)",
border: `1px solid ${statusConfig[health.status].color}33`,
}}
>
{StatusIcon && (
<StatusIcon
className="w-4 h-4 shrink-0"
style={{ color: statusConfig[health.status].color }}
strokeWidth={1.5}
/>
)}
<div>
<p
className="text-sm font-medium"
style={{ color: statusConfig[health.status].color }}
>
{statusConfig[health.status].label}
</p>
<Muted className="text-xs mt-0.5">{health.message}</Muted>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Token */}
<div>
<Label htmlFor="honcho-token" className="flex items-center gap-1.5 mb-1.5 text-sm">
{token ? (
<Lock className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
) : (
<LockOpen
className="w-3.5 h-3.5"
style={{ color: "var(--text-3)" }}
strokeWidth={1.5}
/>
)}
API Token
<span
className="ml-1 text-xs font-normal px-1.5 py-0.5 rounded-full"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
}}
>
optional
</span>
</Label>
<Textarea
id="honcho-token"
value={token}
onChange={(e) => setToken(e.target.value)}
rows={2}
placeholder="eyJ... (required only if your instance has auth enabled)"
className="font-mono rounded-xl"
/>
{health?.status === "auth-required" && !token && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="text-xs mt-1"
style={{ color: COLOR.warning }}
>
This instance requires an API token to proceed
</motion.p>
)}
</div>
<div className="flex gap-2">
{!hideCancel && onCancel && (
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="py-2.5 px-4 rounded-xl"
>
Cancel
</Button>
)}
<Button
type="submit"
variant="primary"
className="flex-1 py-2.5 px-4 rounded-xl"
style={saved ? { background: "#059669" } : undefined}
>
{saved ? "✓ Saved" : (submitLabel ?? (isCreate ? "Add Instance" : "Save Changes"))}
</Button>
</div>
</form>
);
}