mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: on-demand AI feature install with progress indicators
This commit is contained in:
@@ -83,7 +83,7 @@ export function BeforeAfterSlider({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 w-full max-w-2xl mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 w-full max-w-2xl mx-auto h-full min-h-0">
|
||||
{/* Slider container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -105,7 +105,12 @@ export function BeforeAfterSlider({
|
||||
}}
|
||||
>
|
||||
{/* Before image (full width, bottom layer) */}
|
||||
<img src={beforeSrc} alt="Original" className="block w-full h-auto" draggable={false} />
|
||||
<img
|
||||
src={beforeSrc}
|
||||
alt="Original"
|
||||
className="block w-full max-h-[70vh] object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* After image (clipped, top layer) */}
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
|
||||
export function AiInstallIndicator() {
|
||||
const { bundles, installing, queued, fetch } = useFeaturesStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetch();
|
||||
}, [fetch]);
|
||||
|
||||
const activeIds = Object.keys(installing);
|
||||
const totalPending = activeIds.length + queued.length;
|
||||
|
||||
if (totalPending === 0) return null;
|
||||
|
||||
const activeBundle = bundles.find((b) => installing[b.id]);
|
||||
const progress = activeBundle ? installing[activeBundle.id] : null;
|
||||
const completedCount = bundles.filter((b) => b.status === "installed").length;
|
||||
const totalBundles = bundles.length;
|
||||
|
||||
return (
|
||||
<IndicatorContent
|
||||
name={activeBundle?.name ?? "AI Feature"}
|
||||
percent={progress?.percent ?? 0}
|
||||
completedCount={completedCount}
|
||||
totalBundles={totalBundles}
|
||||
queuedCount={queued.length}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IndicatorContent({
|
||||
name,
|
||||
percent,
|
||||
completedCount,
|
||||
totalBundles,
|
||||
queuedCount,
|
||||
}: {
|
||||
name: string;
|
||||
percent: number;
|
||||
completedCount: number;
|
||||
totalBundles: number;
|
||||
queuedCount: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed bottom-16 right-4 z-40 bg-background border border-border rounded-xl shadow-lg px-4 py-3 min-w-[260px] max-w-[320px]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Download className="h-4 w-4 text-primary shrink-0" />
|
||||
<p className="text-sm font-medium text-foreground truncate">Installing {name}</p>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden mb-1.5">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{percent}%</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedCount}/{totalBundles} installed
|
||||
{queuedCount > 0 && ` · ${queuedCount} queued`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Dropzone } from "../common/dropzone";
|
||||
import { GemLogo } from "../common/gem-logo";
|
||||
import { HelpDialog } from "../help/help-dialog";
|
||||
import { SettingsDialog } from "../settings/settings-dialog";
|
||||
import { AiInstallIndicator } from "./ai-install-indicator";
|
||||
import { Footer } from "./footer";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { ToolPanel } from "./tool-panel";
|
||||
@@ -155,6 +156,9 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
|
||||
{/* Help dialog */}
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
|
||||
{/* Global AI install progress */}
|
||||
<AiInstallIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import type { FeatureBundleState } from "@ashim/shared";
|
||||
import { Download, Loader2, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiGet, apiPost } from "@/lib/api";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
|
||||
interface BundleProgress {
|
||||
percent: number;
|
||||
stage: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@@ -16,187 +11,90 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
export function AiFeaturesSection() {
|
||||
const { bundles, fetch, refresh } = useFeaturesStore();
|
||||
const [installing, setInstalling] = useState<Record<string, BundleProgress>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [diskUsage, setDiskUsage] = useState<number | null>(null);
|
||||
const [installAllActive, setInstallAllActive] = useState(false);
|
||||
const esRefs = useRef<Record<string, EventSource>>({});
|
||||
const pollRefs = useRef<Record<string, ReturnType<typeof setInterval>>>({});
|
||||
function formatTimeRemaining(ms: number): string {
|
||||
if (ms < 60000) return "Less than a minute left";
|
||||
const mins = Math.ceil(ms / 60000);
|
||||
if (mins === 1) return "~1 minute left";
|
||||
return `~${mins} minutes left`;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch();
|
||||
loadDiskUsage();
|
||||
return () => {
|
||||
for (const es of Object.values(esRefs.current)) es.close();
|
||||
for (const id of Object.values(pollRefs.current)) clearInterval(id);
|
||||
};
|
||||
}, [fetch]);
|
||||
const PROGRESS_MESSAGES = [
|
||||
"Almost there... probably...",
|
||||
"Good things take time...",
|
||||
"Still faster than watching paint dry...",
|
||||
"Your patience is truly inspiring...",
|
||||
"Working harder than it looks...",
|
||||
"This is the exciting part, trust me...",
|
||||
"Doing important behind-the-scenes stuff...",
|
||||
"If you're reading this, it's working...",
|
||||
"Preparing something awesome...",
|
||||
"Worth every second, pinky promise...",
|
||||
"The suspense is part of the experience...",
|
||||
"Teaching your computer new tricks...",
|
||||
"Setting up your superpowers...",
|
||||
"Your images will thank you later...",
|
||||
"Loading... but make it fancy...",
|
||||
"This would be a great time for coffee...",
|
||||
"Rome wasn't built in a day either...",
|
||||
"Shhh... genius at work...",
|
||||
"Making your photos jealous of what's coming...",
|
||||
"Assembling the dream team...",
|
||||
"Unpacking awesomeness...",
|
||||
"Almost done thinking about starting... just kidding...",
|
||||
"Plot twist: this is actually doing something...",
|
||||
"Warming up the creative engines...",
|
||||
"Imagination loading...",
|
||||
"Not a screensaver, we promise...",
|
||||
"Great art takes time to install...",
|
||||
"Your future self will thank you...",
|
||||
"Grabbing some really smart files...",
|
||||
"Hang tight, the best is yet to come...",
|
||||
];
|
||||
|
||||
export function AiFeaturesSection() {
|
||||
const {
|
||||
bundles,
|
||||
fetch,
|
||||
installing,
|
||||
errors,
|
||||
queued,
|
||||
installAllActive,
|
||||
startTimes,
|
||||
installBundle,
|
||||
uninstallBundle,
|
||||
reinstallBundle,
|
||||
installAll,
|
||||
} = useFeaturesStore();
|
||||
const [diskUsage, setDiskUsage] = useState<number | null>(null);
|
||||
|
||||
const loadDiskUsage = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiGet<{ totalBytes: number }>("/v1/admin/features/disk-usage");
|
||||
setDiskUsage(data.totalBytes);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback(
|
||||
(bundleId: string) => {
|
||||
if (pollRefs.current[bundleId]) return;
|
||||
pollRefs.current[bundleId] = setInterval(async () => {
|
||||
try {
|
||||
await refresh();
|
||||
const updated = useFeaturesStore.getState().bundles.find((b) => b.id === bundleId);
|
||||
if (!updated || updated.status !== "installing") {
|
||||
clearInterval(pollRefs.current[bundleId]);
|
||||
delete pollRefs.current[bundleId];
|
||||
setInstalling((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[bundleId];
|
||||
return next;
|
||||
});
|
||||
if (updated?.status === "error") {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[bundleId]: updated.error ?? "Installation failed",
|
||||
}));
|
||||
}
|
||||
loadDiskUsage();
|
||||
} else if (updated.progress) {
|
||||
setInstalling((prev) => ({ ...prev, [bundleId]: updated.progress! }));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
[refresh, loadDiskUsage],
|
||||
);
|
||||
useEffect(() => {
|
||||
fetch();
|
||||
loadDiskUsage();
|
||||
}, [fetch, loadDiskUsage]);
|
||||
|
||||
const listenToProgress = useCallback(
|
||||
(bundleId: string, jobId: string) => {
|
||||
const es = new EventSource(`/api/v1/jobs/${jobId}/progress`);
|
||||
esRefs.current[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.current[bundleId];
|
||||
setInstalling((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[bundleId];
|
||||
return next;
|
||||
});
|
||||
refresh();
|
||||
loadDiskUsage();
|
||||
return;
|
||||
}
|
||||
if (data.phase === "failed") {
|
||||
es.close();
|
||||
delete esRefs.current[bundleId];
|
||||
setInstalling((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[bundleId];
|
||||
return next;
|
||||
});
|
||||
setErrors((prev) => ({ ...prev, [bundleId]: data.error ?? "Installation failed" }));
|
||||
return;
|
||||
}
|
||||
setInstalling((prev) => ({
|
||||
...prev,
|
||||
[bundleId]: { percent: data.percent, stage: data.stage },
|
||||
}));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
delete esRefs.current[bundleId];
|
||||
startPolling(bundleId);
|
||||
};
|
||||
},
|
||||
[refresh, loadDiskUsage, startPolling],
|
||||
);
|
||||
|
||||
const installBundle = useCallback(
|
||||
async (bundleId: string) => {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[bundleId];
|
||||
return next;
|
||||
});
|
||||
setInstalling((prev) => ({ ...prev, [bundleId]: { percent: 0, stage: "Starting..." } }));
|
||||
|
||||
try {
|
||||
const result = await apiPost<{ jobId: string }>(`/v1/admin/features/${bundleId}/install`);
|
||||
listenToProgress(bundleId, result.jobId);
|
||||
} catch (err) {
|
||||
setInstalling((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[bundleId];
|
||||
return next;
|
||||
});
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[bundleId]: err instanceof Error ? err.message : "Failed to start installation",
|
||||
}));
|
||||
}
|
||||
},
|
||||
[listenToProgress],
|
||||
);
|
||||
|
||||
const uninstallBundle = useCallback(
|
||||
async (bundleId: string) => {
|
||||
try {
|
||||
await apiPost(`/v1/admin/features/${bundleId}/uninstall`);
|
||||
await refresh();
|
||||
const prevInstallingKeys = useRef(new Set(Object.keys(installing)));
|
||||
useEffect(() => {
|
||||
const currentKeys = new Set(Object.keys(installing));
|
||||
for (const key of prevInstallingKeys.current) {
|
||||
if (!currentKeys.has(key)) {
|
||||
loadDiskUsage();
|
||||
} catch (err) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[bundleId]: err instanceof Error ? err.message : "Uninstall failed",
|
||||
}));
|
||||
break;
|
||||
}
|
||||
},
|
||||
[refresh, loadDiskUsage],
|
||||
);
|
||||
|
||||
const handleInstallAll = useCallback(async () => {
|
||||
setInstallAllActive(true);
|
||||
const notInstalled = bundles.filter((b) => b.status === "not_installed");
|
||||
for (const bundle of notInstalled) {
|
||||
await installBundle(bundle.id);
|
||||
// Wait for this bundle to finish before starting next
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
const current = useFeaturesStore.getState().bundles.find((b) => b.id === bundle.id);
|
||||
if (!current || current.status !== "installing") {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
setInstallAllActive(false);
|
||||
}, [bundles, installBundle]);
|
||||
prevInstallingKeys.current = currentKeys;
|
||||
}, [installing, loadDiskUsage]);
|
||||
|
||||
const anyInstalling = Object.keys(installing).length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">AI Features</h3>
|
||||
@@ -206,7 +104,7 @@ export function AiFeaturesSection() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstallAll}
|
||||
onClick={installAll}
|
||||
disabled={
|
||||
anyInstalling || installAllActive || bundles.every((b) => b.status === "installed")
|
||||
}
|
||||
@@ -217,7 +115,6 @@ export function AiFeaturesSection() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bundle cards */}
|
||||
<div className="space-y-3">
|
||||
{bundles.map((bundle) => (
|
||||
<BundleCard
|
||||
@@ -227,12 +124,14 @@ export function AiFeaturesSection() {
|
||||
error={errors[bundle.id] ?? null}
|
||||
onInstall={() => installBundle(bundle.id)}
|
||||
onUninstall={() => uninstallBundle(bundle.id)}
|
||||
onReinstall={() => reinstallBundle(bundle.id)}
|
||||
isInstalling={!!installing[bundle.id]}
|
||||
isQueued={queued.includes(bundle.id)}
|
||||
startTime={startTimes[bundle.id] ?? null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Disk usage footer */}
|
||||
{diskUsage !== null && (
|
||||
<p className="text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
Disk usage: {formatBytes(diskUsage)}
|
||||
@@ -242,22 +141,56 @@ export function AiFeaturesSection() {
|
||||
);
|
||||
}
|
||||
|
||||
interface BundleProgress {
|
||||
percent: number;
|
||||
stage: string;
|
||||
}
|
||||
|
||||
function BundleCard({
|
||||
bundle,
|
||||
progress,
|
||||
error,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onReinstall,
|
||||
isInstalling,
|
||||
isQueued,
|
||||
startTime,
|
||||
}: {
|
||||
bundle: FeatureBundleState;
|
||||
progress: BundleProgress | null;
|
||||
error: string | null;
|
||||
onInstall: () => void;
|
||||
onUninstall: () => void;
|
||||
onReinstall: () => void;
|
||||
isInstalling: boolean;
|
||||
isQueued: boolean;
|
||||
startTime: number | null;
|
||||
}) {
|
||||
const status = isInstalling ? "installing" : bundle.status;
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [messageIndex, setMessageIndex] = useState(() =>
|
||||
Math.floor(Math.random() * PROGRESS_MESSAGES.length),
|
||||
);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const status = isQueued ? "queued" : isInstalling ? "installing" : bundle.status;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInstalling) return;
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % PROGRESS_MESSAGES.length);
|
||||
setNow(Date.now());
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isInstalling]);
|
||||
|
||||
const eta = (() => {
|
||||
if (!progress || !startTime || progress.percent <= 2) return null;
|
||||
const elapsed = now - startTime;
|
||||
const rate = progress.percent / elapsed;
|
||||
if (rate <= 0) return null;
|
||||
const remaining = (100 - progress.percent) / rate;
|
||||
return formatTimeRemaining(remaining);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
@@ -269,7 +202,6 @@ function BundleCard({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 ml-4">
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{status === "installed" && (
|
||||
<>
|
||||
@@ -283,6 +215,12 @@ function BundleCard({
|
||||
<span className="text-xs text-muted-foreground">Not installed</span>
|
||||
</>
|
||||
)}
|
||||
{status === "queued" && (
|
||||
<>
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Queued</span>
|
||||
</>
|
||||
)}
|
||||
{status === "installing" && progress && (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
@@ -299,7 +237,6 @@ function BundleCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{status === "not_installed" && !error && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -310,15 +247,47 @@ function BundleCard({
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
{status === "installed" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUninstall}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Uninstall
|
||||
</button>
|
||||
{status === "installed" && !confirming && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReinstall}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Repair
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "installed" && confirming && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
onUninstall();
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "installing" && (
|
||||
<button
|
||||
@@ -330,7 +299,7 @@ function BundleCard({
|
||||
Installing...
|
||||
</button>
|
||||
)}
|
||||
{(status === "error" || error) && !isInstalling && (
|
||||
{(status === "error" || error) && !isInstalling && !isQueued && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInstall}
|
||||
@@ -342,6 +311,22 @@ function BundleCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{status === "installing" && progress && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{PROGRESS_MESSAGES[messageIndex]}
|
||||
</p>
|
||||
{eta && <p className="text-xs text-muted-foreground shrink-0 ml-2">{eta}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,242 @@
|
||||
import type { FeatureBundleState } from "@ashim/shared";
|
||||
import { TOOL_BUNDLE_MAP } from "@ashim/shared";
|
||||
import { create } from "zustand";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { apiGet, apiPost } from "@/lib/api";
|
||||
|
||||
interface BundleProgress {
|
||||
percent: number;
|
||||
stage: string;
|
||||
}
|
||||
|
||||
interface FeaturesState {
|
||||
bundles: FeatureBundleState[];
|
||||
loaded: 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) => ({
|
||||
bundles: [],
|
||||
loaded: false,
|
||||
export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
||||
const esRefs: Record<string, EventSource> = {};
|
||||
const pollRefs: Record<string, ReturnType<typeof setInterval>> = {};
|
||||
const completionRefs: Record<string, () => void> = {};
|
||||
|
||||
fetch: async () => {
|
||||
if (get().loaded) return;
|
||||
try {
|
||||
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
||||
set({ bundles: data.bundles, loaded: true });
|
||||
} catch {
|
||||
set({ loaded: true });
|
||||
const resolveCompletion = (bundleId: string) => {
|
||||
if (completionRefs[bundleId]) {
|
||||
completionRefs[bundleId]();
|
||||
delete completionRefs[bundleId];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
refresh: async () => {
|
||||
const refreshBundles = async () => {
|
||||
try {
|
||||
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
||||
set({ bundles: data.bundles, loaded: true });
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
|
||||
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";
|
||||
},
|
||||
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 || updated.status !== "installing") {
|
||||
clearInterval(pollRefs[bundleId]);
|
||||
delete pollRefs[bundleId];
|
||||
|
||||
getBundleForTool: (toolId: string) => {
|
||||
const bundleId = TOOL_BUNDLE_MAP[toolId];
|
||||
if (!bundleId) return null;
|
||||
return get().bundles.find((b) => b.id === bundleId) ?? null;
|
||||
},
|
||||
}));
|
||||
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) {
|
||||
set({ installing: { ...get().installing, [bundleId]: updated.progress } });
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
set({
|
||||
installing: {
|
||||
...get().installing,
|
||||
[bundleId]: { percent: data.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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
bundles: [],
|
||||
loaded: false,
|
||||
installing: {},
|
||||
errors: {},
|
||||
queued: [],
|
||||
installAllActive: false,
|
||||
startTimes: {},
|
||||
|
||||
fetch: async () => {
|
||||
if (get().loaded) return;
|
||||
try {
|
||||
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
||||
set({ bundles: data.bundles, loaded: true });
|
||||
recoverActiveInstalls();
|
||||
} catch {
|
||||
set({ loaded: 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 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];
|
||||
set({
|
||||
installing,
|
||||
errors: {
|
||||
...get().errors,
|
||||
[bundleId]: err instanceof Error ? err.message : "Failed to start installation",
|
||||
},
|
||||
});
|
||||
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 });
|
||||
const notInstalled = get().bundles.filter((b) => b.status === "not_installed");
|
||||
set({ queued: notInstalled.map((b) => b.id) });
|
||||
|
||||
for (const bundle of notInstalled) {
|
||||
set({ queued: get().queued.filter((id) => id !== bundle.id) });
|
||||
await new Promise<void>((resolve) => {
|
||||
completionRefs[bundle.id] = resolve;
|
||||
get().installBundle(bundle.id);
|
||||
});
|
||||
}
|
||||
|
||||
set({ queued: [], installAllActive: false });
|
||||
},
|
||||
|
||||
clearError: (bundleId: string) => {
|
||||
const errors = { ...get().errors };
|
||||
delete errors[bundleId];
|
||||
set({ errors });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user