IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

useStoreActions.ts

Open raw
"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<string, string>;
  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<string, string>;
  webUiPort?: number;
  webUiUrl?: string | null;
  composeSource?: string;
};

type StoreActionsHandle = {
  operationsByApp: Record<string, AppOperationState>;
  installApp: (input: InstallAppInput) => Promise<unknown>;
  installCustomApp: (input: InstallCustomAppInput) => Promise<unknown>;
  updateApp: (input: { appId: string }) => Promise<unknown>;
  redeployApp: (input: {
    appId: string;
    env?: Record<string, string>;
    webUiPort?: number;
    composeSource?: string;
  }) => Promise<unknown>;
  uninstallApp: (input: { appId: string; removeVolumes?: boolean }) => Promise<unknown>;
  saveAppSettings: (input: SaveAppSettingsInput) => Promise<unknown>;
  startApp: (appId: string) => Promise<unknown>;
  stopApp: (appId: string) => Promise<unknown>;
  restartApp: (appId: string) => Promise<unknown>;
  checkAppUpdates: (appId: string) => Promise<unknown>;
};

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<string, unknown>,
  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<Record<string, AppOperationState>>({});
  const streamCleanups = useRef<Map<string, () => void>>(new Map());
  const pollCleanups = useRef<Map<string, () => 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<typeof setTimeout> | 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<string, string>;
      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<string, string>;
      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,
};
bytes
19,731
lines
691
Type
ts
Latest commit
16 October 2026