mirror of https://github.com/portainer/portainer
feat(docker/services): show port ranges [EE-4012] (#10657)
parent
4ca6292805
commit
d336a14e50
@ -1,108 +0,0 @@
|
||||
<div>
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="list" title-text="Published ports">
|
||||
<div class="nopadding" authorization="DockerServiceUpdate">
|
||||
<a class="btn btn-secondary btn-sm pull-right" ng-click="isUpdating ||addPublishedPort(service)" ng-disabled="isUpdating">
|
||||
<pr-icon icon="'plus'"></pr-icon> port mapping
|
||||
</a>
|
||||
</div>
|
||||
</rd-widget-header>
|
||||
<rd-widget-body ng-if="!service.Ports || service.Ports.length === 0">
|
||||
<p>This service has no ports published.</p>
|
||||
</rd-widget-body>
|
||||
<rd-widget-body ng-if="service.Ports && service.Ports.length > 0" classes="no-padding">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host port</th>
|
||||
<th>Container port</th>
|
||||
<th>Protocol</th>
|
||||
<th>Publish mode</th>
|
||||
<th authorization="DockerServiceUpdate">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="portBinding in service.Ports">
|
||||
<td>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-addon !leading-none">host</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
ng-model="portBinding.PublishedPort"
|
||||
placeholder="e.g. 8080"
|
||||
ng-change="updatePublishedPort(service, mapping)"
|
||||
ng-disabled="isUpdating"
|
||||
disable-authorization="DockerServiceUpdate"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-addon !leading-none">container</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
ng-model="portBinding.TargetPort"
|
||||
placeholder="e.g. 80"
|
||||
ng-change="updatePublishedPort(service, mapping)"
|
||||
ng-disabled="isUpdating"
|
||||
disable-authorization="DockerServiceUpdate"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="input-group input-group-sm">
|
||||
<select
|
||||
class="selectpicker form-control !rounded"
|
||||
ng-model="portBinding.Protocol"
|
||||
ng-change="updatePublishedPort(service, mapping)"
|
||||
ng-disabled="isUpdating"
|
||||
disable-authorization="DockerServiceUpdate"
|
||||
>
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="input-group input-group-sm">
|
||||
<select
|
||||
class="selectpicker form-control !rounded"
|
||||
ng-model="portBinding.PublishMode"
|
||||
ng-change="updatePublishedPort(service, mapping)"
|
||||
ng-disabled="isUpdating"
|
||||
disable-authorization="DockerServiceUpdate"
|
||||
>
|
||||
<option value="ingress">ingress</option>
|
||||
<option value="host">host</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td authorization="DockerServiceUpdate">
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-sm btn-dangerlight" type="button" ng-click="removePortPublishedBinding(service, $index)" ng-disabled="isUpdating">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
<rd-widget-footer authorization="DockerServiceUpdate">
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['Ports'])" ng-click="updateService(service)">Apply changes</button>
|
||||
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['Ports'])">Reset changes</a></li>
|
||||
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-footer>
|
||||
</rd-widget>
|
||||
</div>
|
@ -1,24 +1,39 @@
|
||||
import { createContext, PropsWithChildren, useContext } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { createContext, PropsWithChildren, Ref, useContext } from 'react';
|
||||
|
||||
const Context = createContext<null | boolean>(null);
|
||||
Context.displayName = 'WidgetContext';
|
||||
|
||||
export function useWidgetContext() {
|
||||
const context = useContext(Context);
|
||||
|
||||
if (context == null) {
|
||||
throw new Error('Should be inside a Widget component');
|
||||
}
|
||||
}
|
||||
|
||||
export function Widget({
|
||||
children,
|
||||
className,
|
||||
mRef,
|
||||
id,
|
||||
'aria-label': ariaLabel,
|
||||
}: PropsWithChildren<{
|
||||
className?: string;
|
||||
mRef?: Ref<HTMLDivElement>;
|
||||
id?: string;
|
||||
'aria-label'?: string;
|
||||
}>) {
|
||||
return (
|
||||
<Context.Provider value>
|
||||
<div id={id} className="widget">
|
||||
<section
|
||||
id={id}
|
||||
className={clsx('widget', className)}
|
||||
ref={mRef}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,209 @@
|
||||
import { List, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import {
|
||||
ArrayError,
|
||||
ItemProps,
|
||||
useInputList,
|
||||
} from '@@/form-components/InputList/InputList';
|
||||
import { Table } from '@@/datatables';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Select } from '@@/form-components/Input';
|
||||
|
||||
import { ServiceWidget } from '../ServiceWidget';
|
||||
|
||||
import { Protocol, Value } from './types';
|
||||
import { RangeOrNumberField } from './RangeOrNumberField';
|
||||
|
||||
export type Values = Array<Value>;
|
||||
|
||||
export function PortsMappingField({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
disabled,
|
||||
readOnly,
|
||||
hasChanges,
|
||||
onReset,
|
||||
onSubmit,
|
||||
}: {
|
||||
values: Values;
|
||||
onChange(value: Values): void;
|
||||
errors?: ArrayError<Values>;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
hasChanges: boolean;
|
||||
onReset(all?: boolean): void;
|
||||
onSubmit(): void;
|
||||
}) {
|
||||
const { handleRemoveItem, handleAdd, handleChangeItem } = useInputList<Value>(
|
||||
{
|
||||
value: values,
|
||||
onChange,
|
||||
itemBuilder: () => ({
|
||||
hostPort: 0,
|
||||
containerPort: 0,
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<ServiceWidget
|
||||
titleIcon={List}
|
||||
title="Published ports"
|
||||
labelForAddButton="port mapping"
|
||||
onAdd={handleAdd}
|
||||
hasChanges={hasChanges}
|
||||
onReset={onReset}
|
||||
onSubmit={onSubmit}
|
||||
isValid={!errors}
|
||||
>
|
||||
{values.length > 0 ? (
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host port</th>
|
||||
<th>Container port</th>
|
||||
<th>Protocol</th>
|
||||
<th>Publish mode</th>
|
||||
<Authorized authorizations="DockerServiceUpdate">
|
||||
<th>Actions</th>
|
||||
</Authorized>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{values.map((item, index) => (
|
||||
<Item
|
||||
key={index}
|
||||
item={item}
|
||||
index={index}
|
||||
onChange={(value) => handleChangeItem(index, value)}
|
||||
error={errors?.[index]}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
onRemove={() => handleRemoveItem(index, item)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="p-5">This service has no ports published.</p>
|
||||
)}
|
||||
{typeof errors === 'string' && (
|
||||
<div className="form-group col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</ServiceWidget>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({
|
||||
onChange,
|
||||
item,
|
||||
error,
|
||||
disabled,
|
||||
readOnly,
|
||||
onRemove,
|
||||
index,
|
||||
}: ItemProps<Value> & { onRemove(): void }) {
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>
|
||||
<div className="flex gap-2 items-center">
|
||||
<RangeOrNumberField
|
||||
value={item.hostPort}
|
||||
onChange={(value) => handleChange('hostPort', value)}
|
||||
id={`hostPort-${index}`}
|
||||
label="host"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<RangeOrNumberField
|
||||
value={item.containerPort}
|
||||
onChange={(value) => handleChange('containerPort', value)}
|
||||
id={`containerPort-${index}`}
|
||||
label="container"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<ButtonSelector<Protocol>
|
||||
onChange={(value) => handleChange('protocol', value)}
|
||||
value={item.protocol}
|
||||
options={[{ value: 'tcp' }, { value: 'udp' }]}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
aria-label="protocol selector"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Select
|
||||
onChange={(e) => handleChange('publishMode', e.target.value)}
|
||||
value={item.publishMode}
|
||||
options={[
|
||||
{ value: 'ingress', label: 'ingress' },
|
||||
{ value: 'host', label: 'host' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
aria-label="publish mode"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
icon={Trash2}
|
||||
color="dangerlight"
|
||||
onClick={() => onRemove()}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{error && (
|
||||
<tr>
|
||||
{typeof error === 'string' ? (
|
||||
<td colSpan={5}>
|
||||
<FormError>{error}</FormError>
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td>
|
||||
<FormError>{rangeError(error.hostPort)}</FormError>
|
||||
</td>
|
||||
<td>
|
||||
<FormError>{rangeError(error.containerPort)}</FormError>
|
||||
</td>
|
||||
<td>
|
||||
<FormError>{error.protocol}</FormError>
|
||||
</td>
|
||||
<td>
|
||||
<FormError>{error.publishMode}</FormError>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleChange(name: keyof Value, value: unknown) {
|
||||
onChange({ ...item, [name]: value });
|
||||
}
|
||||
|
||||
function rangeError(error?: string | { start?: string; end?: string }) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return error.start || error.end;
|
||||
}
|
||||
}
|
@ -0,0 +1,127 @@
|
||||
import { InputLabeled } from '@@/form-components/Input/InputLabeled';
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
|
||||
import { Range, isRange } from './types';
|
||||
|
||||
export function RangeOrNumberField({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
readOnly,
|
||||
id,
|
||||
label,
|
||||
}: {
|
||||
value: Range | number | undefined;
|
||||
onChange: (value: Range | number | undefined) => void;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
id: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<RangeCheckbox value={value} onChange={onChange} />
|
||||
{isRange(value) ? (
|
||||
<RangeInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
id={id}
|
||||
/>
|
||||
) : (
|
||||
<InputLabeled
|
||||
size="small"
|
||||
placeholder="e.g. 80"
|
||||
className="w-1/2"
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
id={id}
|
||||
value={value || ''}
|
||||
type="number"
|
||||
onChange={(e) => onChange(getNumber(e.target.valueAsNumber))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
readOnly,
|
||||
id,
|
||||
label,
|
||||
}: {
|
||||
value: Range;
|
||||
onChange: (value: Range) => void;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
id: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="font-normal m-0">{label}</label>
|
||||
<InputLabeled
|
||||
label="from"
|
||||
size="small"
|
||||
value={value.start || ''}
|
||||
onChange={(e) =>
|
||||
handleChange({ start: getNumber(e.target.valueAsNumber) })
|
||||
}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
id={id}
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<InputLabeled
|
||||
label="to"
|
||||
size="small"
|
||||
value={value.end || ''}
|
||||
onChange={(e) =>
|
||||
handleChange({ end: getNumber(e.target.valueAsNumber) })
|
||||
}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
id={id}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChange(range: Partial<Range>) {
|
||||
onChange({ ...value, ...range });
|
||||
}
|
||||
}
|
||||
|
||||
function getNumber(value: number) {
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
function RangeCheckbox({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Range | number | undefined;
|
||||
onChange: (value: Range | number | undefined) => void;
|
||||
}) {
|
||||
const isValueRange = isRange(value);
|
||||
return (
|
||||
<Checkbox
|
||||
label="range"
|
||||
checked={isValueRange}
|
||||
onChange={() => {
|
||||
if (!isValueRange) {
|
||||
onChange({ start: value || 0, end: value || 0 });
|
||||
} else {
|
||||
onChange(value.start);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { toRequest } from './toRequest';
|
||||
import { toViewModel } from './toViewModel';
|
||||
import { validation } from './validation';
|
||||
|
||||
export { PortsMappingField } from './PortsMappingField';
|
||||
export type { Values as PortsMappingValues } from './PortsMappingField';
|
||||
|
||||
export const portsMappingUtils = {
|
||||
toRequest,
|
||||
toViewModel,
|
||||
validation,
|
||||
};
|
@ -0,0 +1,118 @@
|
||||
import { toRequest } from './toRequest';
|
||||
|
||||
test('should handle empty portBindings', () => {
|
||||
const result = toRequest([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('should handle single port binding', () => {
|
||||
const result = toRequest([
|
||||
{
|
||||
hostPort: 80,
|
||||
protocol: 'tcp',
|
||||
containerPort: 80,
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
PublishedPort: 80,
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle port range', () => {
|
||||
const result = toRequest([
|
||||
{
|
||||
hostPort: { start: 80, end: 82 },
|
||||
protocol: 'tcp',
|
||||
containerPort: { start: 80, end: 82 },
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
PublishedPort: 80,
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
PublishedPort: 81,
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 81,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
PublishedPort: 82,
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 82,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should throw error for unequal port ranges', () => {
|
||||
expect(() =>
|
||||
toRequest([
|
||||
{
|
||||
hostPort: { start: 80, end: 82 },
|
||||
protocol: 'tcp',
|
||||
containerPort: { start: 80, end: 81 },
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
])
|
||||
).toThrow(
|
||||
'Invalid port specification: host port range must be equal to container port range'
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle host port range with single container port', () => {
|
||||
const result = toRequest([
|
||||
{
|
||||
hostPort: { start: 80, end: 82 },
|
||||
protocol: 'tcp',
|
||||
containerPort: 80,
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
PublishedPort: 80,
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
PublishedPort: 81,
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
PublishedPort: 82,
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should throw error for container port range with single host port', () => {
|
||||
expect(() =>
|
||||
toRequest([
|
||||
// @ts-expect-error test invalid input
|
||||
{
|
||||
hostPort: 80,
|
||||
protocol: 'tcp',
|
||||
containerPort: { start: 80, end: 82 },
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
])
|
||||
).toThrow(
|
||||
'Invalid port specification: host port must be a range when container port is a range'
|
||||
);
|
||||
});
|
@ -0,0 +1,62 @@
|
||||
import { EndpointPortConfig } from 'docker-types/generated/1.41';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Values } from './PortsMappingField';
|
||||
import { isRange } from './types';
|
||||
|
||||
export function toRequest(portBindings: Values): Array<EndpointPortConfig> {
|
||||
return _.compact(
|
||||
portBindings.flatMap((portBinding) => {
|
||||
const { hostPort, protocol, containerPort, publishMode } = portBinding;
|
||||
if (!hostPort && !containerPort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRange(hostPort) && isRange(containerPort)) {
|
||||
if (
|
||||
hostPort.end - hostPort.start !==
|
||||
containerPort.end - containerPort.start
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid port specification: host port range must be equal to container port range`
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
{ length: hostPort.end - hostPort.start + 1 },
|
||||
(_, i) => ({
|
||||
PublishedPort: hostPort.start + i,
|
||||
Protocol: protocol,
|
||||
TargetPort: containerPort.start + i,
|
||||
PublishMode: publishMode,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (isRange(hostPort) && !isRange(containerPort)) {
|
||||
return Array.from(
|
||||
{ length: hostPort.end - hostPort.start + 1 },
|
||||
(_, i) => ({
|
||||
PublishedPort: hostPort.start + i,
|
||||
Protocol: protocol,
|
||||
TargetPort: containerPort,
|
||||
PublishMode: publishMode,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRange(hostPort) && !isRange(containerPort)) {
|
||||
return {
|
||||
PublishedPort: hostPort,
|
||||
Protocol: protocol,
|
||||
TargetPort: containerPort,
|
||||
PublishMode: publishMode,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Invalid port specification: host port must be a range when container port is a range`
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
@ -0,0 +1,280 @@
|
||||
import { toViewModel } from './toViewModel';
|
||||
|
||||
test('basic', () => {
|
||||
expect(
|
||||
toViewModel([
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 22,
|
||||
PublishedPort: 222,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 3000,
|
||||
PublishedPort: 3000,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
])
|
||||
).toStrictEqual([
|
||||
{
|
||||
hostPort: 222,
|
||||
containerPort: 22,
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
hostPort: 3000,
|
||||
containerPort: 3000,
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('already combined', () => {
|
||||
expect(
|
||||
toViewModel([
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishedPort: 7000,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishedPort: 7001,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishedPort: 7002,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishedPort: 7003,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishedPort: 7004,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 80,
|
||||
PublishedPort: 7005,
|
||||
PublishMode: 'ingress',
|
||||
},
|
||||
])
|
||||
).toStrictEqual([
|
||||
{
|
||||
hostPort: {
|
||||
start: 7000,
|
||||
end: 7005,
|
||||
},
|
||||
containerPort: 80,
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('simple combine ports', () => {
|
||||
expect(
|
||||
toViewModel([
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 74,
|
||||
PublishedPort: 81,
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
TargetPort: 75,
|
||||
PublishedPort: 82,
|
||||
},
|
||||
])
|
||||
).toStrictEqual([
|
||||
{
|
||||
hostPort: {
|
||||
start: 81,
|
||||
end: 82,
|
||||
},
|
||||
containerPort: {
|
||||
start: 74,
|
||||
end: 75,
|
||||
},
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('combine and sort', () => {
|
||||
expect(
|
||||
toViewModel([
|
||||
{ Protocol: 'tcp', TargetPort: 3244, PublishedPort: 105 },
|
||||
{ Protocol: 'tcp', TargetPort: 3245, PublishedPort: 106 },
|
||||
{ Protocol: 'tcp', TargetPort: 81, PublishedPort: 81 },
|
||||
{ Protocol: 'tcp', TargetPort: 82, PublishedPort: 82 },
|
||||
{ Protocol: 'tcp', TargetPort: 83 },
|
||||
{ Protocol: 'tcp', TargetPort: 84 },
|
||||
])
|
||||
).toStrictEqual([
|
||||
{
|
||||
hostPort: { start: 81, end: 82 },
|
||||
containerPort: { start: 81, end: 82 },
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
hostPort: undefined,
|
||||
containerPort: {
|
||||
start: 83,
|
||||
end: 84,
|
||||
},
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
hostPort: { start: 105, end: 106 },
|
||||
containerPort: { start: 3244, end: 3245 },
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('empty input', () => {
|
||||
expect(toViewModel([])).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test('invalid input', () => {
|
||||
expect(() =>
|
||||
toViewModel(
|
||||
// @ts-expect-error testing invalid input
|
||||
{ Name: 'invalid', Protocol: 'tcp', TargetPort: 22, PublishedPort: 222 }
|
||||
)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test('mixed protocols', () => {
|
||||
expect(
|
||||
toViewModel([
|
||||
{ Protocol: 'tcp', TargetPort: 22, PublishedPort: 222 },
|
||||
{ Protocol: 'udp', TargetPort: 23, PublishedPort: 223 },
|
||||
])
|
||||
).toStrictEqual([
|
||||
{
|
||||
containerPort: 22,
|
||||
hostPort: 222,
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
containerPort: 23,
|
||||
hostPort: 223,
|
||||
protocol: 'udp',
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('non-sequential ports', () => {
|
||||
expect(
|
||||
toViewModel([
|
||||
{ Protocol: 'tcp', TargetPort: 22, PublishedPort: 222 },
|
||||
{ Protocol: 'tcp', TargetPort: 24, PublishedPort: 224 },
|
||||
])
|
||||
).toStrictEqual([
|
||||
{
|
||||
containerPort: 22,
|
||||
hostPort: 222,
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
{
|
||||
containerPort: 24,
|
||||
hostPort: 224,
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('without host', () => {
|
||||
expect(
|
||||
toViewModel([
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
PublishMode: 'ingress',
|
||||
TargetPort: 39003,
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
PublishMode: 'ingress',
|
||||
TargetPort: 39010,
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
PublishMode: 'ingress',
|
||||
TargetPort: 39007,
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
PublishMode: 'ingress',
|
||||
TargetPort: 39008,
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
PublishMode: 'ingress',
|
||||
TargetPort: 39000,
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
PublishMode: 'ingress',
|
||||
TargetPort: 39001,
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
PublishMode: 'ingress',
|
||||
TargetPort: 39002,
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
PublishMode: 'ingress',
|
||||
TargetPort: 39004,
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
PublishMode: 'ingress',
|
||||
TargetPort: 39005,
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
PublishMode: 'ingress',
|
||||
TargetPort: 39006,
|
||||
},
|
||||
{
|
||||
Protocol: 'tcp',
|
||||
PublishMode: 'ingress',
|
||||
TargetPort: 39009,
|
||||
},
|
||||
])
|
||||
).toStrictEqual([
|
||||
{
|
||||
protocol: 'tcp',
|
||||
publishMode: 'ingress',
|
||||
containerPort: {
|
||||
start: 39000,
|
||||
end: 39010,
|
||||
},
|
||||
hostPort: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
@ -0,0 +1,253 @@
|
||||
import { EndpointPortConfig } from 'docker-types/generated/1.41';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { PortBinding, Protocol, Value, isProtocol, isRange } from './types';
|
||||
|
||||
// if container is number then host is number | undefined
|
||||
export function toViewModel(
|
||||
portBindings: Array<EndpointPortConfig> | undefined = []
|
||||
): Array<Value> {
|
||||
const parsedPorts = parsePorts(portBindings);
|
||||
const sortedPorts = sortPorts(parsedPorts);
|
||||
|
||||
return combinePorts(sortedPorts);
|
||||
|
||||
function parsePorts(portBindings: Array<EndpointPortConfig>) {
|
||||
return portBindings.map((binding) => ({
|
||||
hostPort: binding.PublishedPort,
|
||||
protocol: isProtocol(binding.Protocol) ? binding.Protocol : 'tcp',
|
||||
containerPort: binding.TargetPort,
|
||||
publishMode: binding.PublishMode || 'ingress',
|
||||
}));
|
||||
}
|
||||
|
||||
function sortPorts(
|
||||
ports: Array<{
|
||||
hostPort: number | undefined;
|
||||
protocol: Protocol;
|
||||
containerPort: number | undefined;
|
||||
publishMode: 'ingress' | 'host';
|
||||
}>
|
||||
) {
|
||||
return _.sortBy(ports, [
|
||||
'containerPort',
|
||||
'hostPort',
|
||||
'protocol',
|
||||
'publishMode',
|
||||
]);
|
||||
}
|
||||
|
||||
function combinePorts(
|
||||
ports: Array<PortBinding<number | undefined, number | undefined>>
|
||||
): Array<Value> {
|
||||
return ports.reduce((acc, port) => {
|
||||
const lastPort = acc[acc.length - 1];
|
||||
|
||||
if (
|
||||
!lastPort ||
|
||||
lastPort.publishMode !== port.publishMode ||
|
||||
lastPort.protocol !== port.protocol
|
||||
) {
|
||||
return [...acc, port] satisfies Array<Value>;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof lastPort.hostPort === 'undefined' &&
|
||||
typeof port.hostPort === 'undefined'
|
||||
) {
|
||||
if (isRange(lastPort.containerPort)) {
|
||||
if (lastPort.containerPort.end === port.containerPort) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (lastPort.containerPort.end + 1 === port.containerPort) {
|
||||
return [
|
||||
...acc.slice(0, acc.length - 1),
|
||||
{
|
||||
...lastPort,
|
||||
hostPort: undefined,
|
||||
containerPort: {
|
||||
...lastPort.containerPort,
|
||||
end: port.containerPort,
|
||||
},
|
||||
} satisfies Value,
|
||||
];
|
||||
}
|
||||
|
||||
return [...acc, port];
|
||||
}
|
||||
|
||||
if (typeof lastPort.containerPort === 'number') {
|
||||
if (lastPort.containerPort === port.containerPort) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (lastPort.containerPort + 1 === port.containerPort) {
|
||||
return [
|
||||
...acc.slice(0, acc.length - 1),
|
||||
{
|
||||
...lastPort,
|
||||
hostPort: undefined,
|
||||
containerPort: {
|
||||
start: lastPort.containerPort,
|
||||
end: port.containerPort,
|
||||
},
|
||||
} satisfies Value,
|
||||
];
|
||||
}
|
||||
|
||||
return [...acc, port];
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof lastPort.hostPort === 'number' &&
|
||||
typeof lastPort.containerPort === 'number'
|
||||
) {
|
||||
if (
|
||||
lastPort.hostPort === port.hostPort &&
|
||||
lastPort.containerPort === port.containerPort
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (
|
||||
lastPort.hostPort + 1 === port.hostPort &&
|
||||
lastPort.containerPort === port.containerPort
|
||||
) {
|
||||
return [
|
||||
...acc.slice(0, acc.length - 1),
|
||||
{
|
||||
...lastPort,
|
||||
hostPort: {
|
||||
start: lastPort.hostPort,
|
||||
end: port.hostPort,
|
||||
},
|
||||
} satisfies Value,
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
lastPort.hostPort + 1 === port.hostPort &&
|
||||
lastPort.containerPort + 1 === port.containerPort
|
||||
) {
|
||||
return [
|
||||
...acc.slice(0, acc.length - 1),
|
||||
{
|
||||
...lastPort,
|
||||
hostPort: {
|
||||
start: lastPort.hostPort,
|
||||
end: port.hostPort,
|
||||
},
|
||||
containerPort: {
|
||||
start: lastPort.containerPort,
|
||||
end: port.containerPort,
|
||||
},
|
||||
} satisfies Value,
|
||||
];
|
||||
}
|
||||
|
||||
return [...acc, port];
|
||||
}
|
||||
|
||||
if (
|
||||
isRange(lastPort.hostPort) &&
|
||||
typeof lastPort.containerPort === 'number'
|
||||
) {
|
||||
if (
|
||||
lastPort.hostPort.end === port.hostPort &&
|
||||
lastPort.containerPort === port.containerPort
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (
|
||||
lastPort.hostPort.end + 1 === port.hostPort &&
|
||||
lastPort.containerPort === port.containerPort
|
||||
) {
|
||||
return [
|
||||
...acc.slice(0, acc.length - 1),
|
||||
{
|
||||
...lastPort,
|
||||
hostPort: {
|
||||
...lastPort.hostPort,
|
||||
end: port.hostPort,
|
||||
},
|
||||
} satisfies Value,
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
lastPort.hostPort.end + 1 === port.hostPort &&
|
||||
lastPort.containerPort + 1 === port.containerPort
|
||||
) {
|
||||
return [
|
||||
...acc.slice(0, acc.length - 1),
|
||||
{
|
||||
...lastPort,
|
||||
hostPort: {
|
||||
...lastPort.hostPort,
|
||||
end: port.hostPort,
|
||||
},
|
||||
containerPort: {
|
||||
start: lastPort.containerPort,
|
||||
end: port.containerPort,
|
||||
},
|
||||
} satisfies Value,
|
||||
];
|
||||
}
|
||||
|
||||
return [...acc, port];
|
||||
}
|
||||
|
||||
if (isRange(lastPort.hostPort) && isRange(lastPort.containerPort)) {
|
||||
if (
|
||||
lastPort.hostPort.end === port.hostPort &&
|
||||
lastPort.containerPort.end === port.containerPort
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (
|
||||
lastPort.hostPort.end + 1 === port.hostPort &&
|
||||
lastPort.containerPort.end === port.containerPort
|
||||
) {
|
||||
return [
|
||||
...acc.slice(0, acc.length - 1),
|
||||
{
|
||||
...lastPort,
|
||||
hostPort: {
|
||||
...lastPort.hostPort,
|
||||
end: port.hostPort,
|
||||
},
|
||||
} satisfies Value,
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
lastPort.hostPort.end + 1 === port.hostPort &&
|
||||
lastPort.containerPort.end + 1 === port.containerPort
|
||||
) {
|
||||
return [
|
||||
...acc.slice(0, acc.length - 1),
|
||||
{
|
||||
...lastPort,
|
||||
hostPort: {
|
||||
...lastPort.hostPort,
|
||||
end: port.hostPort,
|
||||
},
|
||||
containerPort: {
|
||||
...lastPort.containerPort,
|
||||
end: port.containerPort,
|
||||
},
|
||||
} satisfies Value,
|
||||
];
|
||||
}
|
||||
|
||||
return [...acc, port];
|
||||
}
|
||||
|
||||
return [...acc, port];
|
||||
}, [] as Array<Value>);
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
export type Protocol = 'tcp' | 'udp';
|
||||
|
||||
export type Range = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type PortBinding<THost = number, TContainer = number> = {
|
||||
hostPort: THost;
|
||||
protocol: Protocol;
|
||||
containerPort: TContainer;
|
||||
publishMode: 'ingress' | 'host';
|
||||
};
|
||||
|
||||
export type Value =
|
||||
| PortBinding<number | undefined, number | undefined>
|
||||
| PortBinding<Range | undefined, Range | number | undefined>;
|
||||
|
||||
export function isProtocol(value?: string): value is Protocol {
|
||||
return value === 'tcp' || value === 'udp';
|
||||
}
|
||||
|
||||
export function isRange(value: Range | number | undefined): value is Range {
|
||||
return typeof value === 'object';
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
import {
|
||||
array,
|
||||
lazy,
|
||||
mixed,
|
||||
number,
|
||||
NumberSchema,
|
||||
object,
|
||||
SchemaOf,
|
||||
} from 'yup';
|
||||
|
||||
import { Range, isRange } from './types';
|
||||
|
||||
export function validation() {
|
||||
return array(
|
||||
object({
|
||||
hostPort: rangeOrNumber(),
|
||||
containerPort: mixed().when('hostPort', {
|
||||
is: (hostPort: Range | number | undefined) =>
|
||||
!hostPort || isRange(hostPort),
|
||||
then: rangeOrNumber(),
|
||||
otherwise: port().typeError(
|
||||
'Container port must be a number when host port is not a range'
|
||||
),
|
||||
}),
|
||||
protocol: mixed().oneOf(['tcp', 'udp']),
|
||||
publishMode: mixed().oneOf(['ingress', 'host']),
|
||||
}).test({
|
||||
message:
|
||||
'Invalid port specification: host port range must be equal to container port range',
|
||||
test: (portBinding) => {
|
||||
const hostPort = portBinding.hostPort as Range | number | undefined;
|
||||
return !(
|
||||
isRange(hostPort) &&
|
||||
isRange(portBinding.containerPort) &&
|
||||
hostPort.end - hostPort.start !==
|
||||
portBinding.containerPort.end - portBinding.containerPort.start
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function port() {
|
||||
return number()
|
||||
.optional()
|
||||
.min(0, 'Port must be a number between 0 to 65535')
|
||||
.max(65535, 'Port must be a number between 0 to 65535');
|
||||
}
|
||||
|
||||
function rangeOrNumber() {
|
||||
return lazy<SchemaOf<Range> | NumberSchema>(
|
||||
(value: Range | number | undefined) => (isRange(value) ? range() : port())
|
||||
);
|
||||
}
|
||||
|
||||
function range(): SchemaOf<Range> {
|
||||
return object({
|
||||
start: port().required(),
|
||||
end: port().required(),
|
||||
}).test({
|
||||
message: 'Start port must be less than end port',
|
||||
test: (value) => !value.start || !value.end || value.start <= value.end,
|
||||
});
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
|
||||
import { ServiceWidget } from './ServiceWidget';
|
||||
|
||||
const Wrapped = withUserProvider(ServiceWidget);
|
||||
|
||||
const meta: Meta<typeof ServiceWidget> = {
|
||||
component: ServiceWidget,
|
||||
render: (args) => <Wrapped {...args} />,
|
||||
args: {
|
||||
titleIcon: 'icon-name',
|
||||
title: 'Service Widget',
|
||||
onAdd: () => {},
|
||||
hasChanges: false,
|
||||
onReset: () => {},
|
||||
onSubmit: () => {},
|
||||
labelForAddButton: 'Add',
|
||||
isValid: true,
|
||||
children: <div className="p-5">This service has no ports published.</div>,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ServiceWidget>;
|
||||
|
||||
export const Default: Story = {};
|
@ -0,0 +1,81 @@
|
||||
import { Plus, ChevronDown } from 'lucide-react';
|
||||
import { ComponentProps, PropsWithChildren } from 'react';
|
||||
import { Menu, MenuButton, MenuItem, MenuPopover } from '@reach/menu-button';
|
||||
import { positionRight } from '@reach/popover';
|
||||
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { Widget } from '@@/Widget';
|
||||
import { Button, ButtonGroup } from '@@/buttons';
|
||||
import { ButtonWithRef } from '@@/buttons/Button';
|
||||
|
||||
/**
|
||||
* used for wrapping widget in the service item view
|
||||
*/
|
||||
export function ServiceWidget({
|
||||
titleIcon,
|
||||
title,
|
||||
children,
|
||||
onAdd,
|
||||
hasChanges,
|
||||
onReset,
|
||||
onSubmit,
|
||||
labelForAddButton,
|
||||
isValid,
|
||||
}: PropsWithChildren<{
|
||||
titleIcon: ComponentProps<typeof Widget.Title>['icon'];
|
||||
title: string;
|
||||
onAdd(): void;
|
||||
hasChanges: boolean;
|
||||
onReset(all?: boolean): void;
|
||||
onSubmit(): void;
|
||||
labelForAddButton: string;
|
||||
isValid?: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<Widget aria-label={title}>
|
||||
<Widget.Title icon={titleIcon} title={title}>
|
||||
<Authorized authorizations="DockerServiceUpdate">
|
||||
<Button color="secondary" size="small" onClick={onAdd} icon={Plus}>
|
||||
{labelForAddButton}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</Widget.Title>
|
||||
|
||||
<Widget.Body className="!p-0">{children}</Widget.Body>
|
||||
|
||||
<Authorized authorizations="DockerServiceUpdate">
|
||||
<Widget.Footer>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!hasChanges || !isValid}
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={ButtonWithRef}
|
||||
size="small"
|
||||
color="default"
|
||||
icon={ChevronDown}
|
||||
>
|
||||
<span className="sr-only">Toggle Dropdown</span>
|
||||
</MenuButton>
|
||||
<MenuPopover position={positionRight}>
|
||||
<div className="mt-3 bg-white th-highcontrast:bg-black th-dark:bg-black">
|
||||
<MenuItem onSelect={() => onReset()}>Reset changes</MenuItem>
|
||||
<MenuItem onSelect={() => onReset(true)}>
|
||||
Reset all changes
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
</ButtonGroup>
|
||||
</Widget.Footer>
|
||||
</Authorized>
|
||||
</Widget>
|
||||
);
|
||||
}
|
Loading…
Reference in new issue