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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> * 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 <openhands@all-hands.dev> --------- Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: hieptl <hieptl.developer@gmail.com>
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(<NotFoundState />);
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<RunStatusBadge status={AutomationRunStatus.COMPLETED} />,
|
||||
);
|
||||
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(
|
||||
<RunStatusBadge status={AutomationRunStatus.FAILED} />,
|
||||
);
|
||||
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(
|
||||
<RunStatusBadge status={AutomationRunStatus.PENDING} />,
|
||||
);
|
||||
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(
|
||||
<RunStatusBadge status={AutomationRunStatus.RUNNING} />,
|
||||
);
|
||||
const badge = container.querySelector("span");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge?.className).toContain("text-neutral-400");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<SectionCard icon={<span data-testid="icon" />} title="Test Section">
|
||||
<p>{String("Section content")}</p>
|
||||
</SectionCard>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Section")).toBeInTheDocument();
|
||||
expect(screen.getByText("Section content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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(<ErrorState onRetry={vi.fn()} />);
|
||||
|
||||
// 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(<ErrorState onRetry={onRetry} />);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<MetadataChip
|
||||
icon={<span data-testid="chip-icon" />}
|
||||
label="acme/frontend-app"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("acme/frontend-app")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chip-icon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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(<SearchInput value="" onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.type(input, "test");
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
expect(onChange).toHaveBeenLastCalledWith("t");
|
||||
});
|
||||
|
||||
it("displays the current value", () => {
|
||||
render(<SearchInput value="security" onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("security");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<ToggleSwitch enabled={false} label="Toggle test" onToggle={onToggle} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
|
||||
expect(onToggle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reflects enabled state via aria-checked", () => {
|
||||
render(<ToggleSwitch enabled label="Toggle test" onToggle={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("stops event propagation on click", async () => {
|
||||
const parentClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<div onClick={parentClick} onKeyDown={parentClick} role="presentation">
|
||||
<ToggleSwitch enabled={false} label="Toggle test" onToggle={vi.fn()} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<AutomationsResponse> {
|
||||
const { limit = 50, offset = 0 } = params;
|
||||
const { data } = await automationAxios.get<AutomationsResponse>(
|
||||
`${AUTOMATION_BASE_PATH}/v1`,
|
||||
{
|
||||
params: { limit, offset },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getAutomations(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<AutomationsResponse> {
|
||||
return AutomationService.listAutomations({ limit, offset });
|
||||
}
|
||||
|
||||
static async getAutomation(id: string): Promise<Automation> {
|
||||
const { data } = await automationAxios.get<Automation>(
|
||||
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async updateAutomation(
|
||||
id: string,
|
||||
body: Partial<Automation>,
|
||||
): Promise<Automation> {
|
||||
const { data } = await automationAxios.patch<Automation>(
|
||||
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
|
||||
body,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async deleteAutomation(id: string): Promise<void> {
|
||||
await automationAxios.delete(
|
||||
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
static async listAutomationRuns(
|
||||
id: string,
|
||||
params: { limit?: number; offset?: number } = {},
|
||||
): Promise<AutomationRunsResponse> {
|
||||
const { limit = 50, offset = 0 } = params;
|
||||
const { data } = await automationAxios.get<AutomationRunsResponse>(
|
||||
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}/runs`,
|
||||
{ params: { limit, offset } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getAutomationRuns(
|
||||
id: string,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<AutomationRunsResponse> {
|
||||
return AutomationService.listAutomationRuns(id, { limit, offset });
|
||||
}
|
||||
|
||||
static async toggleAutomation(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
): Promise<Automation> {
|
||||
return AutomationService.updateAutomation(id, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
export default AutomationService;
|
||||
@@ -0,0 +1,19 @@
|
||||
export function AutomationCardSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-testid="automation-card-skeleton"
|
||||
className="rounded-2xl border border-neutral-700 bg-neutral-800 p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="h-5 w-40 animate-pulse rounded bg-neutral-700" />
|
||||
<div className="h-5 w-10 animate-pulse rounded-full bg-neutral-700" />
|
||||
</div>
|
||||
<div className="mt-2 h-4 w-72 animate-pulse rounded bg-neutral-700" />
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="h-7 w-32 animate-pulse rounded-full bg-neutral-700" />
|
||||
<div className="h-7 w-28 animate-pulse rounded-full bg-neutral-700" />
|
||||
<div className="h-7 w-24 animate-pulse rounded-full bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: <PowerIcon className="size-4" />,
|
||||
onClick: () => onToggle(automation.id, automation.enabled),
|
||||
},
|
||||
{
|
||||
label: t(I18nKey.AUTOMATIONS$DELETE),
|
||||
icon: <TrashIcon className="size-4" />,
|
||||
onClick: () => onDelete(automation.id),
|
||||
variant: "danger",
|
||||
},
|
||||
];
|
||||
|
||||
const handleCardClick = () => {
|
||||
navigate?.(`/automations/${automation.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={(e) => {
|
||||
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"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-base font-semibold text-white">
|
||||
{automation.name}
|
||||
</h3>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-neutral-400">
|
||||
{automation.prompt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex shrink-0 items-center gap-2">
|
||||
{canManage && (
|
||||
<ToggleSwitch
|
||||
enabled={automation.enabled}
|
||||
label={`Toggle ${automation.name}`}
|
||||
onToggle={() => onToggle(automation.id, automation.enabled)}
|
||||
/>
|
||||
)}
|
||||
{canManage && <KebabMenu items={menuItems} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{automation.repository && (
|
||||
<MetadataChip
|
||||
icon={<FolderIcon className="size-3.5" />}
|
||||
label={automation.repository}
|
||||
/>
|
||||
)}
|
||||
<MetadataChip
|
||||
icon={<ClockIcon className="size-3.5" />}
|
||||
label={scheduleLabel}
|
||||
/>
|
||||
{automation.model && (
|
||||
<MetadataChip
|
||||
icon={<SparkleIcon className="size-3.5" />}
|
||||
label={automation.model}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section>
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-sm font-semibold text-white">{title}</h2>
|
||||
<StatusBadge count={count} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{automations.map((automation) => (
|
||||
<AutomationCard
|
||||
key={automation.id}
|
||||
automation={automation}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
{/* Option 1: Claude Code / Codex */}
|
||||
<div className="rounded-lg border border-default bg-base-secondary p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalIcon className="size-5 text-basic" />
|
||||
<span className="text-sm font-medium text-content">
|
||||
{t(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_TITLE)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-basic">
|
||||
<a
|
||||
href={PLUGIN_INSTALL_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-content transition-colors"
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$EMPTY_INSTALL_PLUGIN)}
|
||||
</a>{" "}
|
||||
{t(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_DESC)}
|
||||
</p>
|
||||
<code className="mt-2 block rounded bg-base px-3 py-2 font-mono text-xs text-content">
|
||||
{PLUGIN_COMMAND}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Option 2: OpenHands Cloud conversation */}
|
||||
<div className="rounded-lg border border-default bg-base-secondary p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SparkleIcon className="size-5 text-basic" />
|
||||
<span className="text-sm font-medium text-content">
|
||||
{t(I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_TITLE)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-basic">
|
||||
{t(I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC)}
|
||||
</p>
|
||||
<a
|
||||
href={NEW_CONVERSATION_URL}
|
||||
className="mt-2 inline-flex items-center gap-1 rounded-md bg-base px-3 py-2 text-xs font-medium text-content hover:bg-default transition-colors"
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$EMPTY_START_CONVERSATION)}
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documentation link */}
|
||||
<p className="mt-4 text-center text-sm text-basic">
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-content transition-colors"
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$EMPTY_LEARN_MORE)}
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
if (collapsible) {
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-default bg-base-secondary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-expanded={isExpanded}
|
||||
className="flex w-full items-center justify-between p-4 text-left hover:bg-base transition-colors rounded-lg"
|
||||
>
|
||||
<span className="text-sm font-medium text-content">
|
||||
{t(I18nKey.AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE)}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={`size-5 text-basic transition-transform ${
|
||||
isExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && <div className="px-4 pb-4">{content}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl">
|
||||
<h3 className="text-center text-sm font-medium text-content">
|
||||
{t(I18nKey.AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE)}
|
||||
</h3>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
onClick={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
role="presentation"
|
||||
/>
|
||||
<div className="relative w-full max-w-sm rounded-xl border border-neutral-700 bg-neutral-800 p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="absolute right-4 top-4 text-neutral-400 hover:text-white"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="size-5" />
|
||||
</button>
|
||||
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t(I18nKey.AUTOMATIONS$DELETE_CONFIRM_TITLE)}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-neutral-400">
|
||||
{t(I18nKey.AUTOMATIONS$DELETE_CONFIRM_MESSAGE, {
|
||||
name: automationName,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-neutral-600 px-4 py-2 text-sm text-white hover:bg-neutral-700"
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$CANCEL)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700"
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$DELETE)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${
|
||||
active
|
||||
? "bg-success/20 text-success"
|
||||
: "bg-neutral-700 text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{active
|
||||
? t(I18nKey.AUTOMATIONS$DETAIL$ACTIVE)
|
||||
: t(I18nKey.AUTOMATIONS$DETAIL$INACTIVE)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-neutral-200">
|
||||
{formatRunTimestamp(run.started_at, i18n.language)}
|
||||
</span>
|
||||
{!hasConversation && (
|
||||
<span className="text-xs text-neutral-500 italic">
|
||||
({t(I18nKey.AUTOMATIONS$DETAIL$NO_CONVERSATION)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (hasConversation && run.conversation_id) {
|
||||
return (
|
||||
<a
|
||||
href={getConversationUrl(run.conversation_id)}
|
||||
className="flex items-center justify-between px-5 py-3 transition-colors cursor-pointer hover:bg-neutral-700/50 focus:bg-neutral-700/50 focus:outline-none"
|
||||
aria-label={`View conversation for run at ${formatRunTimestamp(run.started_at, i18n.language)}`}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-5 py-3 cursor-default">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="rounded-2xl border border-neutral-700 bg-neutral-800">
|
||||
<div className="flex items-center gap-2 border-b border-neutral-700 px-5 pb-3 pt-4">
|
||||
<span className="size-4 text-neutral-400">
|
||||
<ActivityIcon className="size-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-medium text-neutral-200">
|
||||
{t(I18nKey.AUTOMATIONS$DETAIL$ACTIVITY_LOG)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-1 p-5">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={`skeleton-${i}`}
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div className="h-5 w-64 animate-pulse rounded bg-neutral-700" />
|
||||
<div className="h-6 w-24 animate-pulse rounded-full bg-neutral-700" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && data && data.runs.length === 0 && (
|
||||
<p className="px-5 py-8 text-center text-sm text-neutral-400">
|
||||
{t(I18nKey.AUTOMATIONS$DETAIL$NO_RUNS)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && data && data.runs.length > 0 && (
|
||||
<div>
|
||||
{data.runs.map((run, index) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className={index > 0 ? "border-t border-neutral-700" : ""}
|
||||
>
|
||||
<ActivityLogItem run={run} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<div className="border-t border-neutral-700 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLimit((prev) => prev + PAGE_SIZE)}
|
||||
className="text-sm text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$DETAIL$LOAD_MORE_RUNS)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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, unknown>) => 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 (
|
||||
<SectionCard
|
||||
icon={<ActivityIcon className="size-4" />}
|
||||
title={t(I18nKey.AUTOMATIONS$DETAIL$ACTIVITY)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<ConfigField
|
||||
icon={<CalendarIcon className="size-3.5" />}
|
||||
label={t(I18nKey.AUTOMATIONS$DETAIL$CREATED)}
|
||||
>
|
||||
{formatDate(createdAt, locale)}
|
||||
</ConfigField>
|
||||
|
||||
<ConfigField
|
||||
icon={<ClockIcon className="size-3.5" />}
|
||||
label={t(I18nKey.AUTOMATIONS$DETAIL$LAST_RUN)}
|
||||
>
|
||||
{lastRunAt
|
||||
? formatRelativeTime(lastRunAt, locale, t)
|
||||
: t(I18nKey.AUTOMATIONS$DETAIL$TIME_NEVER)}
|
||||
</ConfigField>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<NavigationLink
|
||||
to="/automations"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
{t(I18nKey.AUTOMATIONS$DETAIL$BACK_TO_LIST)}
|
||||
</NavigationLink>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
interface BranchBadgeProps {
|
||||
branch: string;
|
||||
}
|
||||
|
||||
export function BranchBadge({ branch }: BranchBadgeProps) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full border border-neutral-600 bg-neutral-700/50 px-2.5 py-0.5 text-xs text-neutral-400">
|
||||
{branch}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
interface ConfigFieldProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ConfigField({ icon, label, children }: ConfigFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-3.5 text-neutral-400">{icon}</span>
|
||||
<span className="text-xs font-medium text-neutral-400">{label}</span>
|
||||
</div>
|
||||
<div className="text-sm text-neutral-200">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SectionCard
|
||||
icon={<CogIcon className="size-4" />}
|
||||
title={t(I18nKey.AUTOMATIONS$DETAIL$CONFIGURATION)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-5">
|
||||
{automation.repository && (
|
||||
<ConfigField
|
||||
icon={<GitBranchIcon className="size-3.5" />}
|
||||
label={t(I18nKey.AUTOMATIONS$DETAIL$REPOSITORIES)}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{automation.repository}
|
||||
{automation.branch && <BranchBadge branch={automation.branch} />}
|
||||
</span>
|
||||
</ConfigField>
|
||||
)}
|
||||
|
||||
<ConfigField
|
||||
icon={<CheckCircleIcon className="size-3.5" />}
|
||||
label={t(I18nKey.AUTOMATIONS$DETAIL$TRIGGER)}
|
||||
>
|
||||
{triggerDisplay}
|
||||
</ConfigField>
|
||||
|
||||
<ConfigField
|
||||
icon={<CalendarIcon className="size-3.5" />}
|
||||
label={t(I18nKey.AUTOMATIONS$DETAIL$SCHEDULE)}
|
||||
>
|
||||
{scheduleDisplay}
|
||||
</ConfigField>
|
||||
|
||||
{automation.model && (
|
||||
<ConfigField
|
||||
icon={<SparkleIcon className="size-3.5" />}
|
||||
label={t(I18nKey.AUTOMATIONS$DETAIL$MODEL)}
|
||||
>
|
||||
{automation.model}
|
||||
</ConfigField>
|
||||
)}
|
||||
|
||||
{automation.notification && (
|
||||
<ConfigField
|
||||
icon={<BellIcon className="size-3.5" />}
|
||||
label={t(I18nKey.AUTOMATIONS$DETAIL$NOTIFICATION)}
|
||||
>
|
||||
{automation.notification}
|
||||
</ConfigField>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -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: <PowerIcon className="size-4" />,
|
||||
onClick: onToggle,
|
||||
},
|
||||
{
|
||||
label: t(I18nKey.AUTOMATIONS$DELETE),
|
||||
icon: <TrashIcon className="size-4" />,
|
||||
onClick: onDelete,
|
||||
variant: "danger" as const,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-neutral-100">
|
||||
{automation.name}
|
||||
</h1>
|
||||
<ActiveStatusBadge active={automation.enabled} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canManage && (
|
||||
<ToggleSwitch
|
||||
enabled={automation.enabled}
|
||||
label={
|
||||
automation.enabled
|
||||
? t(I18nKey.AUTOMATIONS$TURN_OFF)
|
||||
: t(I18nKey.AUTOMATIONS$TURN_ON)
|
||||
}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
{canManage && <KebabMenu items={kebabItems} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
function SkeletonBlock({ className }: { className: string }) {
|
||||
return (
|
||||
<div className={`animate-pulse rounded bg-neutral-700 ${className}`} />
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4" data-testid="detail-skeleton">
|
||||
<SkeletonBlock className="h-5 w-40" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonBlock className="h-6 w-48" />
|
||||
<SkeletonBlock className="h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonBlock className="h-6 w-11 rounded-full" />
|
||||
<SkeletonBlock className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonBlock className="h-5 w-96" />
|
||||
<SkeletonBlock className="h-36 w-full rounded-2xl" />
|
||||
<SkeletonBlock className="h-72 w-full rounded-2xl" />
|
||||
<SkeletonBlock className="h-32 w-full rounded-2xl" />
|
||||
<SkeletonBlock className="h-40 w-full rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<ExclamationCircleIcon className="size-12 text-neutral-400" />
|
||||
<p className="mt-4 text-sm font-medium text-neutral-200">
|
||||
{t(I18nKey.AUTOMATIONS$DETAIL$NOT_FOUND_TITLE)}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-neutral-400">
|
||||
{t(I18nKey.AUTOMATIONS$DETAIL$NOT_FOUND_MESSAGE)}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<BackLink />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
interface PluginChipProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function PluginChip({ name }: PluginChipProps) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full border border-neutral-600 bg-neutral-700/30 px-3.5 py-1.5 text-sm text-neutral-200">
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SectionCard
|
||||
icon={<PuzzleIcon className="size-4" />}
|
||||
title={t(I18nKey.AUTOMATIONS$DETAIL$PLUGINS)}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginChip key={plugin} name={plugin} />
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SectionCard
|
||||
icon={<TerminalIcon className="size-4" />}
|
||||
title={t(I18nKey.AUTOMATIONS$DETAIL$PROMPT)}
|
||||
>
|
||||
<div className="rounded-xl bg-neutral-900/50">
|
||||
<p className="whitespace-pre-wrap text-sm leading-6 text-neutral-200">
|
||||
{prompt}
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -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 <CheckCircleIcon className="size-3.5" />;
|
||||
case AutomationRunStatus.FAILED:
|
||||
return <XCircleIcon className="size-3.5" />;
|
||||
default:
|
||||
return <ClockIcon className="size-3.5" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function RunStatusBadge({ status }: RunStatusBadgeProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${config.style}`}
|
||||
>
|
||||
<StatusIcon status={status} />
|
||||
{t(config.label)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
interface SectionCardProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SectionCard({ icon, title, children }: SectionCardProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-700 bg-neutral-800">
|
||||
<div className="flex items-center gap-2 border-b border-neutral-700 px-5 pb-3 pt-4">
|
||||
<span className="size-4 text-neutral-400">{icon}</span>
|
||||
<h3 className="text-sm font-medium text-neutral-200">{title}</h3>
|
||||
</div>
|
||||
<div className="px-5 py-5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<DatabaseIcon className="size-12 text-neutral-500" />
|
||||
<p className="mt-4 text-sm text-neutral-400">
|
||||
{t(I18nKey.AUTOMATIONS$EMPTY)}
|
||||
</p>
|
||||
|
||||
{/* How to create section */}
|
||||
<div className="mt-8 flex justify-center">
|
||||
<CreateInstructions />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<ExclamationCircleIcon className="size-12 text-red-400" />
|
||||
<p className="mt-4 text-sm text-neutral-400">
|
||||
{t(I18nKey.AUTOMATIONS$ERROR_TITLE)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-4 rounded-lg border border-neutral-600 px-4 py-2 text-sm text-white hover:bg-neutral-700"
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$ERROR_RETRY)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
className="flex size-8 cursor-pointer items-center justify-center rounded border border-neutral-600 bg-neutral-800 hover:bg-neutral-700"
|
||||
aria-label="Automation actions"
|
||||
>
|
||||
<KebabVerticalIcon className="size-4 text-neutral-400" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-10 mt-1 w-36 rounded-lg border border-neutral-600 bg-neutral-800 py-1 shadow-lg">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.onClick();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full cursor-pointer items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-700 ${
|
||||
item.variant === "danger" ? "text-red-400" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
interface MetadataChipProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function MetadataChip({ icon, label }: MetadataChipProps) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-neutral-600 px-3 py-1 text-xs text-neutral-400">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
interface StatusBadgeProps {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function StatusBadge({ count }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-neutral-700 px-2 py-0.5 text-xs text-white">
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
interface ToggleSwitchProps {
|
||||
enabled: boolean;
|
||||
label: string;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function ToggleSwitch({ enabled, label, onToggle }: ToggleSwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
className={`relative inline-flex h-[22px] w-[40px] shrink-0 cursor-pointer items-center rounded-full border transition-colors ${
|
||||
enabled
|
||||
? "border-success bg-success/20"
|
||||
: "border-neutral-600 bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block size-4 rounded-full transition-transform ${
|
||||
enabled
|
||||
? "translate-x-[20px] bg-success"
|
||||
: "translate-x-[3px] bg-neutral-400"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": "会話",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 336 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 407 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 208 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 209 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 541 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 256 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 3v12m0 0a3 3 0 1 0 3 3M6 15a3 3 0 0 1 3-3h6a3 3 0 0 0 3-3V3m0 0a3 3 0 1 0 0 0" />
|
||||
<circle cx="6" cy="3" r="2" />
|
||||
<circle cx="18" cy="3" r="2" />
|
||||
<circle cx="9" cy="18" r="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 192 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 218 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 0 1-.657.643 48.39 48.39 0 0 1-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 0 1-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 0 0-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 0 1-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 0 0 .657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 0 1-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 0 0 5.427-.63 48.05 48.05 0 0 0 .582-4.717.532.532 0 0 0-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 0 0 .658-.663 48.422 48.422 0 0 0-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 0 1-.61-.58v0Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M11 19a8 8 0 100-16 8 8 0 000 16z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 232 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 568 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 251 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 202 B |
@@ -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<string, Automation>(
|
||||
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<Automation>;
|
||||
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 });
|
||||
}),
|
||||
];
|
||||
@@ -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<string, AutomationRun[]> = {
|
||||
"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": [],
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<DetailSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (is404) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<NotFoundState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !automation) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<ErrorState onRetry={() => refetch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleMutation.mutate({
|
||||
id: automation.id,
|
||||
enabled: !automation.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteMutation.mutate(automation.id, {
|
||||
onSuccess: () => {
|
||||
navigate?.("/automations");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex flex-col gap-4">
|
||||
<BackLink />
|
||||
<DetailHeader
|
||||
automation={automation}
|
||||
onToggle={handleToggle}
|
||||
onDelete={() => setShowDeleteModal(true)}
|
||||
/>
|
||||
{automation.prompt && <PromptSection prompt={automation.prompt} />}
|
||||
<ConfigurationSection automation={automation} />
|
||||
{automation.plugins && automation.plugins.length > 0 && (
|
||||
<PluginsSection plugins={automation.plugins} />
|
||||
)}
|
||||
<ActivitySection
|
||||
createdAt={automation.created_at}
|
||||
lastRunAt={automation.last_triggered_at}
|
||||
/>
|
||||
<ActivityLogSection automationId={automation.id} />
|
||||
<DeleteConfirmationModal
|
||||
automationName={automation.name}
|
||||
isOpen={showDeleteModal}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<h1 className="text-xl font-semibold text-neutral-100">
|
||||
{t(I18nKey.AUTOMATIONS$TITLE)}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">
|
||||
{t(I18nKey.AUTOMATIONS$SUBTITLE)}
|
||||
</p>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mt-6">
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-6 flex flex-col gap-6">
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<AutomationCardSkeleton key={`skeleton-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && <ErrorState onRetry={refetch} />}
|
||||
|
||||
{!isLoading && !isError && data && data.automations.length === 0 && (
|
||||
<EmptyState />
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && data && data.automations.length > 0 && (
|
||||
<>
|
||||
{/* Collapsible creation instructions at the top */}
|
||||
<CreateInstructions collapsible />
|
||||
|
||||
<AutomationGroup
|
||||
title={t(I18nKey.AUTOMATIONS$ACTIVE)}
|
||||
count={active.length}
|
||||
automations={active}
|
||||
onToggle={handleToggle}
|
||||
onDelete={handleDeleteRequest}
|
||||
/>
|
||||
<AutomationGroup
|
||||
title={t(I18nKey.AUTOMATIONS$INACTIVE)}
|
||||
count={inactive.length}
|
||||
automations={inactive}
|
||||
onToggle={handleToggle}
|
||||
onDelete={handleDeleteRequest}
|
||||
/>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLimit((prev) => prev + PAGE_SIZE)}
|
||||
className="self-center rounded-lg border border-neutral-600 px-6 py-2 text-sm text-white hover:bg-neutral-700"
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$LOAD_MORE)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<DeleteConfirmationModal
|
||||
automationName={deleteTarget?.name ?? ""}
|
||||
isOpen={deleteTarget !== null}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||