feat(docker/services): show port ranges [EE-4012] (#10657)

pull/8244/merge
Chaim Lev-Ari 8 months ago committed by GitHub
parent 4ca6292805
commit d336a14e50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -140,9 +140,11 @@ overrides:
'react/jsx-no-constructed-context-values': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
- files:
- app/**/*.stories.*
rules:
'no-alert': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off

@ -42,6 +42,10 @@
z-index: unset;
}
.input-group-sm > .input-group-addon {
line-height: 1;
}
.text-danger {
color: var(--ui-error-9);
}

@ -1,12 +1,19 @@
import angular from 'angular';
import { SchemaOf } from 'yup';
import { r2a } from '@/react-tools/react2angular';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { ServicesDatatable } from '@/react/docker/services/ListView/ServicesDatatable';
import { TasksDatatable } from '@/react/docker/services/ItemView/TasksDatatable';
import {
PortsMappingField,
portsMappingUtils,
PortsMappingValues,
} from '@/react/docker/services/ItemView/PortMappingField';
import { withFormValidation } from '@/react-tools/withFormValidation';
export const servicesModule = angular
const ngModule = angular
.module('portainer.docker.react.components.services', [])
.component(
'dockerServiceTasksDatatable',
@ -25,4 +32,14 @@ export const servicesModule = angular
'onRefresh',
'titleIcon',
])
).name;
);
export const servicesModule = ngModule.name;
withFormValidation(
ngModule,
withUIRouter(withCurrentUser(PortsMappingField)),
'dockerServicePortsMappingField',
['disabled', 'readOnly', 'hasChanges', 'onReset', 'onSubmit'],
portsMappingUtils.validation as unknown as () => SchemaOf<PortsMappingValues>
);

@ -57,7 +57,7 @@
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceConfigs'])">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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceConfigs'])">Reset changes</a></li>

@ -76,7 +76,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceConstraints'])" 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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceConstraints'])">Reset changes</a></li>

@ -64,7 +64,7 @@
>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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceContainerLabels'])">Reset changes</a></li>

@ -11,9 +11,7 @@
<p>There are no environment variables for this service.</p>
</rd-widget-body>
<rd-widget-body ng-if="service.EnvironmentVariables.length > 0">
<div class="form-group">
<environment-variables-fieldset values="service.EnvironmentVariables" on-change="(onChangeEnvVars)"></environment-variables-fieldset>
</div>
<environment-variables-fieldset values="service.EnvironmentVariables" on-change="(onChangeEnvVars)"></environment-variables-fieldset>
</rd-widget-body>
<rd-widget-footer authorization="DockerServiceUpdate">
<div class="btn-toolbar" role="toolbar">
@ -27,7 +25,7 @@
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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['EnvironmentVariables'])">Reset changes</a></li>

@ -60,7 +60,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['Hosts'])" 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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Hosts'])">Reset changes</a></li>

@ -24,7 +24,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['Image'])" 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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Image'])">Reset changes</a></li>

@ -61,7 +61,7 @@
>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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['LogDriverName', 'LogDriverOpts'])">Reset changes</a></li>

@ -96,7 +96,7 @@
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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceMounts'])">Reset changes</a></li>

@ -59,7 +59,7 @@
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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Networks'])">Reset changes</a></li>

@ -60,7 +60,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServicePreferences'])" 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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServicePreferences'])">Reset changes</a></li>

@ -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>

@ -92,7 +92,7 @@
>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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['LimitNanoCPUs', 'LimitMemoryBytes', 'ReservationNanoCPUs', 'ReservationMemoryBytes'])">Reset changes</a></li>

@ -89,7 +89,7 @@
>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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['RestartCondition', 'RestartDelay', 'RestartMaxAttempts', 'RestartWindow'])">Reset changes</a></li>

@ -54,7 +54,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceSecrets'])" 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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceSecrets'])">Reset changes</a></li>

@ -60,7 +60,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceLabels'])" 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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceLabels'])">Reset changes</a></li>

@ -113,7 +113,7 @@
>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>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism', 'UpdateOrder'])">Reset changes</a></li>

@ -164,7 +164,7 @@
>Apply changes</button
>
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Name'])">Reset changes</a></li>
@ -231,7 +231,17 @@
<div class="col-lg-12 col-md-12 col-xs-12">
<h3 id="service-network-specs">Networks &amp; ports</h3>
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
<div id="service-published-ports" class="padding-top" ng-include="'app/docker/views/services/edit/includes/ports.html'"></div>
<docker-service-ports-mapping-field
id="service-published-ports"
class="block padding-top"
values="formValues.ports"
on-change="(onChangePorts)"
has-changes="hasChanges(service, ['Ports'])"
on-reset="(onResetPorts)"
on-submit="(onSubmit)"
></docker-service-ports-mapping-field>
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
</div>
</div>

@ -9,7 +9,6 @@ require('./includes/logging.html');
require('./includes/mounts.html');
require('./includes/networks.html');
require('./includes/placementPreferences.html');
require('./includes/ports.html');
require('./includes/resources.html');
require('./includes/restart.html');
require('./includes/secrets.html');
@ -27,6 +26,7 @@ import { confirm, confirmDelete } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
import { convertServiceToConfig } from '@/react/docker/services/common/convertServiceToConfig';
import { portsMappingUtils } from '@/react/docker/services/ItemView/PortMappingField';
angular.module('portainer.docker').controller('ServiceController', [
'$q',
@ -108,6 +108,7 @@ angular.module('portainer.docker').controller('ServiceController', [
$scope.formValues = {
RegistryModel: new PorImageRegistryModel(),
ports: [],
};
$scope.tasks = [];
@ -544,12 +545,8 @@ angular.module('portainer.docker').controller('ServiceController', [
}
}
if (service.Ports) {
service.Ports.forEach(function (binding) {
if (binding.PublishedPort === null || binding.PublishedPort === '') {
delete binding.PublishedPort;
}
});
if ($scope.hasChanges(service, ['Ports'])) {
service.Ports = portsMappingUtils.toRequest($scope.formValues.ports);
}
config.EndpointSpec = {
@ -714,6 +711,25 @@ angular.module('portainer.docker').controller('ServiceController', [
service.StopGracePeriod = service.StopGracePeriod ? ServiceHelper.translateNanosToHumanDuration(service.StopGracePeriod) : '';
}
$scope.onChangePorts = function (ports) {
$scope.$evalAsync(() => {
$scope.formValues.ports = ports;
updateServiceArray($scope.service, 'Ports');
});
};
$scope.onResetPorts = function (all = false) {
$scope.$evalAsync(() => {
$scope.formValues.ports = portsMappingUtils.toViewModel($scope.service.Model.Spec.EndpointSpec.Ports);
$scope.cancelChanges($scope.service, all ? undefined : ['Ports']);
});
};
$scope.onSubmit = function () {
$scope.updateService($scope.service);
};
function initView() {
var apiVersion = $scope.applicationState.endpoint.apiVersion;
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
@ -727,6 +743,8 @@ angular.module('portainer.docker').controller('ServiceController', [
$scope.lastVersion = service.Version;
}
$scope.formValues.ports = portsMappingUtils.toViewModel(service.Model.Spec.EndpointSpec.Ports);
transformResources(service);
translateServiceArrays(service);
transformDurations(service);

@ -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>
);
}

@ -24,11 +24,11 @@ export function TableContainer({
return (
<div className="row">
<div className="col-sm-12">
<section className="datatable" aria-label={ariaLabel}>
<Widget>
<div className="datatable">
<Widget aria-label={ariaLabel}>
<WidgetBody className="no-padding">{children}</WidgetBody>
</Widget>
</section>
</div>
</div>
</div>
);

@ -13,7 +13,7 @@ interface Props extends HTMLProps<HTMLInputElement> {
indeterminate?: boolean;
title?: string;
label?: string;
id: string;
id?: string;
className?: string;
role?: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
@ -51,7 +51,7 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
}, [resolvedRef, indeterminate]);
return (
<div className="md-checkbox flex" title={title || label}>
<div className="md-checkbox flex items-center" title={title || label}>
<input
id={id}
type="checkbox"
@ -61,7 +61,7 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
<label htmlFor={id} className={clsx({ '!font-normal': !bold })}>
<label htmlFor={id} className={clsx('m-0', { '!font-normal': !bold })}>
{label}
</label>
</div>

@ -9,6 +9,10 @@ interface Props {
}
export function FormError({ children, className }: PropsWithChildren<Props>) {
if (!children) {
return null;
}
return (
<div
className={clsx(

@ -106,6 +106,21 @@ export function InputList<T = DefaultType>({
const initialItemsCount = useRef(value.length);
const isAddButtonVisible = !(isAddButtonHidden || readOnly);
const isDeleteButtonVisible = !(isDeleteButtonHidden || readOnly);
const {
handleMoveUp,
handleMoveDown,
handleRemoveItem,
handleAdd,
handleChangeItem,
toggleNeedsDeletion,
} = useInputList<T>({
value,
onChange,
itemBuilder,
itemKeyGetter,
movable,
});
return (
<div className="form-group" aria-label={ariaLabel || label}>
{label && (
@ -187,7 +202,7 @@ export function InputList<T = DefaultType>({
itemIndex={index}
initialItemsCount={initialItemsCount.current}
handleRemoveItem={handleRemoveItem}
handleToggleNeedsDeletion={handleToggleNeedsDeletion}
handleToggleNeedsDeletion={toggleNeedsDeletion}
dataCy={`${deleteButtonDataCy}_${index}`}
/>
)}
@ -223,7 +238,21 @@ export function InputList<T = DefaultType>({
)}
</div>
);
}
export function useInputList<T = DefaultType>({
value,
onChange,
itemBuilder = defaultItemBuilder as unknown as () => T,
itemKeyGetter = (item: T, index: number) => index,
movable = false,
}: {
value: T[];
onChange(value: T[], e: OnChangeEvent<T>): void;
itemBuilder?(): T;
itemKeyGetter?(item: T, index: number): Key;
movable?: boolean;
}) {
function handleMoveUp(index: number) {
if (index <= 0) {
return;
@ -260,7 +289,7 @@ export function InputList<T = DefaultType>({
);
}
function handleToggleNeedsDeletion(key: Key, item: CanUndoDeleteItem<T>) {
function toggleNeedsDeletion(key: Key, item: CanUndoDeleteItem<T>) {
handleChangeItem(key, { ...item, needsDeletion: !item.needsDeletion });
}
@ -282,6 +311,15 @@ export function InputList<T = DefaultType>({
item: newItemValue,
});
}
return {
handleMoveUp,
handleMoveDown,
handleRemoveItem,
handleAdd,
handleChangeItem,
toggleNeedsDeletion,
};
}
function defaultItemBuilder(): DefaultType {

@ -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…
Cancel
Save