"use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import type { StoreOperation, StoreOperationAction, StoreOperationEvent } from "@/lib/shared/contracts/apps"; import { queryKeys } from "@/lib/shared/query-keys"; import { fetchStoreOperationSnapshot, subscribeToStoreOperationEvents, } from "@/modules/apps/hooks/useStoreOperation"; type StartOperationResponse = { operationId: string; }; type StartCustomOperationResponse = { appId: string; operationId: string; }; type AppOperationState = { operationId: string; appId: string; action: StoreOperationAction; status: StoreOperation["status"]; progressPercent: number; step: string; message: string | null; }; type StartActionResult = { appId: string; operationId: string; action: StoreOperationAction; }; type InstallAppInput = { appId: string; displayName?: string; env?: Record; webUiPort?: number; composeSource?: string; resetToCatalog?: boolean; }; type InstallCustomAppInput = { name: string; iconUrl?: string; repositoryUrl?: string; sourceType: "docker-compose" | "docker-run"; source: string; }; type SaveAppSettingsInput = { appId: string; displayName?: string; iconUrl?: string | null; env?: Record; webUiPort?: number; webUiUrl?: string | null; composeSource?: string; }; type StoreActionsHandle = { operationsByApp: Record; installApp: (input: InstallAppInput) => Promise; installCustomApp: (input: InstallCustomAppInput) => Promise; updateApp: (input: { appId: string }) => Promise; redeployApp: (input: { appId: string; env?: Record; webUiPort?: number; composeSource?: string; }) => Promise; uninstallApp: (input: { appId: string; removeVolumes?: boolean }) => Promise; saveAppSettings: (input: SaveAppSettingsInput) => Promise; startApp: (appId: string) => Promise; stopApp: (appId: string) => Promise; restartApp: (appId: string) => Promise; checkAppUpdates: (appId: string) => Promise; }; type ErrorResponsePayload = { error?: string; code?: string; }; const OPERATION_SNAPSHOT_POLL_MS = 1_500; const MISSING_OPERATION_SNAPSHOT_LIMIT = 3; // How long to keep a successful operation in state after it completes, // so queries have time to refetch before the "Install" button reappears. const OPERATION_SUCCESS_LINGER_MS = 2_500; // How long to keep a failed operation visible before auto-clearing. const OPERATION_ERROR_LINGER_MS = 8_000; function isTerminalStatus(status: StoreOperation["status"]) { return status === "success" || status === "error"; } function mapOperationToState(operation: StoreOperation): AppOperationState { return { operationId: operation.id, appId: operation.appId, action: operation.action, status: operation.status, progressPercent: operation.progressPercent, step: operation.currentStep, message: operation.errorMessage, }; } function mapEventToState(event: StoreOperationEvent): AppOperationState { return { operationId: event.operationId, appId: event.appId, action: event.action, status: event.status, progressPercent: event.progressPercent, step: event.step, message: event.message ?? null, }; } function toStoreActionErrorMessage( endpoint: string, status: number, payload: ErrorResponsePayload | null, ) { const baseMessage = payload?.error?.trim() || `Failed request (${status}) for ${endpoint}`; const code = payload?.code?.trim(); if (!code) return baseMessage; return `${baseMessage} [${code}]`; } async function parseErrorPayload(response: Response) { try { return (await response.json()) as ErrorResponsePayload; } catch { return null; } } async function startLifecycleRequest( endpoint: string, payload: Record, action: string, ) { void action; const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payload), }); if (!response.ok) { const errorPayload = await parseErrorPayload(response); throw new Error( toStoreActionErrorMessage(endpoint, response.status, errorPayload), ); } return (await response.json()) as StartOperationResponse; } export function useStoreActions(): StoreActionsHandle { const queryClient = useQueryClient(); const [operationsByApp, setOperationsByApp] = useState>({}); const streamCleanups = useRef void>>(new Map()); const pollCleanups = useRef void>>(new Map()); const clearTrackedOperation = useCallback((appId: string, operationId: string) => { setOperationsByApp((previous) => { const current = previous[appId]; if (!current || current.operationId !== operationId) { return previous; } const next = { ...previous }; delete next[appId]; return next; }); }, []); useEffect(() => { const activeStreams = streamCleanups.current; const activePolls = pollCleanups.current; return () => { for (const cleanup of activeStreams.values()) { cleanup(); } activeStreams.clear(); for (const cleanup of activePolls.values()) { cleanup(); } activePolls.clear(); }; }, []); const attachOperationTracking = useCallback( async ({ appId, operationId, action }: StartActionResult) => { const existingStreamCleanup = streamCleanups.current.get(operationId); if (existingStreamCleanup) { existingStreamCleanup(); streamCleanups.current.delete(operationId); } const existingPollCleanup = pollCleanups.current.get(operationId); if (existingPollCleanup) { existingPollCleanup(); pollCleanups.current.delete(operationId); } setOperationsByApp((previous) => ({ ...previous, [appId]: { operationId, appId, action, status: "queued", progressPercent: 0, step: "queued", message: null, }, })); let streamCleanup: (() => void) | null = null; let pollTimer: ReturnType | null = null; let stopped = false; let missingSnapshotCount = 0; const clearPollTimer = () => { if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null; } }; const stopTracking = () => { if (stopped) return; stopped = true; clearPollTimer(); pollCleanups.current.delete(operationId); if (streamCleanup) { streamCleanup(); streamCleanup = null; } streamCleanups.current.delete(operationId); }; const invalidateTerminalState = (terminalAppId: string, terminalOperationId: string) => { void Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.storeCatalog }), queryClient.invalidateQueries({ queryKey: queryKeys.installedApps }), queryClient.invalidateQueries({ queryKey: queryKeys.storeApp(terminalAppId) }), queryClient.invalidateQueries({ queryKey: queryKeys.appCompose(terminalAppId, "installed"), }), queryClient.invalidateQueries({ queryKey: queryKeys.appCompose(terminalAppId, "catalog"), }), queryClient.invalidateQueries({ queryKey: queryKeys.storeOperation(terminalOperationId), }), ]); }; const applySnapshotState = (snapshot: StoreOperation) => { if (stopped) return; missingSnapshotCount = 0; const nextState = mapOperationToState(snapshot); setOperationsByApp((previous) => ({ ...previous, [snapshot.appId]: nextState, })); queryClient.setQueryData(queryKeys.storeOperation(snapshot.id), snapshot); if (isTerminalStatus(snapshot.status)) { stopTracking(); invalidateTerminalState(snapshot.appId, snapshot.id); const lingerMs = snapshot.status === "success" ? OPERATION_SUCCESS_LINGER_MS : OPERATION_ERROR_LINGER_MS; setTimeout(() => { clearTrackedOperation(snapshot.appId, snapshot.id); }, lingerMs); } }; const scheduleSnapshotPoll = () => { if (stopped) return; clearPollTimer(); pollTimer = setTimeout(() => { void syncSnapshot(); }, OPERATION_SNAPSHOT_POLL_MS); }; const syncSnapshot = async () => { if (stopped) return; try { const snapshot = await fetchStoreOperationSnapshot(operationId); if (snapshot) { applySnapshotState(snapshot); } else { missingSnapshotCount += 1; if (missingSnapshotCount >= MISSING_OPERATION_SNAPSHOT_LIMIT) { stopTracking(); void Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.storeCatalog }), queryClient.invalidateQueries({ queryKey: queryKeys.installedApps }), queryClient.invalidateQueries({ queryKey: queryKeys.storeApp(appId) }), ]); setTimeout(() => { clearTrackedOperation(appId, operationId); }, OPERATION_SUCCESS_LINGER_MS); } } } catch { // Keep stream active; we'll retry on next poll. } finally { if (!stopped) { scheduleSnapshotPoll(); } } }; pollCleanups.current.set(operationId, () => { clearPollTimer(); }); try { await syncSnapshot(); } catch { // Keep optimistic queued state and continue with SSE subscription. } if (stopped) { return; } streamCleanup = subscribeToStoreOperationEvents(operationId, { onEvent: (event) => { if (stopped) return; setOperationsByApp((previous) => ({ ...previous, [event.appId]: mapEventToState(event), })); queryClient.setQueryData( queryKeys.storeOperation(event.operationId), (previous: StoreOperation | null | undefined) => previous ? { ...previous, status: event.status, progressPercent: event.progressPercent, currentStep: event.step, errorMessage: event.status === "error" ? (event.message ?? "Operation failed") : null, updatedAt: event.timestamp, } : null, ); if (isTerminalStatus(event.status)) { stopTracking(); invalidateTerminalState(event.appId, event.operationId); const lingerMs = event.status === "success" ? OPERATION_SUCCESS_LINGER_MS : OPERATION_ERROR_LINGER_MS; setTimeout(() => { clearTrackedOperation(event.appId, event.operationId); }, lingerMs); } }, onError: () => { if (stopped) return; void syncSnapshot(); }, }); streamCleanups.current.set(operationId, () => { stopTracking(); }); }, [clearTrackedOperation, queryClient], ); const installMutation = useMutation({ mutationFn: async (input: InstallAppInput) => { const response = await startLifecycleRequest( `/api/v1/store/apps/${encodeURIComponent(input.appId)}/install`, { displayName: input.displayName, env: input.env, webUiPort: input.webUiPort, composeSource: input.composeSource, resetToCatalog: input.resetToCatalog, }, "hooks.useStoreActions.install", ); return { appId: input.appId, operationId: response.operationId, action: "install" as const, }; }, onSuccess: ({ appId, operationId, action }) => { void attachOperationTracking({ appId, operationId, action, }); }, }); const redeployMutation = useMutation({ mutationFn: async (input: { appId: string; env?: Record; webUiPort?: number; composeSource?: string; }) => { const response = await startLifecycleRequest( `/api/v1/store/apps/${encodeURIComponent(input.appId)}/redeploy`, { env: input.env, webUiPort: input.webUiPort, composeSource: input.composeSource, }, "hooks.useStoreActions.redeploy", ); return { appId: input.appId, operationId: response.operationId, action: "redeploy" as const, }; }, onSuccess: ({ appId, operationId, action }) => { void attachOperationTracking({ appId, operationId, action, }); }, }); const updateMutation = useMutation({ mutationFn: async (input: { appId: string; }) => { const response = await startLifecycleRequest( `/api/v1/store/apps/${encodeURIComponent(input.appId)}/update`, {}, "hooks.useStoreActions.update", ); return { appId: input.appId, operationId: response.operationId, action: "update" as const, }; }, onSuccess: ({ appId, operationId, action }) => { void attachOperationTracking({ appId, operationId, action, }); }, }); const uninstallMutation = useMutation({ mutationFn: async (input: { appId: string; removeVolumes?: boolean; }) => { const response = await startLifecycleRequest( `/api/v1/store/apps/${encodeURIComponent(input.appId)}/uninstall`, { removeVolumes: input.removeVolumes, }, "hooks.useStoreActions.uninstall", ); return { appId: input.appId, operationId: response.operationId, action: "uninstall" as const, }; }, onSuccess: ({ appId, operationId, action }) => { void attachOperationTracking({ appId, operationId, action, }); }, }); const installCustomMutation = useMutation({ mutationFn: async (input: { name: string; iconUrl?: string; repositoryUrl?: string; sourceType: "docker-compose" | "docker-run"; source: string; }) => { const result = await fetch("/api/v1/store/custom-apps/install", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(input), }); if (!result.ok) { const errorPayload = await parseErrorPayload(result); throw new Error( toStoreActionErrorMessage( "/api/v1/store/custom-apps/install", result.status, errorPayload, ), ); } return (await result.json()) as StartCustomOperationResponse; }, onSuccess: ({ appId, operationId }) => { void queryClient.invalidateQueries({ queryKey: queryKeys.storeCatalog }); void queryClient.invalidateQueries({ queryKey: queryKeys.storeApp(appId) }); void attachOperationTracking({ appId, operationId, action: "install", }); }, }); const saveSettingsMutation = useMutation({ mutationFn: async (input: { appId: string; displayName?: string; iconUrl?: string | null; env?: Record; webUiPort?: number; webUiUrl?: string | null; composeSource?: string; }) => { const result = await fetch( `/api/v1/store/apps/${encodeURIComponent(input.appId)}/settings`, { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ displayName: input.displayName, iconUrl: input.iconUrl, env: input.env, webUiPort: input.webUiPort, webUiUrl: input.webUiUrl, composeSource: input.composeSource, }), }, ); if (!result.ok) { const errorPayload = await parseErrorPayload(result); throw new Error( toStoreActionErrorMessage( `/api/v1/store/apps/${input.appId}/settings`, result.status, errorPayload, ), ); } const response = (await result.json()) as { saved: boolean; operationId?: string; }; return { appId: input.appId, ...response }; }, onSuccess: ({ appId, operationId }) => { void queryClient.invalidateQueries({ queryKey: queryKeys.storeCatalog }); void queryClient.invalidateQueries({ queryKey: queryKeys.installedApps }); void queryClient.invalidateQueries({ queryKey: queryKeys.storeApp(appId) }); void queryClient.invalidateQueries({ queryKey: queryKeys.appCompose(appId, "installed"), }); if (operationId) { void attachOperationTracking({ appId, operationId, action: "redeploy", }); } }, }); return { operationsByApp, installApp: installMutation.mutateAsync, installCustomApp: installCustomMutation.mutateAsync, updateApp: updateMutation.mutateAsync, redeployApp: redeployMutation.mutateAsync, startApp: async (appId: string) => { const response = await startLifecycleRequest( `/api/v1/apps/${encodeURIComponent(appId)}/start`, {}, "hooks.useStoreActions.start", ); const payload = { appId, operationId: response.operationId, action: "start" as const, }; await attachOperationTracking(payload); return payload; }, stopApp: async (appId: string) => { const response = await startLifecycleRequest( `/api/v1/apps/${encodeURIComponent(appId)}/stop`, {}, "hooks.useStoreActions.stop", ); const payload = { appId, operationId: response.operationId, action: "stop" as const, }; await attachOperationTracking(payload); return payload; }, restartApp: async (appId: string) => { const response = await startLifecycleRequest( `/api/v1/apps/${encodeURIComponent(appId)}/restart`, {}, "hooks.useStoreActions.restart", ); const payload = { appId, operationId: response.operationId, action: "restart" as const, }; await attachOperationTracking(payload); return payload; }, checkAppUpdates: async (appId: string) => { const response = await startLifecycleRequest( `/api/v1/apps/${encodeURIComponent(appId)}/check-updates`, {}, "hooks.useStoreActions.checkUpdates", ); const payload = { appId, operationId: response.operationId, action: "check-updates" as const, }; await attachOperationTracking(payload); return payload; }, uninstallApp: uninstallMutation.mutateAsync, saveAppSettings: saveSettingsMutation.mutateAsync, }; } export type { AppOperationState }; export type { InstallAppInput, InstallCustomAppInput, SaveAppSettingsInput, StoreActionsHandle, };