mirror of https://github.com/ctk-hq/ctk
commit
138fd407b5
@ -1,5 +1,103 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { PlusIcon } from "@heroicons/react/outline";
|
||||||
|
import { Button, styled } from "@mui/joy";
|
||||||
|
import { useFormikContext } from "formik";
|
||||||
|
import Record from "../../Record";
|
||||||
|
import { IEditServiceForm } from "../../../types";
|
||||||
|
|
||||||
|
const Root = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Records = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: ${({ theme }) => theme.spacing(1)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AddButton = styled(Button)`
|
||||||
|
width: 140px;
|
||||||
|
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Description = styled("p")`
|
||||||
|
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||||
|
text-align: center;
|
||||||
|
color: #7a7a7a;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
const Volumes = () => {
|
const Volumes = () => {
|
||||||
return <></>;
|
const formik = useFormikContext<IEditServiceForm>();
|
||||||
|
const { volumes } = formik.values;
|
||||||
|
|
||||||
|
const handleNewVolume = useCallback(() => {
|
||||||
|
formik.setFieldValue(`volumes[${volumes.length}]`, {
|
||||||
|
name: "",
|
||||||
|
containerPath: "",
|
||||||
|
accessMode: ""
|
||||||
|
});
|
||||||
|
}, [formik]);
|
||||||
|
|
||||||
|
const handleRemoveVolume = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const newVolumes = volumes.filter(
|
||||||
|
(_: unknown, currentIndex: number) => currentIndex != index
|
||||||
|
);
|
||||||
|
formik.setFieldValue(`volumes`, newVolumes);
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
);
|
||||||
|
|
||||||
|
const emptyVolumes = volumes && volumes.length === 0 ? true : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
{!emptyVolumes && (
|
||||||
|
<Records>
|
||||||
|
{volumes.map((_: unknown, index: number) => (
|
||||||
|
<Record
|
||||||
|
key={index}
|
||||||
|
index={index}
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
name: `volumes[${index}].name`,
|
||||||
|
placeholder: "Name",
|
||||||
|
required: true,
|
||||||
|
type: "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `volumes[${index}].containerPath`,
|
||||||
|
placeholder: "Container path",
|
||||||
|
type: "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `volumes[${index}].accessMode`,
|
||||||
|
placeholder: "Access mode",
|
||||||
|
type: "text"
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onRemove={handleRemoveVolume}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Records>
|
||||||
|
)}
|
||||||
|
{emptyVolumes && (
|
||||||
|
<Description>
|
||||||
|
This service does not have any volumes.
|
||||||
|
<br />
|
||||||
|
Click "+ New volume" to add a new volume.
|
||||||
|
</Description>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddButton size="sm" variant="plain" onClick={handleNewVolume}>
|
||||||
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
|
New volume
|
||||||
|
</AddButton>
|
||||||
|
</Root>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Volumes;
|
export default Volumes;
|
||||||
|
|||||||
@ -0,0 +1,200 @@
|
|||||||
|
import type { IEditServiceForm, IServiceNodeItem } from "../../../types";
|
||||||
|
import * as yup from "yup";
|
||||||
|
import lodash from "lodash";
|
||||||
|
|
||||||
|
const initialValues: IEditServiceForm = {
|
||||||
|
imageName: "",
|
||||||
|
imageTag: "",
|
||||||
|
serviceName: "",
|
||||||
|
containerName: "",
|
||||||
|
ports: [],
|
||||||
|
environmentVariables: [],
|
||||||
|
volumes: [],
|
||||||
|
labels: []
|
||||||
|
};
|
||||||
|
|
||||||
|
yup.addMethod<yup.StringSchema>(yup.string, "port", function (message) {
|
||||||
|
return this.test("test-port", message, function (value):
|
||||||
|
| boolean
|
||||||
|
| yup.ValidationError {
|
||||||
|
const { path, createError } = this;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const result = parseInt(value, 10);
|
||||||
|
if (isNaN(result) || result < 0 || result > 65535) {
|
||||||
|
return createError({ path, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const validationSchema = yup.object({
|
||||||
|
serviceName: yup
|
||||||
|
.string()
|
||||||
|
.max(256, "Service name should be 256 characters or less")
|
||||||
|
.required("Service name is required"),
|
||||||
|
imageName: yup
|
||||||
|
.string()
|
||||||
|
.max(256, "Image name should be 256 characters or less")
|
||||||
|
.required("Image name is required"),
|
||||||
|
imageTag: yup.string().max(256, "Image tag should be 256 characters or less"),
|
||||||
|
containerName: yup
|
||||||
|
.string()
|
||||||
|
.max(256, "Container name should be 256 characters or less")
|
||||||
|
.required("Container name is required"),
|
||||||
|
ports: yup.array(
|
||||||
|
yup.object({
|
||||||
|
hostPort: (yup.string().required("Host port is required") as any).port(
|
||||||
|
"Host port should be an integer in the range 0-65535"
|
||||||
|
),
|
||||||
|
containerPort: (yup.string() as any).port(
|
||||||
|
"Container port should be an integer in the range 0-65535"
|
||||||
|
),
|
||||||
|
protocol: yup
|
||||||
|
.string()
|
||||||
|
.oneOf(["tcp", "udp"], "Protocol should be tcp or udp")
|
||||||
|
})
|
||||||
|
),
|
||||||
|
environmentVariables: yup.array(
|
||||||
|
yup.object({
|
||||||
|
key: yup.string().required("Key is required"),
|
||||||
|
value: yup.string().required("Value is required")
|
||||||
|
})
|
||||||
|
),
|
||||||
|
volumes: yup.array(
|
||||||
|
yup.object({
|
||||||
|
name: yup.string().required("Name is required"),
|
||||||
|
containerPath: yup.string(),
|
||||||
|
accessMode: yup.string()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
labels: yup.array(
|
||||||
|
yup.object({
|
||||||
|
key: yup.string().required("Key is required"),
|
||||||
|
value: yup.string().required("Value is required")
|
||||||
|
})
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getInitialValues = (node?: IServiceNodeItem): IEditServiceForm => {
|
||||||
|
if (!node) {
|
||||||
|
return {
|
||||||
|
...initialValues
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { canvasConfig, serviceConfig } = node;
|
||||||
|
const { node_name = "" } = canvasConfig;
|
||||||
|
const {
|
||||||
|
image,
|
||||||
|
container_name = "",
|
||||||
|
environment,
|
||||||
|
volumes,
|
||||||
|
ports,
|
||||||
|
labels
|
||||||
|
} = serviceConfig;
|
||||||
|
|
||||||
|
const checkArray = <T>(array: any, name: string): T => {
|
||||||
|
if (!Array.isArray(array)) {
|
||||||
|
throw new Error(
|
||||||
|
`Looks like we encountered a bug. The current implementation expects "${name}" to be an array.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return array as unknown as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const environment0: string[] = checkArray(environment, "environment");
|
||||||
|
const volumes0: string[] = checkArray(volumes, "volumes");
|
||||||
|
const ports0: string[] = checkArray(ports, "ports");
|
||||||
|
const labels0: string[] = checkArray(labels, "labels");
|
||||||
|
const [imageName, imageTag] = (image ?? ":").split(":");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...initialValues,
|
||||||
|
imageName,
|
||||||
|
imageTag,
|
||||||
|
serviceName: node_name,
|
||||||
|
containerName: container_name,
|
||||||
|
environmentVariables: environment0.map((variable) => {
|
||||||
|
const [key, value] = variable.split(":");
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
volumes: volumes0.map((volume) => {
|
||||||
|
const [name, containerPath, accessMode] = volume.split(":");
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
containerPath,
|
||||||
|
accessMode
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
ports: ports0.map((port) => {
|
||||||
|
const slashIndex = port.lastIndexOf("/");
|
||||||
|
const protocol = slashIndex >= 0 ? port.substring(slashIndex + 1) : "";
|
||||||
|
const [hostPort, containerPort] = port
|
||||||
|
.substring(0, slashIndex)
|
||||||
|
.split(":");
|
||||||
|
|
||||||
|
if (!["tcp", "udp"].includes(protocol)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid protocol "${protocol}" found while deserializing.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hostPort, containerPort, protocol } as any;
|
||||||
|
}),
|
||||||
|
labels: labels0.map((label) => {
|
||||||
|
const [key, value] = label.split(":");
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFinalValues = (
|
||||||
|
values: IEditServiceForm,
|
||||||
|
previous?: IServiceNodeItem
|
||||||
|
): IServiceNodeItem => {
|
||||||
|
const { environmentVariables, ports, volumes, labels } = values;
|
||||||
|
|
||||||
|
return lodash.merge(
|
||||||
|
lodash.cloneDeep(previous) || {
|
||||||
|
key: "service",
|
||||||
|
type: "SERVICE",
|
||||||
|
inputs: ["op_source"],
|
||||||
|
outputs: [],
|
||||||
|
config: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
canvasConfig: {
|
||||||
|
node_name: values.serviceName
|
||||||
|
},
|
||||||
|
serviceConfig: {
|
||||||
|
image: `${values.imageName}:${values.imageTag}`,
|
||||||
|
container_name: values.containerName,
|
||||||
|
environment: environmentVariables.map(
|
||||||
|
(variable) => `${variable.key}:${variable.value}`
|
||||||
|
),
|
||||||
|
volumes: volumes.map(
|
||||||
|
(volume) =>
|
||||||
|
volume.name +
|
||||||
|
(volume.containerPath ? `:${volume.containerPath}` : "") +
|
||||||
|
(volume.accessMode ? `:${volume.accessMode}` : "")
|
||||||
|
),
|
||||||
|
ports: ports.map(
|
||||||
|
(port) =>
|
||||||
|
port.hostPort +
|
||||||
|
(port.containerPort ? `:${port.containerPort}` : "") +
|
||||||
|
(port.protocol ? `/${port.protocol}` : "")
|
||||||
|
),
|
||||||
|
labels: labels.map((label) => `${label.key}:${label.value}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) as any;
|
||||||
|
};
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import _ from "lodash";
|
|
||||||
import { useFormikContext } from "formik";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
name: string;
|
|
||||||
help?: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TextField = (props: Props) => {
|
|
||||||
const { label, name, help, ...otherProps } = props;
|
|
||||||
const formik = useFormikContext();
|
|
||||||
const error = _.get(formik.touched, name) && _.get(formik.errors, name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative pb-3 flex-auto">
|
|
||||||
<div className="grid grid-cols-6 gap-4">
|
|
||||||
<div className="col-span-3">
|
|
||||||
<label
|
|
||||||
htmlFor={name}
|
|
||||||
className="block text-xs font-medium text-gray-700"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<input
|
|
||||||
id={name}
|
|
||||||
name={name}
|
|
||||||
type="text"
|
|
||||||
autoComplete="none"
|
|
||||||
className="input-util"
|
|
||||||
onBlur={formik.handleBlur}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
{...otherProps}
|
|
||||||
value={_.get(formik.values, name)}
|
|
||||||
/>
|
|
||||||
{
|
|
||||||
<div className="mt-1 text-xs text-red-600">
|
|
||||||
{error && <div className="caption">{error}</div>}
|
|
||||||
{!error && help}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TextField;
|
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import lodash from "lodash";
|
||||||
|
import { useFormikContext } from "formik";
|
||||||
|
import { FunctionComponent, ReactElement } from "react";
|
||||||
|
import { styled } from "@mui/joy";
|
||||||
|
|
||||||
|
export interface ITextFieldProps {
|
||||||
|
name: string;
|
||||||
|
help?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Root = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TextField: FunctionComponent<ITextFieldProps> = (
|
||||||
|
props: ITextFieldProps
|
||||||
|
): ReactElement => {
|
||||||
|
const { label, name, help, required, ...otherProps } = props;
|
||||||
|
const formik = useFormikContext();
|
||||||
|
const error =
|
||||||
|
lodash.get(formik.touched, name) && lodash.get(formik.errors, name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={name}
|
||||||
|
className="block text-xs font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
{label + (required ? "*" : "")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
type="text"
|
||||||
|
autoComplete="none"
|
||||||
|
className="input-util mt-1"
|
||||||
|
required={required}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
value={lodash.get(formik.values, name)}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-1 text-xs text-red-600">
|
||||||
|
{error && <span className="caption">{error}</span>}
|
||||||
|
{!error && help}
|
||||||
|
</div>
|
||||||
|
</Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextField;
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
import lodash from "lodash";
|
||||||
|
import { useFormikContext } from "formik";
|
||||||
|
import { FunctionComponent, ReactElement, useCallback } from "react";
|
||||||
|
import { Button, styled } from "@mui/joy";
|
||||||
|
|
||||||
|
export interface IToggleProps {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
help?: string;
|
||||||
|
options: {
|
||||||
|
text: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IToggleButtonProps {
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Root = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ToggleButton = styled(Button)<IToggleButtonProps>(({ index, total }) => ({
|
||||||
|
borderRadius: `
|
||||||
|
${index === 0 ? "8px" : "0px"}
|
||||||
|
${index === total - 1 ? "8px" : "0px"}
|
||||||
|
${index === total - 1 ? "8px" : "0px"}
|
||||||
|
${index === 0 ? "8px" : "0px"}
|
||||||
|
`,
|
||||||
|
fontSize: 11
|
||||||
|
}));
|
||||||
|
|
||||||
|
const Toggle: FunctionComponent<IToggleProps> = (
|
||||||
|
props: IToggleProps
|
||||||
|
): ReactElement => {
|
||||||
|
const { name, help, options } = props;
|
||||||
|
const formik = useFormikContext();
|
||||||
|
const error =
|
||||||
|
lodash.get(formik.touched, name) && lodash.get(formik.errors, name);
|
||||||
|
const value = lodash.get(formik.values, name);
|
||||||
|
|
||||||
|
const handleChange = (value: string) => () => {
|
||||||
|
formik.setFieldValue(name, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Root>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<ToggleButton
|
||||||
|
variant={value === option.value ? "solid" : "soft"}
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
index={index}
|
||||||
|
total={options.length}
|
||||||
|
onClick={handleChange(option.value)}
|
||||||
|
>
|
||||||
|
{option.text}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-1 text-xs text-red-600">
|
||||||
|
{error && <span className="caption">{error}</span>}
|
||||||
|
{!error && help}
|
||||||
|
</div>
|
||||||
|
</Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toggle;
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import { extendTheme } from "@mui/joy/styles";
|
||||||
|
|
||||||
|
export const lightTheme = extendTheme({
|
||||||
|
colorSchemes: {
|
||||||
|
light: {
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
mainChannel: "#4f46e5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue