diff --git a/__tests__/components/automations/backend-not-configured.test.tsx b/__tests__/components/automations/backend-not-configured.test.tsx new file mode 100644 index 0000000000..2259805c1e --- /dev/null +++ b/__tests__/components/automations/backend-not-configured.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { BackendUnavailable } from "#/components/features/automations/backend-not-configured"; +import { I18nKey } from "#/i18n/declaration"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_TITLE]: + "Automations Unavailable", + [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_MESSAGE]: + "The automations backend is not available right now.", + [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_RETRY]: "Retry", + }; + return translations[key] || key; + }, + }), +})); + +describe("BackendUnavailable", () => { + it("renders the unavailable message", () => { + const onRetry = vi.fn(); + render(); + + expect(screen.getByText("Automations Unavailable")).toBeInTheDocument(); + expect( + screen.getByText("The automations backend is not available right now."), + ).toBeInTheDocument(); + }); + + it("displays the retry button", () => { + const onRetry = vi.fn(); + render(); + + const retryButton = screen.getByRole("button", { name: "Retry" }); + expect(retryButton).toBeInTheDocument(); + }); + + it("calls onRetry when retry button is clicked", () => { + const onRetry = vi.fn(); + render(); + + const retryButton = screen.getByRole("button", { name: "Retry" }); + fireEvent.click(retryButton); + + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/hooks/query/use-automation-health.test.tsx b/__tests__/hooks/query/use-automation-health.test.tsx new file mode 100644 index 0000000000..ba712dbeec --- /dev/null +++ b/__tests__/hooks/query/use-automation-health.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { useAutomationHealth } from "#/hooks/query/use-automation-health"; +import AutomationService from "#/api/automation-service/automation-service.api"; + +vi.mock("#/api/automation-service/automation-service.api", () => ({ + default: { + checkHealth: vi.fn(), + }, +})); + +vi.mock("#/contexts/active-backend-context", () => ({ + useActiveBackend: () => ({ + backend: { id: "test-backend", kind: "local" }, + orgId: null, + }), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe("useAutomationHealth", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return healthy status when backend is available", async () => { + vi.mocked(AutomationService.checkHealth).mockResolvedValue({ status: "ok" }); + + const { result } = renderHook(() => useAutomationHealth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.status).toBe("ok"); + }); + + it("should return error status when backend is not available", async () => { + vi.mocked(AutomationService.checkHealth).mockResolvedValue({ + status: "error", + message: "Automation backend is not available", + }); + + const { result } = renderHook(() => useAutomationHealth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.status).toBe("error"); + expect(result.current.data?.message).toBe( + "Automation backend is not available", + ); + }); +}); diff --git a/__tests__/hooks/query/use-automations-backend-switch.test.tsx b/__tests__/hooks/query/use-automations-backend-switch.test.tsx index 2c44725dad..708c35bcee 100644 --- a/__tests__/hooks/query/use-automations-backend-switch.test.tsx +++ b/__tests__/hooks/query/use-automations-backend-switch.test.tsx @@ -90,9 +90,10 @@ afterEach(() => { describe("automation hooks — backend switch", () => { it("useAutomations refetches when the active backend changes", async () => { // Arrange — mount under the local backend; capture the initial fetch. - const { result } = renderHook(() => useAutomations(50, 0), { - wrapper: makeWrapper(), - }); + const { result } = renderHook( + () => useAutomations({ limit: 50, offset: 0 }), + { wrapper: makeWrapper() }, + ); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(AutomationService.getAutomations).toHaveBeenCalledTimes(1); @@ -108,9 +109,10 @@ describe("automation hooks — backend switch", () => { }); it("useAutomationDetail refetches when the active backend changes", async () => { - const { result } = renderHook(() => useAutomationDetail("auto-1"), { - wrapper: makeWrapper(), - }); + const { result } = renderHook( + () => useAutomationDetail({ id: "auto-1" }), + { wrapper: makeWrapper() }, + ); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(AutomationService.getAutomation).toHaveBeenCalledTimes(1); diff --git a/__tests__/routes/automation-detail.test.tsx b/__tests__/routes/automation-detail.test.tsx index d9834f173d..3e4ad412e7 100644 --- a/__tests__/routes/automation-detail.test.tsx +++ b/__tests__/routes/automation-detail.test.tsx @@ -24,6 +24,7 @@ vi.mock("#/api/automation-service/automation-service.api", () => ({ getAutomationRuns: vi.fn(), toggleAutomation: vi.fn(), deleteAutomation: vi.fn(), + checkHealth: vi.fn(), }, })); @@ -80,6 +81,8 @@ function renderDetail() { beforeEach(() => { window.localStorage.clear(); __resetActiveStoreForTests(); + vi.mocked(AutomationService.checkHealth).mockReset(); + vi.mocked(AutomationService.checkHealth).mockResolvedValue({ status: "ok" }); vi.mocked(AutomationService.getAutomation).mockReset(); vi.mocked(AutomationService.getAutomation).mockResolvedValue(automation); vi.mocked(AutomationService.getAutomationRuns).mockReset(); diff --git a/src/api/automation-service/automation-service.api.ts b/src/api/automation-service/automation-service.api.ts index 42cb231fdd..75adaba858 100644 --- a/src/api/automation-service/automation-service.api.ts +++ b/src/api/automation-service/automation-service.api.ts @@ -10,6 +10,11 @@ import { callCloudProxy } from "../cloud/proxy"; const AUTOMATION_BASE_PATH = "/api/automation"; +export interface AutomationHealthResponse { + status: "ok" | "error"; + message?: string; +} + // Local automation calls go to the automation sidecar that // `scripts/dev-with-automation.mjs` mounts behind the bundled agent-server. // That sidecar authenticates via its own `VITE_AUTOMATION_API_KEY` Bearer @@ -152,6 +157,30 @@ class AutomationService { ): Promise { return AutomationService.updateAutomation(id, { enabled }); } + + static async checkHealth(): Promise { + const active = getActiveBackend().backend; + const path = `${AUTOMATION_BASE_PATH}/health`; + + try { + if (active.kind === "cloud") { + const response = await callCloudProxy({ + backend: active, + method: "GET", + path, + }); + return response; + } + + const { data } = await localAutomationAxios.get( + path, + { timeout: 5000 }, + ); + return data; + } catch { + return { status: "error" }; + } + } } export default AutomationService; diff --git a/src/components/features/automations/backend-not-configured.tsx b/src/components/features/automations/backend-not-configured.tsx new file mode 100644 index 0000000000..a8bd46090e --- /dev/null +++ b/src/components/features/automations/backend-not-configured.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import ExclamationCircleIcon from "#/icons/exclamation-circle.svg?react"; + +interface BackendUnavailableProps { + onRetry: () => void; +} + +export function BackendUnavailable({ onRetry }: BackendUnavailableProps) { + const { t } = useTranslation("openhands"); + + return ( +
+ +

+ {t(I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_TITLE)} +

+

+ {t(I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_MESSAGE)} +

+ +
+ ); +} + +// Keep old export name for backward compatibility +export { BackendUnavailable as BackendNotConfigured }; diff --git a/src/components/features/automations/detail/activity-log-section.tsx b/src/components/features/automations/detail/activity-log-section.tsx index 1c9bc0abe2..f0b3f69931 100644 --- a/src/components/features/automations/detail/activity-log-section.tsx +++ b/src/components/features/automations/detail/activity-log-section.tsx @@ -14,7 +14,11 @@ const PAGE_SIZE = 20; export function ActivityLogSection({ automationId }: ActivityLogSectionProps) { const { t } = useTranslation("openhands"); const [limit, setLimit] = useState(PAGE_SIZE); - const { data, isLoading } = useAutomationRuns(automationId, limit, 0); + const { data, isLoading } = useAutomationRuns({ + id: automationId, + limit, + offset: 0, + }); const hasMore = data ? data.total > data.runs.length : false; diff --git a/src/hooks/query/use-automation-detail.ts b/src/hooks/query/use-automation-detail.ts index c41519ca1f..8ff1d8ff5d 100644 --- a/src/hooks/query/use-automation-detail.ts +++ b/src/hooks/query/use-automation-detail.ts @@ -5,7 +5,13 @@ import { useActiveBackend } from "#/contexts/active-backend-context"; export const AUTOMATION_DETAIL_QUERY_KEY = ["automation-detail"] as const; export const AUTOMATION_RUNS_QUERY_KEY = ["automation-runs"] as const; -export function useAutomationDetail(id: string) { +interface UseAutomationDetailOptions { + id: string; + enabled?: boolean; +} + +export function useAutomationDetail(options: UseAutomationDetailOptions) { + const { id, enabled = true } = options; const active = useActiveBackend(); return useQuery({ queryKey: [ @@ -16,11 +22,19 @@ export function useAutomationDetail(id: string) { ], queryFn: () => AutomationService.getAutomation(id), staleTime: 5 * 60 * 1000, - enabled: !!id, + enabled: !!id && enabled, }); } -export function useAutomationRuns(id: string, limit = 20, offset = 0) { +interface UseAutomationRunsOptions { + id: string; + limit?: number; + offset?: number; + enabled?: boolean; +} + +export function useAutomationRuns(options: UseAutomationRunsOptions) { + const { id, limit = 20, offset = 0, enabled = true } = options; const active = useActiveBackend(); return useQuery({ queryKey: [ @@ -32,6 +46,6 @@ export function useAutomationRuns(id: string, limit = 20, offset = 0) { ], queryFn: () => AutomationService.getAutomationRuns(id, limit, offset), staleTime: 60 * 1000, - enabled: !!id, + enabled: !!id && enabled, }); } diff --git a/src/hooks/query/use-automation-health.ts b/src/hooks/query/use-automation-health.ts new file mode 100644 index 0000000000..c81065ba9d --- /dev/null +++ b/src/hooks/query/use-automation-health.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import AutomationService from "#/api/automation-service/automation-service.api"; +import { useActiveBackend } from "#/contexts/active-backend-context"; + +export const AUTOMATION_HEALTH_QUERY_KEY = ["automation-health"] as const; + +export function useAutomationHealth() { + const active = useActiveBackend(); + return useQuery({ + queryKey: [...AUTOMATION_HEALTH_QUERY_KEY, active.backend.id, active.orgId], + queryFn: () => AutomationService.checkHealth(), + staleTime: 30 * 1000, // 30 seconds + retry: false, // Don't retry on failure - we want to show the error state immediately + }); +} diff --git a/src/hooks/query/use-automations.ts b/src/hooks/query/use-automations.ts index 7bbd3ecc02..43acbffb4f 100644 --- a/src/hooks/query/use-automations.ts +++ b/src/hooks/query/use-automations.ts @@ -5,7 +5,14 @@ import { AUTOMATION_DETAIL_QUERY_KEY } from "./use-automation-detail"; export const AUTOMATIONS_QUERY_KEY = ["automations"] as const; -export function useAutomations(limit = 50, offset = 0) { +interface UseAutomationsOptions { + limit?: number; + offset?: number; + enabled?: boolean; +} + +export function useAutomations(options: UseAutomationsOptions = {}) { + const { limit = 50, offset = 0, enabled = true } = options; const active = useActiveBackend(); return useQuery({ queryKey: [ @@ -16,6 +23,7 @@ export function useAutomations(limit = 50, offset = 0) { ], queryFn: () => AutomationService.getAutomations(limit, offset), staleTime: 5 * 60 * 1000, + enabled, }); } diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 72fdfa0aee..2ac3fab21d 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -12001,6 +12001,15 @@ "uk": "Тригер", "ca": "Activador" }, + "AUTOMATIONS$BACKEND_UNAVAILABLE_TITLE": { + "en": "Automations Unavailable" + }, + "AUTOMATIONS$BACKEND_UNAVAILABLE_MESSAGE": { + "en": "The automations backend is not available right now. Please try again later or check that the automation service is running." + }, + "AUTOMATIONS$BACKEND_UNAVAILABLE_RETRY": { + "en": "Retry" + }, "SIDEBAR$CONVERSATIONS": { "en": "Conversations", "ja": "会話", diff --git a/src/routes/automation-detail.tsx b/src/routes/automation-detail.tsx index 048ea9e686..9eaa32e4bd 100644 --- a/src/routes/automation-detail.tsx +++ b/src/routes/automation-detail.tsx @@ -6,6 +6,7 @@ import { useToggleAutomation, useDeleteAutomation, } from "#/hooks/query/use-automations"; +import { useAutomationHealth } from "#/hooks/query/use-automation-health"; import { useActiveBackend } from "#/contexts/active-backend-context"; import { useNavigation } from "#/context/navigation-context"; import { BackLink } from "#/components/features/automations/detail/back-link"; @@ -18,6 +19,7 @@ import { ActivityLogSection } from "#/components/features/automations/detail/act import { DetailSkeleton } from "#/components/features/automations/detail/detail-skeleton"; import { NotFoundState } from "#/components/features/automations/detail/not-found-state"; import { ErrorState } from "#/components/features/automations/error-state"; +import { BackendNotConfigured } from "#/components/features/automations/backend-not-configured"; import { DeleteConfirmationModal } from "#/components/features/automations/delete-confirmation-modal"; export default function AutomationDetail() { @@ -25,6 +27,14 @@ export default function AutomationDetail() { const { navigate } = useNavigation(); const [showDeleteModal, setShowDeleteModal] = useState(false); + const { + data: healthData, + isLoading: isHealthLoading, + refetch: refetchHealth, + } = useAutomationHealth(); + + const isBackendHealthy = healthData?.status === "ok"; + // The automationId in the URL belongs to whichever backend was active // when the page first mounted. If the user switches backends, the id // is meaningless under the new backend — disable the query so we @@ -34,13 +44,17 @@ export default function AutomationDetail() { const mountedBackendId = useRef(active.backend.id); const backendChanged = mountedBackendId.current !== active.backend.id; + // Only fetch automation details if the backend is healthy and hasn't changed const { data: automation, isLoading, isError, error, refetch, - } = useAutomationDetail(backendChanged ? "" : (automationId ?? "")); + } = useAutomationDetail({ + id: automationId ?? "", + enabled: isBackendHealthy && !backendChanged, + }); const toggleMutation = useToggleAutomation(); const deleteMutation = useDeleteAutomation(); @@ -48,6 +62,28 @@ export default function AutomationDetail() { const is404 = isError && isAxiosError(error) && error.response?.status === 404; + // Show loading state while checking health + if (isHealthLoading) { + return ( +
+
+ +
+
+ ); + } + + // Show backend not configured state if health check failed + if (!isBackendHealthy) { + return ( +
+
+ +
+
+ ); + } + if (isLoading) { return (
diff --git a/src/routes/automations-list.tsx b/src/routes/automations-list.tsx index daf57d7408..c2b85a643d 100644 --- a/src/routes/automations-list.tsx +++ b/src/routes/automations-list.tsx @@ -6,11 +6,13 @@ import { useToggleAutomation, useDeleteAutomation, } from "#/hooks/query/use-automations"; +import { useAutomationHealth } from "#/hooks/query/use-automation-health"; import { SearchInput } from "#/components/features/automations/search-input"; import { AutomationGroup } from "#/components/features/automations/automation-group"; import { AutomationCardSkeleton } from "#/components/features/automations/automation-card-skeleton"; import { EmptyState } from "#/components/features/automations/empty-state"; import { ErrorState } from "#/components/features/automations/error-state"; +import { BackendNotConfigured } from "#/components/features/automations/backend-not-configured"; import { DeleteConfirmationModal } from "#/components/features/automations/delete-confirmation-modal"; import { CreateInstructions } from "#/components/features/automations/create-instructions"; @@ -25,7 +27,20 @@ export default function AutomationsList() { name: string; } | null>(null); - const { data, isLoading, isError, refetch } = useAutomations(limit, 0); + const { + data: healthData, + isLoading: isHealthLoading, + refetch: refetchHealth, + } = useAutomationHealth(); + + const isBackendHealthy = healthData?.status === "ok"; + + // Only fetch automations if the backend is healthy + const { data, isLoading, isError, refetch } = useAutomations({ + limit, + offset: 0, + enabled: isBackendHealthy, + }); const toggleMutation = useToggleAutomation(); const deleteMutation = useDeleteAutomation(); @@ -68,6 +83,44 @@ export default function AutomationsList() { const hasMore = data ? data.total > data.automations.length : false; + // Show loading state while checking health + if (isHealthLoading) { + return ( +
+
+

+ {t(I18nKey.AUTOMATIONS$TITLE)} +

+

+ {t(I18nKey.AUTOMATIONS$SUBTITLE)} +

+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+
+ ); + } + + // Show backend not configured state if health check failed + if (!isBackendHealthy) { + return ( +
+
+

+ {t(I18nKey.AUTOMATIONS$TITLE)} +

+

+ {t(I18nKey.AUTOMATIONS$SUBTITLE)} +

+ +
+
+ ); + } + return (