mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
import type { FeatureBundleState } from "@snapotter/shared";
|
|
import { TOOL_BUNDLE_MAP } from "@snapotter/shared";
|
|
import { create } from "zustand";
|
|
import { apiGet, apiPost } from "@/lib/api";
|
|
|
|
interface BundleProgress {
|
|
percent: number;
|
|
stage: string;
|
|
}
|
|
|
|
interface FeaturesState {
|
|
bundles: FeatureBundleState[];
|
|
loaded: boolean;
|
|
loadError: boolean;
|
|
installing: Record<string, BundleProgress>;
|
|
errors: Record<string, string>;
|
|
queued: string[];
|
|
installAllActive: boolean;
|
|
startTimes: Record<string, number>;
|
|
|
|
fetch: () => Promise<void>;
|
|
refresh: () => Promise<void>;
|
|
isToolInstalled: (toolId: string) => boolean;
|
|
getBundleForTool: (toolId: string) => FeatureBundleState | null;
|
|
installBundle: (bundleId: string) => Promise<void>;
|
|
uninstallBundle: (bundleId: string) => Promise<void>;
|
|
reinstallBundle: (bundleId: string) => Promise<void>;
|
|
installAll: () => Promise<void>;
|
|
clearError: (bundleId: string) => void;
|
|
}
|
|
|
|
export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
|
const esRefs: Record<string, EventSource> = {};
|
|
const pollRefs: Record<string, ReturnType<typeof setInterval>> = {};
|
|
const completionRefs: Record<string, () => void> = {};
|
|
|
|
const resolveCompletion = (bundleId: string) => {
|
|
if (completionRefs[bundleId]) {
|
|
completionRefs[bundleId]();
|
|
delete completionRefs[bundleId];
|
|
}
|
|
};
|
|
|
|
const refreshBundles = async () => {
|
|
try {
|
|
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
|
set({ bundles: data.bundles, loaded: true });
|
|
} catch {}
|
|
};
|
|
|
|
const startPolling = (bundleId: string) => {
|
|
if (pollRefs[bundleId]) return;
|
|
pollRefs[bundleId] = setInterval(async () => {
|
|
try {
|
|
await refreshBundles();
|
|
const updated = get().bundles.find((b) => b.id === bundleId);
|
|
if (updated?.status !== "installing") {
|
|
clearInterval(pollRefs[bundleId]);
|
|
delete pollRefs[bundleId];
|
|
|
|
const installing = { ...get().installing };
|
|
delete installing[bundleId];
|
|
set({ installing });
|
|
|
|
if (updated?.status === "error") {
|
|
set({
|
|
errors: { ...get().errors, [bundleId]: updated.error ?? "Installation failed" },
|
|
});
|
|
}
|
|
resolveCompletion(bundleId);
|
|
} else if (updated.progress) {
|
|
const current = get().installing[bundleId];
|
|
const percent = Math.max(updated.progress.percent, current?.percent ?? 0);
|
|
set({
|
|
installing: {
|
|
...get().installing,
|
|
[bundleId]: { percent, stage: updated.progress.stage },
|
|
},
|
|
});
|
|
}
|
|
} catch {}
|
|
}, 3000);
|
|
};
|
|
|
|
const listenToProgress = (bundleId: string, jobId: string) => {
|
|
const es = new EventSource(`/api/v1/jobs/${jobId}/progress`);
|
|
esRefs[bundleId] = es;
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data) as {
|
|
phase: string;
|
|
percent: number;
|
|
stage: string;
|
|
error?: string;
|
|
};
|
|
if (data.phase === "complete") {
|
|
es.close();
|
|
delete esRefs[bundleId];
|
|
const installing = { ...get().installing };
|
|
delete installing[bundleId];
|
|
set({ installing });
|
|
refreshBundles();
|
|
resolveCompletion(bundleId);
|
|
return;
|
|
}
|
|
if (data.phase === "failed") {
|
|
es.close();
|
|
delete esRefs[bundleId];
|
|
const installing = { ...get().installing };
|
|
delete installing[bundleId];
|
|
set({ installing });
|
|
set({ errors: { ...get().errors, [bundleId]: data.error ?? "Installation failed" } });
|
|
resolveCompletion(bundleId);
|
|
return;
|
|
}
|
|
const current = get().installing[bundleId];
|
|
const percent = Math.max(data.percent, current?.percent ?? 0);
|
|
set({
|
|
installing: {
|
|
...get().installing,
|
|
[bundleId]: { percent, stage: data.stage },
|
|
},
|
|
});
|
|
} catch {}
|
|
};
|
|
|
|
es.onerror = () => {
|
|
es.close();
|
|
delete esRefs[bundleId];
|
|
startPolling(bundleId);
|
|
};
|
|
};
|
|
|
|
const recoverActiveInstalls = () => {
|
|
for (const bundle of get().bundles) {
|
|
if (bundle.status === "installing" && !get().installing[bundle.id]) {
|
|
set({
|
|
installing: {
|
|
...get().installing,
|
|
[bundle.id]: bundle.progress ?? { percent: 0, stage: "Resuming..." },
|
|
},
|
|
startTimes: { ...get().startTimes, [bundle.id]: Date.now() },
|
|
});
|
|
startPolling(bundle.id);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (typeof document !== "undefined") {
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState !== "visible") return;
|
|
const activeIds = Object.keys(get().installing);
|
|
if (activeIds.length === 0) return;
|
|
|
|
for (const bundleId of activeIds) {
|
|
const es = esRefs[bundleId];
|
|
if (es && es.readyState === EventSource.OPEN) continue;
|
|
if (es) {
|
|
es.close();
|
|
delete esRefs[bundleId];
|
|
}
|
|
if (!pollRefs[bundleId]) {
|
|
startPolling(bundleId);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
bundles: [],
|
|
loaded: false,
|
|
loadError: false,
|
|
installing: {},
|
|
errors: {},
|
|
queued: [],
|
|
installAllActive: false,
|
|
startTimes: {},
|
|
|
|
fetch: async () => {
|
|
if (get().loaded && !get().loadError) {
|
|
// Already loaded successfully before. Refresh in the background
|
|
// so navigating between tool pages picks up status changes
|
|
// (e.g. a bundle installed from the settings page).
|
|
refreshBundles();
|
|
return;
|
|
}
|
|
try {
|
|
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
|
set({ bundles: data.bundles, loaded: true, loadError: false });
|
|
recoverActiveInstalls();
|
|
} catch {
|
|
set({ loaded: true, loadError: true });
|
|
}
|
|
},
|
|
|
|
refresh: refreshBundles,
|
|
|
|
isToolInstalled: (toolId: string) => {
|
|
const bundleId = TOOL_BUNDLE_MAP[toolId];
|
|
if (!bundleId) return true;
|
|
const bundle = get().bundles.find((b) => b.id === bundleId);
|
|
return bundle?.status === "installed";
|
|
},
|
|
|
|
getBundleForTool: (toolId: string) => {
|
|
const bundleId = TOOL_BUNDLE_MAP[toolId];
|
|
if (!bundleId) return null;
|
|
return get().bundles.find((b) => b.id === bundleId) ?? null;
|
|
},
|
|
|
|
installBundle: async (bundleId: string) => {
|
|
const activeIds = Object.keys(get().installing);
|
|
if (activeIds.length > 0 && !activeIds.includes(bundleId)) {
|
|
const alreadyQueued = get().queued.includes(bundleId);
|
|
if (!alreadyQueued) {
|
|
set({ queued: [...get().queued, bundleId] });
|
|
}
|
|
const errors = { ...get().errors };
|
|
delete errors[bundleId];
|
|
set({ errors });
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const check = () => {
|
|
const current = get().installing;
|
|
if (Object.keys(current).length === 0 || Object.keys(current).includes(bundleId)) {
|
|
resolve();
|
|
} else {
|
|
setTimeout(check, 500);
|
|
}
|
|
};
|
|
check();
|
|
});
|
|
|
|
set({ queued: get().queued.filter((id) => id !== bundleId) });
|
|
const currentBundle = get().bundles.find((b) => b.id === bundleId);
|
|
if (currentBundle?.status === "installed") {
|
|
resolveCompletion(bundleId);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const errors = { ...get().errors };
|
|
delete errors[bundleId];
|
|
set({
|
|
errors,
|
|
installing: { ...get().installing, [bundleId]: { percent: 5, stage: "Starting..." } },
|
|
startTimes: { ...get().startTimes, [bundleId]: Date.now() },
|
|
});
|
|
|
|
try {
|
|
const result = await apiPost<{ jobId: string }>(
|
|
`/v1/admin/features/${bundleId}/install`,
|
|
{},
|
|
);
|
|
listenToProgress(bundleId, result.jobId);
|
|
} catch (err) {
|
|
const installing = { ...get().installing };
|
|
delete installing[bundleId];
|
|
|
|
const message = err instanceof Error ? err.message : "Failed to start installation";
|
|
const isAlreadyInstalled = /already installed/i.test(message);
|
|
|
|
if (isAlreadyInstalled) {
|
|
// 409 "already installed": clear the error and refresh status
|
|
// so the UI transitions to the installed state silently.
|
|
const errors = { ...get().errors };
|
|
delete errors[bundleId];
|
|
set({ installing, errors });
|
|
await refreshBundles();
|
|
} else {
|
|
set({
|
|
installing,
|
|
errors: { ...get().errors, [bundleId]: message },
|
|
});
|
|
}
|
|
resolveCompletion(bundleId);
|
|
}
|
|
},
|
|
|
|
uninstallBundle: async (bundleId: string) => {
|
|
try {
|
|
await apiPost(`/v1/admin/features/${bundleId}/uninstall`, {});
|
|
await refreshBundles();
|
|
} catch (err) {
|
|
set({
|
|
errors: {
|
|
...get().errors,
|
|
[bundleId]: err instanceof Error ? err.message : "Uninstall failed",
|
|
},
|
|
});
|
|
}
|
|
},
|
|
|
|
reinstallBundle: async (bundleId: string) => {
|
|
await get().uninstallBundle(bundleId);
|
|
await get().installBundle(bundleId);
|
|
},
|
|
|
|
installAll: async () => {
|
|
set({ installAllActive: true });
|
|
|
|
// Immediately mark every not-yet-installed bundle as queued so the UI
|
|
// updates right away. Exclude bundles that are already installing.
|
|
const activeIds = new Set(Object.keys(get().installing));
|
|
const pending = get().bundles.filter((b) => b.status !== "installed" && !activeIds.has(b.id));
|
|
// Clear stale errors for these bundles
|
|
const errors = { ...get().errors };
|
|
for (const b of pending) delete errors[b.id];
|
|
set({ queued: pending.map((b) => b.id), errors });
|
|
|
|
// If an install is already in progress (user clicked an individual
|
|
// install before Install All), wait for it to finish first.
|
|
if (activeIds.size > 0) {
|
|
const activeId = [...activeIds][0];
|
|
await new Promise<void>((resolve) => {
|
|
completionRefs[activeId] = resolve;
|
|
});
|
|
await refreshBundles();
|
|
}
|
|
|
|
// Process the queue sequentially. After each install, wait briefly
|
|
// so the backend lock file is fully released before the next attempt.
|
|
// If a bundle fails, re-enqueue it for one retry.
|
|
const retried = new Set<string>();
|
|
while (true) {
|
|
const q = get().queued;
|
|
if (q.length === 0) break;
|
|
const nextId = q[0];
|
|
set({ queued: q.slice(1) });
|
|
|
|
const current = get().bundles.find((b) => b.id === nextId);
|
|
if (current?.status === "installed") continue;
|
|
|
|
await new Promise<void>((resolve) => {
|
|
completionRefs[nextId] = resolve;
|
|
get().installBundle(nextId);
|
|
});
|
|
await refreshBundles();
|
|
|
|
const after = get().bundles.find((b) => b.id === nextId);
|
|
if (after?.status !== "installed" && !retried.has(nextId)) {
|
|
retried.add(nextId);
|
|
const errors = { ...get().errors };
|
|
delete errors[nextId];
|
|
set({ queued: [...get().queued, nextId], errors });
|
|
}
|
|
|
|
// Brief pause to let the backend fully release the install lock
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
}
|
|
|
|
set({ queued: [], installAllActive: false });
|
|
},
|
|
|
|
clearError: (bundleId: string) => {
|
|
const errors = { ...get().errors };
|
|
delete errors[bundleId];
|
|
set({ errors });
|
|
},
|
|
};
|
|
});
|