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.
170 lines
5.0 KiB
TypeScript
170 lines
5.0 KiB
TypeScript
import { z } from "zod";
|
|
import { httpFetch } from "@/lib/http";
|
|
|
|
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" }),
|
|
token: z.string().optional().default(""),
|
|
});
|
|
|
|
export type Config = z.infer<typeof configSchema>;
|
|
|
|
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<typeof instanceSchema>;
|
|
|
|
const storeSchema = z.object({
|
|
instances: z.array(instanceSchema),
|
|
activeId: z.string().nullable(),
|
|
});
|
|
|
|
export type InstanceStore = z.infer<typeof storeSchema>;
|
|
|
|
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 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 {
|
|
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(STORE_KEY);
|
|
localStorage.removeItem(LEGACY_KEY);
|
|
}
|
|
|
|
export function addInstance(input: Omit<Instance, "id">): 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<Omit<Instance, "id">>): 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";
|
|
|
|
export async function checkConnection(
|
|
baseUrl: string,
|
|
token?: string,
|
|
): Promise<{
|
|
status: HealthStatus;
|
|
message: string;
|
|
}> {
|
|
try {
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
|
|
const res = await httpFetch(`${baseUrl}/v3/workspaces/list`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({}),
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
|
|
if (res.ok) return { status: "ok", message: "Connected successfully" };
|
|
if (res.status === 401 || res.status === 403) {
|
|
return { status: "auth-required", message: "Authentication required — provide an API token" };
|
|
}
|
|
return { status: "unreachable", message: `Server returned ${res.status}` };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
if (msg.includes("AbortError") || msg.includes("timeout")) {
|
|
return { status: "unreachable", message: "Connection timed out" };
|
|
}
|
|
return { status: "unreachable", message: `Cannot reach server: ${msg}` };
|
|
}
|
|
}
|