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 = () => {
|
||||
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;
|
||||
|
||||
@ -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