diff --git a/.dockerignore b/.dockerignore index 6043e867d..3b0113d6a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,8 @@ .git .env node_modules +Dockerfile +.dockerignore npm-debug.log .storybook .vscode diff --git a/env.js b/env.js index d135ef065..89c73d772 100644 --- a/env.js +++ b/env.js @@ -15,6 +15,15 @@ const CORS_PROXY_PREFIX = process.env.CORS_PROXY_PREFIX || '/cors_proxy'; const CONFIG_DIR = process.env.CONFIG_DIR || './config'; const CONFIG_CACHE_TTL_SECONDS = process.env.CONFIG_CACHE_TTL_SECONDS || '60'; +// Defines a file to be required which will provide implementations for +// any user-definable code. +const PLUGINS_MODULE = process.env.PLUGINS_MODULE; + +// Optional URL which will provide information about system status. This is used +// to show informational banners to the user. +// If it has no protocol, it will be treated as relative to window.location.origin +const STATUS_URL = process.env.STATUS_URL; + module.exports = { ADMIN_API_URL, BASE_URL, @@ -23,10 +32,13 @@ module.exports = { CONFIG_DIR, CORS_PROXY_PREFIX, NODE_ENV, + PLUGINS_MODULE, + STATUS_URL, processEnv: { ADMIN_API_URL, BASE_URL, CORS_PROXY_PREFIX, - NODE_ENV + NODE_ENV, + STATUS_URL } }; diff --git a/index.js b/index.js index 7df78d5ff..44e34e25c 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,7 @@ const fs = require('fs'); const morgan = require('morgan'); const express = require('express'); const env = require('./env'); +const { applyMiddleware } = require('./plugins'); const corsProxy = require('./corsProxy.js'); @@ -15,10 +16,16 @@ const app = express(); // Enable logging for HTTP access app.use(morgan('combined')); +app.use(express.json()); app.get(`${env.BASE_URL}/healthz`, (_req, res) => res.status(200).send()); app.use(corsProxy(`${env.BASE_URL}${env.CORS_PROXY_PREFIX}`)); +if (typeof applyMiddleware === 'function') { + console.log('Found middleware plugins, applying...'); + applyMiddleware(app); +} + if (process.env.NODE_ENV === 'production') { const path = require('path'); const expressStaticGzip = require('express-static-gzip'); diff --git a/package.json b/package.json index 9947556a1..0d4319ffa 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@types/html-webpack-plugin": "^2.28.0", "@types/jest": "^25.1.2", "@types/js-yaml": "^3.10.1", + "@types/linkify-it": "^2.1.0", "@types/lodash": "^4.14.68", "@types/long": "^3.0.32", "@types/lossless-json": "^1.0.0", @@ -143,6 +144,7 @@ "identity-obj-proxy": "^3.0.0", "intersection-observer": "^0.7.0", "jest": "^24.9.0", + "linkify-it": "^2.2.0", "lint-staged": "^7.0.4", "lossless-json": "^1.0.3", "memoize-one": "^5.0.0", diff --git a/plugins.js b/plugins.js new file mode 100644 index 000000000..789249752 --- /dev/null +++ b/plugins.js @@ -0,0 +1,11 @@ +const env = require('./env'); + +const { middleware } = env.PLUGINS_MODULE ? require(env.PLUGINS_MODULE) : {}; + +if (Array.isArray(middleware)) { + module.exports.applyMiddleware = app => middleware.forEach(m => m(app)); +} else if (middleware !== undefined) { + console.log( + `Expected middleware to be of type Array, but got ${middleware} instead` + ); +} diff --git a/src/common/linkify.ts b/src/common/linkify.ts new file mode 100644 index 000000000..4ae30a92a --- /dev/null +++ b/src/common/linkify.ts @@ -0,0 +1,45 @@ +import * as LinkifyIt from 'linkify-it'; +export const linkify = new LinkifyIt(); + +export type LinkifiedTextChunkType = 'text' | 'link'; +export interface LinkifiedTextChunk { + type: LinkifiedTextChunkType; + text: string; + url?: string; +} + +/** Detects any links in the given text and splits the string on the + * link boundaries, returning an array of text/link entries for each + * portion of the string. + */ +export function getLinkifiedTextChunks(text: string): LinkifiedTextChunk[] { + const matches = linkify.match(text); + if (matches === null) { + return [{ text, type: 'text' }]; + } + + const chunks: LinkifiedTextChunk[] = []; + let lastMatchEndIndex = 0; + matches.forEach(match => { + if (lastMatchEndIndex !== match.index) { + chunks.push({ + text: text.substring(lastMatchEndIndex, match.index), + type: 'text' + }); + } + chunks.push({ + text: match.text, + type: 'link', + url: match.url + }); + lastMatchEndIndex = match.lastIndex; + }); + if (lastMatchEndIndex !== text.length) { + chunks.push({ + text: text.substring(lastMatchEndIndex, text.length), + type: 'text' + }); + } + + return chunks; +} diff --git a/src/common/test/linkify.test.ts b/src/common/test/linkify.test.ts new file mode 100644 index 000000000..ee1f734bb --- /dev/null +++ b/src/common/test/linkify.test.ts @@ -0,0 +1,50 @@ +import { getLinkifiedTextChunks, LinkifiedTextChunk } from 'common/linkify'; + +function text(text: string): LinkifiedTextChunk { + return { text, type: 'text' }; +} + +function link(text: string): LinkifiedTextChunk { + return { text, url: text, type: 'link' }; +} + +describe('linkify/getLinkifiedTextChunks', () => { + const testCases: [string, LinkifiedTextChunk[]][] = [ + ['No match expected', [text('No match expected')]], + [ + 'Points to http://example.com', + [text('Points to '), link('http://example.com')] + ], + [ + 'https://example.com link is at beginning', + [link('https://example.com'), text(' link is at beginning')] + ], + [ + 'A link to http://example.com is in the middle', + [ + text('A link to '), + link('http://example.com'), + text(' is in the middle') + ] + ], + [ + 'A link at the end to http://example.com', + [text('A link at the end to '), link('http://example.com')] + ], + [ + 'A link to http://example.com and another link to https://flyte.org in the middle.', + [ + text('A link to '), + link('http://example.com'), + text(' and another link to '), + link('https://flyte.org'), + text(' in the middle.') + ] + ] + ]; + testCases.forEach(([input, expected]) => + it(`correctly splits: ${input}`, () => { + expect(getLinkifiedTextChunks(input)).toEqual(expected); + }) + ); +}); diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index fb58111ca..1b5af2f2d 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -4,6 +4,7 @@ import { env } from 'common/env'; import { debug, debugPrefix } from 'common/log'; import { APIContext, useAPIState } from 'components/data/apiContext'; import { LoginExpiredHandler } from 'components/Errors/LoginExpiredHandler'; +import { SystemStatusBanner } from 'components/Notifications/SystemStatusBanner'; import { skeletonColor, skeletonHighlightColor } from 'components/Theme'; import { muiTheme } from 'components/Theme/muiTheme'; import * as React from 'react'; @@ -41,6 +42,7 @@ export const AppComponent: React.StatelessComponent<{}> = () => { + diff --git a/src/components/Notifications/SystemStatusBanner.tsx b/src/components/Notifications/SystemStatusBanner.tsx new file mode 100644 index 000000000..0f1f2e9a8 --- /dev/null +++ b/src/components/Notifications/SystemStatusBanner.tsx @@ -0,0 +1,151 @@ +import { ButtonBase, SvgIcon, Typography } from '@material-ui/core'; +import Paper from '@material-ui/core/Paper'; +import { makeStyles, Theme } from '@material-ui/core/styles'; +import Close from '@material-ui/icons/Close'; +import Info from '@material-ui/icons/Info'; +import Warning from '@material-ui/icons/Warning'; +import { WaitForData } from 'components/common'; +import { LinkifiedText } from 'components/common/LinkifiedText'; +import { + infoIconColor, + mutedButtonColor, + mutedButtonHoverColor, + warningIconColor +} from 'components/Theme'; +import { StatusString, SystemStatus } from 'models/Common/types'; +import * as React from 'react'; +import { useSystemStatus } from './useSystemStatus'; + +const useStyles = makeStyles((theme: Theme) => ({ + container: { + bottom: 0, + display: 'flex', + justifyContent: 'center', + left: 0, + // The parent container extends the full width of the page. + // We don't want it intercepting pointer events for visible items below it. + pointerEvents: 'none', + position: 'fixed', + padding: theme.spacing(2), + right: 0 + }, + closeButton: { + alignItems: 'center', + color: mutedButtonColor, + '&:hover': { + color: mutedButtonHoverColor + }, + display: 'flex', + height: theme.spacing(3) + }, + statusPaper: { + display: 'flex', + padding: theme.spacing(2), + pointerEvents: 'initial' + }, + statusContentContainer: { + alignItems: 'flex-start', + display: 'flex', + maxWidth: theme.spacing(131) + }, + statusClose: { + alignItems: 'flex-start', + display: 'flex', + flex: '0 0 auto' + }, + statusIcon: { + alignItems: 'center', + display: 'flex', + flex: '0 0 auto', + lineHeight: `${theme.spacing(3)}px` + }, + statusMessage: { + flex: '1 1 auto', + fontWeight: 'normal', + lineHeight: `${theme.spacing(3)}px`, + marginLeft: theme.spacing(2), + marginRight: theme.spacing(2) + } +})); + +interface StatusConstantValues { + color: string; + IconComponent: typeof SvgIcon; +} + +const InfoIcon = () => ( + +); + +const WarningIcon = () => ( + +); + +const statusIcons: Record = { + normal: InfoIcon, + degraded: WarningIcon, + down: WarningIcon +}; + +const StatusIcon: React.FC<{ status: StatusString }> = ({ status }) => { + const IconComponent = statusIcons[status] || statusIcons.normal; + return ; +}; + +const RenderSystemStatusBanner: React.FC<{ + systemStatus: SystemStatus; + onClose: () => void; +}> = ({ systemStatus: { message, status }, onClose }) => { + const styles = useStyles(); + return ( +
+ +
+ +
+
+ + + +
+
+ + + +
+
+
+ ); +}; + +/** Fetches and renders the system status returned by issuing a GET to + * `env.STATUS_URL`. If the status includes a message, a dismissable toast + * will be rendered. Otherwise, nothing will be rendered. + */ +export const SystemStatusBanner: React.FC<{}> = () => { + const systemStatus = useSystemStatus(); + const [dismissed, setDismissed] = React.useState(false); + const onClose = () => setDismissed(true); + if (dismissed) { + return null; + } + return ( + + {systemStatus.value.message ? ( + + ) : null} + + ); +}; diff --git a/src/components/Notifications/__stories__/SystemStatusBanner.stories.tsx b/src/components/Notifications/__stories__/SystemStatusBanner.stories.tsx new file mode 100644 index 000000000..21c29056d --- /dev/null +++ b/src/components/Notifications/__stories__/SystemStatusBanner.stories.tsx @@ -0,0 +1,48 @@ +import { storiesOf } from '@storybook/react'; +import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; +import { APIContext } from 'components/data/apiContext'; +import { SystemStatus } from 'models'; +import * as React from 'react'; +import { SystemStatusBanner } from '../SystemStatusBanner'; + +const normalStatus: SystemStatus = { + status: 'normal', + message: + 'This is a test. It is only a test. Check out https://flyte.org for more information.' +}; + +// Max length for a status should be 500 characters +const longTextStatus: SystemStatus = { + status: 'normal', + message: + 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibu' +}; + +const degradedStatus: SystemStatus = { + status: 'degraded', + message: + 'Something is a bit wrong with the system. We can probably still do some things, but may be slow about it.' +}; + +const downStatus: SystemStatus = { + status: 'down', + message: + 'We are down. You should probably go get a cup of coffee while the administrators attempt to troubleshoot.' +}; + +function renderBanner(status: SystemStatus) { + const mockApi = mockAPIContextValue({ + getSystemStatus: () => Promise.resolve(status) + }); + return ( + + + + ); +} + +const stories = storiesOf('Notifications/SystemStatusBanner', module); +stories.add('Normal Status', () => renderBanner(normalStatus)); +stories.add('Degraded Status', () => renderBanner(degradedStatus)); +stories.add('Down Status', () => renderBanner(downStatus)); +stories.add('Long Message', () => renderBanner(longTextStatus)); diff --git a/src/components/Notifications/test/SystemStatusBanner.test.tsx b/src/components/Notifications/test/SystemStatusBanner.test.tsx new file mode 100644 index 000000000..9d08db007 --- /dev/null +++ b/src/components/Notifications/test/SystemStatusBanner.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, wait } from '@testing-library/react'; +import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; +import { APIContext, APIContextValue } from 'components/data/apiContext'; +import { StatusString, SystemStatus } from 'models'; +import * as React from 'react'; +import { SystemStatusBanner } from '../SystemStatusBanner'; + +describe('SystemStatusBanner', () => { + let systemStatus: SystemStatus; + let apiContext: APIContextValue; + + beforeEach(() => { + systemStatus = { + status: 'normal', + message: 'Everything is fine.' + }; + apiContext = mockAPIContextValue({ + getSystemStatus: jest + .fn() + .mockImplementation(() => Promise.resolve(systemStatus)) + }); + }); + + const renderStatusBanner = () => + render( + + + + ); + + it('should display an info icon for normal status', async () => { + const { getByTestId } = renderStatusBanner(); + await wait(); + + expect(getByTestId('info-icon')).toBeInTheDocument(); + }); + + it('should display a warning icon for degraded status', async () => { + systemStatus.status = 'degraded'; + const { getByTestId } = renderStatusBanner(); + await wait(); + + expect(getByTestId('warning-icon')).toBeInTheDocument(); + }); + + it('should display a warning icon for down status', async () => { + systemStatus.status = 'down'; + const { getByTestId } = renderStatusBanner(); + await wait(); + + expect(getByTestId('warning-icon')).toBeInTheDocument(); + }); + + it('should render normal status icon for any unknown status', async () => { + systemStatus.status = 'unknown' as StatusString; + const { getByTestId } = renderStatusBanner(); + await wait(); + + expect(getByTestId('info-icon')).toBeInTheDocument(); + }); + + it('should only display if a `message` property is present', async () => { + delete systemStatus.message; + const { queryByRole } = renderStatusBanner(); + await wait(); + expect(queryByRole('banner')).toBeNull(); + }); + + it('should hide when dismissed by user', async () => { + const { getByRole, queryByRole } = renderStatusBanner(); + await wait(); + + expect(getByRole('banner')).toBeInTheDocument(); + + const closeButton = getByRole('button'); + fireEvent.click(closeButton); + await wait(); + + expect(queryByRole('banner')).toBeNull(); + }); + + it('should render inline urls as links', async () => { + systemStatus.message = 'Check out http://flyte.org for more info'; + const { getByText } = renderStatusBanner(); + await wait(); + expect(getByText('http://flyte.org').closest('a')).toHaveAttribute( + 'href', + 'http://flyte.org' + ); + }); +}); diff --git a/src/components/Notifications/useSystemStatus.ts b/src/components/Notifications/useSystemStatus.ts new file mode 100644 index 000000000..267b6a22e --- /dev/null +++ b/src/components/Notifications/useSystemStatus.ts @@ -0,0 +1,15 @@ +import { useAPIContext } from 'components/data/apiContext'; +import { FetchableData, useFetchableData } from 'components/hooks'; +import { SystemStatus } from 'models'; +import { defaultSystemStatus } from 'models/Common/constants'; + +/** Hook for fetching the current system status. Defaults to a safe value + * indicating normal system status. + */ +export function useSystemStatus(): FetchableData { + const { getSystemStatus } = useAPIContext(); + return useFetchableData({ + defaultValue: defaultSystemStatus, + doFetch: getSystemStatus + }); +} diff --git a/src/components/Theme/constants.ts b/src/components/Theme/constants.ts index c37630f43..0af47716c 100644 --- a/src/components/Theme/constants.ts +++ b/src/components/Theme/constants.ts @@ -38,9 +38,14 @@ export const nestedListColor = COLOR_SPECTRUM.gray0.color; export const buttonHoverColor = COLOR_SPECTRUM.gray0.color; export const inputFocusBorderColor = COLOR_SPECTRUM.blue60.color; +export const warningIconColor = COLOR_SPECTRUM.sunset60.color; +export const infoIconColor = COLOR_SPECTRUM.blue40.color; + export const dangerousButtonBorderColor = COLOR_SPECTRUM.red20.color; export const dangerousButtonColor = COLOR_SPECTRUM.red30.color; export const dangerousButtonHoverColor = COLOR_SPECTRUM.red40.color; +export const mutedButtonColor = COLOR_SPECTRUM.gray30.color; +export const mutedButtonHoverColor = COLOR_SPECTRUM.gray60.color; export const errorBackgroundColor = '#FBFBFC'; diff --git a/src/components/common/LinkifiedText.tsx b/src/components/common/LinkifiedText.tsx new file mode 100644 index 000000000..71b546001 --- /dev/null +++ b/src/components/common/LinkifiedText.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { NewTargetLink } from './NewTargetLink'; +import { useLinkifiedChunks } from './useLinkifiedChunks'; + +/** Renders the given text with any inline valid URLs rendered as + * `NewTargetLink`s. + */ +export const LinkifiedText: React.FC<{ text?: string }> = ({ text = '' }) => { + const chunks = useLinkifiedChunks(text); + return ( + <> + {chunks.map((chunk, idx) => { + const key = `${chunk.type}-${idx}`; + if (chunk.type === 'link') { + return ( + + {chunk.text} + + ); + } + return {chunk.text}; + })} + + ); +}; diff --git a/src/components/common/useLinkifiedChunks.ts b/src/components/common/useLinkifiedChunks.ts new file mode 100644 index 000000000..3fa39eccf --- /dev/null +++ b/src/components/common/useLinkifiedChunks.ts @@ -0,0 +1,6 @@ +import { getLinkifiedTextChunks } from 'common/linkify'; +import { useMemo } from 'react'; + +export function useLinkifiedChunks(text: string) { + return useMemo(() => getLinkifiedTextChunks(text), [text]); +} diff --git a/src/config/types.ts b/src/config/types.ts index 78a2a81b0..d7762cffe 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -12,4 +12,5 @@ export interface Env extends NodeJS.ProcessEnv { BASE_URL?: string; CORS_PROXY_PREFIX: string; DISABLE_ANALYTICS?: string; + STATUS_URL?: string; } diff --git a/src/models/Common/api.ts b/src/models/Common/api.ts index ae879b54d..48a464361 100644 --- a/src/models/Common/api.ts +++ b/src/models/Common/api.ts @@ -9,16 +9,22 @@ import { RequestConfig } from 'models/AdminEntity'; +import { env } from 'common/env'; import { log } from 'common/log'; import { createCorsProxyURL } from 'common/utils'; import { transformRequestError } from 'models/AdminEntity/transformRequestError'; -import { defaultAxiosConfig, identifierPrefixes } from './constants'; +import { + defaultAxiosConfig, + defaultSystemStatus, + identifierPrefixes +} from './constants'; import { IdentifierScope, LiteralMap, NamedEntity, NamedEntityIdentifier, ResourceType, + SystemStatus, UserProfile } from './types'; import { makeIdentifierPath, makeNamedEntityPath } from './utils'; @@ -127,6 +133,28 @@ export const getUserProfile = async () => { } }; +/** If env.STATUS_URL is set, will issue a fetch to retrieve the current system + * status. If not, will resolve immediately with a default value indicating + * normal system status. + */ +export const getSystemStatus = async () => { + if (!env.STATUS_URL) { + return defaultSystemStatus; + } + const path = env.STATUS_URL; + + try { + const { data } = await axios.get( + path, + defaultAxiosConfig + ); + return data; + } catch (e) { + const { message } = transformRequestError(e, path); + throw new Error(`Failed to fetch system status: ${message}`); + } +}; + /** Given a url to a `LiteralMap` stored at a remote location, will fetch and * decode the resulting object. */ diff --git a/src/models/Common/constants.ts b/src/models/Common/constants.ts index 9b0522712..f91825ebe 100644 --- a/src/models/Common/constants.ts +++ b/src/models/Common/constants.ts @@ -2,7 +2,7 @@ import axios, { AxiosRequestConfig, AxiosTransformer } from 'axios'; import * as camelcaseKeys from 'camelcase-keys'; import * as snakecaseKeys from 'snakecase-keys'; import { isObject } from 'util'; -import { LiteralMapBlob, ResourceType } from './types'; +import { LiteralMapBlob, ResourceType, SystemStatus } from './types'; export const endpointPrefixes = { execution: '/executions', @@ -44,3 +44,5 @@ export const defaultAxiosConfig: AxiosRequestConfig = { ], withCredentials: true }; + +export const defaultSystemStatus: SystemStatus = { status: 'normal' }; diff --git a/src/models/Common/types.ts b/src/models/Common/types.ts index bde98d504..45b2ba67a 100644 --- a/src/models/Common/types.ts +++ b/src/models/Common/types.ts @@ -176,3 +176,10 @@ export interface UserProfile { email: string; picture: string; } + +export type StatusString = 'normal' | 'degraded' | 'down'; + +export interface SystemStatus { + message?: string; + status: StatusString; +} diff --git a/yarn.lock b/yarn.lock index 2d2b165ea..548b8e14f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2831,6 +2831,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA== +"@types/linkify-it@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-2.1.0.tgz#ea3dd64c4805597311790b61e872cbd1ed2cd806" + integrity sha512-Q7DYAOi9O/+cLLhdaSvKdaumWyHbm7HAk/bFwwyTuU0arR5yyCeW5GOoqt4tJTpDRxhpx9Q8kQL6vMpuw9hDSw== + "@types/lodash@^4.14.68": version "4.14.149" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440" @@ -8998,6 +9003,13 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= +linkify-it@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.2.0.tgz#e3b54697e78bf915c70a38acd78fd09e0058b1cf" + integrity sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw== + dependencies: + uc.micro "^1.0.1" + lint-staged@^7.0.4: version "7.3.0" resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-7.3.0.tgz#90ff33e5ca61ed3dbac35b6f6502dbefdc0db58d" @@ -13420,6 +13432,11 @@ ua-parser-js@^0.7.18: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777" integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ== +uc.micro@^1.0.1: + version "1.0.6" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" + integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== + uglify-es@^3.3.4: version "3.3.9" resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz#0c1c4f0700bed8dbc124cdb304d2592ca203e677"