feat(app): introduce react configurations [EE-1809] (#5953)

pull/6015/head
Chaim Lev-Ari 2021-11-03 12:41:59 +02:00 committed by GitHub
parent b285219a58
commit 85a6a80722
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 8974 additions and 599 deletions

View File

@ -1,13 +0,0 @@
{
"plugins": ["lodash", "angularjs-annotate"],
"presets": [
[
"@babel/preset-env",
{
"modules": false,
"useBuiltIns": "entry",
"corejs": "2"
}
]
]
}

View File

@ -17,12 +17,75 @@ plugins:
parserOptions:
ecmaVersion: 2018
sourceType: module
project: './tsconfig.json'
ecmaFeatures:
modules: true
rules:
no-control-regex: off
no-control-regex: 'off'
no-empty: warn
no-empty-function: warn
no-useless-escape: off
import/order: error
no-useless-escape: 'off'
import/order:
[
'error',
{
pathGroups: [{ pattern: '@/**', group: 'internal' }, { pattern: '{Kubernetes,Portainer,Agent,Azure,Docker}/**', group: 'internal' }],
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
pathGroupsExcludedImportTypes: ['internal'],
},
]
settings:
'import/resolver':
alias:
map:
- ['@', './app']
extensions: ['.js', '.ts', '.tsx']
overrides:
- files:
- app/**/*.ts{,x}
parserOptions:
project: './tsconfig.json'
parser: '@typescript-eslint/parser'
plugins:
- '@typescript-eslint'
extends:
- airbnb
- airbnb-typescript
- 'plugin:eslint-comments/recommended'
- 'plugin:react-hooks/recommended'
- 'plugin:react/jsx-runtime'
- 'plugin:@typescript-eslint/recommended'
- 'plugin:@typescript-eslint/eslint-recommended'
- 'plugin:promise/recommended'
- prettier # should be last
settings:
react:
version: 'detect'
rules:
import/order:
['error', { pathGroups: [{ pattern: '@/**', group: 'internal' }], groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], 'newlines-between': 'always' }]
func-style: [error, 'declaration']
import/prefer-default-export: off
no-use-before-define: ['error', { functions: false }]
'@typescript-eslint/no-use-before-define': ['error', { functions: false }]
no-shadow: 'off'
'@typescript-eslint/no-shadow': off
jsx-a11y/no-autofocus: warn
react/forbid-prop-types: off
react/require-default-props: off
react/no-array-index-key: off
react/jsx-filename-extension: [0]
import/no-extraneous-dependencies: ['error', { devDependencies: true }]
'@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-unused-vars': 'error'
'@typescript-eslint/no-explicit-any': 'error'
- files:
- app/**/*.test.*
extends:
- 'plugin:jest/recommended'
- 'plugin:jest/style'
env:
'jest/globals': true

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
dist

View File

@ -4,10 +4,20 @@
"htmlWhitespaceSensitivity": "strict",
"overrides": [
{
"files": ["*.html"],
"files": [
"*.html"
],
"options": {
"parser": "angular"
}
},
{
"files": [
"*.{j,t}sx"
],
"options": {
"printWidth": 80,
}
}
]
}
}

31
.storybook/main.js Normal file
View File

@ -0,0 +1,31 @@
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
module.exports = {
stories: ['../app/**/*.stories.mdx', '../app/**/*.stories.@(ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
{
name: '@storybook/addon-postcss',
options: {
cssLoaderOptions: {
importLoaders: 1,
modules: {
localIdentName: '[path][name]__[local]',
auto: true,
exportLocalsConvention: 'camelCaseOnly',
},
},
},
},
],
webpackFinal: (config) => {
config.resolve.plugins = [
...(config.resolve.plugins || []),
new TsconfigPathsPlugin({
extensions: config.resolve.extensions,
}),
];
return config;
},
};

11
.storybook/preview.js Normal file
View File

@ -0,0 +1,11 @@
import '../app/assets/css';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};

View File

@ -27,6 +27,7 @@ import (
"github.com/portainer/portainer/api/http/handler/ssl"
"github.com/portainer/portainer/api/http/handler/stacks"
"github.com/portainer/portainer/api/http/handler/status"
"github.com/portainer/portainer/api/http/handler/storybook"
"github.com/portainer/portainer/api/http/handler/tags"
"github.com/portainer/portainer/api/http/handler/teammemberships"
"github.com/portainer/portainer/api/http/handler/teams"
@ -63,6 +64,7 @@ type Handler struct {
SSLHandler *ssl.Handler
StackHandler *stacks.Handler
StatusHandler *status.Handler
StorybookHandler *storybook.Handler
TagHandler *tags.Handler
TeamMembershipHandler *teammemberships.Handler
TeamHandler *teams.Handler
@ -227,6 +229,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/webhooks"):
http.StripPrefix("/api", h.WebhookHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/storybook"):
http.StripPrefix("/storybook", h.StorybookHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/"):
h.FileHandler.ServeHTTP(w, r)
}

View File

@ -0,0 +1,23 @@
package storybook
import (
"net/http"
"path"
)
// Handler represents an HTTP API handler for managing static files.
type Handler struct {
http.Handler
}
// NewHandler creates a handler to serve static files.
func NewHandler(assetsPath string) *Handler {
h := &Handler{
http.FileServer(http.Dir(path.Join(assetsPath, "storybook"))),
}
return h
}
func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handler.Handler.ServeHTTP(w, r)
}

View File

@ -38,6 +38,7 @@ import (
sslhandler "github.com/portainer/portainer/api/http/handler/ssl"
"github.com/portainer/portainer/api/http/handler/stacks"
"github.com/portainer/portainer/api/http/handler/status"
"github.com/portainer/portainer/api/http/handler/storybook"
"github.com/portainer/portainer/api/http/handler/tags"
"github.com/portainer/portainer/api/http/handler/teammemberships"
"github.com/portainer/portainer/api/http/handler/teams"
@ -213,6 +214,8 @@ func (server *Server) Start() error {
stackHandler.ComposeStackManager = server.ComposeStackManager
stackHandler.StackDeployer = server.StackDeployer
var storybookHandler = storybook.NewHandler(server.AssetsPath)
var tagHandler = tags.NewHandler(requestBouncer)
tagHandler.DataStore = server.DataStore
@ -271,6 +274,7 @@ func (server *Server) Start() error {
SSLHandler: sslHandler,
StatusHandler: statusHandler,
StackHandler: stackHandler,
StorybookHandler: storybookHandler,
TagHandler: tagHandler,
TeamHandler: teamHandler,
TeamMembershipHandler: teamMembershipHandler,

View File

@ -0,0 +1 @@
module.exports = 'test-file-stub';

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -2,6 +2,7 @@ import './assets/css';
import '@babel/polyfill';
import angular from 'angular';
import { UI_ROUTER_REACT_HYBRID } from '@uirouter/react-hybrid';
import './matomo-setup';
import analyticsModule from './angulartics.matomo';
@ -15,6 +16,7 @@ import './portainer/__module';
angular.module('portainer', [
'ui.bootstrap',
'ui.router',
UI_ROUTER_REACT_HYBRID,
'ui.select',
'isteven-multi-select',
'ngSanitize',
@ -44,7 +46,10 @@ angular.module('portainer', [
if (require) {
var req = require.context('./', true, /^(.*\.(js$))[^.]*$/im);
req.keys().forEach(function (key) {
req(key);
});
req
.keys()
.filter((path) => !path.includes('.test'))
.forEach(function (key) {
req(key);
});
}

View File

@ -1,4 +1,21 @@
import './rdash.css';
import './app.css';
import 'ui-select/dist/select.css';
import 'bootstrap/dist/css/bootstrap.css';
import '@fortawesome/fontawesome-free/css/brands.css';
import '@fortawesome/fontawesome-free/css/solid.css';
import '@fortawesome/fontawesome-free/css/fontawesome.css';
import 'toastr/build/toastr.css';
import 'xterm/dist/xterm.css';
import 'angularjs-slider/dist/rzslider.css';
import 'codemirror/lib/codemirror.css';
import 'codemirror/addon/lint/lint.css';
import 'angular-json-tree/dist/angular-json-tree.css';
import 'angular-loading-bar/build/loading-bar.css';
import 'angular-moment-picker/dist/angular-moment-picker.min.css';
import 'angular-multiselect/isteven-multi-select.css';
import 'spinkit/spinkit.min.css';
import './theme.css';
import './vendor-override.css';

View File

@ -1,7 +1,6 @@
import _ from 'lodash-es';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import angular from 'angular';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
class CreateConfigController {
/* @ngInject */

View File

@ -1,8 +1,8 @@
import _ from 'lodash-es';
import * as envVarsUtils from '@/portainer/helpers/env-vars';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import * as envVarsUtils from '@/portainer/helpers/env-vars';
import { ContainerCapabilities, ContainerCapability } from '../../../models/containerCapabilities';
import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel';
import { ContainerDetailsViewModel } from '../../../models/container';

View File

@ -1,7 +1,7 @@
import _ from 'lodash-es';
import * as envVarsUtils from '@/portainer/helpers/env-vars';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import * as envVarsUtils from '@/portainer/helpers/env-vars';
import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel';
require('./includes/update-restart.html');

View File

@ -19,9 +19,8 @@ require('./includes/updateconfig.html');
import _ from 'lodash-es';
import * as envVarsUtils from '@/portainer/helpers/env-vars';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import * as envVarsUtils from '@/portainer/helpers/env-vars';
angular.module('portainer.docker').controller('ServiceController', [
'$q',

8
app/global.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
declare module '*.jpg' {
export default '' as string;
}
declare module '*.png' {
export default '' as string;
}
declare module '*.css';

View File

@ -1,8 +1,8 @@
import _ from 'lodash-es';
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models';
import _ from 'lodash-es';
angular.module('portainer.docker').controller('KubernetesApplicationsDatatableController', [
'$scope',

View File

@ -1,5 +1,5 @@
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import _ from 'lodash-es';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
export default class HelmTemplatesController {
/* @ngInject */

View File

@ -1,7 +1,7 @@
import _ from 'lodash-es';
import { KubernetesSecretCreatePayload, KubernetesSecretUpdatePayload } from 'Kubernetes/models/secret/payloads';
import { KubernetesApplicationSecret } from 'Kubernetes/models/secret/models';
import { KubernetesPortainerConfigurationDataAnnotation } from 'Kubernetes/models/configuration/models';
import _ from 'lodash-es';
import { KubernetesPortainerConfigurationOwnerLabel } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';

View File

@ -1,5 +1,5 @@
import { KubernetesEndpoint, KubernetesEndpointAnnotationLeader, KubernetesEndpointSubset } from 'Kubernetes/endpoint/models';
import _ from 'lodash-es';
import { KubernetesEndpoint, KubernetesEndpointAnnotationLeader, KubernetesEndpointSubset } from 'Kubernetes/endpoint/models';
class KubernetesEndpointConverter {
static apiToEndpoint(data) {

View File

@ -1,7 +1,7 @@
import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
import _ from 'lodash-es';
import YAML from 'yaml';
import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
class KubernetesConfigurationHelper {
static getUsingApplications(config, applications) {

View File

@ -0,0 +1,12 @@
import KubernetesStackHelper from './stackHelper';
describe('stacksFromApplications', () => {
const { stacksFromApplications } = KubernetesStackHelper;
test('should return an empty array when passed an empty array', () => {
expect(stacksFromApplications([])).toHaveLength(0);
});
test('should return an empty array when passed a list of applications without stacks', () => {
expect(stacksFromApplications([{ StackName: '' }, { StackName: '' }, { StackName: '' }, { StackName: '' }])).toHaveLength(0);
});
});

View File

@ -1,10 +1,10 @@
import _ from 'lodash-es';
import * as JsonPatch from 'fast-json-patch';
import { KubernetesNode, KubernetesNodeDetails, KubernetesNodeTaint, KubernetesNodeAvailabilities, KubernetesPortainerNodeDrainLabel } from 'Kubernetes/node/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import { KubernetesNodeFormValues, KubernetesNodeTaintFormValues, KubernetesNodeLabelFormValues } from 'Kubernetes/node/formValues';
import { KubernetesNodeCreatePayload, KubernetesNodeTaintPayload } from 'Kubernetes/node/payload';
import * as JsonPatch from 'fast-json-patch';
class KubernetesNodeConverter {
static apiToNode(data, res) {

View File

@ -1,9 +1,9 @@
import angular from 'angular';
import _ from 'lodash-es';
import filesizeParser from 'filesize-parser';
import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper';
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
import { KubernetesStorageClassAccessPolicies } from 'Kubernetes/models/storage-class/models';
import filesizeParser from 'filesize-parser';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
class KubernetesVolumeController {

View File

@ -0,0 +1,15 @@
import { ReactNode } from 'react';
import { UISref, UISrefProps } from '@uirouter/react';
export function Link({
children,
...props
}: { children: ReactNode } & UISrefProps) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<UISref {...props}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a>{children}</a>
</UISref>
);
}

View File

@ -0,0 +1,3 @@
.red-bg {
background: red;
}

View File

@ -0,0 +1,23 @@
import { UIRouter, pushStateLocationPlugin } from '@uirouter/react';
import { Meta } from '@storybook/react';
import { ReactExample } from './ReactExample';
const meta: Meta = {
title: 'ReactExample',
component: ReactExample,
};
export default meta;
interface Props {
text: string;
}
export function Example({ text }: Props) {
return (
<UIRouter plugins={[pushStateLocationPlugin]}>
<ReactExample text={text} />
</UIRouter>
);
}

View File

@ -0,0 +1,11 @@
import { render } from '@/react-tools/test-utils';
import { ReactExample } from './ReactExample';
test('loads component', async () => {
const text = 'hello';
const { getByText } = render(<ReactExample text={text} />);
expect(getByText(text)).toBeInTheDocument();
});

View File

@ -0,0 +1,34 @@
import { useSref } from '@uirouter/react';
import { react2angular } from '@/react-tools/react2angular';
import { Link } from './Link';
import styles from './ReactExample.module.css';
export interface ReactExampleProps {
/**
* Example text to displayed in the component.
*/
text: string;
}
export function ReactExample({ text }: ReactExampleProps) {
const route = 'portainer.registries';
const { onClick, href } = useSref(route);
return (
<div className={styles.redBg}>
{text}
<div>
<a href={href} onClick={onClick}>
Registries useSref
</a>
</div>
<div>
<Link to={route}>Registries Link</Link>
</div>
</div>
);
}
export const ReactExampleAngular = react2angular(ReactExample, ['text']);

View File

@ -5,4 +5,8 @@ import gitFormModule from './forms/git-form';
import porAccessManagementModule from './accessManagement';
import formComponentsModule from './form-components';
export default angular.module('portainer.app.components', [sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule]).name;
import { ReactExampleAngular } from './ReactExample';
export default angular
.module('portainer.app.components', [sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
.component('reactExample', ReactExampleAngular).name;

View File

@ -4,182 +4,137 @@ import filesize from 'filesize';
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
angular
.module('portainer.app')
.filter('truncate', function () {
'use strict';
return function (text, length, end) {
if (isNaN(length)) {
length = 10;
}
export function truncateLeftRight(text, max, left, right) {
max = isNaN(max) ? 50 : max;
left = isNaN(left) ? 25 : left;
right = isNaN(right) ? 25 : right;
if (end === undefined) {
end = '...';
}
if (text.length <= max) {
return text;
} else {
return text.substring(0, left) + '[...]' + text.substring(text.length - right, text.length);
}
}
if (text.length <= length || text.length - end.length <= length) {
return text;
} else {
return String(text).substring(0, length - end.length) + end;
}
};
})
.filter('truncatelr', function () {
'use strict';
return function (text, max, left, right) {
max = isNaN(max) ? 50 : max;
left = isNaN(left) ? 25 : left;
right = isNaN(right) ? 25 : right;
export function stripProtocol(url) {
return url.replace(/.*?:\/\//g, '');
}
if (text.length <= max) {
return text;
} else {
return text.substring(0, left) + '[...]' + text.substring(text.length - right, text.length);
}
};
})
.filter('capitalize', function () {
'use strict';
return function (text) {
return text ? _.capitalize(text) : '';
};
})
.filter('stripprotocol', function () {
'use strict';
return function (url) {
return url.replace(/.*?:\/\//g, '');
};
})
.filter('humansize', function () {
'use strict';
return function (bytes, round, base) {
if (!round) {
round = 1;
}
if (!base) {
base = 10;
}
if (bytes || bytes === 0) {
return filesize(bytes, { base: base, round: round });
}
};
})
.filter('getisodatefromtimestamp', function () {
'use strict';
return function (timestamp) {
return moment.unix(timestamp).format('YYYY-MM-DD HH:mm:ss');
};
})
.filter('getisodate', function () {
'use strict';
return function (date) {
return moment(date).format('YYYY-MM-DD HH:mm:ss');
};
})
.filter('key', function () {
'use strict';
return function (pair, separator) {
if (!pair.includes(separator)) {
return pair;
}
export function humanize(bytes, round, base) {
if (!round) {
round = 1;
}
if (!base) {
base = 10;
}
if (bytes || bytes === 0) {
return filesize(bytes, { base: base, round: round });
}
}
return pair.slice(0, pair.indexOf(separator));
};
})
.filter('value', function () {
'use strict';
return function (pair, separator) {
if (!pair.includes(separator)) {
return '';
}
export function isoDateFromTimestamp(timestamp) {
return moment.unix(timestamp).format('YYYY-MM-DD HH:mm:ss');
}
return pair.slice(pair.indexOf(separator) + 1);
};
})
.filter('emptyobject', function () {
'use strict';
return function (obj) {
return _.isEmpty(obj);
};
})
.filter('ipaddress', function () {
'use strict';
return function (ip) {
return ip.slice(0, ip.indexOf('/'));
};
})
.filter('arraytostr', function () {
'use strict';
return function (arr, separator) {
if (arr) {
return _.join(arr, separator);
}
return '';
};
})
.filter('labelsToStr', function () {
'use strict';
return function (arr, separator) {
if (arr) {
return _.join(
arr.map((item) => item.key + ':' + item.value),
separator
);
}
return '';
};
})
.filter('endpointtypename', function () {
'use strict';
return function (type) {
if (type === 1) {
return 'Docker';
} else if (type === 2 || type === 6) {
return 'Agent';
} else if (type === 3) {
return 'Azure ACI';
} else if (type === 5) {
return 'Kubernetes';
} else if (type === 4 || type === 7) {
return 'Edge Agent';
}
return '';
};
})
.filter('endpointtypeicon', function () {
'use strict';
return function (type) {
if (type === 3) {
return 'fab fa-microsoft';
} else if (type === 4) {
return 'fa fa-cloud';
} else if (type === 5 || type === 6 || type === 7) {
return 'fas fa-dharmachakra';
}
return 'fab fa-docker';
};
})
.filter('ownershipicon', function () {
'use strict';
return function (ownership) {
switch (ownership) {
case RCO.PRIVATE:
return 'fa fa-eye-slash';
case RCO.ADMINISTRATORS:
return 'fa fa-eye-slash';
case RCO.RESTRICTED:
return 'fa fa-users';
default:
return 'fa fa-eye';
}
};
})
.filter('endpointstatusbadge', function () {
'use strict';
return function (status) {
if (status === 2) {
return 'danger';
}
return 'success';
};
});
export function isoDate(date) {
return moment(date).format('YYYY-MM-DD HH:mm:ss');
}
export function getPairKey(pair, separator) {
if (!pair.includes(separator)) {
return pair;
}
return pair.slice(0, pair.indexOf(separator));
}
export function getPairValue(pair, separator) {
if (!pair.includes(separator)) {
return '';
}
return pair.slice(pair.indexOf(separator) + 1);
}
export function ipAddress(ip) {
return ip.slice(0, ip.indexOf('/'));
}
export function arrayToStr(arr, separator) {
if (arr) {
return _.join(arr, separator);
}
return '';
}
export function labelsToStr(arr, separator) {
if (arr) {
return _.join(
arr.map((item) => item.key + ':' + item.value),
separator
);
}
return '';
}
export function endpointTypeName(type) {
if (type === 1) {
return 'Docker';
} else if (type === 2 || type === 6) {
return 'Agent';
} else if (type === 3) {
return 'Azure ACI';
} else if (type === 5) {
return 'Kubernetes';
} else if (type === 4 || type === 7) {
return 'Edge Agent';
}
return '';
}
export function endpointTypeIcon(type) {
if (type === 3) {
return 'fab fa-microsoft';
} else if (type === 4) {
return 'fa fa-cloud';
} else if (type === 5 || type === 6 || type === 7) {
return 'fas fa-dharmachakra';
}
return 'fab fa-docker';
}
export function ownershipIcon(ownership) {
switch (ownership) {
case RCO.PRIVATE:
return 'fa fa-eye-slash';
case RCO.ADMINISTRATORS:
return 'fa fa-eye-slash';
case RCO.RESTRICTED:
return 'fa fa-users';
default:
return 'fa fa-eye';
}
}
export function truncate(text, length, end) {
if (isNaN(length)) {
length = 10;
}
if (end === undefined) {
end = '...';
}
if (text.length <= length || text.length - end.length <= length) {
return text;
} else {
return String(text).substring(0, length - end.length) + end;
}
}
export function endpointStatusBadge(status) {
if (status === 2) {
return 'danger';
}
return 'success';
}

View File

@ -0,0 +1,40 @@
import angular from 'angular';
import _ from 'lodash-es';
import {
arrayToStr,
endpointStatusBadge,
endpointTypeIcon,
endpointTypeName,
getPairKey,
getPairValue,
humanize,
ipAddress,
isoDate,
isoDateFromTimestamp,
labelsToStr,
ownershipIcon,
stripProtocol,
truncate,
truncateLeftRight,
} from './filters';
angular
.module('portainer.app')
.filter('truncate', () => truncate)
.filter('truncatelr', () => truncateLeftRight)
.filter('capitalize', () => _.capitalize)
.filter('stripprotocol', () => stripProtocol)
.filter('humansize', () => humanize)
.filter('getisodatefromtimestamp', () => isoDateFromTimestamp)
.filter('getisodate', () => isoDate)
.filter('key', () => getPairKey)
.filter('value', () => getPairValue)
.filter('emptyobject', () => _.isEmpty)
.filter('ipaddress', () => ipAddress)
.filter('arraytostr', () => arrayToStr)
.filter('labelsToStr', () => labelsToStr)
.filter('endpointtypename', () => endpointTypeName)
.filter('endpointtypeicon', () => endpointTypeIcon)
.filter('ownershipicon', () => ownershipIcon)
.filter('endpointstatusbadge', () => endpointStatusBadge);

View File

@ -1,3 +1,5 @@
<!-- <react-example text="'text'"></react-example> -->
<rd-header>
<rd-header-title title-text="Home">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.home" ui-sref-opts="{reload: true}">

View File

@ -1,8 +1,9 @@
import angular from 'angular';
import uuidv4 from 'uuid/v4';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
import { STACK_NAME_VALIDATION_REGEX } from '@/constants';
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { AccessControlFormData } from '../../../components/accessControlForm/porAccessControlFormModel';
import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy';
angular
.module('portainer.app')

View File

@ -1,8 +1,7 @@
//import { getAgentShortVersion } from 'Portainer/views/endpoints/helpers';
import { PortainerEndpointCreationTypes } from 'Portainer/models/endpoint/models';
import { buildOption } from '@/portainer/components/box-selector';
import { EndpointSecurityFormData } from 'Portainer/components/endpointSecurity/porEndpointSecurityModel';
import { getAgentShortVersion } from 'Portainer/views/endpoints/helpers';
import { buildOption } from '@/portainer/components/box-selector';
export default class WizardDockerController {
/* @ngInject */

View File

@ -0,0 +1,58 @@
import { UIRouterContextComponent } from '@uirouter/react-hybrid';
import ReactDOM from 'react-dom';
import { IComponentOptions, IController } from 'angular';
function toProps(
propNames: string[],
controller: IController,
$q: ng.IQService
) {
return Object.fromEntries(
propNames.map((key) => {
const prop = controller[key];
if (typeof prop !== 'function') {
return [key, prop];
}
return [
key,
(...args: unknown[]) =>
$q((resolve) => resolve(controller[key](...args))),
];
})
);
}
export function react2angular<T>(
Component: React.ComponentType<T>,
propNames: string[]
): IComponentOptions {
const bindings = Object.fromEntries(propNames.map((key) => [key, '<']));
return {
bindings,
controller: Controller,
};
/* @ngInject */
function Controller(
this: IController,
$element: HTMLElement[],
$q: ng.IQService
) {
const el = $element[0];
this.$onChanges = () => {
const props = toProps(propNames, this, $q);
ReactDOM.render(
<UIRouterContextComponent>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<Component {...(props as T)} />
</UIRouterContextComponent>,
el
);
};
this.$onDestroy = () => ReactDOM.unmountComponentAtNode(el);
}
}
export const r2a = react2angular;

View File

@ -0,0 +1,19 @@
import '@testing-library/jest-dom';
import { render, RenderOptions } from '@testing-library/react';
import { UIRouter, pushStateLocationPlugin } from '@uirouter/react';
import { PropsWithChildren, ReactElement } from 'react';
function Provider({ children }: PropsWithChildren<unknown>) {
return <UIRouter plugins={[pushStateLocationPlugin]}>{children}</UIRouter>;
}
function customRender(ui: ReactElement, options?: RenderOptions) {
return render(ui, { wrapper: Provider, ...options });
}
// re-export everything
export * from '@testing-library/react';
// override render method
export { customRender as render };

5
app/setup-tests.js Normal file
View File

@ -0,0 +1,5 @@
import 'regenerator-runtime/runtime';
export default function setupTests() {
// pass
}

View File

@ -1,22 +1,5 @@
import 'ui-select/dist/select.css';
import 'bootstrap/dist/css/bootstrap.css';
import '@fortawesome/fontawesome-free/css/brands.css';
import '@fortawesome/fontawesome-free/css/solid.css';
import '@fortawesome/fontawesome-free/css/fontawesome.css';
import 'toastr/build/toastr.css';
import 'xterm/dist/xterm.css';
import 'angularjs-slider/dist/rzslider.css';
import 'codemirror/lib/codemirror.css';
import 'codemirror/addon/lint/lint.css';
import 'angular-json-tree/dist/angular-json-tree.css';
import 'angular-loading-bar/build/loading-bar.css';
import 'angular-moment-picker/dist/angular-moment-picker.min.css';
import 'angular-multiselect/isteven-multi-select.css';
import 'spinkit/spinkit.min.css';
import angular from 'angular';
import 'moment';
import '@uirouter/angularjs';
import 'ui-select';
import 'angular-sanitize';
import 'ng-file-upload';

36
babel.config.js Normal file
View File

@ -0,0 +1,36 @@
module.exports = {
plugins: ['lodash', 'angularjs-annotate'],
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage',
corejs: '3',
targets: { node: 'current' },
modules: 'auto',
},
],
],
overrides: [
{
test: ['app/**/*.ts', 'app/**/*.tsx'],
presets: [
'@babel/preset-typescript',
[
'@babel/preset-react',
{
runtime: 'automatic',
},
],
[
'@babel/preset-env',
{
modules: 'auto',
useBuiltIns: 'usage',
corejs: '3',
},
],
],
},
],
};

View File

@ -56,7 +56,14 @@ module.exports = function (grunt) {
});
grunt.task.registerTask('devopsbuild', 'devopsbuild:<platform>:<arch>:<env>', function (platform, a = arch, env = 'prod') {
grunt.task.run([`env:${env}`, 'clean:all', `shell:build_binary_azuredevops:${platform}:${a}`, `download_binaries:${platform}:${a}`, `webpack:${env}`]);
grunt.task.run([
`env:${env}`,
'clean:all',
`shell:build_binary_azuredevops:${platform}:${a}`,
`download_binaries:${platform}:${a}`,
`webpack:${env}`,
`shell:storybook:${env}`,
]);
});
grunt.task.registerTask('download_binaries', 'download_binaries:<platform>:<arch>', function (platform = 'linux', a = arch) {
@ -109,8 +116,19 @@ gruntConfig.shell = {
run_container: { command: shell_run_container },
run_localserver: { command: shell_run_localserver, options: { async: true } },
install_yarndeps: { command: shell_install_yarndeps },
storybook: { command: shell_storybook },
};
function shell_storybook(env) {
if (env === 'production') {
return '';
}
return `
yarn build-storybook
`;
}
function shell_build_binary(platform, arch) {
const binfile = 'dist/portainer';
if (platform === 'linux') {

201
jest.config.js Normal file
View File

@ -0,0 +1,201 @@
/*
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/tmp/jest_rs",
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: 'v8',
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
globalSetup: `<rootDir>/app/setup-tests.js`,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/app/__mocks__/fileMock.js',
'\\.(css|less)$': '<rootDir>/app/__mocks__/styleMock.js',
'^Agent/(.*)?': '<rootDir>/app/agent/$1',
'^Azure/(.*)$': '<rootDir>/app/azure/$1',
'^Docker/(.*)$': '<rootDir>/app/docker/$1',
'^Kubernetes/(.*)$': '<rootDir>/app/kubernetes/$1',
'^Portainer/(.*)$': '<rootDir>/app/portainer/$1',
'^@/(.*)$': '<rootDir>/app/$1',
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
roots: ['<rootDir>/app'],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: 'jsdom', //"jest-environment-node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: [],
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};

View File

@ -13,5 +13,8 @@
"@/*": ["../app/*"],
}
},
"exclude": ["api", "build", "dist", "distribution", "node_modules", "test", "webpack"]
"exclude": ["api", "build", "dist", "distribution", "node_modules", "test", "webpack"],
"typeAcquisition": {
"include": ["jest"]
}
}

5
lint-staged.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
'*.(js|ts){,x}': 'eslint --cache --fix',
'*.(ts){,x}': () => 'tsc --noEmit',
'*.{js,ts,tsx,css,md,html}': 'prettier --write',
};

View File

@ -34,11 +34,16 @@
"start:toolkit": "grunt start:toolkit",
"build:server:offline": "cd ./api/cmd/portainer && CGO_ENABLED=0 go build --installsuffix cgo --ldflags '-s' && mv -f portainer ../../../dist/portainer",
"clean:all": "grunt clean:all",
"format": "prettier --loglevel warn --write \"**/*.{js,css,html}\"",
"format": "prettier --loglevel warn --write \"**/*.{js,css,html,jsx,tsx,ts}\"",
"lint": "yarn lint:client; yarn lint:server",
"lint:server": "cd api && golangci-lint run -E exportloopref",
"lint:client": "eslint --cache --fix .",
"test:server": "cd api && go test ./..."
"lint:client": "eslint --cache --fix ./**/*.{js,jsx,ts,tsx}",
"lint:pr": "make lint-pr",
"test": "yarn test:client; yarn test:server",
"test:server": "cd api && go test ./...",
"test:client": "jest",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook -o ./dist/storybook"
},
"scriptsComments": {
"build": "Build the entire app (backend/frontend) in development mode",
@ -61,6 +66,8 @@
"@fortawesome/fontawesome-free": "^5.11.2",
"@nxmix/tokenize-ansi": "^3.0.0",
"@uirouter/angularjs": "1.0.11",
"@uirouter/react": "^1.0.7",
"@uirouter/react-hybrid": "^1.0.4",
"angular": "1.8.0",
"angular-clipboard": "^1.6.2",
"angular-file-saver": "^1.1.3",
@ -85,7 +92,7 @@
"chardet": "^1.3.0",
"chart.js": "~2.7.0",
"codemirror": "~5.30.0",
"core-js": "2",
"core-js": "^3.16.3",
"fast-json-patch": "^3.0.0-1",
"filesize": "~3.3.0",
"filesize-parser": "^1.5.0",
@ -96,6 +103,8 @@
"moment": "^2.21.0",
"ng-file-upload": "~12.2.13",
"parse-duration": "^1.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"source-map-loader": "^1.1.2",
"spinkit": "^2.0.1",
"splitargs": "github:deviantony/splitargs#semver:~0.2.0",
@ -110,20 +119,45 @@
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "^7.15.0",
"@storybook/addon-actions": "^6.3.11",
"@storybook/addon-essentials": "^6.3.11",
"@storybook/addon-links": "^6.3.11",
"@storybook/addon-postcss": "^2.0.0",
"@storybook/react": "^6.3.11",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@types/angular": "^1.8.3",
"@types/jquery": "^3.5.6",
"@types/react": "^17.0.27",
"@types/react-dom": "^17.0.9",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"auto-ngtemplate-loader": "^2.0.1",
"autoprefixer": "^7.1.1",
"babel-jest": "^27.1.0",
"babel-loader": "^8.0.4",
"babel-plugin-lodash": "^3.3.4",
"clean-terminal-webpack-plugin": "^1.1.0",
"clean-webpack-plugin": "^0.1.19",
"css-loader": "^1.0.0",
"css-loader": "5",
"cssnano": "^4.1.10",
"cypress": "^5.2.0",
"cypress-wait-until": "^1.7.1",
"eslint": "^7.24.0",
"eslint-config-prettier": "^8.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-webpack-plugin": "^2.5.3",
"eslint": "^7.29.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-airbnb-typescript": "^14.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-import": "^2.24.2",
"eslint-plugin-jest": "^24.4.0",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-promise": "^5.1.1",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-webpack-plugin": "^2.5.4",
"file-loader": "^1.1.11",
"grunt": "^1.1.0",
"grunt-cli": "^1.3.2",
@ -141,17 +175,22 @@
"html-webpack-plugin": "^3.2.0",
"husky": ">=4",
"image-webpack-loader": "^4.5.0",
"jest": "^27.1.0",
"lint-staged": ">=10",
"load-grunt-tasks": "^3.5.2",
"lodash-webpack-plugin": "^0.11.5",
"mini-css-extract-plugin": "^0.4.4",
"mini-css-extract-plugin": "1",
"ngtemplate-loader": "^2.0.1",
"plop": "^2.6.0",
"postcss": "7",
"postcss-loader": "4",
"prettier": "^2.0.2",
"react-test-renderer": "^17.0.2",
"speed-measure-webpack-plugin": "^1.2.3",
"style-loader": "^0.23.1",
"storybook-css-modules-preset": "^1.1.1",
"style-loader": "2",
"tsconfig-paths-webpack-plugin": "^3.5.1",
"typescript": "^4.4.3",
"url-loader": "^1.1.1",
"webpack": "^4.26.0",
"webpack-build-notifier": "^0.1.30",
@ -165,15 +204,14 @@
"lodash": "^4.17.21",
"js-yaml": "^3.14.0",
"minimist": "^1.2.5",
"http-proxy": "^1.18.1"
"http-proxy": "^1.18.1",
"**/@uirouter/react": "^1.0.7",
"**/@uirouter/angularjs": "1.0.11",
"**/css-loader": "5"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.js": "eslint --cache --fix",
"*.{js,css,md,html}": "prettier --write"
}
}
}

39
tsconfig.json Normal file
View File

@ -0,0 +1,39 @@
{
"compilerOptions": {
"baseUrl": "app",
"outDir": "./dist/public",
"module": "es6",
// "module": "commonjs",
// "module": "esnext",
"target": "es2017",
"allowJs": true,
"checkJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"isolatedModules": true,
"noEmit": false,
"jsx": "react-jsx",
"noImplicitReturns": true,
"noImplicitAny": true,
"noImplicitThis": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"removeComments": true,
// "sourceMap": true,
"lib": ["dom", "dom.iterable", "esnext"],
"paths": {
// paths relative to the baseUrl
"@/*": ["./*", "../app/*"]
}
},
"exclude": ["api", "build", "dist", "distribution", "node_modules", "test", "webpack"],
"include": ["app"],
"typeAcquisition": {
"include": ["jest"]
}
}

View File

@ -7,6 +7,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const pkg = require('../package.json');
const projectRoot = path.resolve(__dirname, '..');
@ -37,7 +38,7 @@ module.exports = {
],
},
{
test: /\.js$/,
test: /\.(js|ts)(x)?$/,
exclude: /node_modules/,
use: ['babel-loader', 'auto-ngtemplate-loader'],
},
@ -61,7 +62,21 @@ module.exports = {
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, { loader: 'css-loader', options: { importLoaders: 1 } }, 'postcss-loader'],
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
importLoaders: 1,
modules: {
localIdentName: '[path][name]__[local]',
auto: true,
exportLocalsConvention: 'camelCaseOnly',
},
},
},
'postcss-loader',
],
},
],
},
@ -103,7 +118,6 @@ module.exports = {
new MiniCssExtractPlugin({
filename: '[name].[hash].css',
chunkFilename: '[name].[id].css',
sourceMap: true,
}),
new CleanWebpackPlugin(['dist/public']),
new IgnorePlugin(/^\.\/locale$/, /moment$/),
@ -136,5 +150,11 @@ module.exports = {
Portainer: path.resolve(projectRoot, 'app/portainer'),
'@': path.resolve(projectRoot, 'app'),
},
extensions: ['.js', '.ts', '.tsx'],
plugins: [
new TsconfigPathsPlugin({
extensions: ['.js', '.ts', '.tsx'],
}),
],
},
};

8379
yarn.lock

File diff suppressed because it is too large Load Diff