mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 13:38:55 +08:00
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:
committed by
GitHub
co-authored by
openhands
parent
465e776522
commit
4e6eee71fa
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "会話",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user