diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx
index 84831c9a6..55f29d832 100644
--- a/apps/fabro-web/app/components/automation-form.tsx
+++ b/apps/fabro-web/app/components/automation-form.tsx
@@ -1,8 +1,10 @@
import { useRef, type ReactNode } from "react";
+import { Link } from "react-router";
import { Switch } from "@headlessui/react";
import type {
Automation,
AutomationTrigger,
+ Environment,
Run,
RunProjection,
WorkflowSettings,
@@ -22,6 +24,7 @@ export interface AutomationFormValues {
id: string;
name: string;
description: string;
+ environmentId: string;
repository: string;
branch: string;
tag: string;
@@ -36,6 +39,7 @@ export const EMPTY_AUTOMATION_FORM: AutomationFormValues = {
id: "",
name: "",
description: "",
+ environmentId: "",
repository: "",
branch: "main",
tag: "",
@@ -61,6 +65,7 @@ export function automationToFormValues(automation: Automation): AutomationFormVa
id: automation.id,
name: automation.name,
description: automation.description ?? "",
+ environmentId: automation.environment_id ?? "",
repository: target?.repo ?? "",
branch: target?.branch ?? EMPTY_AUTOMATION_FORM.branch,
tag: target?.tag ?? "",
@@ -76,6 +81,7 @@ export function automationFormValuesFromRun(
run: Run,
runState?: RunProjection | null,
settings?: WorkflowSettings | null,
+ environments?: Environment[],
): AutomationFormValues {
const name = firstPresentString(
run.title,
@@ -96,10 +102,17 @@ export function automationFormValuesFromRun(
?? githubRepositoryFromOriginUrl(run.repository?.origin_url)
?? "";
const cloneBranch = sandboxRuntime(run.sandbox)?.clone_branch;
+ const sourceEnvironment = settings?.run?.environment;
+ const environmentId = sourceEnvironment
+ && sourceEnvironment.provider !== "local"
+ && environments?.some((environment) => environment.id === sourceEnvironment.id)
+ ? sourceEnvironment.id
+ : "";
return {
...EMPTY_AUTOMATION_FORM,
id: kebabify(name),
name,
+ environmentId,
repository,
branch: canonicalTarget?.branch
?? cloneBranch
@@ -130,6 +143,7 @@ export function isFormValid(values: AutomationFormValues): boolean {
return (
values.id.trim() !== "" &&
values.name.trim() !== "" &&
+ values.environmentId.trim() !== "" &&
values.repository.trim() !== "" &&
values.branch.trim() !== "" &&
isOptionalShaValid(values.sha) &&
@@ -172,6 +186,10 @@ function firstPresentString(...values: Array): string
return "";
}
+function providerLabel(provider: string): string {
+ return provider.charAt(0).toUpperCase() + provider.slice(1);
+}
+
function githubRepositoryFromSettings(
settings?: WorkflowSettings | null,
): string | null {
@@ -226,15 +244,26 @@ interface AutomationFormFieldsProps {
values: AutomationFormValues;
onChange: (values: AutomationFormValues) => void;
lockIdAndTarget?: boolean;
+ environments?: Environment[];
+ environmentsLoading?: boolean;
+ environmentsError?: boolean;
}
export function AutomationFormFields({
values,
onChange,
lockIdAndTarget = false,
+ environments = [],
+ environmentsLoading = false,
+ environmentsError = false,
}: AutomationFormFieldsProps) {
const slugTouchedRef = useRef(values.id.length > 0);
const shaValid = isOptionalShaValid(values.sha);
+ const compatibleEnvironments = environments
+ .filter((environment) => environment.provider === "docker" || environment.provider === "daytona")
+ .sort((left, right) => left.id.localeCompare(right.id));
+ const selectedEnvironmentMissing = values.environmentId !== ""
+ && !compatibleEnvironments.some((environment) => environment.id === values.environmentId);
function patch(partial: Partial) {
onChange({ ...values, ...partial });
@@ -304,6 +333,55 @@ export function AutomationFormFields({
+
+ Environment}
+ help="Server-managed Docker or Daytona environment used whenever this automation runs."
+ >
+
+
+ {environmentsError ? (
+
+ Couldn't load environments. Refresh the page and try again.
+
+ ) : !environmentsLoading && compatibleEnvironments.length === 0 ? (
+
+ No Docker or Daytona environments are available.{" "}
+
+ Create an environment
+ {" "}
+ before saving this automation.
+
+ ) : selectedEnvironmentMissing ? (
+
+ This environment is no longer available. Choose another environment before saving.
+
+ ) : null}
+
+
+
+
Repository} help="GitHub repository in owner/repo form.">
{automation.workflow}
+
+ {automation.environment_id ?? (
+ Environment required
+ )}
+
{scheduleTrigger ? (
{scheduleTrigger.expression}
) : null}
@@ -164,6 +170,11 @@ function AutomationHeader({ automation }: { automation: Automation }) {
{automation.description}
) : null}
+ {automation.last_error ? (
+
+ Last scheduled run failed: {automation.last_error}
+
+ ) : null}
@@ -177,7 +188,13 @@ function AutomationHeader({ automation }: { automation: Automation }) {
type="button"
onClick={onRun}
disabled={!canRun || running}
- title={canRun ? undefined : "Enable the API trigger to run it"}
+ title={
+ canRun
+ ? undefined
+ : automation.environment_id === null
+ ? "Select an environment before running this automation"
+ : "Enable the API trigger to run it"
+ }
className={PRIMARY_BUTTON_CLASS}
>
diff --git a/apps/fabro-web/app/routes/automations-edit.tsx b/apps/fabro-web/app/routes/automations-edit.tsx
index 41d5b90c1..939c60c6f 100644
--- a/apps/fabro-web/app/routes/automations-edit.tsx
+++ b/apps/fabro-web/app/routes/automations-edit.tsx
@@ -2,10 +2,10 @@ import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { useSWRConfig } from "swr";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
-import type { Automation } from "@qltysh/fabro-api-client";
+import type { Automation, Environment } from "@qltysh/fabro-api-client";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
-import { useAutomation } from "../lib/queries";
+import { useAutomation, useEnvironments } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import {
AutomationFormFields,
@@ -32,12 +32,19 @@ export const handle = { hideHeader: true };
export default function AutomationsEdit() {
const { id } = useParams<{ id: string }>();
const query = useAutomation(id);
+ const environmentsQuery = useEnvironments();
return (
{query.data ? (
-
+
) : query.error ? (
@@ -63,7 +70,17 @@ function PageHeader({ id, name }: { id: string; name: string | undefined }) {
);
}
-function EditAutomationForm({ automation }: { automation: Automation }) {
+function EditAutomationForm({
+ automation,
+ environments = [],
+ environmentsLoading,
+ environmentsError,
+}: {
+ automation: Automation;
+ environments?: Environment[];
+ environmentsLoading: boolean;
+ environmentsError: boolean;
+}) {
const navigate = useNavigate();
const { mutate } = useSWRConfig();
const toast = useToast();
@@ -73,7 +90,10 @@ function EditAutomationForm({ automation }: { automation: Automation }) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState
(null);
- const canSubmit = isFormValid(values) && !submitting;
+ const canSubmit = isFormValid(values)
+ && !environmentsLoading
+ && !environmentsError
+ && !submitting;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
@@ -86,6 +106,7 @@ function EditAutomationForm({ automation }: { automation: Automation }) {
automationsApi.replaceAutomation(automation.id, automation.revision, {
name: trimmedName,
description: values.description.trim() || null,
+ environment_id: values.environmentId.trim(),
target: targetFromFormValues(values),
workflow: values.workflow.trim(),
triggers: triggersFromFormValues(values),
@@ -107,7 +128,14 @@ function EditAutomationForm({ automation }: { automation: Automation }) {
return (