mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-29 01:42:21 +00:00
feat(web): add server-managed Environments CRUD settings UI
Add /settings/environments for the server-owned Environments REST API: list, create, edit, and delete environments. The page lives in the new Workflows settings nav section, before Variables. - Shared environment-form component covering provider, image (reference + inline Dockerfile), resources, network, lifecycle, labels, env vars, and volumes - Edit uses the environment revision as If-Match for optimistic concurrency and surfaces 409 conflicts; the default environment is protected from deletion - Adds environmentsApi client, environments query keys, and useEnvironments/useEnvironment hooks Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4abce34ba4
commit
c4dbbb4c27
9 changed files with 1135 additions and 0 deletions
630
apps/fabro-web/app/components/environment-form.tsx
Normal file
630
apps/fabro-web/app/components/environment-form.tsx
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import { PlusIcon, XMarkIcon } from "@heroicons/react/16/solid";
|
||||
import {
|
||||
EnvironmentApiDockerfileSourceInlineTypeEnum,
|
||||
EnvironmentNetworkMode,
|
||||
EnvironmentProvider,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
import type {
|
||||
CreateEnvironmentRequest,
|
||||
Environment,
|
||||
EnvironmentApiImageSettings,
|
||||
EnvironmentLifecycleSettings,
|
||||
EnvironmentNetworkSettings,
|
||||
EnvironmentResourcesSettings,
|
||||
EnvironmentVolumeSettings,
|
||||
ReplaceEnvironmentRequest,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
import { Panel, Row } from "./settings-panel";
|
||||
import { INPUT_CLASS } from "./ui";
|
||||
|
||||
// Environment ids are server-managed file names: lowercase, digits, hyphens.
|
||||
const ENVIRONMENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
|
||||
interface KeyValueEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface VolumeEntry {
|
||||
id: string;
|
||||
mountPath: string;
|
||||
subpath: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentFormValues {
|
||||
id: string;
|
||||
provider: EnvironmentProvider;
|
||||
dockerRef: string;
|
||||
dockerfile: string;
|
||||
cpu: string;
|
||||
memory: string;
|
||||
disk: string;
|
||||
networkMode: EnvironmentNetworkMode;
|
||||
allow: string;
|
||||
preserve: boolean;
|
||||
stopOnTerminal: boolean;
|
||||
autoStop: string;
|
||||
labels: KeyValueEntry[];
|
||||
envVars: KeyValueEntry[];
|
||||
volumes: VolumeEntry[];
|
||||
}
|
||||
|
||||
export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = {
|
||||
id: "",
|
||||
provider: EnvironmentProvider.DOCKER,
|
||||
dockerRef: "",
|
||||
dockerfile: "",
|
||||
cpu: "",
|
||||
memory: "",
|
||||
disk: "",
|
||||
networkMode: EnvironmentNetworkMode.ALLOW_ALL,
|
||||
allow: "",
|
||||
preserve: false,
|
||||
stopOnTerminal: true,
|
||||
autoStop: "",
|
||||
labels: [],
|
||||
envVars: [],
|
||||
volumes: [],
|
||||
};
|
||||
|
||||
export function environmentToFormValues(environment: Environment): EnvironmentFormValues {
|
||||
return {
|
||||
id: environment.id,
|
||||
provider: environment.provider,
|
||||
dockerRef: environment.image.docker ?? "",
|
||||
dockerfile: environment.image.dockerfile?.value ?? "",
|
||||
cpu: environment.resources.cpu === null ? "" : String(environment.resources.cpu),
|
||||
memory: environment.resources.memory ?? "",
|
||||
disk: environment.resources.disk ?? "",
|
||||
networkMode: environment.network.mode,
|
||||
allow: environment.network.allow.join("\n"),
|
||||
preserve: environment.lifecycle.preserve,
|
||||
stopOnTerminal: environment.lifecycle.stop_on_terminal,
|
||||
autoStop: environment.lifecycle.auto_stop ?? "",
|
||||
labels: entriesFromMap(environment.labels),
|
||||
envVars: entriesFromMap(environment.env),
|
||||
volumes: environment.volumes.map((volume) => ({
|
||||
id: volume.id,
|
||||
mountPath: volume.mount_path,
|
||||
subpath: volume.subpath ?? "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function isEnvironmentFormValid(values: EnvironmentFormValues): boolean {
|
||||
if (!ENVIRONMENT_ID_PATTERN.test(values.id.trim())) return false;
|
||||
if (values.cpu.trim() !== "" && !Number.isFinite(Number(values.cpu))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createRequestFromForm(values: EnvironmentFormValues): CreateEnvironmentRequest {
|
||||
return { id: values.id.trim(), ...settingsFromForm(values) };
|
||||
}
|
||||
|
||||
export function replaceRequestFromForm(values: EnvironmentFormValues): ReplaceEnvironmentRequest {
|
||||
return settingsFromForm(values);
|
||||
}
|
||||
|
||||
function settingsFromForm(values: EnvironmentFormValues): ReplaceEnvironmentRequest {
|
||||
return {
|
||||
provider: values.provider,
|
||||
image: imageFromForm(values),
|
||||
resources: resourcesFromForm(values),
|
||||
network: networkFromForm(values),
|
||||
lifecycle: lifecycleFromForm(values),
|
||||
labels: mapFromEntries(values.labels),
|
||||
volumes: volumesFromForm(values),
|
||||
env: mapFromEntries(values.envVars),
|
||||
};
|
||||
}
|
||||
|
||||
function imageFromForm(values: EnvironmentFormValues): EnvironmentApiImageSettings {
|
||||
const dockerfile = values.dockerfile.trim();
|
||||
return {
|
||||
docker: values.dockerRef.trim() || null,
|
||||
dockerfile: dockerfile
|
||||
? { type: EnvironmentApiDockerfileSourceInlineTypeEnum.INLINE, value: values.dockerfile }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function resourcesFromForm(values: EnvironmentFormValues): EnvironmentResourcesSettings {
|
||||
const cpu = values.cpu.trim();
|
||||
return {
|
||||
cpu: cpu === "" ? null : Number(cpu),
|
||||
memory: values.memory.trim() || null,
|
||||
disk: values.disk.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function networkFromForm(values: EnvironmentFormValues): EnvironmentNetworkSettings {
|
||||
return {
|
||||
mode: values.networkMode,
|
||||
allow: values.allow
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== ""),
|
||||
};
|
||||
}
|
||||
|
||||
function lifecycleFromForm(values: EnvironmentFormValues): EnvironmentLifecycleSettings {
|
||||
return {
|
||||
preserve: values.preserve,
|
||||
stop_on_terminal: values.stopOnTerminal,
|
||||
auto_stop: values.autoStop.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function volumesFromForm(values: EnvironmentFormValues): EnvironmentVolumeSettings[] {
|
||||
return values.volumes
|
||||
.map((volume) => ({
|
||||
id: volume.id.trim(),
|
||||
mount_path: volume.mountPath.trim(),
|
||||
subpath: volume.subpath.trim() || null,
|
||||
}))
|
||||
.filter((volume) => volume.id !== "" && volume.mount_path !== "");
|
||||
}
|
||||
|
||||
function entriesFromMap(map: { [key: string]: string }): KeyValueEntry[] {
|
||||
return Object.entries(map).map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
|
||||
function mapFromEntries(entries: KeyValueEntry[]): { [key: string]: string } {
|
||||
return Object.fromEntries(
|
||||
entries
|
||||
.map((entry): [string, string] => [entry.key.trim(), entry.value])
|
||||
.filter((entry) => entry[0] !== ""),
|
||||
);
|
||||
}
|
||||
|
||||
function parseProvider(value: string): EnvironmentProvider {
|
||||
switch (value) {
|
||||
case EnvironmentProvider.LOCAL:
|
||||
return EnvironmentProvider.LOCAL;
|
||||
case EnvironmentProvider.DAYTONA:
|
||||
return EnvironmentProvider.DAYTONA;
|
||||
default:
|
||||
return EnvironmentProvider.DOCKER;
|
||||
}
|
||||
}
|
||||
|
||||
function parseNetworkMode(value: string): EnvironmentNetworkMode {
|
||||
switch (value) {
|
||||
case EnvironmentNetworkMode.BLOCK:
|
||||
return EnvironmentNetworkMode.BLOCK;
|
||||
case EnvironmentNetworkMode.CIDR_ALLOW_LIST:
|
||||
return EnvironmentNetworkMode.CIDR_ALLOW_LIST;
|
||||
default:
|
||||
return EnvironmentNetworkMode.ALLOW_ALL;
|
||||
}
|
||||
}
|
||||
|
||||
interface EnvironmentFormFieldsProps {
|
||||
values: EnvironmentFormValues;
|
||||
onChange: (values: EnvironmentFormValues) => void;
|
||||
lockId?: boolean;
|
||||
}
|
||||
|
||||
export function EnvironmentFormFields({
|
||||
values,
|
||||
onChange,
|
||||
lockId = false,
|
||||
}: EnvironmentFormFieldsProps) {
|
||||
function patch(partial: Partial<EnvironmentFormValues>) {
|
||||
onChange({ ...values, ...partial });
|
||||
}
|
||||
|
||||
const idValid = ENVIRONMENT_ID_PATTERN.test(values.id.trim());
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel title="Identity">
|
||||
<Row
|
||||
title={<Label required>ID</Label>}
|
||||
help="Lowercase identifier (letters, digits, hyphens). Runs select this environment by id. Cannot be changed after creation."
|
||||
>
|
||||
{lockId ? (
|
||||
<div className="font-mono text-sm text-fg">{values.id}</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
name="id"
|
||||
aria-label="Environment ID"
|
||||
value={values.id}
|
||||
onChange={(e) => patch({ id: e.target.value })}
|
||||
placeholder="fabro-dev"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
<Row title={<Label required>Provider</Label>} help="Where runs using this environment execute.">
|
||||
<select
|
||||
name="provider"
|
||||
aria-label="Provider"
|
||||
value={values.provider}
|
||||
onChange={(e) => patch({ provider: parseProvider(e.target.value) })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{Object.values(EnvironmentProvider).map((provider) => (
|
||||
<option key={provider} value={provider}>
|
||||
{provider}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Image">
|
||||
<Row
|
||||
title={<Label optional>Image reference</Label>}
|
||||
help="Docker image or Daytona snapshot name (e.g. fabro-v11)."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="docker_ref"
|
||||
aria-label="Image reference"
|
||||
value={values.dockerRef}
|
||||
onChange={(e) => patch({ dockerRef: e.target.value })}
|
||||
placeholder="ubuntu:24.04"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
title={<Label optional>Dockerfile</Label>}
|
||||
help="Inline Dockerfile contents. The REST API accepts inline Dockerfiles only — local paths are rejected."
|
||||
>
|
||||
<textarea
|
||||
name="dockerfile"
|
||||
aria-label="Dockerfile"
|
||||
value={values.dockerfile}
|
||||
onChange={(e) => patch({ dockerfile: e.target.value })}
|
||||
rows={5}
|
||||
placeholder={"FROM ubuntu:24.04\nRUN apt-get update && apt-get install -y git"}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} resize-y font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Resources">
|
||||
<Row title={<Label optional>CPU</Label>} help="Number of vCPUs. Leave blank for the provider default.">
|
||||
<input
|
||||
type="number"
|
||||
name="cpu"
|
||||
aria-label="CPU"
|
||||
value={values.cpu}
|
||||
onChange={(e) => patch({ cpu: e.target.value })}
|
||||
placeholder="8"
|
||||
min={0}
|
||||
step={1}
|
||||
autoComplete="off"
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row title={<Label optional>Memory</Label>} help="Memory limit (e.g. 16GB). Leave blank for the provider default.">
|
||||
<input
|
||||
type="text"
|
||||
name="memory"
|
||||
aria-label="Memory"
|
||||
value={values.memory}
|
||||
onChange={(e) => patch({ memory: e.target.value })}
|
||||
placeholder="16GB"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
<Row title={<Label optional>Disk</Label>} help="Disk limit (e.g. 20GB). Leave blank for the provider default.">
|
||||
<input
|
||||
type="text"
|
||||
name="disk"
|
||||
aria-label="Disk"
|
||||
value={values.disk}
|
||||
onChange={(e) => patch({ disk: e.target.value })}
|
||||
placeholder="20GB"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Network">
|
||||
<Row title={<Label required>Mode</Label>} help="How network egress is restricted for runs in this environment.">
|
||||
<select
|
||||
name="network_mode"
|
||||
aria-label="Network mode"
|
||||
value={values.networkMode}
|
||||
onChange={(e) => patch({ networkMode: parseNetworkMode(e.target.value) })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{Object.values(EnvironmentNetworkMode).map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{mode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
{values.networkMode === EnvironmentNetworkMode.CIDR_ALLOW_LIST ? (
|
||||
<Row
|
||||
title={<Label optional>Allowed CIDRs</Label>}
|
||||
help="One CIDR per line. Only egress to these ranges is permitted."
|
||||
>
|
||||
<textarea
|
||||
name="allow"
|
||||
aria-label="Allowed CIDRs"
|
||||
value={values.allow}
|
||||
onChange={(e) => patch({ allow: e.target.value })}
|
||||
rows={3}
|
||||
placeholder={"10.0.0.0/8\n192.168.0.0/16"}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} resize-y font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Lifecycle">
|
||||
<Row title="Preserve" help="Keep the sandbox after the run finishes instead of tearing it down.">
|
||||
<ToggleSwitch
|
||||
checked={values.preserve}
|
||||
onChange={(preserve) => patch({ preserve })}
|
||||
label="Preserve sandbox after run"
|
||||
/>
|
||||
</Row>
|
||||
<Row title="Stop on terminal" help="Stop the sandbox when the run reaches a terminal state.">
|
||||
<ToggleSwitch
|
||||
checked={values.stopOnTerminal}
|
||||
onChange={(stopOnTerminal) => patch({ stopOnTerminal })}
|
||||
label="Stop sandbox on terminal state"
|
||||
/>
|
||||
</Row>
|
||||
<Row title={<Label optional>Auto-stop</Label>} help="Idle duration before the sandbox is stopped (e.g. 30m). Leave blank to disable.">
|
||||
<input
|
||||
type="text"
|
||||
name="auto_stop"
|
||||
aria-label="Auto-stop"
|
||||
value={values.autoStop}
|
||||
onChange={(e) => patch({ autoStop: e.target.value })}
|
||||
placeholder="30m"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Labels">
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="mb-3 text-xs/5 text-fg-3">
|
||||
Arbitrary key/value labels attached to the environment.
|
||||
</p>
|
||||
<KeyValueEditor
|
||||
entries={values.labels}
|
||||
onChange={(labels) => patch({ labels })}
|
||||
keyPlaceholder="team"
|
||||
valuePlaceholder="infra"
|
||||
addLabel="Add label"
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Environment variables">
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="mb-3 text-xs/5 text-fg-3">
|
||||
Variables injected into the sandbox for every run.
|
||||
</p>
|
||||
<KeyValueEditor
|
||||
entries={values.envVars}
|
||||
onChange={(envVars) => patch({ envVars })}
|
||||
keyPlaceholder="TZ"
|
||||
valuePlaceholder="UTC"
|
||||
addLabel="Add variable"
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Volumes">
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="mb-3 text-xs/5 text-fg-3">
|
||||
Named volumes mounted into the sandbox. Id and mount path are required.
|
||||
</p>
|
||||
<VolumeEditor
|
||||
volumes={values.volumes}
|
||||
onChange={(volumes) => patch({ volumes })}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{!lockId && values.id.trim() !== "" && !idValid ? (
|
||||
<p className="text-xs text-coral">
|
||||
ID must be lowercase letters, digits, or hyphens and start with a letter or digit.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyValueEditor({
|
||||
entries,
|
||||
onChange,
|
||||
keyPlaceholder,
|
||||
valuePlaceholder,
|
||||
addLabel,
|
||||
}: {
|
||||
entries: KeyValueEntry[];
|
||||
onChange: (entries: KeyValueEntry[]) => void;
|
||||
keyPlaceholder: string;
|
||||
valuePlaceholder: string;
|
||||
addLabel: string;
|
||||
}) {
|
||||
function update(index: number, partial: Partial<KeyValueEntry>) {
|
||||
onChange(entries.map((entry, i) => (i === index ? { ...entry, ...partial } : entry)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Key"
|
||||
value={entry.key}
|
||||
onChange={(e) => update(index, { key: e.target.value })}
|
||||
placeholder={keyPlaceholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Value"
|
||||
value={entry.value}
|
||||
onChange={(e) => update(index, { value: e.target.value })}
|
||||
placeholder={valuePlaceholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
<RemoveButton onClick={() => onChange(entries.filter((_, i) => i !== index))} />
|
||||
</div>
|
||||
))}
|
||||
<AddButton label={addLabel} onClick={() => onChange([...entries, { key: "", value: "" }])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VolumeEditor({
|
||||
volumes,
|
||||
onChange,
|
||||
}: {
|
||||
volumes: VolumeEntry[];
|
||||
onChange: (volumes: VolumeEntry[]) => void;
|
||||
}) {
|
||||
function update(index: number, partial: Partial<VolumeEntry>) {
|
||||
onChange(volumes.map((volume, i) => (i === index ? { ...volume, ...partial } : volume)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{volumes.map((volume, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Volume id"
|
||||
value={volume.id}
|
||||
onChange={(e) => update(index, { id: e.target.value })}
|
||||
placeholder="cache"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Mount path"
|
||||
value={volume.mountPath}
|
||||
onChange={(e) => update(index, { mountPath: e.target.value })}
|
||||
placeholder="/cache"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Subpath"
|
||||
value={volume.subpath}
|
||||
onChange={(e) => update(index, { subpath: e.target.value })}
|
||||
placeholder="subpath (optional)"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${INPUT_CLASS} font-mono`}
|
||||
/>
|
||||
<RemoveButton onClick={() => onChange(volumes.filter((_, i) => i !== index))} />
|
||||
</div>
|
||||
))}
|
||||
<AddButton
|
||||
label="Add volume"
|
||||
onClick={() => onChange([...volumes, { id: "", mountPath: "", subpath: "" }])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-2.5 py-1 text-xs font-medium text-fg-3 transition-colors hover:border-line-strong hover:bg-panel hover:text-fg"
|
||||
>
|
||||
<PlusIcon className="size-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label="Remove row"
|
||||
title="Remove"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-overlay hover:text-coral"
|
||||
>
|
||||
<XMarkIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({
|
||||
children,
|
||||
required,
|
||||
optional,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
required?: boolean;
|
||||
optional?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1.5">
|
||||
<span>{children}</span>
|
||||
{required ? (
|
||||
<span aria-label="required" className="text-coral">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
{optional ? <span className="text-xs font-normal text-fg-muted">Optional</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
aria-label={label}
|
||||
className="group relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-overlay-strong outline-1 -outline-offset-1 outline-line-strong transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 data-checked:bg-teal-500"
|
||||
>
|
||||
<span className="pointer-events-none inline-block size-4 translate-x-0.5 rounded-full bg-fg shadow-sm transition-transform duration-150 group-data-checked:translate-x-[1.125rem]" />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
AuthApi,
|
||||
AutomationsApi,
|
||||
Configuration,
|
||||
EnvironmentsApi,
|
||||
HumanInTheLoopApi,
|
||||
InsightsApi,
|
||||
InstallApi,
|
||||
|
|
@ -81,6 +82,11 @@ export const automationsApi = new AutomationsApi(
|
|||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const environmentsApi = new EnvironmentsApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const humanInTheLoopApi = new HumanInTheLoopApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import type {
|
|||
AutomationListResponse,
|
||||
BoardColumn,
|
||||
CommandLogResponse,
|
||||
Environment,
|
||||
EnvironmentListResponse,
|
||||
EventEnvelope,
|
||||
ListRunsDirectionEnum,
|
||||
ListRunsSortEnum,
|
||||
|
|
@ -45,6 +47,7 @@ import {
|
|||
apiResponse,
|
||||
authApi,
|
||||
automationsApi,
|
||||
environmentsApi,
|
||||
fetchAllPages,
|
||||
fetchAllStageEvents,
|
||||
generatedAxios,
|
||||
|
|
@ -427,6 +430,20 @@ export function useAutomationRuns(id: string | undefined, opts: AutomationRunsPa
|
|||
);
|
||||
}
|
||||
|
||||
export function useEnvironments() {
|
||||
return useSWR<EnvironmentListResponse>(
|
||||
queryKeys.environments.list(),
|
||||
() => apiData(() => environmentsApi.listEnvironments()),
|
||||
);
|
||||
}
|
||||
|
||||
export function useEnvironment(id: string | undefined) {
|
||||
return useSWR<Environment | null>(
|
||||
id ? queryKeys.environments.detail(id) : null,
|
||||
id ? () => apiNullableData(() => environmentsApi.retrieveEnvironment(id)) : null,
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkflows() {
|
||||
return useSWR<PaginatedWorkflowListResponse | null>(
|
||||
queryKeys.workflows.list(),
|
||||
|
|
|
|||
|
|
@ -117,4 +117,8 @@ export const queryKeys = {
|
|||
list: () => ["variables", "list"] as const,
|
||||
detail: (name: string) => ["variables", "detail", name] as const,
|
||||
},
|
||||
environments: {
|
||||
list: () => ["environments", "list"] as const,
|
||||
detail: (id: string) => ["environments", "detail", id] as const,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ import * as SettingsGeneral from "./routes/settings-general";
|
|||
import * as SettingsIntegrations from "./routes/settings-integrations";
|
||||
import * as SettingsModels from "./routes/settings-models";
|
||||
import * as SettingsSandboxes from "./routes/settings-sandboxes";
|
||||
import * as SettingsEnvironments from "./routes/settings-environments";
|
||||
import * as SettingsEnvironmentsNew from "./routes/settings-environments-new";
|
||||
import * as SettingsEnvironmentsEdit from "./routes/settings-environments-edit";
|
||||
import * as SettingsSecrets from "./routes/settings-secrets";
|
||||
import * as SettingsSecretsNew from "./routes/settings-secrets-new";
|
||||
import * as SettingsVariables from "./routes/settings-variables";
|
||||
|
|
@ -146,6 +149,9 @@ export const routes: RouteObject[] = [
|
|||
route("integrations", SettingsIntegrations),
|
||||
route("sandboxes", SettingsSandboxes),
|
||||
route("security", SettingsSecurity),
|
||||
route("environments", SettingsEnvironments),
|
||||
route("environments/new", SettingsEnvironmentsNew),
|
||||
route("environments/:id/edit", SettingsEnvironmentsEdit),
|
||||
route("variables", SettingsVariables),
|
||||
route("variables/new", SettingsVariablesNew),
|
||||
route("variables/:name/edit", SettingsVariablesEdit),
|
||||
|
|
|
|||
129
apps/fabro-web/app/routes/settings-environments-edit.tsx
Normal file
129
apps/fabro-web/app/routes/settings-environments-edit.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import type { Environment } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { ApiError, apiData, environmentsApi } from "../lib/api-client";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import { useEnvironment } from "../lib/queries";
|
||||
import {
|
||||
EnvironmentFormFields,
|
||||
environmentToFormValues,
|
||||
isEnvironmentFormValid,
|
||||
replaceRequestFromForm,
|
||||
type EnvironmentFormValues,
|
||||
} from "../components/environment-form";
|
||||
import { Panel, PanelSkeleton } from "../components/settings-panel";
|
||||
import {
|
||||
ErrorMessage,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
SECONDARY_BUTTON_CLASS,
|
||||
} from "../components/ui";
|
||||
import { useToast } from "../components/toast";
|
||||
|
||||
export function meta() {
|
||||
return [{ title: "Edit environment — Fabro" }];
|
||||
}
|
||||
|
||||
export default function SettingsEnvironmentsEdit() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const query = useEnvironment(id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader id={id ?? ""} />
|
||||
{query.data ? (
|
||||
<EditEnvironmentForm key={query.data.revision} environment={query.data} />
|
||||
) : query.error ? (
|
||||
<Panel title="Environment">
|
||||
<div className="px-4 py-6 text-sm text-fg-2">
|
||||
Couldn't load this environment. It may have been deleted.
|
||||
</div>
|
||||
</Panel>
|
||||
) : (
|
||||
<PanelSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader({ id }: { id: string }) {
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm text-fg-muted">
|
||||
<Link to="/settings/environments" className="text-fg-3 hover:text-fg">
|
||||
Environments
|
||||
</Link>
|
||||
<ChevronRightIcon className="size-3" aria-hidden="true" />
|
||||
<span className="font-mono text-fg-2">{id}</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function EditEnvironmentForm({ environment }: { environment: Environment }) {
|
||||
const navigate = useNavigate();
|
||||
const { mutate } = useSWRConfig();
|
||||
const toast = useToast();
|
||||
const [values, setValues] = useState<EnvironmentFormValues>(() =>
|
||||
environmentToFormValues(environment),
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSubmit = isEnvironmentFormValid(values) && !submitting;
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiData(() =>
|
||||
environmentsApi.replaceEnvironment(
|
||||
environment.id,
|
||||
environment.revision,
|
||||
replaceRequestFromForm(values),
|
||||
),
|
||||
);
|
||||
await mutate(queryKeys.environments.list());
|
||||
await mutate(queryKeys.environments.detail(environment.id));
|
||||
toast.push({ message: `Environment “${environment.id}” updated.` });
|
||||
navigate("/settings/environments");
|
||||
} catch (cause) {
|
||||
setError(staleAwareMessage(cause));
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<EnvironmentFormFields values={values} onChange={setValues} lockId />
|
||||
|
||||
{error ? <ErrorMessage message={error} /> : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/settings/environments")}
|
||||
disabled={submitting}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
|
||||
{submitting ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function staleAwareMessage(cause: unknown): string {
|
||||
if (cause instanceof ApiError && cause.status === 409) {
|
||||
return "This environment changed since you opened it. Reload the page to get the latest version, then reapply your edits.";
|
||||
}
|
||||
if (cause instanceof ApiError && cause.message) {
|
||||
return cause.message;
|
||||
}
|
||||
return "Couldn't update the environment. Please try again.";
|
||||
}
|
||||
99
apps/fabro-web/app/routes/settings-environments-new.tsx
Normal file
99
apps/fabro-web/app/routes/settings-environments-new.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
import { ApiError, apiData, environmentsApi } from "../lib/api-client";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import {
|
||||
EMPTY_ENVIRONMENT_FORM,
|
||||
EnvironmentFormFields,
|
||||
createRequestFromForm,
|
||||
isEnvironmentFormValid,
|
||||
type EnvironmentFormValues,
|
||||
} from "../components/environment-form";
|
||||
import {
|
||||
ErrorMessage,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
SECONDARY_BUTTON_CLASS,
|
||||
} from "../components/ui";
|
||||
import { useToast } from "../components/toast";
|
||||
|
||||
export function meta() {
|
||||
return [{ title: "New environment — Fabro" }];
|
||||
}
|
||||
|
||||
export default function SettingsEnvironmentsNew() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader />
|
||||
<CreateEnvironmentForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader() {
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm text-fg-muted">
|
||||
<Link to="/settings/environments" className="text-fg-3 hover:text-fg">
|
||||
Environments
|
||||
</Link>
|
||||
<ChevronRightIcon className="size-3" aria-hidden="true" />
|
||||
<span>New environment</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateEnvironmentForm() {
|
||||
const navigate = useNavigate();
|
||||
const { mutate } = useSWRConfig();
|
||||
const toast = useToast();
|
||||
const [values, setValues] = useState<EnvironmentFormValues>(EMPTY_ENVIRONMENT_FORM);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSubmit = isEnvironmentFormValid(values) && !submitting;
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const id = values.id.trim();
|
||||
try {
|
||||
await apiData(() => environmentsApi.createEnvironment(createRequestFromForm(values)));
|
||||
await mutate(queryKeys.environments.list());
|
||||
toast.push({ message: `Environment “${id}” created.` });
|
||||
navigate("/settings/environments");
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof ApiError && cause.message
|
||||
? cause.message
|
||||
: "Couldn't create the environment. Please try again.",
|
||||
);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<EnvironmentFormFields values={values} onChange={setValues} />
|
||||
|
||||
{error ? <ErrorMessage message={error} /> : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/settings/environments")}
|
||||
disabled={submitting}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
|
||||
{submitting ? "Creating…" : "Create environment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
236
apps/fabro-web/app/routes/settings-environments.tsx
Normal file
236
apps/fabro-web/app/routes/settings-environments.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import { PlusIcon } from "@heroicons/react/16/solid";
|
||||
import { EllipsisVerticalIcon } from "@heroicons/react/20/solid";
|
||||
import type { Environment } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { ApiError, apiData, environmentsApi } from "../lib/api-client";
|
||||
import { useEnvironments } from "../lib/queries";
|
||||
import { queryKeys } from "../lib/query-keys";
|
||||
import {
|
||||
Badge,
|
||||
Muted,
|
||||
Panel,
|
||||
PanelSkeleton,
|
||||
SettingsPageIntro,
|
||||
} from "../components/settings-panel";
|
||||
import { ConfirmDialog } from "../components/ui";
|
||||
import { useToast } from "../components/toast";
|
||||
|
||||
// `default` is seeded and protected from deletion by the server; reflect that
|
||||
// in the UI instead of letting the delete fail with a 409.
|
||||
const PROTECTED_ID = "default";
|
||||
|
||||
const MENU_ITEM_CLASS =
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-3 transition-colors data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
const MENU_ITEM_DANGER_CLASS =
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-coral transition-colors data-focus:bg-coral/10 data-focus:text-coral data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
export function meta() {
|
||||
return [{ title: "Environments — Fabro" }];
|
||||
}
|
||||
|
||||
const DESCRIPTION =
|
||||
"Environments are server-managed runtime definitions — provider, image, resources, network, and lifecycle — that workflow runs select by id. They are operator policy stored on this Fabro server.";
|
||||
|
||||
export default function SettingsEnvironments() {
|
||||
const query = useEnvironments();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsPageIntro
|
||||
description={DESCRIPTION}
|
||||
action={
|
||||
<Link
|
||||
to="/settings/environments/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-2.5 py-1 text-sm font-medium text-fg-3 transition-colors hover:border-line-strong hover:bg-panel hover:text-fg"
|
||||
>
|
||||
<PlusIcon className="size-3.5" aria-hidden="true" />
|
||||
New environment
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
{query.data ? (
|
||||
<EnvironmentsPanel environments={query.data.data} />
|
||||
) : query.error ? (
|
||||
<Panel title="Environments">
|
||||
<div className="px-4 py-6 text-sm text-fg-2">
|
||||
Couldn't load environments. Please try again.
|
||||
</div>
|
||||
</Panel>
|
||||
) : (
|
||||
<PanelSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentsPanel({ environments }: { environments: Environment[] }) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const toast = useToast();
|
||||
const [pendingDelete, setPendingDelete] = useState<Environment | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!pendingDelete) return;
|
||||
const target = pendingDelete;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await apiData(() => environmentsApi.deleteEnvironment(target.id, target.revision));
|
||||
await mutate(queryKeys.environments.list());
|
||||
toast.push({ message: `Environment “${target.id}” deleted.` });
|
||||
setPendingDelete(null);
|
||||
} catch (cause) {
|
||||
toast.push({
|
||||
tone: "error",
|
||||
message:
|
||||
cause instanceof ApiError && cause.message
|
||||
? cause.message
|
||||
: "Couldn't delete the environment. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel title="Environments">
|
||||
{environments.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-fg-muted">
|
||||
No environments defined yet.
|
||||
</div>
|
||||
) : (
|
||||
environments.map((environment) => (
|
||||
<EnvironmentRow
|
||||
key={environment.id}
|
||||
environment={environment}
|
||||
disabled={deleting}
|
||||
onDelete={() => setPendingDelete(environment)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Panel>
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
title="Delete environment"
|
||||
description={
|
||||
<>
|
||||
Delete{" "}
|
||||
<span className="font-mono text-fg-2">{pendingDelete?.id}</span>? Runs that
|
||||
select this environment will fail until it is recreated.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
pendingLabel="Deleting…"
|
||||
pending={deleting}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => {
|
||||
if (!deleting) setPendingDelete(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentRow({
|
||||
environment,
|
||||
disabled,
|
||||
onDelete,
|
||||
}: {
|
||||
environment: Environment;
|
||||
disabled: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_auto] items-center gap-4 px-4 py-3.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-mono text-sm text-fg" title={environment.id}>
|
||||
{environment.id}
|
||||
</span>
|
||||
<Badge>{environment.provider}</Badge>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs/5 text-fg-3">
|
||||
{resourcesSummary(environment)} · network {environment.network.mode}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="min-w-0 truncate font-mono text-xs text-fg-2"
|
||||
title={imageSummary(environment) ?? undefined}
|
||||
>
|
||||
{imageSummary(environment) ?? <Muted>No image</Muted>}
|
||||
</div>
|
||||
<RowMenu environment={environment} disabled={disabled} onDelete={onDelete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function imageSummary(environment: Environment): string | null {
|
||||
if (environment.image.docker) return environment.image.docker;
|
||||
if (environment.image.dockerfile) return "Dockerfile (inline)";
|
||||
return null;
|
||||
}
|
||||
|
||||
function resourcesSummary(environment: Environment): string {
|
||||
const parts = [
|
||||
environment.resources.cpu === null ? null : `${environment.resources.cpu} CPU`,
|
||||
environment.resources.memory,
|
||||
environment.resources.disk,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
return parts.length > 0 ? parts.join(" · ") : "Default resources";
|
||||
}
|
||||
|
||||
function RowMenu({
|
||||
environment,
|
||||
disabled,
|
||||
onDelete,
|
||||
}: {
|
||||
environment: Environment;
|
||||
disabled: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const protectedFromDelete = environment.id === PROTECTED_ID;
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block">
|
||||
<MenuButton
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={`Actions for ${environment.id}`}
|
||||
title="Actions"
|
||||
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-4" aria-hidden="true" />
|
||||
</MenuButton>
|
||||
<MenuItems
|
||||
transition
|
||||
anchor={{ to: "bottom end", gap: 4 }}
|
||||
className="z-30 w-36 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem>
|
||||
<Link
|
||||
to={`/settings/environments/${encodeURIComponent(environment.id)}/edit`}
|
||||
className={MENU_ITEM_CLASS}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<hr className="my-1 h-px border-0 bg-line" />
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={disabled || protectedFromDelete}
|
||||
title={protectedFromDelete ? "The default environment is protected" : undefined}
|
||||
className={MENU_ITEM_DANGER_CLASS}
|
||||
>
|
||||
{protectedFromDelete ? "Protected" : "Delete"}
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
KeyIcon,
|
||||
PuzzlePieceIcon,
|
||||
ShieldCheckIcon,
|
||||
Square3Stack3DIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Fragment } from "react";
|
||||
import { Link, Outlet, useLocation, useMatches } from "react-router";
|
||||
|
|
@ -65,6 +66,13 @@ export const navSections: NavSection[] = [
|
|||
key: "workflows",
|
||||
label: "Workflows",
|
||||
items: [
|
||||
{
|
||||
name: "Environments",
|
||||
href: "/settings/environments",
|
||||
icon: Square3Stack3DIcon,
|
||||
description: "Server-managed runtime definitions for runs.",
|
||||
match: (p) => p.startsWith("/settings/environments"),
|
||||
},
|
||||
{
|
||||
name: "Variables",
|
||||
href: "/settings/variables",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue