feat(automations): add backend health check before loading automations UI (#185)

* feat(automations): add backend health check before loading automations UI

- Add checkHealth() method to AutomationService that calls /api/automation/health
- Create useAutomationHealth hook for React Query integration
- Create BackendNotConfigured component to display when backend is unavailable
- Update automations-list.tsx and automation-detail.tsx routes to check health
- Show 'Automations Backend Not Configured' UI with host URL and retry button
- Add i18n translations for new UI strings
- Add tests for hook and component

* fix: perform health check for cloud backends too, update message

- Remove assumption that cloud backends are always healthy
- Call /api/automation/health via cloud proxy for cloud backends
- Update message to generic 'Automations Unavailable' / 'not available right now'
- Remove host URL display (not needed for generic message)
- Rename component to BackendUnavailable (keep BackendNotConfigured as alias)

* fix: disable automation API calls when health check fails

- Add enabled option to useAutomations, useAutomationDetail, and useAutomationRuns hooks
- Only fetch automations data when the backend health check passes
- Update hook call sites to pass enabled flag based on isBackendHealthy
- Update tests to use new options-based API

* test: add checkHealth mock to automation-detail test

The test was failing because the health check now gates automation API calls.
Added checkHealth mock returning { status: 'ok' } so the test can proceed.

---------

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Rohit Malhotra
2026-05-08 13:00:59 -04:00
committed by GitHub
co-authored by openhands
parent 465e776522
commit 4e6eee71fa
13 changed files with 336 additions and 14 deletions
@@ -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<string, string> = {
[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(<BackendUnavailable onRetry={onRetry} />);
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(<BackendUnavailable onRetry={onRetry} />);
const retryButton = screen.getByRole("button", { name: "Retry" });
expect(retryButton).toBeInTheDocument();
});
it("calls onRetry when retry button is clicked", () => {
const onRetry = vi.fn();
render(<BackendUnavailable onRetry={onRetry} />);
const retryButton = screen.getByRole("button", { name: "Retry" });
fireEvent.click(retryButton);
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
@@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
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",
);
});
});
@@ -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);
@@ -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();
@@ -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<Automation> {
return AutomationService.updateAutomation(id, { enabled });
}
static async checkHealth(): Promise<AutomationHealthResponse> {
const active = getActiveBackend().backend;
const path = `${AUTOMATION_BASE_PATH}/health`;
try {
if (active.kind === "cloud") {
const response = await callCloudProxy<AutomationHealthResponse>({
backend: active,
method: "GET",
path,
});
return response;
}
const { data } = await localAutomationAxios.get<AutomationHealthResponse>(
path,
{ timeout: 5000 },
);
return data;
} catch {
return { status: "error" };
}
}
}
export default AutomationService;
@@ -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 (
<div className="flex flex-col items-center justify-center py-20 px-4">
<ExclamationCircleIcon className="size-12 text-amber-500" />
<h2 className="mt-4 text-lg font-semibold text-content">
{t(I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_TITLE)}
</h2>
<p className="mt-2 text-sm text-content-muted text-center max-w-md">
{t(I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_MESSAGE)}
</p>
<button
type="button"
onClick={onRetry}
className="mt-6 rounded-lg border border-border px-4 py-2 text-sm text-white hover:bg-surface-elevated"
>
{t(I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_RETRY)}
</button>
</div>
);
}
// Keep old export name for backward compatibility
export { BackendUnavailable as BackendNotConfigured };
@@ -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;
+18 -4
View File
@@ -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,
});
}
+15
View File
@@ -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
});
}
+9 -1
View File
@@ -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,
});
}
+9
View File
@@ -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": "会話",
+37 -1
View File
@@ -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 (
<div className="min-h-full bg-surface">
<div className="p-6 max-w-4xl mx-auto">
<DetailSkeleton />
</div>
</div>
);
}
// Show backend not configured state if health check failed
if (!isBackendHealthy) {
return (
<div className="min-h-full bg-surface">
<div className="p-6 max-w-4xl mx-auto">
<BackendNotConfigured onRetry={refetchHealth} />
</div>
</div>
);
}
if (isLoading) {
return (
<div className="min-h-full bg-surface">
+54 -1
View File
@@ -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 (
<div className="min-h-full bg-surface">
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-xl font-semibold text-content">
{t(I18nKey.AUTOMATIONS$TITLE)}
</h1>
<p className="mt-1 text-sm text-content-muted">
{t(I18nKey.AUTOMATIONS$SUBTITLE)}
</p>
<div className="mt-6 flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<AutomationCardSkeleton key={`skeleton-${String(i)}`} />
))}
</div>
</div>
</div>
);
}
// Show backend not configured state if health check failed
if (!isBackendHealthy) {
return (
<div className="min-h-full bg-surface">
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-xl font-semibold text-content">
{t(I18nKey.AUTOMATIONS$TITLE)}
</h1>
<p className="mt-1 text-sm text-content-muted">
{t(I18nKey.AUTOMATIONS$SUBTITLE)}
</p>
<BackendNotConfigured onRetry={refetchHealth} />
</div>
</div>
);
}
return (
<div className="min-h-full bg-surface">
<div className="p-6 max-w-4xl mx-auto">