2026-04-24 21:30:48 -05:00
|
|
|
import { z } from "zod";
|
2026-04-27 11:20:47 -05:00
|
|
|
import { httpFetch } from "@/lib/http";
|
2026-04-24 21:30:48 -05:00
|
|
|
|
2026-05-04 11:09:28 -05:00
|
|
|
const LEGACY_KEY = "openconcho:config";
|
|
|
|
|
const STORE_KEY = "openconcho:instances";
|
2026-04-24 21:30:48 -05:00
|
|
|
|
|
|
|
|
export const configSchema = z.object({
|
2026-04-27 11:20:47 -05:00
|
|
|
baseUrl: z.string().url({ message: "Must be a valid URL" }),
|
2026-04-24 21:30:48 -05:00
|
|
|
token: z.string().optional().default(""),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export type Config = z.infer<typeof configSchema>;
|
|
|
|
|
|
2026-05-04 11:09:28 -05:00
|
|
|
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;
|
2026-04-24 21:30:48 -05:00
|
|
|
try {
|
2026-05-04 11:09:28 -05:00
|
|
|
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;
|
2026-04-24 21:30:48 -05:00
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 11:09:28 -05:00
|
|
|
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. */
|
2026-04-24 21:30:48 -05:00
|
|
|
export function saveConfig(config: Config): void {
|
2026-05-04 11:09:28 -05:00
|
|
|
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 });
|
2026-04-24 21:30:48 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clearConfig(): void {
|
2026-05-04 11:09:28 -05:00
|
|
|
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 });
|
2026-04-24 21:30:48 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking";
|
|
|
|
|
|
2026-04-24 16:52:40 -05:00
|
|
|
export async function checkConnection(
|
|
|
|
|
baseUrl: string,
|
|
|
|
|
token?: string,
|
|
|
|
|
): Promise<{
|
2026-04-24 21:30:48 -05:00
|
|
|
status: HealthStatus;
|
|
|
|
|
message: string;
|
|
|
|
|
}> {
|
|
|
|
|
try {
|
|
|
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
2026-04-24 16:52:40 -05:00
|
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
2026-04-24 21:30:48 -05:00
|
|
|
|
2026-04-24 16:52:40 -05:00
|
|
|
const res = await httpFetch(`${baseUrl}/v3/workspaces/list`, {
|
2026-04-24 21:30:48 -05:00
|
|
|
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}` };
|
|
|
|
|
}
|
|
|
|
|
}
|