Files
mcma-webui/src/config/instances.ts
T
2026-06-13 12:35:20 +03:00

197 lines
6.2 KiB
TypeScript

/*
* Multi-instance backend registry + per-backend scoped storage.
*
* The web UI is backend-agnostic: it can connect to any server speaking the
* `/api/v1` contract. Everything we persist (auth tokens, cached prefs, …) is
* therefore bound to the *instance it came from* — never global. Each saved
* backend gets a stable `id` derived from its base URL, and every persisted key
* lives under the `mcma:<id>:` namespace so switching backends can never mix
* one server's session/cache into another's.
*
* Layout in localStorage:
* mcma:instances -> Instance[] (the saved-backends registry)
* mcma:activeInstance -> <id> (which one is current)
* mcma:<id>:auth -> persisted auth slice (per-backend)
* mcma:<id>:<key> -> any other scoped value
*/
export interface Instance {
id: string;
baseUrl: string;
name: string;
lastUsedAt: number;
}
const REGISTRY_KEY = 'mcma:instances';
const ACTIVE_KEY = 'mcma:activeInstance';
// pre-multi-instance keys, migrated once on first load
const LEGACY_URL_KEY = 'mcma_api_base_url';
const LEGACY_AUTH_KEY = 'mcma_auth';
// The UI always talks to the `/api/v1` contract, so users only enter the
// origin (and optional reverse-proxy prefix). We append the contract path
// here, the single choke point for both the base URL and the instance id, so
// `domain.com`, `domain.com/`, and `domain.com/api/v1` all converge.
function normalizeUrl(url: string): string {
const trimmed = url.trim().replace(/\/+$/, '');
return /\/api\/v1$/.test(trimmed) ? trimmed : `${trimmed}/api/v1`;
}
/** Stable, readable id from a base URL — also serves as the storage namespace. */
export function instanceIdFromUrl(url: string): string {
const stripped = normalizeUrl(url)
.toLowerCase()
.replace(/^https?:\/\//, '');
const slug = stripped.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return slug || 'default';
}
function defaultName(baseUrl: string): string {
try {
const u = new URL(
/^https?:\/\//.test(baseUrl) ? baseUrl : `http://${baseUrl}`,
);
return u.host || baseUrl;
} catch {
return baseUrl;
}
}
function readRegistry(): Instance[] {
try {
const raw = localStorage.getItem(REGISTRY_KEY);
return raw ? (JSON.parse(raw) as Instance[]) : [];
} catch {
return [];
}
}
function writeRegistry(list: Instance[]): void {
try {
localStorage.setItem(REGISTRY_KEY, JSON.stringify(list));
} catch {
/* storage unavailable */
}
}
/** Saved backends, most-recently-used first. */
export function listInstances(): Instance[] {
return readRegistry().sort((a, b) => b.lastUsedAt - a.lastUsedAt);
}
/** Add (or refresh) a backend in the registry and return its record. */
export function upsertInstance(url: string, name?: string): Instance {
const baseUrl = normalizeUrl(url);
const id = instanceIdFromUrl(baseUrl);
const list = readRegistry();
const existing = list.find((i) => i.id === id);
const inst: Instance = {
id,
baseUrl,
name: name ?? existing?.name ?? defaultName(baseUrl),
lastUsedAt: Date.now(),
};
writeRegistry(
existing ? list.map((i) => (i.id === id ? inst : i)) : [...list, inst],
);
return inst;
}
/** Clear a backend's stored session without forgetting the instance itself. */
export function clearInstanceAuth(id: string): void {
localStorage.removeItem(scopedKey('auth', id));
}
/** Remove a backend and wipe every scoped key it owns. */
export function removeInstance(id: string): void {
writeRegistry(readRegistry().filter((i) => i.id !== id));
clearScope(id);
if (getActiveInstanceId() === id) {
const next = listInstances()[0];
if (next) setActiveInstanceId(next.id);
else localStorage.removeItem(ACTIVE_KEY);
}
}
export function getActiveInstanceId(): string | null {
return localStorage.getItem(ACTIVE_KEY);
}
export function setActiveInstanceId(id: string): void {
localStorage.setItem(ACTIVE_KEY, id);
}
export function getActiveInstance(): Instance | null {
const id = getActiveInstanceId();
if (!id) return null;
return readRegistry().find((i) => i.id === id) ?? null;
}
/** Build the namespaced localStorage key for a value scoped to a backend. */
export function scopedKey(
key: string,
instanceId: string | null = getActiveInstanceId(),
): string {
return `mcma:${instanceId ?? 'none'}:${key}`;
}
/**
* Per-backend key/value store. Reads/writes are no-ops when no instance is
* active, so callers never have to special-case the unconnected state.
*/
export const instanceStorage = {
get(key: string): string | null {
const id = getActiveInstanceId();
return id ? localStorage.getItem(scopedKey(key, id)) : null;
},
set(key: string, value: string): void {
const id = getActiveInstanceId();
if (!id) return;
try {
localStorage.setItem(scopedKey(key, id), value);
} catch {
/* storage unavailable */
}
},
remove(key: string): void {
const id = getActiveInstanceId();
if (id) localStorage.removeItem(scopedKey(key, id));
},
};
function clearScope(id: string): void {
const prefix = `mcma:${id}:`;
const toRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(prefix)) toRemove.push(k);
}
toRemove.forEach((k) => localStorage.removeItem(k));
}
/** One-time migration of the old single-backend keys into the namespaced model. */
function migrateLegacy(): void {
try {
const legacyUrl = localStorage.getItem(LEGACY_URL_KEY);
if (!legacyUrl || readRegistry().length > 0) return;
const inst = upsertInstance(legacyUrl);
const legacyAuth = localStorage.getItem(LEGACY_AUTH_KEY);
if (legacyAuth)
localStorage.setItem(scopedKey('auth', inst.id), legacyAuth);
setActiveInstanceId(inst.id);
localStorage.removeItem(LEGACY_URL_KEY);
localStorage.removeItem(LEGACY_AUTH_KEY);
} catch {
/* best-effort */
}
}
// Fold any pre-multi-instance keys into the namespaced model on first load.
// We deliberately do NOT seed an instance from the env default: before the
// user connects there is no active instance, getApiBaseUrl() falls back to the
// env default, and the connect flow registers the real backend.
if (typeof localStorage !== 'undefined') {
migrateLegacy();
}