1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-23 15:29:42 +02:00

refactor(docker/containers): migrate commands tab to react [EE-5208] (#10085)

This commit is contained in:
Chaim Lev-Ari 2023-09-04 19:07:29 +01:00 committed by GitHub
parent 46e73ee524
commit f7366d9788
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1783 additions and 951 deletions

View file

@ -0,0 +1,105 @@
import { FormikErrors } from 'formik';
import { useState } from 'react';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { ConsoleSettings } from './ConsoleSettings';
import { LoggerConfig } from './LoggerConfig';
import { OverridableInput } from './OverridableInput';
import { Values } from './types';
export function CommandsTab({
apiVersion,
values,
onChange,
errors,
}: {
apiVersion: number;
values: Values;
onChange: (values: Values) => void;
errors?: FormikErrors<Values>;
}) {
const [controlledValues, setControlledValues] = useState(values);
return (
<div className="mt-3">
<FormControl
label="Command"
inputId="command-input"
size="xsmall"
errors={errors?.cmd}
>
<OverridableInput
value={controlledValues.cmd}
onChange={(cmd) => handleChange({ cmd })}
id="command-input"
placeholder="e.g. '-logtostderr' '--housekeeping_interval=5s' or /usr/bin/nginx -t -c /mynginx.conf"
/>
</FormControl>
<FormControl
label="Entrypoint"
inputId="entrypoint-input"
size="xsmall"
tooltip="When container entrypoint is entered as part of the Command field, set Entrypoint to Override mode and leave blank, else it will revert to default."
errors={errors?.entrypoint}
>
<OverridableInput
value={controlledValues.entrypoint}
onChange={(entrypoint) => handleChange({ entrypoint })}
id="entrypoint-input"
placeholder="e.g. /bin/sh -c"
/>
</FormControl>
<div className="flex justify-between gap-4">
<FormControl
label="Working Dir"
inputId="working-dir-input"
className="w-1/2"
errors={errors?.workingDir}
>
<Input
value={controlledValues.workingDir}
onChange={(e) => handleChange({ workingDir: e.target.value })}
placeholder="e.g. /myapp"
/>
</FormControl>
<FormControl
label="User"
inputId="user-input"
className="w-1/2"
errors={errors?.user}
>
<Input
value={controlledValues.user}
onChange={(e) => handleChange({ user: e.target.value })}
placeholder="e.g. nginx"
/>
</FormControl>
</div>
<ConsoleSettings
value={controlledValues.console}
onChange={(console) => handleChange({ console })}
/>
<LoggerConfig
apiVersion={apiVersion}
value={controlledValues.logConfig}
onChange={(logConfig) =>
handleChange({
logConfig,
})
}
errors={errors?.logConfig}
/>
</div>
);
function handleChange(newValues: Partial<Values>) {
onChange({ ...values, ...newValues });
setControlledValues((values) => ({ ...values, ...newValues }));
}
}

View file

@ -0,0 +1,95 @@
import { ReactNode } from 'react';
import { mixed } from 'yup';
import { ContainerConfig } from 'docker-types/generated/1.41';
import { FormControl } from '@@/form-components/FormControl';
const consoleSettingTypes = ['tty', 'interactive', 'both', 'none'] as const;
export type ConsoleSetting = (typeof consoleSettingTypes)[number];
export type ConsoleConfig = Pick<ContainerConfig, 'OpenStdin' | 'Tty'>;
export function ConsoleSettings({
value,
onChange,
}: {
value: ConsoleSetting;
onChange(value: ConsoleSetting): void;
}) {
return (
<FormControl label="Console" size="xsmall">
<Item
value="both"
onChange={handleChange}
label={
<>
Interactive & TTY <span className="small text-muted">(-i -t)</span>
</>
}
selected={value}
/>
<Item
value="interactive"
onChange={handleChange}
label={
<>
Interactive <span className="small text-muted">(-i)</span>
</>
}
selected={value}
/>
<Item
value="tty"
onChange={handleChange}
label={
<>
TTY <span className="small text-muted">(-t)</span>
</>
}
selected={value}
/>
<Item
value="none"
onChange={handleChange}
label={<>None</>}
selected={value}
/>
</FormControl>
);
function handleChange(value: ConsoleSetting) {
onChange(value);
}
}
function Item({
value,
selected,
onChange,
label,
}: {
value: ConsoleSetting;
selected: ConsoleSetting;
onChange(value: ConsoleSetting): void;
label: ReactNode;
}) {
return (
<label className="radio-inline !m-0 w-1/2">
<input
type="radio"
name="container_console"
value={value}
checked={value === selected}
onChange={() => onChange(value)}
/>
{label}
</label>
);
}
export function validation() {
return mixed<ConsoleSetting>()
.oneOf([...consoleSettingTypes])
.default('none');
}

View file

@ -0,0 +1,138 @@
import { FormikErrors } from 'formik';
import { array, object, SchemaOf, string } from 'yup';
import _ from 'lodash';
import { useLoggingPlugins } from '@/react/docker/proxy/queries/useServicePlugins';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
import { InputGroup } from '@@/form-components/InputGroup';
import { InputList, ItemProps } from '@@/form-components/InputList';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { TextTip } from '@@/Tip/TextTip';
import { FormError } from '@@/form-components/FormError';
export interface LogConfig {
type: string;
options: Array<{ option: string; value: string }>;
}
export function LoggerConfig({
value,
onChange,
apiVersion,
errors,
}: {
value: LogConfig;
onChange: (value: LogConfig) => void;
apiVersion: number;
errors?: FormikErrors<LogConfig>;
}) {
const envId = useEnvironmentId();
const pluginsQuery = useLoggingPlugins(envId, apiVersion < 1.25);
if (!pluginsQuery.data) {
return null;
}
const isDisabled = !value.type || value.type === 'none';
const pluginOptions = [
{ label: 'Default logging driver', value: '' },
...pluginsQuery.data.map((p) => ({ label: p, value: p })),
{ label: 'none', value: 'none' },
];
return (
<FormSection title="Logging">
<FormControl label="Driver">
<PortainerSelect
value={value.type}
onChange={(type) => onChange({ ...value, type: type || '' })}
options={pluginOptions}
/>
</FormControl>
<TextTip color="blue">
Logging driver that will override the default docker daemon driver.
Select Default logging driver if you don&apos;t want to override it.
Supported logging drivers can be found
<a
href="https://docs.docker.com/engine/admin/logging/overview/#supported-logging-drivers"
target="_blank"
rel="noreferrer"
>
in the Docker documentation
</a>
.
</TextTip>
<InputList
tooltip={
isDisabled
? 'Add button is disabled unless a driver other than none or default is selected. Options are specific to the selected driver, refer to the driver documentation.'
: ''
}
label="Options"
onChange={(options) => handleChange({ options })}
value={value.options}
item={Item}
itemBuilder={() => ({ option: '', value: '' })}
disabled={isDisabled}
errors={errors?.options}
/>
</FormSection>
);
function handleChange(partial: Partial<LogConfig>) {
onChange({ ...value, ...partial });
}
}
function Item({
item: { option, value },
onChange,
error,
}: ItemProps<{ option: string; value: string }>) {
return (
<div>
<div className="flex w-full gap-4">
<InputGroup className="w-1/2">
<InputGroup.Addon>option</InputGroup.Addon>
<InputGroup.Input
value={option}
onChange={(e) => handleChange({ option: e.target.value })}
placeholder="e.g. FOO"
/>
</InputGroup>
<InputGroup className="w-1/2">
<InputGroup.Addon>value</InputGroup.Addon>
<InputGroup.Input
value={value}
onChange={(e) => handleChange({ value: e.target.value })}
placeholder="e.g bar"
/>
</InputGroup>
</div>
{error && <FormError>{_.first(Object.values(error))}</FormError>}
</div>
);
function handleChange(partial: Partial<{ option: string; value: string }>) {
onChange({ option, value, ...partial });
}
}
export function validation(): SchemaOf<LogConfig> {
return object({
options: array().of(
object({
option: string().required('Option is required'),
value: string().required('Value is required'),
})
),
type: string().default('none'),
});
}

View file

@ -0,0 +1,48 @@
import clsx from 'clsx';
import { Button } from '@@/buttons';
import { InputGroup } from '@@/form-components/InputGroup';
export function OverridableInput({
value,
onChange,
id,
placeholder,
}: {
value: string | null;
onChange: (value: string | null) => void;
id: string;
placeholder: string;
}) {
const override = value !== null;
return (
<InputGroup>
<InputGroup.ButtonWrapper>
<Button
color="light"
size="medium"
className={clsx('!ml-0', { active: !override })}
onClick={() => onChange(null)}
>
Default
</Button>
<Button
color="light"
size="medium"
className={clsx({ active: override })}
onClick={() => onChange('')}
>
Override
</Button>
</InputGroup.ButtonWrapper>
<InputGroup.Input
disabled={!override}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
id={id}
placeholder={placeholder}
/>
</InputGroup>
);
}

View file

@ -0,0 +1,14 @@
import { validation } from './validation';
import { toRequest } from './toRequest';
import { toViewModel, getDefaultViewModel } from './toViewModel';
export { CommandsTab } from './CommandsTab';
export { validation as commandsTabValidation } from './validation';
export { type Values as CommandsTabValues } from './types';
export const commandsTabUtils = {
toRequest,
toViewModel,
validation,
getDefaultViewModel,
};

View file

@ -0,0 +1,60 @@
import { commandStringToArray } from '@/docker/helpers/containers';
import { CreateContainerRequest } from '../types';
import { Values } from './types';
import { LogConfig } from './LoggerConfig';
import { ConsoleConfig, ConsoleSetting } from './ConsoleSettings';
export function toRequest(
oldConfig: CreateContainerRequest,
values: Values
): CreateContainerRequest {
const config = {
...oldConfig,
HostConfig: {
...oldConfig.HostConfig,
LogConfig: getLogConfig(values.logConfig),
},
User: values.user,
WorkingDir: values.workingDir,
...getConsoleConfig(values.console),
};
if (values.cmd) {
config.Cmd = commandStringToArray(values.cmd);
}
if (values.entrypoint) {
config.Entrypoint = commandStringToArray(values.entrypoint);
}
return config;
function getLogConfig(
value: LogConfig
): CreateContainerRequest['HostConfig']['LogConfig'] {
return {
Type: value.type,
Config: Object.fromEntries(
value.options.map(({ option, value }) => [option, value])
),
// docker types - requires union while it should allow also custom string for custom plugins
} as CreateContainerRequest['HostConfig']['LogConfig'];
}
function getConsoleConfig(value: ConsoleSetting): ConsoleConfig {
switch (value) {
case 'both':
return { OpenStdin: true, Tty: true };
case 'interactive':
return { OpenStdin: true, Tty: false };
case 'tty':
return { OpenStdin: false, Tty: true };
case 'none':
default:
return { OpenStdin: false, Tty: false };
}
}
}

View file

@ -0,0 +1,70 @@
import { HostConfig } from 'docker-types/generated/1.41';
import { commandArrayToString } from '@/docker/helpers/containers';
import { ContainerJSON } from '../../queries/container';
import { ConsoleConfig, ConsoleSetting } from './ConsoleSettings';
import { LogConfig } from './LoggerConfig';
import { Values } from './types';
export function getDefaultViewModel(): Values {
return {
cmd: null,
entrypoint: null,
user: '',
workingDir: '',
console: 'none',
logConfig: getLogConfig(),
};
}
export function toViewModel(config: ContainerJSON): Values {
if (!config.Config) {
return getDefaultViewModel();
}
return {
cmd: config.Config.Cmd ? commandArrayToString(config.Config.Cmd) : null,
entrypoint: config.Config.Entrypoint
? commandArrayToString(config.Config.Entrypoint)
: null,
user: config.Config.User || '',
workingDir: config.Config.WorkingDir || '',
console: config ? getConsoleSetting(config.Config) : 'none',
logConfig: getLogConfig(config.HostConfig?.LogConfig),
};
}
function getLogConfig(value?: HostConfig['LogConfig']): LogConfig {
if (!value || !value.Type) {
return {
type: 'none',
options: [],
};
}
return {
type: value.Type,
options: Object.entries(value.Config || {}).map(([option, value]) => ({
option,
value,
})),
};
}
function getConsoleSetting(value: ConsoleConfig): ConsoleSetting {
if (value.OpenStdin && value.Tty) {
return 'both';
}
if (!value.OpenStdin && value.Tty) {
return 'tty';
}
if (value.OpenStdin && !value.Tty) {
return 'interactive';
}
return 'none';
}

View file

@ -0,0 +1,11 @@
import { ConsoleSetting } from './ConsoleSettings';
import { LogConfig } from './LoggerConfig';
export interface Values {
cmd: string | null;
entrypoint: string | null;
workingDir: string;
user: string;
console: ConsoleSetting;
logConfig: LogConfig;
}

View file

@ -0,0 +1,16 @@
import { object, SchemaOf, string } from 'yup';
import { validation as consoleValidation } from './ConsoleSettings';
import { validation as logConfigValidation } from './LoggerConfig';
import { Values } from './types';
export function validation(): SchemaOf<Values> {
return object({
cmd: string().nullable().default(''),
entrypoint: string().nullable().default(''),
logConfig: logConfigValidation(),
console: consoleValidation(),
user: string().default(''),
workingDir: string().default(''),
});
}