From 62dd1ae21102b5be185fd6204253d49f74fa8d04 Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Sun, 19 Apr 2026 21:00:19 +0800 Subject: [PATCH] refactor: unify feature install UI with global store, add healthcheck and GPU profile to compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace FeatureInstallPrompt's local SSE/polling with useFeaturesStore so install progress, errors, and recovery are handled globally — works across navigation, logout/login, and partial downloads. Shows fun progress messages and ETA matching the settings page. Add compose healthcheck and optional GPU profile (--profile gpu). --- .../features/feature-install-prompt.tsx | 199 +++++++----------- docker/docker-compose.yml | 22 +- 2 files changed, 101 insertions(+), 120 deletions(-) diff --git a/apps/web/src/components/features/feature-install-prompt.tsx b/apps/web/src/components/features/feature-install-prompt.tsx index 2591e791..96648985 100644 --- a/apps/web/src/components/features/feature-install-prompt.tsx +++ b/apps/web/src/components/features/feature-install-prompt.tsx @@ -1,125 +1,88 @@ import type { FeatureBundleState } from "@ashim/shared"; import { AlertCircle, Download, Loader2, RotateCcw } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { apiPost } from "@/lib/api"; +import { useEffect, useState } from "react"; import { useFeaturesStore } from "@/stores/features-store"; +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...", +]; + +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`; +} + interface FeatureInstallPromptProps { bundle: FeatureBundleState; isAdmin: boolean; } -interface ProgressState { - percent: number; - stage: string; -} - export function FeatureInstallPrompt({ bundle, isAdmin }: FeatureInstallPromptProps) { - const [installing, setInstalling] = useState(false); - const [progress, setProgress] = useState(null); - const [error, setError] = useState(null); - const eventSourceRef = useRef(null); - const pollingRef = useRef | null>(null); - const refresh = useFeaturesStore((s) => s.refresh); + const { installBundle, clearError, installing, errors, startTimes } = useFeaturesStore(); + const progress = installing[bundle.id] ?? null; + const error = errors[bundle.id] ?? null; + const isInstalling = !!progress; + const startTime = startTimes[bundle.id] ?? null; + + const [messageIndex, setMessageIndex] = useState(() => + Math.floor(Math.random() * PROGRESS_MESSAGES.length), + ); + const [now, setNow] = useState(Date.now()); - // If bundle is already installing on mount, show progress state immediately useEffect(() => { - if (bundle.status === "installing") { - setInstalling(true); - setProgress(bundle.progress ?? { percent: 0, stage: "Installing..." }); - } - }, [bundle.status, bundle.progress]); - - // Cleanup on unmount - useEffect(() => { - return () => { - eventSourceRef.current?.close(); - if (pollingRef.current) clearInterval(pollingRef.current); - }; - }, []); - - function startPollingFallback() { - if (pollingRef.current) return; - pollingRef.current = setInterval(async () => { - try { - await refresh(); - const updated = useFeaturesStore.getState().bundles.find((b) => b.id === bundle.id); - if (!updated || updated.status !== "installing") { - if (pollingRef.current) clearInterval(pollingRef.current); - pollingRef.current = null; - setInstalling(false); - if (updated?.status === "error") { - setError(updated.error ?? "Installation failed"); - } else { - setProgress(null); - } - } else if (updated.progress) { - setProgress(updated.progress); - } - } catch { - // ignore polling errors - } + 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); + })(); + + function handleInstall() { + clearError(bundle.id); + installBundle(bundle.id); } - function listenToProgress(jobId: string) { - const es = new EventSource(`/api/v1/jobs/${jobId}/progress`); - eventSourceRef.current = 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(); - eventSourceRef.current = null; - setInstalling(false); - setProgress(null); - refresh(); - return; - } - - if (data.phase === "failed") { - es.close(); - eventSourceRef.current = null; - setInstalling(false); - setError(data.error ?? "Installation failed"); - return; - } - - setProgress({ percent: data.percent, stage: data.stage }); - } catch { - // ignore malformed messages - } - }; - - es.onerror = () => { - es.close(); - eventSourceRef.current = null; - startPollingFallback(); - }; - } - - async function handleInstall() { - setInstalling(true); - setError(null); - setProgress({ percent: 0, stage: "Starting installation..." }); - - try { - const result = await apiPost<{ jobId: string }>(`/v1/admin/features/${bundle.id}/install`); - listenToProgress(result.jobId); - } catch (err) { - setInstalling(false); - setError(err instanceof Error ? err.message : "Failed to start installation"); - } - } - - // Non-admin: show "not enabled" message if (!isAdmin) { return (
@@ -132,7 +95,6 @@ export function FeatureInstallPrompt({ bundle, isAdmin }: FeatureInstallPromptPr ); } - // Admin: show install prompt return (
@@ -144,7 +106,6 @@ export function FeatureInstallPrompt({ bundle, isAdmin }: FeatureInstallPromptPr

- {/* Error banner */} {error && (
@@ -160,29 +121,29 @@ export function FeatureInstallPrompt({ bundle, isAdmin }: FeatureInstallPromptPr
)} - {/* Progress bar */} - {installing && progress && ( + {isInstalling && progress && (
-
- - {progress.stage} +
+
+ + {PROGRESS_MESSAGES[messageIndex]} +
+ {eta &&

{eta}

}
)} - {/* Install button (hidden when installing or showing error) */} - {!installing && !error && ( + {!isInstalling && !error && ( diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b4b30e67..92fe4fa7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: .. dockerfile: docker/Dockerfile - image: ashimhq/ashim:latest + image: ashim:latest container_name: ashim ports: - "1349:1349" @@ -15,12 +15,32 @@ services: - DEFAULT_USERNAME=admin - DEFAULT_PASSWORD=admin restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"] + interval: 30s + timeout: 5s + start_period: 60s + retries: 3 logging: driver: json-file options: max-size: "10m" max-file: "3" + ashim-gpu: + extends: + service: ashim + container_name: ashim-gpu + profiles: + - gpu + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + volumes: ashim-data: ashim-workspace: