From 6ebe7b4e7ec0f1a7a1b6411c08f3640a397a449e Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Thu, 7 May 2026 14:49:13 -0400 Subject: [PATCH] feat: add automations frontend on /automations subpath (#141) * feat: add automations frontend on /automations subpath Port automations frontend from automation-repo to agent-canvas. ## Changes ### New Routes - /automations - List view of all automations - /automations/:automationId - Automation detail view ### New Components - Automation list components: card, card-skeleton, group, empty-state, error-state - Automation detail components: header, sections (config, prompt, plugins, activity) - Shared UI components: toggle-switch, metadata-chip, status-badge, kebab-menu, search-input ### API Integration - automation-service.api.ts - API client for automation CRUD operations - Uses existing openHands axios client (shared base URL with agent server) ### MSW Mock Server Handlers - automation-handlers.ts - Mock handlers for testing - automations.mock.ts - Sample automation data - automation-runs.mock.ts - Sample automation run data ### Tests - API tests: automation-service.test.ts, automation-handlers.test.ts - Component tests: toggle-switch, metadata-chip, search-input, error-state - Detail component tests: section-card, run-status-badge, not-found-state ### Hooks - use-automations.ts - React Query hook for fetching automations list - use-automation-detail.ts - React Query hook for fetching single automation - use-has-permission.ts - Permission checking utility hook ### Types - automation.ts - TypeScript types for automation entities ### Icons - Added SVG icons: activity, bell, calendar, check-circle, chevron-down, chevron-left, clock, cog, database, exclamation-circle, git-branch, kebab-vertical, power, puzzle, search, sparkle, target, trash, x-circle, x-mark Co-authored-by: openhands * feat: add local API key auth for automation backend - Use VITE_AUTOMATION_API_KEY env var for frontend to authenticate - Pass AUTOMATION_LOCAL_API_KEY to automation backend in dev mode - Use dedicated axios instance with Bearer auth interceptor - Add --refresh to uvx to ensure latest git commits are fetched - URL-encode automation IDs in API paths The default local API key is 'openhands-local-api-key' which matches between the frontend and backend for local development. Co-authored-by: openhands * fix: automation service tests and ingress port conflicts - Fix automation-service.test.ts to mock axios instance correctly (was mocking openHands but service uses automationAxios) - Use vi.hoisted() for mock functions available during vi.mock hoisting - Change ingress test ports from 19000-19003 to 29000-29003 to avoid conflict with VS Code server on port 19000 Co-authored-by: openhands * fix: add CORS origins for automation backend in dev mode The automation backend defaults CORS origins to app.all-hands.dev, which blocks localhost requests. Add localhost origins for the ingress port and Vite dev server port. Co-authored-by: openhands * fix: update create-instructions styling and add missing i18n keys - Use semantic color tokens (text-content, text-basic, bg-base-secondary, bg-base, border-default) instead of hardcoded neutral-* colors - Add all AUTOMATIONS$ i18n keys for the automations frontend Co-authored-by: openhands --------- Co-authored-by: openhands Co-authored-by: hieptl --- __tests__/api/automation-handlers.test.ts | 123 ++++++++++ __tests__/api/automation-service.test.ts | 203 +++++++++++++++++ .../detail/not-found-state.test.tsx | 16 ++ .../detail/run-status-badge.test.tsx | 42 ++++ .../automations/detail/section-card.test.tsx | 17 ++ .../automations/error-state.test.tsx | 25 +++ .../automations/metadata-chip.test.tsx | 17 ++ .../automations/search-input.test.tsx | 24 ++ .../automations/toggle-switch.test.tsx | 38 ++++ __tests__/scripts/ingress.test.ts | 9 +- scripts/dev-with-automation.mjs | 25 ++- .../automation-service.api.ts | 97 ++++++++ .../automations/automation-card-skeleton.tsx | 19 ++ .../features/automations/automation-card.tsx | 106 +++++++++ .../features/automations/automation-group.tsx | 40 ++++ .../automations/create-instructions.tsx | 119 ++++++++++ .../automations/delete-confirmation-modal.tsx | 70 ++++++ .../detail/active-status-badge.tsx | 24 ++ .../automations/detail/activity-log-item.tsx | 64 ++++++ .../detail/activity-log-section.tsx | 78 +++++++ .../automations/detail/activity-section.tsx | 77 +++++++ .../features/automations/detail/back-link.tsx | 18 ++ .../automations/detail/branch-badge.tsx | 11 + .../automations/detail/config-field.tsx | 17 ++ .../detail/configuration-section.tsx | 85 +++++++ .../automations/detail/detail-header.tsx | 67 ++++++ .../automations/detail/detail-skeleton.tsx | 28 +++ .../automations/detail/not-found-state.tsx | 23 ++ .../automations/detail/plugin-chip.tsx | 11 + .../automations/detail/plugins-section.tsx | 26 +++ .../automations/detail/prompt-section.tsx | 25 +++ .../automations/detail/run-status-badge.tsx | 57 +++++ .../automations/detail/section-card.tsx | 17 ++ .../features/automations/empty-state.tsx | 22 ++ .../features/automations/error-state.tsx | 27 +++ .../features/automations/kebab-menu.tsx | 69 ++++++ .../features/automations/metadata-chip.tsx | 13 ++ .../features/automations/search-input.tsx | 25 +++ .../features/automations/status-badge.tsx | 11 + .../features/automations/toggle-switch.tsx | 33 +++ src/hooks/query/use-automation-detail.ts | 23 ++ src/hooks/query/use-automations.ts | 35 +++ src/hooks/use-has-permission.ts | 6 + src/i18n/translation.json | 212 ++++++++++++++++++ src/icons/activity.svg | 3 + src/icons/bell.svg | 3 + src/icons/calendar.svg | 3 + src/icons/check-circle.svg | 3 + src/icons/chevron-down.svg | 3 + src/icons/chevron-left.svg | 3 + src/icons/clock.svg | 3 + src/icons/cog.svg | 4 + src/icons/database.svg | 3 + src/icons/exclamation-circle.svg | 3 + src/icons/git-branch.svg | 6 + src/icons/kebab-vertical.svg | 3 + src/icons/power.svg | 3 + src/icons/puzzle.svg | 3 + src/icons/search.svg | 3 + src/icons/sparkle.svg | 3 + src/icons/target.svg | 3 + src/icons/trash.svg | 3 + src/icons/x-circle.svg | 3 + src/icons/x-mark.svg | 3 + src/mocks/automation-handlers.ts | 123 ++++++++++ src/mocks/automation-runs.mock.ts | 61 +++++ src/mocks/automations.mock.ts | 127 +++++++++++ src/mocks/handlers.ts | 11 +- src/routes.ts | 2 + src/routes/automation-detail.tsx | 108 +++++++++ src/routes/automations-list.tsx | 144 ++++++++++++ src/types/automation.ts | 48 ++++ test-utils.tsx | 8 +- 73 files changed, 2777 insertions(+), 13 deletions(-) create mode 100644 __tests__/api/automation-handlers.test.ts create mode 100644 __tests__/api/automation-service.test.ts create mode 100644 __tests__/components/automations/detail/not-found-state.test.tsx create mode 100644 __tests__/components/automations/detail/run-status-badge.test.tsx create mode 100644 __tests__/components/automations/detail/section-card.test.tsx create mode 100644 __tests__/components/automations/error-state.test.tsx create mode 100644 __tests__/components/automations/metadata-chip.test.tsx create mode 100644 __tests__/components/automations/search-input.test.tsx create mode 100644 __tests__/components/automations/toggle-switch.test.tsx create mode 100644 src/api/automation-service/automation-service.api.ts create mode 100644 src/components/features/automations/automation-card-skeleton.tsx create mode 100644 src/components/features/automations/automation-card.tsx create mode 100644 src/components/features/automations/automation-group.tsx create mode 100644 src/components/features/automations/create-instructions.tsx create mode 100644 src/components/features/automations/delete-confirmation-modal.tsx create mode 100644 src/components/features/automations/detail/active-status-badge.tsx create mode 100644 src/components/features/automations/detail/activity-log-item.tsx create mode 100644 src/components/features/automations/detail/activity-log-section.tsx create mode 100644 src/components/features/automations/detail/activity-section.tsx create mode 100644 src/components/features/automations/detail/back-link.tsx create mode 100644 src/components/features/automations/detail/branch-badge.tsx create mode 100644 src/components/features/automations/detail/config-field.tsx create mode 100644 src/components/features/automations/detail/configuration-section.tsx create mode 100644 src/components/features/automations/detail/detail-header.tsx create mode 100644 src/components/features/automations/detail/detail-skeleton.tsx create mode 100644 src/components/features/automations/detail/not-found-state.tsx create mode 100644 src/components/features/automations/detail/plugin-chip.tsx create mode 100644 src/components/features/automations/detail/plugins-section.tsx create mode 100644 src/components/features/automations/detail/prompt-section.tsx create mode 100644 src/components/features/automations/detail/run-status-badge.tsx create mode 100644 src/components/features/automations/detail/section-card.tsx create mode 100644 src/components/features/automations/empty-state.tsx create mode 100644 src/components/features/automations/error-state.tsx create mode 100644 src/components/features/automations/kebab-menu.tsx create mode 100644 src/components/features/automations/metadata-chip.tsx create mode 100644 src/components/features/automations/search-input.tsx create mode 100644 src/components/features/automations/status-badge.tsx create mode 100644 src/components/features/automations/toggle-switch.tsx create mode 100644 src/hooks/query/use-automation-detail.ts create mode 100644 src/hooks/query/use-automations.ts create mode 100644 src/hooks/use-has-permission.ts create mode 100644 src/icons/activity.svg create mode 100644 src/icons/bell.svg create mode 100644 src/icons/calendar.svg create mode 100644 src/icons/check-circle.svg create mode 100644 src/icons/chevron-down.svg create mode 100644 src/icons/chevron-left.svg create mode 100644 src/icons/clock.svg create mode 100644 src/icons/cog.svg create mode 100644 src/icons/database.svg create mode 100644 src/icons/exclamation-circle.svg create mode 100644 src/icons/git-branch.svg create mode 100644 src/icons/kebab-vertical.svg create mode 100644 src/icons/power.svg create mode 100644 src/icons/puzzle.svg create mode 100644 src/icons/search.svg create mode 100644 src/icons/sparkle.svg create mode 100644 src/icons/target.svg create mode 100644 src/icons/trash.svg create mode 100644 src/icons/x-circle.svg create mode 100644 src/icons/x-mark.svg create mode 100644 src/mocks/automation-handlers.ts create mode 100644 src/mocks/automation-runs.mock.ts create mode 100644 src/mocks/automations.mock.ts create mode 100644 src/routes/automation-detail.tsx create mode 100644 src/routes/automations-list.tsx create mode 100644 src/types/automation.ts diff --git a/__tests__/api/automation-handlers.test.ts b/__tests__/api/automation-handlers.test.ts new file mode 100644 index 0000000000..34a1592d48 --- /dev/null +++ b/__tests__/api/automation-handlers.test.ts @@ -0,0 +1,123 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { + AUTOMATION_HANDLERS, + resetAutomationMockData, +} from "#/mocks/automation-handlers"; +import { MOCK_AUTOMATIONS_RESPONSE } from "#/mocks/automations.mock"; + +const server = setupServer(...AUTOMATION_HANDLERS); + +describe("Automation MSW Handlers", () => { + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + afterAll(() => server.close()); + afterEach(() => { + server.resetHandlers(); + resetAutomationMockData(); + }); + + describe("GET /api/automation/v1", () => { + it("returns paginated automations list", async () => { + const res = await fetch("/api/automation/v1?limit=10&offset=0"); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.automations).toHaveLength(5); + expect(data.total).toBe(5); + }); + + it("respects pagination parameters", async () => { + const res = await fetch("/api/automation/v1?limit=2&offset=2"); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.automations).toHaveLength(2); + expect(data.automations[0].name).toBe("Docs Sync on Push"); + }); + }); + + describe("GET /api/automation/v1/:id", () => { + it("returns a single automation by id", async () => { + const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id; + const res = await fetch(`/api/automation/v1/${id}`); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.id).toBe(id); + expect(data.name).toBe("PR Triage Digest"); + }); + + it("returns 404 for non-existent automation", async () => { + const res = await fetch("/api/automation/v1/non-existent-id"); + + expect(res.status).toBe(404); + }); + }); + + describe("PATCH /api/automation/v1/:id", () => { + it("updates an automation", async () => { + const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id; + const res = await fetch(`/api/automation/v1/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.enabled).toBe(false); + }); + + it("returns 404 for non-existent automation", async () => { + const res = await fetch("/api/automation/v1/non-existent-id", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false }), + }); + + expect(res.status).toBe(404); + }); + }); + + describe("DELETE /api/automation/v1/:id", () => { + it("deletes an automation", async () => { + const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id; + const res = await fetch(`/api/automation/v1/${id}`, { + method: "DELETE", + }); + + expect(res.status).toBe(204); + + // Verify it's actually deleted + const getRes = await fetch(`/api/automation/v1/${id}`); + expect(getRes.status).toBe(404); + }); + + it("returns 404 for non-existent automation", async () => { + const res = await fetch("/api/automation/v1/non-existent-id", { + method: "DELETE", + }); + + expect(res.status).toBe(404); + }); + }); + + describe("GET /api/automation/v1/:id/runs", () => { + it("returns automation runs", async () => { + const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id; + const res = await fetch(`/api/automation/v1/${id}/runs?limit=5&offset=0`); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.runs).toBeInstanceOf(Array); + expect(data.total).toBeGreaterThanOrEqual(0); + }); + + it("returns 404 for non-existent automation", async () => { + const res = await fetch("/api/automation/v1/non-existent-id/runs"); + + expect(res.status).toBe(404); + }); + }); +}); diff --git a/__tests__/api/automation-service.test.ts b/__tests__/api/automation-service.test.ts new file mode 100644 index 0000000000..9a64aae8af --- /dev/null +++ b/__tests__/api/automation-service.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { + Automation, + AutomationsResponse, + AutomationRunsResponse, +} from "#/types/automation"; + +// Use vi.hoisted to define mocks that will be available during vi.mock hoisting +const { mockGet, mockPatch, mockDelete } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockPatch: vi.fn(), + mockDelete: vi.fn(), +})); + +vi.mock("axios", () => ({ + default: { + create: () => ({ + get: mockGet, + patch: mockPatch, + delete: mockDelete, + interceptors: { + request: { + use: vi.fn(), + }, + }, + }), + }, +})); + +// Import after mocking +import AutomationService from "#/api/automation-service/automation-service.api"; + +const mockAutomation: Automation = { + id: "1", + name: "Test Automation", + prompt: "A test automation", + trigger: { type: "schedule", schedule_human: "Daily at 09:00" }, + enabled: true, + repository: "acme/repo", + model: "Claude Opus", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", +}; + +describe("AutomationService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("listAutomations", () => { + it("fetches paginated automations list with params object", async () => { + const response: AutomationsResponse = { + automations: [mockAutomation], + total: 1, + }; + mockGet.mockResolvedValue({ data: response }); + + const result = await AutomationService.listAutomations({ + limit: 10, + offset: 5, + }); + + expect(mockGet).toHaveBeenCalledWith("/api/automation/v1", { + params: { limit: 10, offset: 5 }, + }); + expect(result).toEqual(response); + }); + + it("uses default params when none provided", async () => { + const response: AutomationsResponse = { + automations: [], + total: 0, + }; + mockGet.mockResolvedValue({ data: response }); + + await AutomationService.listAutomations(); + + expect(mockGet).toHaveBeenCalledWith("/api/automation/v1", { + params: { limit: 50, offset: 0 }, + }); + }); + }); + + describe("getAutomations", () => { + it("delegates to listAutomations", async () => { + const response: AutomationsResponse = { + automations: [mockAutomation], + total: 1, + }; + vi.spyOn(AutomationService, "listAutomations").mockResolvedValue( + response, + ); + + const result = await AutomationService.getAutomations(10, 5); + + expect(AutomationService.listAutomations).toHaveBeenCalledWith({ + limit: 10, + offset: 5, + }); + expect(result).toEqual(response); + }); + }); + + describe("getAutomation", () => { + it("fetches a single automation by id", async () => { + mockGet.mockResolvedValue({ + data: mockAutomation, + }); + + const result = await AutomationService.getAutomation("1"); + + expect(mockGet).toHaveBeenCalledWith("/api/automation/v1/1"); + expect(result).toEqual(mockAutomation); + }); + }); + + describe("updateAutomation", () => { + it("patches an automation with the provided body", async () => { + const updated = { ...mockAutomation, name: "Updated Name" }; + mockPatch.mockResolvedValue({ data: updated }); + + const result = await AutomationService.updateAutomation("1", { + name: "Updated Name", + }); + + expect(mockPatch).toHaveBeenCalledWith("/api/automation/v1/1", { + name: "Updated Name", + }); + expect(result).toEqual(updated); + }); + }); + + describe("deleteAutomation", () => { + it("deletes an automation by id", async () => { + mockDelete.mockResolvedValue({}); + + await AutomationService.deleteAutomation("1"); + + expect(mockDelete).toHaveBeenCalledWith("/api/automation/v1/1"); + }); + }); + + describe("listAutomationRuns", () => { + it("fetches runs with params object", async () => { + const response: AutomationRunsResponse = { runs: [], total: 0 }; + mockGet.mockResolvedValue({ data: response }); + + const result = await AutomationService.listAutomationRuns("1", { + limit: 20, + offset: 10, + }); + + expect(mockGet).toHaveBeenCalledWith("/api/automation/v1/1/runs", { + params: { limit: 20, offset: 10 }, + }); + expect(result).toEqual(response); + }); + + it("uses default params when none provided", async () => { + const response: AutomationRunsResponse = { runs: [], total: 0 }; + mockGet.mockResolvedValue({ data: response }); + + await AutomationService.listAutomationRuns("1"); + + expect(mockGet).toHaveBeenCalledWith("/api/automation/v1/1/runs", { + params: { limit: 50, offset: 0 }, + }); + }); + }); + + describe("getAutomationRuns", () => { + it("delegates to listAutomationRuns", async () => { + const response: AutomationRunsResponse = { runs: [], total: 0 }; + vi.spyOn(AutomationService, "listAutomationRuns").mockResolvedValue( + response, + ); + + const result = await AutomationService.getAutomationRuns("1", 25, 5); + + expect(AutomationService.listAutomationRuns).toHaveBeenCalledWith("1", { + limit: 25, + offset: 5, + }); + expect(result).toEqual(response); + }); + }); + + describe("toggleAutomation", () => { + it("delegates to updateAutomation with enabled field", async () => { + const toggled = { ...mockAutomation, enabled: false }; + vi.spyOn(AutomationService, "updateAutomation").mockResolvedValue( + toggled, + ); + + const result = await AutomationService.toggleAutomation("1", false); + + expect(AutomationService.updateAutomation).toHaveBeenCalledWith("1", { + enabled: false, + }); + expect(result).toEqual(toggled); + }); + }); +}); diff --git a/__tests__/components/automations/detail/not-found-state.test.tsx b/__tests__/components/automations/detail/not-found-state.test.tsx new file mode 100644 index 0000000000..bb9e4d20ca --- /dev/null +++ b/__tests__/components/automations/detail/not-found-state.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { NotFoundState } from "#/components/features/automations/detail/not-found-state"; + +describe("NotFoundState", () => { + it("renders not found state with link back to automations list", () => { + const { container } = render(); + + // Check for the main container + expect(container.querySelector("div")).toBeInTheDocument(); + // Check for the link to go back + const link = screen.getByRole("link"); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "/automations"); + }); +}); diff --git a/__tests__/components/automations/detail/run-status-badge.test.tsx b/__tests__/components/automations/detail/run-status-badge.test.tsx new file mode 100644 index 0000000000..f2d8569c90 --- /dev/null +++ b/__tests__/components/automations/detail/run-status-badge.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { RunStatusBadge } from "#/components/features/automations/detail/run-status-badge"; +import { AutomationRunStatus } from "#/types/automation"; + +describe("RunStatusBadge", () => { + it("renders badge with correct style for completed status", () => { + const { container } = render( + , + ); + const badge = container.querySelector("span"); + expect(badge).toBeInTheDocument(); + expect(badge?.className).toContain("text-success"); + }); + + it("renders badge with correct style for failed status", () => { + const { container } = render( + , + ); + const badge = container.querySelector("span"); + expect(badge).toBeInTheDocument(); + expect(badge?.className).toContain("text-red-400"); + }); + + it("renders badge with correct style for pending status", () => { + const { container } = render( + , + ); + const badge = container.querySelector("span"); + expect(badge).toBeInTheDocument(); + expect(badge?.className).toContain("text-neutral-400"); + }); + + it("renders badge with correct style for running status", () => { + const { container } = render( + , + ); + const badge = container.querySelector("span"); + expect(badge).toBeInTheDocument(); + expect(badge?.className).toContain("text-neutral-400"); + }); +}); diff --git a/__tests__/components/automations/detail/section-card.test.tsx b/__tests__/components/automations/detail/section-card.test.tsx new file mode 100644 index 0000000000..087da2e677 --- /dev/null +++ b/__tests__/components/automations/detail/section-card.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { SectionCard } from "#/components/features/automations/detail/section-card"; + +describe("SectionCard", () => { + it("renders the title and children content", () => { + render( + } title="Test Section"> +

{String("Section content")}

+
, + ); + + expect(screen.getByText("Test Section")).toBeInTheDocument(); + expect(screen.getByText("Section content")).toBeInTheDocument(); + expect(screen.getByTestId("icon")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/automations/error-state.test.tsx b/__tests__/components/automations/error-state.test.tsx new file mode 100644 index 0000000000..c5b22ef371 --- /dev/null +++ b/__tests__/components/automations/error-state.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect } from "vitest"; +import { ErrorState } from "#/components/features/automations/error-state"; + +describe("ErrorState", () => { + it("renders error icon and retry button", () => { + const { container } = render(); + + // Check for the error icon (ExclamationCircleIcon) + expect(container.querySelector("svg")).toBeInTheDocument(); + // Check for the retry button + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("calls onRetry when retry button is clicked", async () => { + const onRetry = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button")); + + expect(onRetry).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/components/automations/metadata-chip.test.tsx b/__tests__/components/automations/metadata-chip.test.tsx new file mode 100644 index 0000000000..8b0b3501c8 --- /dev/null +++ b/__tests__/components/automations/metadata-chip.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { MetadataChip } from "#/components/features/automations/metadata-chip"; + +describe("MetadataChip", () => { + it("renders the label and icon", () => { + render( + } + label="acme/frontend-app" + />, + ); + + expect(screen.getByText("acme/frontend-app")).toBeInTheDocument(); + expect(screen.getByTestId("chip-icon")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/automations/search-input.test.tsx b/__tests__/components/automations/search-input.test.tsx new file mode 100644 index 0000000000..adc66227b2 --- /dev/null +++ b/__tests__/components/automations/search-input.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect } from "vitest"; +import { SearchInput } from "#/components/features/automations/search-input"; + +describe("SearchInput", () => { + it("calls onChange when user types", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByRole("textbox"); + await user.type(input, "test"); + + expect(onChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenLastCalledWith("t"); + }); + + it("displays the current value", () => { + render(); + + expect(screen.getByRole("textbox")).toHaveValue("security"); + }); +}); diff --git a/__tests__/components/automations/toggle-switch.test.tsx b/__tests__/components/automations/toggle-switch.test.tsx new file mode 100644 index 0000000000..a86e0aeddf --- /dev/null +++ b/__tests__/components/automations/toggle-switch.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect } from "vitest"; +import { ToggleSwitch } from "#/components/features/automations/toggle-switch"; + +describe("ToggleSwitch", () => { + it("calls onToggle when clicked", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("switch")); + + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it("reflects enabled state via aria-checked", () => { + render(); + + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true"); + }); + + it("stops event propagation on click", async () => { + const parentClick = vi.fn(); + const user = userEvent.setup(); + render( +
+ +
, + ); + + await user.click(screen.getByRole("switch")); + + expect(parentClick).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/scripts/ingress.test.ts b/__tests__/scripts/ingress.test.ts index 2cda7a6ce1..317e09556e 100644 --- a/__tests__/scripts/ingress.test.ts +++ b/__tests__/scripts/ingress.test.ts @@ -110,9 +110,10 @@ describe("ingress proxy functionality", () => { let backend1: Server; let backend2: Server; let ingressProcess: ChildProcess; - const backend1Port = 19001; - const backend2Port = 19002; - const ingressPort = 19000; + // Use ports in 29000 range to avoid conflicts with VS Code server (19000) + const backend1Port = 29001; + const backend2Port = 29002; + const ingressPort = 29000; beforeAll(async () => { // Create mock backend 1 @@ -198,7 +199,7 @@ describe("ingress proxy functionality", () => { it("returns 502 when backend is unavailable", async () => { // Start a fresh ingress pointing to a non-existent backend - const badIngressPort = 19003; + const badIngressPort = 29003; const badIngress = spawn( process.execPath, [ diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs index d14b8358aa..d2291da23a 100644 --- a/scripts/dev-with-automation.mjs +++ b/scripts/dev-with-automation.mjs @@ -56,6 +56,8 @@ const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation"; const DEFAULT_AUTOMATION_GIT_REF = "main"; const DEFAULT_BACKEND_PORT = 18000; const DEFAULT_AUTOMATION_PORT = 18001; +// Default local API key for automation backend auth (matches agent-server pattern) +const DEFAULT_LOCAL_API_KEY = "openhands-local-api-key"; // ═══════════════════════════════════════════════════════════════════════════ // Terminal Styling @@ -168,12 +170,12 @@ function buildAutomationCommand(env = process.env) { // Build git URL with ref const gitUrl = `git+${repoUrl}@${gitRef}`; + // Use --refresh to ensure latest commit is fetched for git refs + const args = ["--refresh", "--from", gitUrl, "uvicorn", "automation.app:app"]; + return { command: "uvx", - args: [ - "--from", gitUrl, - "uvicorn", "automation.app:app", - ], + args, source: `git (${gitRef})`, }; } @@ -193,6 +195,9 @@ function buildConfig(args, env = process.env) { const vitePort = 3001; const vscodePort = backendPort + 1000; + // Local API key for automation backend auth + const localApiKey = env.AUTOMATION_LOCAL_API_KEY || DEFAULT_LOCAL_API_KEY; + return { // Ingress port (main entry point) ingressPort, @@ -209,6 +214,9 @@ function buildConfig(args, env = process.env) { // Data directories (same as dev-safe.mjs) stateDir: join(homedir(), ".openhands", "agent-canvas"), + // Auth + localApiKey, + verbose: args.verbose, }; } @@ -379,9 +387,12 @@ function startAutomationBackend(config) { env: { AUTOMATION_AGENT_SERVER_URL: `http://localhost:${config.agentServerPort}`, AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(config.stateDir, "automations.db")}`, - AUTOMATION_BASE_URL: `http://localhost:3001`, + AUTOMATION_BASE_URL: `http://localhost:${config.ingressPort}`, AUTOMATION_WORKSPACE_BASE: join(config.stateDir, "workspaces"), - AUTOMATION_AUTH_DISABLED: "true", + // Local API key for self-hosted auth (no cloud API needed) + AUTOMATION_LOCAL_API_KEY: config.localApiKey, + // CORS: allow localhost origins for dev + AUTOMATION_CORS_ORIGINS: `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`, FILE_STORE: "local", LOCAL_STORAGE_PATH: join(config.stateDir, "storage"), OPENHANDS_SUPPRESS_BANNER: "1", @@ -462,6 +473,8 @@ function startVite(config) { VITE_BACKEND_BASE_URL: `http://127.0.0.1:${config.ingressPort}`, VITE_WORKING_DIR: join(config.stateDir, "workspaces"), VITE_FRONTEND_PORT: config.vitePort.toString(), + // Automation API key for frontend to authenticate with automation backend + VITE_AUTOMATION_API_KEY: config.localApiKey, }, color: c.magenta, }); diff --git a/src/api/automation-service/automation-service.api.ts b/src/api/automation-service/automation-service.api.ts new file mode 100644 index 0000000000..cfda9c262a --- /dev/null +++ b/src/api/automation-service/automation-service.api.ts @@ -0,0 +1,97 @@ +import axios from "axios"; +import type { + Automation, + AutomationsResponse, + AutomationRunsResponse, +} from "#/types/automation"; +import { getAgentServerBaseUrl } from "../agent-server-config"; + +const AUTOMATION_BASE_PATH = "/api/automation"; + +// Create axios instance for automation API with Bearer auth +const automationAxios = axios.create({ + baseURL: getAgentServerBaseUrl(), +}); + +automationAxios.interceptors.request.use((config) => { + const apiKey = import.meta.env.VITE_AUTOMATION_API_KEY?.trim(); + if (apiKey) { + config.headers.set("Authorization", `Bearer ${apiKey}`); + } + return config; +}); + +class AutomationService { + static async listAutomations( + params: { limit?: number; offset?: number } = {}, + ): Promise { + const { limit = 50, offset = 0 } = params; + const { data } = await automationAxios.get( + `${AUTOMATION_BASE_PATH}/v1`, + { + params: { limit, offset }, + }, + ); + return data; + } + + static async getAutomations( + limit = 50, + offset = 0, + ): Promise { + return AutomationService.listAutomations({ limit, offset }); + } + + static async getAutomation(id: string): Promise { + const { data } = await automationAxios.get( + `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`, + ); + return data; + } + + static async updateAutomation( + id: string, + body: Partial, + ): Promise { + const { data } = await automationAxios.patch( + `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`, + body, + ); + return data; + } + + static async deleteAutomation(id: string): Promise { + await automationAxios.delete( + `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`, + ); + } + + static async listAutomationRuns( + id: string, + params: { limit?: number; offset?: number } = {}, + ): Promise { + const { limit = 50, offset = 0 } = params; + const { data } = await automationAxios.get( + `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}/runs`, + { params: { limit, offset } }, + ); + return data; + } + + static async getAutomationRuns( + id: string, + limit = 50, + offset = 0, + ): Promise { + return AutomationService.listAutomationRuns(id, { limit, offset }); + } + + static async toggleAutomation( + id: string, + enabled: boolean, + ): Promise { + return AutomationService.updateAutomation(id, { enabled }); + } +} + +export default AutomationService; diff --git a/src/components/features/automations/automation-card-skeleton.tsx b/src/components/features/automations/automation-card-skeleton.tsx new file mode 100644 index 0000000000..7b29d8833f --- /dev/null +++ b/src/components/features/automations/automation-card-skeleton.tsx @@ -0,0 +1,19 @@ +export function AutomationCardSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/components/features/automations/automation-card.tsx b/src/components/features/automations/automation-card.tsx new file mode 100644 index 0000000000..416000e794 --- /dev/null +++ b/src/components/features/automations/automation-card.tsx @@ -0,0 +1,106 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import type { Automation } from "#/types/automation"; +import { ToggleSwitch } from "./toggle-switch"; +import { MetadataChip } from "./metadata-chip"; +import { KebabMenu } from "./kebab-menu"; +import type { KebabMenuItem } from "./kebab-menu"; +import { useHasPermission } from "#/hooks/use-has-permission"; +import { useNavigation } from "#/context/navigation-context"; +import FolderIcon from "#/icons/folder.svg?react"; +import ClockIcon from "#/icons/clock.svg?react"; +import SparkleIcon from "#/icons/sparkle.svg?react"; +import PowerIcon from "#/icons/power.svg?react"; +import TrashIcon from "#/icons/trash.svg?react"; + +interface AutomationCardProps { + automation: Automation; + onToggle: (id: string, enabled: boolean) => void; + onDelete: (id: string) => void; +} + +export function AutomationCard({ + automation, + onToggle, + onDelete, +}: AutomationCardProps) { + const { navigate } = useNavigation(); + const { t } = useTranslation("openhands"); + const canManage = useHasPermission("manage_automations"); + + const scheduleLabel = + automation.trigger.schedule_human || automation.trigger.type; + + const menuItems: KebabMenuItem[] = [ + { + label: automation.enabled + ? t(I18nKey.AUTOMATIONS$TURN_OFF) + : t(I18nKey.AUTOMATIONS$TURN_ON), + icon: , + onClick: () => onToggle(automation.id, automation.enabled), + }, + { + label: t(I18nKey.AUTOMATIONS$DELETE), + icon: , + onClick: () => onDelete(automation.id), + variant: "danger", + }, + ]; + + const handleCardClick = () => { + navigate?.(`/automations/${automation.id}`); + }; + + return ( +
{ + if (e.key === "Enter") handleCardClick(); + }} + className="cursor-pointer rounded-2xl border border-neutral-700 bg-neutral-800 p-5 transition-colors hover:border-neutral-600" + > +
+
+

+ {automation.name} +

+

+ {automation.prompt} +

+
+ +
+ {canManage && ( + onToggle(automation.id, automation.enabled)} + /> + )} + {canManage && } +
+
+ +
+ {automation.repository && ( + } + label={automation.repository} + /> + )} + } + label={scheduleLabel} + /> + {automation.model && ( + } + label={automation.model} + /> + )} +
+
+ ); +} diff --git a/src/components/features/automations/automation-group.tsx b/src/components/features/automations/automation-group.tsx new file mode 100644 index 0000000000..70e0251dfa --- /dev/null +++ b/src/components/features/automations/automation-group.tsx @@ -0,0 +1,40 @@ +import type { Automation } from "#/types/automation"; +import { AutomationCard } from "./automation-card"; +import { StatusBadge } from "./status-badge"; + +interface AutomationGroupProps { + title: string; + count: number; + automations: Automation[]; + onToggle: (id: string, enabled: boolean) => void; + onDelete: (id: string) => void; +} + +export function AutomationGroup({ + title, + count, + automations, + onToggle, + onDelete, +}: AutomationGroupProps) { + if (automations.length === 0) return null; + + return ( +
+
+

{title}

+ +
+
+ {automations.map((automation) => ( + + ))} +
+
+ ); +} diff --git a/src/components/features/automations/create-instructions.tsx b/src/components/features/automations/create-instructions.tsx new file mode 100644 index 0000000000..8f6aa479a8 --- /dev/null +++ b/src/components/features/automations/create-instructions.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import TerminalIcon from "#/icons/terminal.svg?react"; +import SparkleIcon from "#/icons/sparkle.svg?react"; +import ChevronDownIcon from "#/icons/chevron-down.svg?react"; + +const DOCS_URL = + "https://docs.openhands.dev/openhands/usage/automations/overview"; +const NEW_CONVERSATION_URL = "/"; +const PLUGIN_COMMAND = "/openhands-automation create"; +const PLUGIN_INSTALL_URL = + "https://github.com/OpenHands/extensions#quick-start"; + +interface CreateInstructionsProps { + /** If true, the instructions are collapsible and start collapsed */ + collapsible?: boolean; +} + +export function CreateInstructions({ + collapsible = false, +}: CreateInstructionsProps) { + const { t } = useTranslation("openhands"); + const [isExpanded, setIsExpanded] = useState(!collapsible); + + const content = ( + <> +
+ {/* Option 1: Claude Code / Codex */} +
+
+ + + {t(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_TITLE)} + +
+

+ + {t(I18nKey.AUTOMATIONS$EMPTY_INSTALL_PLUGIN)} + {" "} + {t(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_DESC)} +

+ + {PLUGIN_COMMAND} + +
+ + {/* Option 2: OpenHands Cloud conversation */} +
+
+ + + {t(I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_TITLE)} + +
+

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

+ + {t(I18nKey.AUTOMATIONS$EMPTY_START_CONVERSATION)} + + +
+
+ + {/* Documentation link */} +

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

+ + ); + + if (collapsible) { + return ( +
+ + {isExpanded &&
{content}
} +
+ ); + } + + return ( +
+

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

+ {content} +
+ ); +} diff --git a/src/components/features/automations/delete-confirmation-modal.tsx b/src/components/features/automations/delete-confirmation-modal.tsx new file mode 100644 index 0000000000..1cdb98f5d5 --- /dev/null +++ b/src/components/features/automations/delete-confirmation-modal.tsx @@ -0,0 +1,70 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import XMarkIcon from "#/icons/x-mark.svg?react"; + +interface DeleteConfirmationModalProps { + automationName: string; + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export function DeleteConfirmationModal({ + automationName, + isOpen, + onConfirm, + onCancel, +}: DeleteConfirmationModalProps) { + const { t } = useTranslation("openhands"); + + if (!isOpen) return null; + + return ( +
+
{ + if (e.key === "Escape") onCancel(); + }} + role="presentation" + /> +
+ + +

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

+

+ {t(I18nKey.AUTOMATIONS$DELETE_CONFIRM_MESSAGE, { + name: automationName, + })} +

+ +
+ + +
+
+
+ ); +} diff --git a/src/components/features/automations/detail/active-status-badge.tsx b/src/components/features/automations/detail/active-status-badge.tsx new file mode 100644 index 0000000000..a85041eaef --- /dev/null +++ b/src/components/features/automations/detail/active-status-badge.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; + +interface ActiveStatusBadgeProps { + active: boolean; +} + +export function ActiveStatusBadge({ active }: ActiveStatusBadgeProps) { + const { t } = useTranslation("openhands"); + + return ( + + {active + ? t(I18nKey.AUTOMATIONS$DETAIL$ACTIVE) + : t(I18nKey.AUTOMATIONS$DETAIL$INACTIVE)} + + ); +} diff --git a/src/components/features/automations/detail/activity-log-item.tsx b/src/components/features/automations/detail/activity-log-item.tsx new file mode 100644 index 0000000000..0fc27184ca --- /dev/null +++ b/src/components/features/automations/detail/activity-log-item.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import type { AutomationRun } from "#/types/automation"; +import { RunStatusBadge } from "./run-status-badge"; + +interface ActivityLogItemProps { + run: AutomationRun; +} + +function formatRunTimestamp(dateStr: string, locale: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString(locale, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function getConversationUrl(conversationId: string): string { + // In agent-canvas, conversations are at /conversations/:id + return `/conversations/${conversationId}`; +} + +export function ActivityLogItem({ run }: ActivityLogItemProps) { + const { t, i18n } = useTranslation("openhands"); + const hasConversation = !!run.conversation_id; + + const content = ( + <> +
+ + {formatRunTimestamp(run.started_at, i18n.language)} + + {!hasConversation && ( + + ({t(I18nKey.AUTOMATIONS$DETAIL$NO_CONVERSATION)}) + + )} +
+ + + ); + + if (hasConversation && run.conversation_id) { + return ( + + {content} + + ); + } + + return ( +
+ {content} +
+ ); +} diff --git a/src/components/features/automations/detail/activity-log-section.tsx b/src/components/features/automations/detail/activity-log-section.tsx new file mode 100644 index 0000000000..3be3724ac4 --- /dev/null +++ b/src/components/features/automations/detail/activity-log-section.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { useAutomationRuns } from "#/hooks/query/use-automation-detail"; +import ActivityIcon from "#/icons/activity.svg?react"; +import { ActivityLogItem } from "./activity-log-item"; + +interface ActivityLogSectionProps { + automationId: string; +} + +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 hasMore = data ? data.total > data.runs.length : false; + + return ( +
+
+ + + +

+ {t(I18nKey.AUTOMATIONS$DETAIL$ACTIVITY_LOG)} +

+
+ + {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+ ))} +
+ )} + + {!isLoading && data && data.runs.length === 0 && ( +

+ {t(I18nKey.AUTOMATIONS$DETAIL$NO_RUNS)} +

+ )} + + {!isLoading && data && data.runs.length > 0 && ( +
+ {data.runs.map((run, index) => ( +
0 ? "border-t border-neutral-700" : ""} + > + +
+ ))} + + {hasMore && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/features/automations/detail/activity-section.tsx b/src/components/features/automations/detail/activity-section.tsx new file mode 100644 index 0000000000..f4c63784cb --- /dev/null +++ b/src/components/features/automations/detail/activity-section.tsx @@ -0,0 +1,77 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import ActivityIcon from "#/icons/activity.svg?react"; +import CalendarIcon from "#/icons/calendar.svg?react"; +import ClockIcon from "#/icons/clock.svg?react"; +import { SectionCard } from "./section-card"; +import { ConfigField } from "./config-field"; + +interface ActivitySectionProps { + createdAt: string; + lastRunAt: string | null | undefined; +} + +function formatDate(dateStr: string, locale: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString(locale, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function formatRelativeTime( + dateStr: string, + locale: string, + t: (key: I18nKey, options?: Record) => string, +): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + const diffMins = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMs / 3_600_000); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffMins < 1) return t(I18nKey.AUTOMATIONS$DETAIL$TIME_JUST_NOW); + if (diffMins < 60) + return t(I18nKey.AUTOMATIONS$DETAIL$TIME_MINUTES_AGO, { count: diffMins }); + if (diffHours < 24) + return t(I18nKey.AUTOMATIONS$DETAIL$TIME_HOURS_AGO, { count: diffHours }); + if (diffDays === 1) return t(I18nKey.AUTOMATIONS$DETAIL$TIME_YESTERDAY); + if (diffDays < 7) + return t(I18nKey.AUTOMATIONS$DETAIL$TIME_DAYS_AGO, { count: diffDays }); + return formatDate(dateStr, locale); +} + +export function ActivitySection({ + createdAt, + lastRunAt, +}: ActivitySectionProps) { + const { t, i18n } = useTranslation("openhands"); + const locale = i18n.language; + + return ( + } + title={t(I18nKey.AUTOMATIONS$DETAIL$ACTIVITY)} + > +
+ } + label={t(I18nKey.AUTOMATIONS$DETAIL$CREATED)} + > + {formatDate(createdAt, locale)} + + + } + label={t(I18nKey.AUTOMATIONS$DETAIL$LAST_RUN)} + > + {lastRunAt + ? formatRelativeTime(lastRunAt, locale, t) + : t(I18nKey.AUTOMATIONS$DETAIL$TIME_NEVER)} + +
+
+ ); +} diff --git a/src/components/features/automations/detail/back-link.tsx b/src/components/features/automations/detail/back-link.tsx new file mode 100644 index 0000000000..bce6eb2407 --- /dev/null +++ b/src/components/features/automations/detail/back-link.tsx @@ -0,0 +1,18 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { NavigationLink } from "#/components/shared/navigation-link"; +import ChevronLeftIcon from "#/icons/chevron-left.svg?react"; + +export function BackLink() { + const { t } = useTranslation("openhands"); + + return ( + + + {t(I18nKey.AUTOMATIONS$DETAIL$BACK_TO_LIST)} + + ); +} diff --git a/src/components/features/automations/detail/branch-badge.tsx b/src/components/features/automations/detail/branch-badge.tsx new file mode 100644 index 0000000000..05511e2941 --- /dev/null +++ b/src/components/features/automations/detail/branch-badge.tsx @@ -0,0 +1,11 @@ +interface BranchBadgeProps { + branch: string; +} + +export function BranchBadge({ branch }: BranchBadgeProps) { + return ( + + {branch} + + ); +} diff --git a/src/components/features/automations/detail/config-field.tsx b/src/components/features/automations/detail/config-field.tsx new file mode 100644 index 0000000000..817e06aa87 --- /dev/null +++ b/src/components/features/automations/detail/config-field.tsx @@ -0,0 +1,17 @@ +interface ConfigFieldProps { + icon: React.ReactNode; + label: string; + children: React.ReactNode; +} + +export function ConfigField({ icon, label, children }: ConfigFieldProps) { + return ( +
+
+ {icon} + {label} +
+
{children}
+
+ ); +} diff --git a/src/components/features/automations/detail/configuration-section.tsx b/src/components/features/automations/detail/configuration-section.tsx new file mode 100644 index 0000000000..0963d7b4ba --- /dev/null +++ b/src/components/features/automations/detail/configuration-section.tsx @@ -0,0 +1,85 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import type { Automation } from "#/types/automation"; +import CogIcon from "#/icons/cog.svg?react"; +import GitBranchIcon from "#/icons/git-branch.svg?react"; +import CheckCircleIcon from "#/icons/check-circle.svg?react"; +import CalendarIcon from "#/icons/calendar.svg?react"; +import SparkleIcon from "#/icons/sparkle.svg?react"; +import BellIcon from "#/icons/bell.svg?react"; +import { SectionCard } from "./section-card"; +import { ConfigField } from "./config-field"; +import { BranchBadge } from "./branch-badge"; + +interface ConfigurationSectionProps { + automation: Automation; +} + +export function ConfigurationSection({ + automation, +}: ConfigurationSectionProps) { + const { t } = useTranslation("openhands"); + + let scheduleDisplay = automation.trigger.schedule ?? ""; + if (automation.trigger.schedule_human) { + scheduleDisplay = automation.timezone + ? `${automation.trigger.schedule_human} (${automation.timezone})` + : automation.trigger.schedule_human; + } + + const triggerDisplay = + automation.trigger.type === "cron" ? "Schedule" : automation.trigger.type; + + return ( + } + title={t(I18nKey.AUTOMATIONS$DETAIL$CONFIGURATION)} + > +
+ {automation.repository && ( + } + label={t(I18nKey.AUTOMATIONS$DETAIL$REPOSITORIES)} + > + + {automation.repository} + {automation.branch && } + + + )} + + } + label={t(I18nKey.AUTOMATIONS$DETAIL$TRIGGER)} + > + {triggerDisplay} + + + } + label={t(I18nKey.AUTOMATIONS$DETAIL$SCHEDULE)} + > + {scheduleDisplay} + + + {automation.model && ( + } + label={t(I18nKey.AUTOMATIONS$DETAIL$MODEL)} + > + {automation.model} + + )} + + {automation.notification && ( + } + label={t(I18nKey.AUTOMATIONS$DETAIL$NOTIFICATION)} + > + {automation.notification} + + )} +
+
+ ); +} diff --git a/src/components/features/automations/detail/detail-header.tsx b/src/components/features/automations/detail/detail-header.tsx new file mode 100644 index 0000000000..2865ceb734 --- /dev/null +++ b/src/components/features/automations/detail/detail-header.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import type { Automation } from "#/types/automation"; +import { ToggleSwitch } from "#/components/features/automations/toggle-switch"; +import { KebabMenu } from "#/components/features/automations/kebab-menu"; +import PowerIcon from "#/icons/power.svg?react"; +import TrashIcon from "#/icons/trash.svg?react"; +import { useHasPermission } from "#/hooks/use-has-permission"; +import { ActiveStatusBadge } from "./active-status-badge"; + +interface DetailHeaderProps { + automation: Automation; + onToggle: () => void; + onDelete: () => void; +} + +export function DetailHeader({ + automation, + onToggle, + onDelete, +}: DetailHeaderProps) { + const { t } = useTranslation("openhands"); + const canManage = useHasPermission("manage_automations"); + + const kebabItems = [ + { + label: automation.enabled + ? t(I18nKey.AUTOMATIONS$TURN_OFF) + : t(I18nKey.AUTOMATIONS$TURN_ON), + icon: , + onClick: onToggle, + }, + { + label: t(I18nKey.AUTOMATIONS$DELETE), + icon: , + onClick: onDelete, + variant: "danger" as const, + }, + ]; + + return ( +
+
+
+

+ {automation.name} +

+ +
+
+ {canManage && ( + + )} + {canManage && } +
+
+
+ ); +} diff --git a/src/components/features/automations/detail/detail-skeleton.tsx b/src/components/features/automations/detail/detail-skeleton.tsx new file mode 100644 index 0000000000..07d2fdd59c --- /dev/null +++ b/src/components/features/automations/detail/detail-skeleton.tsx @@ -0,0 +1,28 @@ +function SkeletonBlock({ className }: { className: string }) { + return ( +
+ ); +} + +export function DetailSkeleton() { + return ( +
+ +
+
+ + +
+
+ + +
+
+ + + + + +
+ ); +} diff --git a/src/components/features/automations/detail/not-found-state.tsx b/src/components/features/automations/detail/not-found-state.tsx new file mode 100644 index 0000000000..785da91960 --- /dev/null +++ b/src/components/features/automations/detail/not-found-state.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import ExclamationCircleIcon from "#/icons/exclamation-circle.svg?react"; +import { BackLink } from "./back-link"; + +export function NotFoundState() { + const { t } = useTranslation("openhands"); + + return ( +
+ +

+ {t(I18nKey.AUTOMATIONS$DETAIL$NOT_FOUND_TITLE)} +

+

+ {t(I18nKey.AUTOMATIONS$DETAIL$NOT_FOUND_MESSAGE)} +

+
+ +
+
+ ); +} diff --git a/src/components/features/automations/detail/plugin-chip.tsx b/src/components/features/automations/detail/plugin-chip.tsx new file mode 100644 index 0000000000..077981a7c0 --- /dev/null +++ b/src/components/features/automations/detail/plugin-chip.tsx @@ -0,0 +1,11 @@ +interface PluginChipProps { + name: string; +} + +export function PluginChip({ name }: PluginChipProps) { + return ( + + {name} + + ); +} diff --git a/src/components/features/automations/detail/plugins-section.tsx b/src/components/features/automations/detail/plugins-section.tsx new file mode 100644 index 0000000000..a43e22ce51 --- /dev/null +++ b/src/components/features/automations/detail/plugins-section.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import PuzzleIcon from "#/icons/puzzle.svg?react"; +import { SectionCard } from "./section-card"; +import { PluginChip } from "./plugin-chip"; + +interface PluginsSectionProps { + plugins: string[]; +} + +export function PluginsSection({ plugins }: PluginsSectionProps) { + const { t } = useTranslation("openhands"); + + return ( + } + title={t(I18nKey.AUTOMATIONS$DETAIL$PLUGINS)} + > +
+ {plugins.map((plugin) => ( + + ))} +
+
+ ); +} diff --git a/src/components/features/automations/detail/prompt-section.tsx b/src/components/features/automations/detail/prompt-section.tsx new file mode 100644 index 0000000000..188807958a --- /dev/null +++ b/src/components/features/automations/detail/prompt-section.tsx @@ -0,0 +1,25 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import TerminalIcon from "#/icons/terminal.svg?react"; +import { SectionCard } from "./section-card"; + +interface PromptSectionProps { + prompt: string; +} + +export function PromptSection({ prompt }: PromptSectionProps) { + const { t } = useTranslation("openhands"); + + return ( + } + title={t(I18nKey.AUTOMATIONS$DETAIL$PROMPT)} + > +
+

+ {prompt} +

+
+
+ ); +} diff --git a/src/components/features/automations/detail/run-status-badge.tsx b/src/components/features/automations/detail/run-status-badge.tsx new file mode 100644 index 0000000000..b053729c6f --- /dev/null +++ b/src/components/features/automations/detail/run-status-badge.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import CheckCircleIcon from "#/icons/check-circle.svg?react"; +import XCircleIcon from "#/icons/x-circle.svg?react"; +import ClockIcon from "#/icons/clock.svg?react"; +import { AutomationRunStatus } from "#/types/automation"; + +interface RunStatusBadgeProps { + status: AutomationRunStatus; +} + +const statusConfig: Record< + AutomationRunStatus, + { label: I18nKey; style: string } +> = { + [AutomationRunStatus.COMPLETED]: { + label: I18nKey.AUTOMATIONS$DETAIL$SUCCESSFUL, + style: "border-success/50 bg-success/10 text-success", + }, + [AutomationRunStatus.FAILED]: { + label: I18nKey.AUTOMATIONS$DETAIL$FAILED, + style: "border-red-500/50 bg-red-500/10 text-red-400", + }, + [AutomationRunStatus.PENDING]: { + label: I18nKey.AUTOMATIONS$DETAIL$PENDING, + style: "border-neutral-600 bg-neutral-700 text-neutral-400", + }, + [AutomationRunStatus.RUNNING]: { + label: I18nKey.AUTOMATIONS$DETAIL$RUNNING, + style: "border-neutral-600 bg-neutral-700 text-neutral-400", + }, +}; + +function StatusIcon({ status }: { status: AutomationRunStatus }) { + switch (status) { + case AutomationRunStatus.COMPLETED: + return ; + case AutomationRunStatus.FAILED: + return ; + default: + return ; + } +} + +export function RunStatusBadge({ status }: RunStatusBadgeProps) { + const { t } = useTranslation("openhands"); + const config = statusConfig[status]; + + return ( + + + {t(config.label)} + + ); +} diff --git a/src/components/features/automations/detail/section-card.tsx b/src/components/features/automations/detail/section-card.tsx new file mode 100644 index 0000000000..b50df654c2 --- /dev/null +++ b/src/components/features/automations/detail/section-card.tsx @@ -0,0 +1,17 @@ +interface SectionCardProps { + icon: React.ReactNode; + title: string; + children: React.ReactNode; +} + +export function SectionCard({ icon, title, children }: SectionCardProps) { + return ( +
+
+ {icon} +

{title}

+
+
{children}
+
+ ); +} diff --git a/src/components/features/automations/empty-state.tsx b/src/components/features/automations/empty-state.tsx new file mode 100644 index 0000000000..51f1bcbed6 --- /dev/null +++ b/src/components/features/automations/empty-state.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import DatabaseIcon from "#/icons/database.svg?react"; +import { CreateInstructions } from "./create-instructions"; + +export function EmptyState() { + const { t } = useTranslation("openhands"); + + return ( +
+ +

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

+ + {/* How to create section */} +
+ +
+
+ ); +} diff --git a/src/components/features/automations/error-state.tsx b/src/components/features/automations/error-state.tsx new file mode 100644 index 0000000000..d2c6b2fc8d --- /dev/null +++ b/src/components/features/automations/error-state.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import ExclamationCircleIcon from "#/icons/exclamation-circle.svg?react"; + +interface ErrorStateProps { + onRetry: () => void; +} + +export function ErrorState({ onRetry }: ErrorStateProps) { + const { t } = useTranslation("openhands"); + + return ( +
+ +

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

+ +
+ ); +} diff --git a/src/components/features/automations/kebab-menu.tsx b/src/components/features/automations/kebab-menu.tsx new file mode 100644 index 0000000000..bf656e7286 --- /dev/null +++ b/src/components/features/automations/kebab-menu.tsx @@ -0,0 +1,69 @@ +import { useState, useEffect, useRef } from "react"; +import KebabVerticalIcon from "#/icons/kebab-vertical.svg?react"; + +export interface KebabMenuItem { + label: string; + icon: React.ReactNode; + onClick: () => void; + variant?: "default" | "danger"; +} + +interface KebabMenuProps { + items: KebabMenuItem[]; +} + +export function KebabMenu({ items }: KebabMenuProps) { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!open) return undefined; + + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [open]); + + return ( +
+ + + {open && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/features/automations/metadata-chip.tsx b/src/components/features/automations/metadata-chip.tsx new file mode 100644 index 0000000000..c8871c3be2 --- /dev/null +++ b/src/components/features/automations/metadata-chip.tsx @@ -0,0 +1,13 @@ +interface MetadataChipProps { + icon: React.ReactNode; + label: string; +} + +export function MetadataChip({ icon, label }: MetadataChipProps) { + return ( + + {icon} + {label} + + ); +} diff --git a/src/components/features/automations/search-input.tsx b/src/components/features/automations/search-input.tsx new file mode 100644 index 0000000000..03b48f0d58 --- /dev/null +++ b/src/components/features/automations/search-input.tsx @@ -0,0 +1,25 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import SearchIcon from "#/icons/search.svg?react"; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; +} + +export function SearchInput({ value, onChange }: SearchInputProps) { + const { t } = useTranslation("openhands"); + + return ( +
+ + onChange(e.target.value)} + placeholder={t(I18nKey.AUTOMATIONS$SEARCH_PLACEHOLDER)} + className="w-full max-w-sm rounded-lg border border-neutral-600 bg-neutral-800 py-2 pl-10 pr-3 text-sm text-white placeholder:text-neutral-400 focus:border-neutral-500 focus:outline-none" + /> +
+ ); +} diff --git a/src/components/features/automations/status-badge.tsx b/src/components/features/automations/status-badge.tsx new file mode 100644 index 0000000000..88af655078 --- /dev/null +++ b/src/components/features/automations/status-badge.tsx @@ -0,0 +1,11 @@ +interface StatusBadgeProps { + count: number; +} + +export function StatusBadge({ count }: StatusBadgeProps) { + return ( + + {count} + + ); +} diff --git a/src/components/features/automations/toggle-switch.tsx b/src/components/features/automations/toggle-switch.tsx new file mode 100644 index 0000000000..debb1cfd68 --- /dev/null +++ b/src/components/features/automations/toggle-switch.tsx @@ -0,0 +1,33 @@ +interface ToggleSwitchProps { + enabled: boolean; + label: string; + onToggle: () => void; +} + +export function ToggleSwitch({ enabled, label, onToggle }: ToggleSwitchProps) { + return ( + + ); +} diff --git a/src/hooks/query/use-automation-detail.ts b/src/hooks/query/use-automation-detail.ts new file mode 100644 index 0000000000..85f7849348 --- /dev/null +++ b/src/hooks/query/use-automation-detail.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; +import AutomationService from "#/api/automation-service/automation-service.api"; + +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) { + return useQuery({ + queryKey: [...AUTOMATION_DETAIL_QUERY_KEY, id], + queryFn: () => AutomationService.getAutomation(id), + staleTime: 5 * 60 * 1000, + enabled: !!id, + }); +} + +export function useAutomationRuns(id: string, limit = 20, offset = 0) { + return useQuery({ + queryKey: [...AUTOMATION_RUNS_QUERY_KEY, id, { limit, offset }], + queryFn: () => AutomationService.getAutomationRuns(id, limit, offset), + staleTime: 60 * 1000, + enabled: !!id, + }); +} diff --git a/src/hooks/query/use-automations.ts b/src/hooks/query/use-automations.ts new file mode 100644 index 0000000000..40279bdcbe --- /dev/null +++ b/src/hooks/query/use-automations.ts @@ -0,0 +1,35 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import AutomationService from "#/api/automation-service/automation-service.api"; +import { AUTOMATION_DETAIL_QUERY_KEY } from "./use-automation-detail"; + +export const AUTOMATIONS_QUERY_KEY = ["automations"] as const; + +export function useAutomations(limit = 50, offset = 0) { + return useQuery({ + queryKey: [...AUTOMATIONS_QUERY_KEY, { limit, offset }], + queryFn: () => AutomationService.getAutomations(limit, offset), + staleTime: 5 * 60 * 1000, + }); +} + +export function useToggleAutomation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + AutomationService.toggleAutomation(id, enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: AUTOMATIONS_QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: AUTOMATION_DETAIL_QUERY_KEY }); + }, + }); +} + +export function useDeleteAutomation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => AutomationService.deleteAutomation(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: AUTOMATIONS_QUERY_KEY }); + }, + }); +} diff --git a/src/hooks/use-has-permission.ts b/src/hooks/use-has-permission.ts new file mode 100644 index 0000000000..3d4d7258f1 --- /dev/null +++ b/src/hooks/use-has-permission.ts @@ -0,0 +1,6 @@ +// Simple permission hook for OSS agent-canvas +// In the OSS context, all authenticated users have full access +export function useHasPermission(_permission: string): boolean { + // In OSS mode, always return true - no permission restrictions + return true; +} diff --git a/src/i18n/translation.json b/src/i18n/translation.json index d56b8ef35d..61eb8ac2b2 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -11123,6 +11123,218 @@ "uk": "Автоматизації", "ca": "Automatitzacions" }, + "AUTOMATIONS$TITLE": { + "en": "Automations", + "ja": "オートメーション" + }, + "AUTOMATIONS$SUBTITLE": { + "en": "View active and inactive automations, search by metadata, and inspect read-only details.", + "ja": "アクティブおよび非アクティブなオートメーションを表示し、メタデータで検索し、読み取り専用の詳細を確認します。" + }, + "AUTOMATIONS$EMPTY": { + "en": "No automations configured", + "ja": "オートメーションが設定されていません" + }, + "AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE": { + "en": "How to create an automation", + "ja": "オートメーションの作成方法" + }, + "AUTOMATIONS$EMPTY_OPTION_PLUGIN_TITLE": { + "en": "Using Claude Code or Codex", + "ja": "Claude CodeまたはCodexを使用" + }, + "AUTOMATIONS$EMPTY_OPTION_PLUGIN_DESC": { + "en": "and run the command:", + "ja": "以下のコマンドを実行:" + }, + "AUTOMATIONS$EMPTY_INSTALL_PLUGIN": { + "en": "Install the OpenHands plugin", + "ja": "OpenHandsプラグインをインストール" + }, + "AUTOMATIONS$EMPTY_OPTION_CONVERSATION_TITLE": { + "en": "Using OpenHands Cloud", + "ja": "OpenHands Cloudを使用" + }, + "AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC": { + "en": "Start a new conversation and ask OpenHands to create an automation for you.", + "ja": "新しい会話を開始し、OpenHandsにオートメーションの作成を依頼してください。" + }, + "AUTOMATIONS$EMPTY_START_CONVERSATION": { + "en": "Start a conversation", + "ja": "会話を開始" + }, + "AUTOMATIONS$EMPTY_LEARN_MORE": { + "en": "Learn more in the documentation", + "ja": "ドキュメントで詳細を確認" + }, + "AUTOMATIONS$SEARCH_PLACEHOLDER": { + "en": "Search automations...", + "ja": "オートメーションを検索..." + }, + "AUTOMATIONS$ACTIVE": { + "en": "Active", + "ja": "アクティブ" + }, + "AUTOMATIONS$INACTIVE": { + "en": "Inactive", + "ja": "非アクティブ" + }, + "AUTOMATIONS$TURN_ON": { + "en": "Turn on", + "ja": "オンにする" + }, + "AUTOMATIONS$TURN_OFF": { + "en": "Turn off", + "ja": "オフにする" + }, + "AUTOMATIONS$DELETE": { + "en": "Delete", + "ja": "削除" + }, + "AUTOMATIONS$DELETE_CONFIRM_TITLE": { + "en": "Delete automation", + "ja": "オートメーションを削除" + }, + "AUTOMATIONS$DELETE_CONFIRM_MESSAGE": { + "en": "Delete \"{{name}}\"? This cannot be undone.", + "ja": "「{{name}}」を削除しますか?この操作は取り消せません。" + }, + "AUTOMATIONS$CANCEL": { + "en": "Cancel", + "ja": "キャンセル" + }, + "AUTOMATIONS$LOAD_MORE": { + "en": "Load more", + "ja": "さらに読み込む" + }, + "AUTOMATIONS$ERROR_TITLE": { + "en": "Failed to load automations", + "ja": "オートメーションの読み込みに失敗しました" + }, + "AUTOMATIONS$ERROR_RETRY": { + "en": "Retry", + "ja": "再試行" + }, + "AUTOMATIONS$DETAIL$ACTIVE": { + "en": "Active", + "ja": "アクティブ" + }, + "AUTOMATIONS$DETAIL$ACTIVITY": { + "en": "Activity", + "ja": "アクティビティ" + }, + "AUTOMATIONS$DETAIL$ACTIVITY_LOG": { + "en": "Activity Log", + "ja": "アクティビティログ" + }, + "AUTOMATIONS$DETAIL$BACK_TO_LIST": { + "en": "Back to Automations", + "ja": "オートメーション一覧に戻る" + }, + "AUTOMATIONS$DETAIL$CONFIGURATION": { + "en": "Configuration", + "ja": "設定" + }, + "AUTOMATIONS$DETAIL$CREATED": { + "en": "Created", + "ja": "作成日" + }, + "AUTOMATIONS$DETAIL$FAILED": { + "en": "Failed", + "ja": "失敗" + }, + "AUTOMATIONS$DETAIL$PENDING": { + "en": "Pending", + "ja": "保留中" + }, + "AUTOMATIONS$DETAIL$RUNNING": { + "en": "Running", + "ja": "実行中" + }, + "AUTOMATIONS$DETAIL$INACTIVE": { + "en": "Inactive", + "ja": "非アクティブ" + }, + "AUTOMATIONS$DETAIL$LAST_RUN": { + "en": "Last run", + "ja": "最終実行" + }, + "AUTOMATIONS$DETAIL$LOAD_MORE_RUNS": { + "en": "Load more", + "ja": "さらに読み込む" + }, + "AUTOMATIONS$DETAIL$MODEL": { + "en": "Model", + "ja": "モデル" + }, + "AUTOMATIONS$DETAIL$NO_RUNS": { + "en": "No activity yet", + "ja": "まだアクティビティはありません" + }, + "AUTOMATIONS$DETAIL$NO_CONVERSATION": { + "en": "Conversation not created", + "ja": "会話が作成されていません" + }, + "AUTOMATIONS$DETAIL$TIME_JUST_NOW": { + "en": "Just now", + "ja": "たった今" + }, + "AUTOMATIONS$DETAIL$TIME_MINUTES_AGO": { + "en": "{{count}}m ago", + "ja": "{{count}}分前" + }, + "AUTOMATIONS$DETAIL$TIME_HOURS_AGO": { + "en": "{{count}}h ago", + "ja": "{{count}}時間前" + }, + "AUTOMATIONS$DETAIL$TIME_YESTERDAY": { + "en": "Yesterday", + "ja": "昨日" + }, + "AUTOMATIONS$DETAIL$TIME_DAYS_AGO": { + "en": "{{count}}d ago", + "ja": "{{count}}日前" + }, + "AUTOMATIONS$DETAIL$TIME_NEVER": { + "en": "Never", + "ja": "未実行" + }, + "AUTOMATIONS$DETAIL$NOT_FOUND_MESSAGE": { + "en": "The automation you're looking for doesn't exist or has been deleted.", + "ja": "お探しのオートメーションは存在しないか、削除されています。" + }, + "AUTOMATIONS$DETAIL$NOT_FOUND_TITLE": { + "en": "Automation not found", + "ja": "オートメーションが見つかりません" + }, + "AUTOMATIONS$DETAIL$NOTIFICATION": { + "en": "Notification", + "ja": "通知" + }, + "AUTOMATIONS$DETAIL$PLUGINS": { + "en": "Plugins", + "ja": "プラグイン" + }, + "AUTOMATIONS$DETAIL$PROMPT": { + "en": "Prompt", + "ja": "プロンプト" + }, + "AUTOMATIONS$DETAIL$REPOSITORIES": { + "en": "Repositories", + "ja": "リポジトリ" + }, + "AUTOMATIONS$DETAIL$SCHEDULE": { + "en": "Schedule", + "ja": "スケジュール" + }, + "AUTOMATIONS$DETAIL$SUCCESSFUL": { + "en": "Successful", + "ja": "成功" + }, + "AUTOMATIONS$DETAIL$TRIGGER": { + "en": "Trigger", + "ja": "トリガー" + }, "SIDEBAR$CONVERSATIONS": { "en": "Conversations", "ja": "会話", diff --git a/src/icons/activity.svg b/src/icons/activity.svg new file mode 100644 index 0000000000..161729d381 --- /dev/null +++ b/src/icons/activity.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/bell.svg b/src/icons/bell.svg new file mode 100644 index 0000000000..b5d7f207d8 --- /dev/null +++ b/src/icons/bell.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/calendar.svg b/src/icons/calendar.svg new file mode 100644 index 0000000000..dd391d7531 --- /dev/null +++ b/src/icons/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/check-circle.svg b/src/icons/check-circle.svg new file mode 100644 index 0000000000..6eff56672c --- /dev/null +++ b/src/icons/check-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/chevron-down.svg b/src/icons/chevron-down.svg new file mode 100644 index 0000000000..908fcc2d13 --- /dev/null +++ b/src/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/chevron-left.svg b/src/icons/chevron-left.svg new file mode 100644 index 0000000000..b885c74ad3 --- /dev/null +++ b/src/icons/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/clock.svg b/src/icons/clock.svg new file mode 100644 index 0000000000..6db21960c0 --- /dev/null +++ b/src/icons/clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/cog.svg b/src/icons/cog.svg new file mode 100644 index 0000000000..0a424e353e --- /dev/null +++ b/src/icons/cog.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/database.svg b/src/icons/database.svg new file mode 100644 index 0000000000..92815ccce2 --- /dev/null +++ b/src/icons/database.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/exclamation-circle.svg b/src/icons/exclamation-circle.svg new file mode 100644 index 0000000000..b20b01a391 --- /dev/null +++ b/src/icons/exclamation-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/git-branch.svg b/src/icons/git-branch.svg new file mode 100644 index 0000000000..1f7fc2fc7e --- /dev/null +++ b/src/icons/git-branch.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/icons/kebab-vertical.svg b/src/icons/kebab-vertical.svg new file mode 100644 index 0000000000..dc0958d997 --- /dev/null +++ b/src/icons/kebab-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/power.svg b/src/icons/power.svg new file mode 100644 index 0000000000..f8f8b8d561 --- /dev/null +++ b/src/icons/power.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/puzzle.svg b/src/icons/puzzle.svg new file mode 100644 index 0000000000..2e7b29504a --- /dev/null +++ b/src/icons/puzzle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/search.svg b/src/icons/search.svg new file mode 100644 index 0000000000..9a476e804b --- /dev/null +++ b/src/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/sparkle.svg b/src/icons/sparkle.svg new file mode 100644 index 0000000000..73e65f2bda --- /dev/null +++ b/src/icons/sparkle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/target.svg b/src/icons/target.svg new file mode 100644 index 0000000000..6eff56672c --- /dev/null +++ b/src/icons/target.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/trash.svg b/src/icons/trash.svg new file mode 100644 index 0000000000..786b6e331a --- /dev/null +++ b/src/icons/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/x-circle.svg b/src/icons/x-circle.svg new file mode 100644 index 0000000000..6ddf2d1a6b --- /dev/null +++ b/src/icons/x-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/x-mark.svg b/src/icons/x-mark.svg new file mode 100644 index 0000000000..bec5620217 --- /dev/null +++ b/src/icons/x-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/mocks/automation-handlers.ts b/src/mocks/automation-handlers.ts new file mode 100644 index 0000000000..8c87c6f305 --- /dev/null +++ b/src/mocks/automation-handlers.ts @@ -0,0 +1,123 @@ +import { http, HttpResponse, delay } from "msw"; +import type { + Automation, + AutomationsResponse, + AutomationRunsResponse, +} from "#/types/automation"; +import { MOCK_AUTOMATIONS_RESPONSE } from "./automations.mock"; +import { MOCK_AUTOMATION_RUNS } from "./automation-runs.mock"; + +// Mutable copy for CRUD operations within the mock session +const automations = new Map( + MOCK_AUTOMATIONS_RESPONSE.automations.map((a) => [a.id, { ...a }]), +); + +export const resetAutomationMockData = () => { + automations.clear(); + MOCK_AUTOMATIONS_RESPONSE.automations.forEach((a) => { + automations.set(a.id, { ...a }); + }); +}; + +export const AUTOMATION_HANDLERS = [ + // GET /api/automation/v1 — List automations + http.get("/api/automation/v1", async ({ request }) => { + await delay(300); + + const url = new URL(request.url); + const limit = Number(url.searchParams.get("limit") ?? "50"); + const offset = Number(url.searchParams.get("offset") ?? "0"); + + const all = Array.from(automations.values()); + const page = all.slice(offset, offset + limit); + + const response: AutomationsResponse = { + automations: page, + total: all.length, + }; + + return HttpResponse.json(response); + }), + + // GET /api/automation/v1/:id/runs — List automation runs + http.get("/api/automation/v1/:id/runs", async ({ params, request }) => { + await delay(200); + + const id = params.id as string; + if (!automations.has(id)) { + return HttpResponse.json( + { detail: "Automation not found" }, + { status: 404 }, + ); + } + + const url = new URL(request.url); + const limit = Number(url.searchParams.get("limit") ?? "50"); + const offset = Number(url.searchParams.get("offset") ?? "0"); + + const allRuns = MOCK_AUTOMATION_RUNS[id] ?? []; + const page = allRuns.slice(offset, offset + limit); + + const response: AutomationRunsResponse = { + runs: page, + total: allRuns.length, + }; + + return HttpResponse.json(response); + }), + + // GET /api/automation/v1/:id — Get automation detail + http.get("/api/automation/v1/:id", async ({ params }) => { + await delay(200); + + const automation = automations.get(params.id as string); + if (!automation) { + return HttpResponse.json( + { detail: "Automation not found" }, + { status: 404 }, + ); + } + + return HttpResponse.json(automation); + }), + + // PATCH /api/automation/v1/:id — Update automation (toggle enabled) + http.patch("/api/automation/v1/:id", async ({ params, request }) => { + await delay(200); + + const id = params.id as string; + const automation = automations.get(id); + if (!automation) { + return HttpResponse.json( + { detail: "Automation not found" }, + { status: 404 }, + ); + } + + const body = (await request.json()) as Partial; + const updated: Automation = { + ...automation, + ...body, + updated_at: new Date().toISOString(), + }; + automations.set(id, updated); + + return HttpResponse.json(updated); + }), + + // DELETE /api/automation/v1/:id — Delete automation + http.delete("/api/automation/v1/:id", async ({ params }) => { + await delay(200); + + const id = params.id as string; + if (!automations.has(id)) { + return HttpResponse.json( + { detail: "Automation not found" }, + { status: 404 }, + ); + } + + automations.delete(id); + return new HttpResponse(null, { status: 204 }); + }), +]; diff --git a/src/mocks/automation-runs.mock.ts b/src/mocks/automation-runs.mock.ts new file mode 100644 index 0000000000..b4b1b5325b --- /dev/null +++ b/src/mocks/automation-runs.mock.ts @@ -0,0 +1,61 @@ +import { AutomationRunStatus } from "#/types/automation"; +import type { AutomationRun } from "#/types/automation"; + +const daysAgo = (days: number, hour = 9) => { + const d = new Date(Date.now() - days * 86_400_000); + d.setHours(hour, 0, 0, 0); + return d.toISOString(); +}; + +function makeRun( + id: string, + status: AutomationRunStatus, + startedDaysAgo: number, + hour = 9, + hasConversation = true, +): AutomationRun { + const started = daysAgo(startedDaysAgo, hour); + return { + id, + status, + conversation_id: hasConversation ? `conv-${id}` : null, + error_detail: + status === AutomationRunStatus.FAILED + ? "Process exited with code 1" + : null, + started_at: started, + completed_at: new Date(new Date(started).getTime() + 120_000).toISOString(), + }; +} + +export const MOCK_AUTOMATION_RUNS: Record = { + "a1000000-0000-0000-0000-000000000001": [ + makeRun("r1-01", AutomationRunStatus.COMPLETED, 0), + makeRun("r1-02", AutomationRunStatus.COMPLETED, 1), + makeRun("r1-03", AutomationRunStatus.FAILED, 2), + makeRun("r1-04", AutomationRunStatus.COMPLETED, 3), + makeRun("r1-05", AutomationRunStatus.COMPLETED, 4), + makeRun("r1-06", AutomationRunStatus.COMPLETED, 7), + makeRun("r1-07", AutomationRunStatus.FAILED, 8), + makeRun("r1-08", AutomationRunStatus.COMPLETED, 9), + makeRun("r1-09", AutomationRunStatus.COMPLETED, 10), + makeRun("r1-10", AutomationRunStatus.COMPLETED, 11), + ], + "a1000000-0000-0000-0000-000000000002": [ + makeRun("r2-01", AutomationRunStatus.COMPLETED, 0, 1), + makeRun("r2-02", AutomationRunStatus.COMPLETED, 1, 1), + makeRun("r2-03", AutomationRunStatus.COMPLETED, 2, 1), + makeRun("r2-04", AutomationRunStatus.FAILED, 3, 1), + makeRun("r2-05", AutomationRunStatus.COMPLETED, 4, 1), + ], + "a1000000-0000-0000-0000-000000000003": [ + makeRun("r3-01", AutomationRunStatus.COMPLETED, 1), + makeRun("r3-02", AutomationRunStatus.COMPLETED, 2), + makeRun("r3-03", AutomationRunStatus.COMPLETED, 3), + ], + "a1000000-0000-0000-0000-000000000004": [ + makeRun("r4-01", AutomationRunStatus.FAILED, 14, 11, false), // Failed before sandbox creation + makeRun("r4-02", AutomationRunStatus.COMPLETED, 21, 11), + ], + "a1000000-0000-0000-0000-000000000005": [], +}; diff --git a/src/mocks/automations.mock.ts b/src/mocks/automations.mock.ts new file mode 100644 index 0000000000..b89f1995fa --- /dev/null +++ b/src/mocks/automations.mock.ts @@ -0,0 +1,127 @@ +import type { AutomationsResponse } from "#/types/automation"; + +/** + * Mock automations data matching the AutomationResponse schema from the backend. + * + * Backend schema fields: id, user_id, org_id, name, trigger (JSONB), + * tarball_path, setup_script_path, entrypoint, timeout, enabled, + * last_triggered_at, created_at, updated_at. + * + * The frontend Automation type only uses a subset of these fields. + * Additional backend fields are included here for API fidelity. + */ + +const now = new Date().toISOString(); +const daysAgo = (days: number) => + new Date(Date.now() - days * 86_400_000).toISOString(); + +export const MOCK_AUTOMATIONS_RESPONSE: AutomationsResponse = { + automations: [ + { + id: "a1000000-0000-0000-0000-000000000001", + name: "PR Triage Digest", + trigger: { + type: "cron", + schedule: "0 9 * * 1-5", + schedule_human: "Weekdays at 09:00", + }, + enabled: true, + repository: "acme/frontend-app", + model: "Claude Opus", + created_at: daysAgo(90), + updated_at: now, + prompt: + "Review newly opened pull requests in acme/frontend-app, identify risky changes, summarize likely impact, and prepare a concise digest with priority ordering for the engineering review channel.", + branch: "main", + plugins: ["GitHub", "Slack", "Linear"], + notification: "Slack digest to #eng-reviews", + timezone: "America/Los_Angeles", + last_triggered_at: daysAgo(0), + }, + { + id: "a1000000-0000-0000-0000-000000000002", + name: "Nightly Security Pass", + trigger: { + type: "cron", + schedule: "30 1 * * *", + schedule_human: "Daily at 01:30", + }, + enabled: true, + repository: "acme/backend-api", + model: "GPT-5", + created_at: daysAgo(60), + updated_at: now, + prompt: + "Scan the acme/backend-api repository for known security vulnerabilities, outdated dependencies, and insecure code patterns. Produce a prioritized remediation summary.", + branch: "main", + plugins: ["GitHub"], + notification: "Email to security-team@acme.com", + timezone: "UTC", + last_triggered_at: daysAgo(0), + }, + { + id: "a1000000-0000-0000-0000-000000000003", + name: "Docs Sync on Push", + trigger: { + type: "cron", + schedule: "*/5 * * * *", + schedule_human: "Runs on every push", + }, + enabled: true, + repository: "acme/docs", + model: "GPT-4o", + created_at: daysAgo(45), + updated_at: now, + prompt: + "Monitor acme/docs for new pushes. For each push, generate a changelog-ready summary of what changed and why.", + branch: "main", + plugins: ["GitHub", "Slack"], + notification: "Slack to #docs-updates", + timezone: "America/New_York", + last_triggered_at: daysAgo(1), + }, + { + id: "a1000000-0000-0000-0000-000000000004", + name: "Release Readiness Review", + trigger: { + type: "cron", + schedule: "0 11 * * 5", + schedule_human: "Fridays at 11:00", + }, + enabled: false, + repository: "acme/realtime-service", + model: "Gemini 2.5 Pro", + created_at: daysAgo(80), + updated_at: now, + prompt: + "Compile a release readiness report: list open blockers, active incidents, and pending approvals for acme/realtime-service.", + branch: "release", + plugins: ["GitHub", "Linear"], + notification: "Slack to #releases", + timezone: "America/Chicago", + last_triggered_at: daysAgo(14), + }, + { + id: "a1000000-0000-0000-0000-000000000005", + name: "Incident Webhook Summary", + trigger: { + type: "cron", + schedule: "0 */2 * * *", + schedule_human: "On incident webhook", + }, + enabled: false, + repository: "acme/incident-service", + model: "Claude Sonnet", + created_at: daysAgo(30), + updated_at: now, + prompt: + "Summarize incoming incident webhooks, categorize by severity, and post a digest to the on-call Slack channel.", + branch: "main", + plugins: ["Slack"], + notification: "Slack to #oncall", + timezone: "UTC", + last_triggered_at: null, + }, + ], + total: 5, +}; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 300deed564..f5f9c5f7c6 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -12,6 +12,10 @@ import { CONVERSATION_HANDLERS } from "./conversation-handlers"; import { AUTH_HANDLERS } from "./auth-handlers"; import { FEEDBACK_HANDLERS } from "./feedback-handlers"; import { ANALYTICS_HANDLERS } from "./analytics-handlers"; +import { + AUTOMATION_HANDLERS, + resetAutomationMockData, +} from "./automation-handlers"; export const handlers = [ ...API_KEYS_HANDLERS, @@ -24,6 +28,11 @@ export const handlers = [ ...AUTH_HANDLERS, ...FEEDBACK_HANDLERS, ...ANALYTICS_HANDLERS, + ...AUTOMATION_HANDLERS, ]; -export { MOCK_DEFAULT_USER_SETTINGS, resetTestHandlersMockSettings }; +export { + MOCK_DEFAULT_USER_SETTINGS, + resetTestHandlersMockSettings, + resetAutomationMockData, +}; diff --git a/src/routes.ts b/src/routes.ts index 903369d82b..0eab31ff98 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -22,6 +22,8 @@ export default [ ]), route("conversations/:conversationId", "routes/conversation.tsx"), route("oauth/device/verify", "routes/device-verify.tsx"), + route("automations", "routes/automations-list.tsx"), + route("automations/:automationId", "routes/automation-detail.tsx"), ]), route( "shared/conversations/:conversationId", diff --git a/src/routes/automation-detail.tsx b/src/routes/automation-detail.tsx new file mode 100644 index 0000000000..a81dea44a0 --- /dev/null +++ b/src/routes/automation-detail.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { useParams } from "react-router"; +import { isAxiosError } from "axios"; +import { useAutomationDetail } from "#/hooks/query/use-automation-detail"; +import { + useToggleAutomation, + useDeleteAutomation, +} from "#/hooks/query/use-automations"; +import { useNavigation } from "#/context/navigation-context"; +import { BackLink } from "#/components/features/automations/detail/back-link"; +import { DetailHeader } from "#/components/features/automations/detail/detail-header"; +import { PromptSection } from "#/components/features/automations/detail/prompt-section"; +import { ConfigurationSection } from "#/components/features/automations/detail/configuration-section"; +import { PluginsSection } from "#/components/features/automations/detail/plugins-section"; +import { ActivitySection } from "#/components/features/automations/detail/activity-section"; +import { ActivityLogSection } from "#/components/features/automations/detail/activity-log-section"; +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 { DeleteConfirmationModal } from "#/components/features/automations/delete-confirmation-modal"; + +export default function AutomationDetail() { + const { automationId } = useParams(); + const { navigate } = useNavigation(); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const { + data: automation, + isLoading, + isError, + error, + refetch, + } = useAutomationDetail(automationId ?? ""); + + const toggleMutation = useToggleAutomation(); + const deleteMutation = useDeleteAutomation(); + + const is404 = + isError && isAxiosError(error) && error.response?.status === 404; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (is404) { + return ( +
+ +
+ ); + } + + if (isError || !automation) { + return ( +
+ refetch()} /> +
+ ); + } + + const handleToggle = () => { + toggleMutation.mutate({ + id: automation.id, + enabled: !automation.enabled, + }); + }; + + const handleDelete = () => { + deleteMutation.mutate(automation.id, { + onSuccess: () => { + navigate?.("/automations"); + }, + }); + }; + + return ( +
+
+ + setShowDeleteModal(true)} + /> + {automation.prompt && } + + {automation.plugins && automation.plugins.length > 0 && ( + + )} + + + setShowDeleteModal(false)} + /> +
+
+ ); +} diff --git a/src/routes/automations-list.tsx b/src/routes/automations-list.tsx new file mode 100644 index 0000000000..879ef6807c --- /dev/null +++ b/src/routes/automations-list.tsx @@ -0,0 +1,144 @@ +import { useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { + useAutomations, + useToggleAutomation, + useDeleteAutomation, +} from "#/hooks/query/use-automations"; +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 { DeleteConfirmationModal } from "#/components/features/automations/delete-confirmation-modal"; +import { CreateInstructions } from "#/components/features/automations/create-instructions"; + +const PAGE_SIZE = 50; + +export default function AutomationsList() { + const { t } = useTranslation("openhands"); + const [searchQuery, setSearchQuery] = useState(""); + const [limit, setLimit] = useState(PAGE_SIZE); + const [deleteTarget, setDeleteTarget] = useState<{ + id: string; + name: string; + } | null>(null); + + const { data, isLoading, isError, refetch } = useAutomations(limit, 0); + const toggleMutation = useToggleAutomation(); + const deleteMutation = useDeleteAutomation(); + + const filtered = useMemo(() => { + if (!data?.automations) return []; + const q = searchQuery.toLowerCase(); + if (!q) return data.automations; + return data.automations.filter( + (a) => + a.name.toLowerCase().includes(q) || + (a.prompt ?? "").toLowerCase().includes(q) || + a.repository?.toLowerCase().includes(q) || + a.model?.toLowerCase().includes(q), + ); + }, [data?.automations, searchQuery]); + + const active = useMemo(() => filtered.filter((a) => a.enabled), [filtered]); + const inactive = useMemo( + () => filtered.filter((a) => !a.enabled), + [filtered], + ); + + const handleToggle = (id: string, currentEnabled: boolean) => { + toggleMutation.mutate({ id, enabled: !currentEnabled }); + }; + + const handleDeleteRequest = (id: string) => { + const automation = data?.automations.find((a) => a.id === id); + if (automation) { + setDeleteTarget({ id, name: automation.name }); + } + }; + + const handleDeleteConfirm = () => { + if (deleteTarget) { + deleteMutation.mutate(deleteTarget.id); + setDeleteTarget(null); + } + }; + + const hasMore = data ? data.total > data.automations.length : false; + + return ( +
+ {/* Header */} +

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

+

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

+ + {/* Search */} +
+ +
+ + {/* Content */} +
+ {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {isError && !isLoading && } + + {!isLoading && !isError && data && data.automations.length === 0 && ( + + )} + + {!isLoading && !isError && data && data.automations.length > 0 && ( + <> + {/* Collapsible creation instructions at the top */} + + + + + + {hasMore && ( + + )} + + )} +
+ + {/* Delete confirmation modal */} + setDeleteTarget(null)} + /> +
+ ); +} diff --git a/src/types/automation.ts b/src/types/automation.ts new file mode 100644 index 0000000000..2b08d7c215 --- /dev/null +++ b/src/types/automation.ts @@ -0,0 +1,48 @@ +export interface AutomationTrigger { + type: string; + schedule?: string; + schedule_human?: string; +} + +export interface Automation { + id: string; + name: string; + trigger: AutomationTrigger; + enabled: boolean; + repository?: string; + model?: string; + created_at: string; + updated_at: string; + prompt: string | null; + branch?: string; + plugins?: string[]; + notification?: string; + timezone?: string; + last_triggered_at?: string | null; +} + +export interface AutomationsResponse { + automations: Automation[]; + total: number; +} + +export enum AutomationRunStatus { + PENDING = "PENDING", + RUNNING = "RUNNING", + COMPLETED = "COMPLETED", + FAILED = "FAILED", +} + +export interface AutomationRun { + id: string; + status: AutomationRunStatus; + conversation_id: string | null; + error_detail: string | null; + started_at: string; + completed_at: string | null; +} + +export interface AutomationRunsResponse { + runs: AutomationRun[]; + total: number; +} diff --git a/test-utils.tsx b/test-utils.tsx index 5bf5c784e9..f729392963 100644 --- a/test-utils.tsx +++ b/test-utils.tsx @@ -34,20 +34,24 @@ vi.mock("react-router", async () => { }; }); -// Initialize i18n for tests +// Initialize i18n for tests - return key when translation is missing i18n.use(initReactI18next).init({ lng: "en", fallbackLng: "en", - ns: ["translation"], + ns: ["translation", "openhands"], defaultNS: "translation", resources: { en: { translation: {}, + openhands: {}, }, }, interpolation: { escapeValue: false, }, + returnEmptyString: false, + missingKeyHandler: false, + saveMissing: false, }); const createNavigationValue = (