diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index a13b18ed0e..35e71a9043 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -110,6 +110,22 @@ To run a subset of e2e tests, you need to specify the suite with `-run`, and the E2E_TEST_OPTIONS="-run 'TestCanarySuite' -testify.m 'TestCanaryScaleDownOnAbortNoTrafficRouting'" make test-e2e ``` +## Running the UI + +If you'd like to run the UI locally, you first need a running Rollouts controller. This can be a locally running controller with a k3d cluster, as described above, or a controller running in a remote Kubernetes cluster. + +In order for the local React app to communicate with the controller and Kubernetes API, run the following to open a port forward to the dashboard: +```bash +kubectl argo rollouts dashboard +``` + +In another terminal, run the following to start the UI: +```bash +cd ui +yarn install +yarn start +``` + ## Controller architecture Argo Rollouts is actually a collection of individual controllers diff --git a/pkg/kubectl-argo-rollouts/info/rollout_info.go b/pkg/kubectl-argo-rollouts/info/rollout_info.go index 59ee3f076a..8afc8cf02f 100644 --- a/pkg/kubectl-argo-rollouts/info/rollout_info.go +++ b/pkg/kubectl-argo-rollouts/info/rollout_info.go @@ -29,6 +29,8 @@ func NewRolloutInfo( ObjectMeta: &v1.ObjectMeta{ Name: ro.Name, Namespace: ro.Namespace, + Labels: ro.Labels, + Annotations: ro.Annotations, UID: ro.UID, CreationTimestamp: ro.CreationTimestamp, ResourceVersion: ro.ObjectMeta.ResourceVersion, diff --git a/ui/package.json b/ui/package.json index fac0a758f3..46c0e34666 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.4.0", + "@fortawesome/free-regular-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/react-fontawesome": "^0.2.0", "antd": "^5.4.2", diff --git a/ui/src/app/App.tsx b/ui/src/app/App.tsx index 60ba5419c6..ae0bd2b54e 100644 --- a/ui/src/app/App.tsx +++ b/ui/src/app/App.tsx @@ -7,7 +7,7 @@ import './App.scss'; import {NamespaceContext, RolloutAPI} from './shared/context/api'; import {Modal} from './components/modal/modal'; import {Rollout} from './components/rollout/rollout'; -import {RolloutsList} from './components/rollouts-list/rollouts-list'; +import {RolloutsHome} from './components/rollouts-home/rollouts-home'; import {Shortcut, Shortcuts} from './components/shortcuts/shortcuts'; import {ConfigProvider} from 'antd'; import {theme} from '../config/theme'; @@ -84,7 +84,7 @@ const App = () => { } + component={} shortcuts={[ {key: '/', description: 'Search'}, {key: 'TAB', description: 'Search, navigate search items'}, diff --git a/ui/src/app/components/confirm-button/confirm-button.tsx b/ui/src/app/components/confirm-button/confirm-button.tsx index 4dd4f37e7c..48ce3fba77 100644 --- a/ui/src/app/components/confirm-button/confirm-button.tsx +++ b/ui/src/app/components/confirm-button/confirm-button.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import {Button, Popconfirm, Tooltip} from 'antd'; import {ButtonProps} from 'antd/es/button/button'; import {useState} from 'react'; -import { TooltipPlacement } from 'antd/es/tooltip'; +import {TooltipPlacement} from 'antd/es/tooltip'; interface ConfirmButtonProps extends ButtonProps { skipconfirm?: boolean; @@ -51,7 +51,8 @@ export const ConfirmButton = (props: ConfirmButtonProps) => { onClick={(e) => { e.stopPropagation(); e.preventDefault(); - }}> + }} + > { okText='Yes' cancelText='No' onOpenChange={handleOpenChange} - placement={props.placement || 'bottom'}> + placement={props.placement || 'bottom'} + >
diff --git a/ui/src/app/components/info-item/info-item.tsx b/ui/src/app/components/info-item/info-item.tsx index 7e7bf8e617..ab64712885 100644 --- a/ui/src/app/components/info-item/info-item.tsx +++ b/ui/src/app/components/info-item/info-item.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import './info-item.scss'; -import { Tooltip } from 'antd'; +import {Tooltip} from 'antd'; export enum InfoItemKind { Default = 'default', diff --git a/ui/src/app/components/pods/pods.tsx b/ui/src/app/components/pods/pods.tsx index c3e5fecb32..1107a8444d 100644 --- a/ui/src/app/components/pods/pods.tsx +++ b/ui/src/app/components/pods/pods.tsx @@ -55,7 +55,7 @@ export const ReplicaSets = (props: {replicaSets: RolloutReplicaSetInfo[]; showRe
- ) + ), )}
); @@ -84,7 +84,8 @@ export const ReplicaSet = (props: {rs: RolloutReplicaSetInfo; showRevision?: boo Scaledown in - }> + } + > ) as any} icon='fa fa-clock'> ); diff --git a/ui/src/app/components/rollout-actions/rollout-actions.tsx b/ui/src/app/components/rollout-actions/rollout-actions.tsx index 94a4b289f2..81b5ce1ead 100644 --- a/ui/src/app/components/rollout-actions/rollout-actions.tsx +++ b/ui/src/app/components/rollout-actions/rollout-actions.tsx @@ -25,93 +25,95 @@ interface ActionData { shouldConfirm?: boolean; } -export const RolloutActionButton = (props: {action: RolloutAction; rollout: RolloutInfo; callback?: Function; indicateLoading: boolean; disabled?: boolean}) => { - const api = React.useContext(RolloutAPIContext); - const namespaceCtx = React.useContext(NamespaceContext); +export const RolloutActionButton = React.memo( + ({action, rollout, callback, indicateLoading, disabled}: {action: RolloutAction; rollout: RolloutInfo; callback?: Function; indicateLoading: boolean; disabled?: boolean}) => { + const [loading, setLoading] = React.useState(false); + const api = React.useContext(RolloutAPIContext); + const namespaceCtx = React.useContext(NamespaceContext); - const restartedAt = formatTimestamp(props.rollout.restartedAt || ''); - const isDeploying = props.rollout.status === RolloutStatus.Progressing || props.rollout.status === RolloutStatus.Paused; + const restartedAt = formatTimestamp(rollout.restartedAt || ''); + const isDeploying = rollout.status === RolloutStatus.Progressing || rollout.status === RolloutStatus.Paused; - const actionMap = new Map([ - [ - RolloutAction.Restart, - { - label: 'RESTART', - icon: faSync, - action: api.rolloutServiceRestartRollout, - tooltip: restartedAt === 'Never' ? 'Never restarted' : `Last restarted ${restartedAt}`, - shouldConfirm: true, - }, - ], - [ - RolloutAction.Retry, - { - label: 'RETRY', - icon: faRedoAlt, - action: api.rolloutServiceRetryRollout, - disabled: props.rollout.status !== RolloutStatus.Degraded, - shouldConfirm: true, - }, - ], - [ - RolloutAction.Abort, - { - label: 'ABORT', - icon: faExclamationCircle, - action: api.rolloutServiceAbortRollout, - disabled: !isDeploying, - shouldConfirm: true, - }, - ], - [ - RolloutAction.Promote, - { - label: 'PROMOTE', - icon: faChevronCircleUp, - action: api.rolloutServicePromoteRollout, - body: {full: false}, - disabled: !isDeploying, - shouldConfirm: true, - }, - ], - [ - RolloutAction.PromoteFull, - { - label: 'PROMOTE-FULL', - icon: faArrowCircleUp, - action: api.rolloutServicePromoteRollout, - body: {full: true}, - disabled: !isDeploying, - shouldConfirm: true, - }, - ], - ]); + const actionMap = new Map([ + [ + RolloutAction.Restart, + { + label: 'RESTART', + icon: faSync, + action: api.rolloutServiceRestartRollout, + tooltip: restartedAt === 'Never' ? 'Never restarted' : `Last restarted ${restartedAt}`, + shouldConfirm: true, + }, + ], + [ + RolloutAction.Retry, + { + label: 'RETRY', + icon: faRedoAlt, + action: api.rolloutServiceRetryRollout, + disabled: rollout.status !== RolloutStatus.Degraded, + shouldConfirm: true, + }, + ], + [ + RolloutAction.Abort, + { + label: 'ABORT', + icon: faExclamationCircle, + action: api.rolloutServiceAbortRollout, + disabled: !isDeploying, + shouldConfirm: true, + }, + ], + [ + RolloutAction.Promote, + { + label: 'PROMOTE', + icon: faChevronCircleUp, + action: api.rolloutServicePromoteRollout, + body: {full: false}, + disabled: !isDeploying, + shouldConfirm: true, + }, + ], + [ + RolloutAction.PromoteFull, + { + label: 'PROMOTE-FULL', + icon: faArrowCircleUp, + action: api.rolloutServicePromoteRollout, + body: {full: true}, + disabled: !isDeploying, + shouldConfirm: true, + }, + ], + ]); - const ap = actionMap.get(props.action); + const ap = actionMap.get(action); - const [loading, setLoading] = React.useState(false); - - return ( - { - setLoading(true); - await ap.action(ap.body || {}, namespaceCtx.namespace, props.rollout.objectMeta?.name || ''); - if (props.callback) { - await props.callback(); - } - setLoading(false); - }} - disabled={ap.disabled} - loading={loading} - tooltip={ap.tooltip} - icon={}> - {props.action} - - ); -}; + return ( + { + setLoading(true); + await ap.action(ap.body || {}, namespaceCtx.namespace, rollout.objectMeta?.name || ''); + if (callback) { + await callback(); + } + setLoading(false); + }} + disabled={ap.disabled} + loading={loading} + tooltip={ap.tooltip} + icon={} + > + {action} + + ); + }, +); export const RolloutActions = (props: {rollout: RolloutInfo}) => (
diff --git a/ui/src/app/components/rollouts-list/rollouts-list.scss b/ui/src/app/components/rollout-widget/rollout-widget.scss similarity index 98% rename from ui/src/app/components/rollouts-list/rollouts-list.scss rename to ui/src/app/components/rollout-widget/rollout-widget.scss index f0ec5fdcc7..68f92bf93e 100644 --- a/ui/src/app/components/rollouts-list/rollouts-list.scss +++ b/ui/src/app/components/rollout-widget/rollout-widget.scss @@ -171,6 +171,8 @@ $colWidth: ($WIDGET_WIDTH + (2 * $widgetPadding)) + $widgetMarginRight; align-items: center; margin-top: 1.5em; z-index: 10 !important; + color: $argo-color-gray-7; + font-size: 14px; } } -} +} \ No newline at end of file diff --git a/ui/src/app/components/rollout-widget/rollout-widget.tsx b/ui/src/app/components/rollout-widget/rollout-widget.tsx new file mode 100644 index 0000000000..4853388872 --- /dev/null +++ b/ui/src/app/components/rollout-widget/rollout-widget.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import {Link} from 'react-router-dom'; + +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faCircleNotch, faRedoAlt} from '@fortawesome/free-solid-svg-icons'; +import {Tooltip} from 'antd'; + +import {ParsePodStatus, PodStatus, ReplicaSets} from '../pods/pods'; +import {RolloutInfo} from '../../../models/rollout/rollout'; +import {useWatchRollout} from '../../shared/services/rollout'; +import {useClickOutside} from '../../shared/utils/utils'; +import {InfoItemKind, InfoItemRow} from '../info-item/info-item'; +import {RolloutAction, RolloutActionButton} from '../rollout-actions/rollout-actions'; +import {RolloutStatus, StatusIcon} from '../status-icon/status-icon'; +import './rollout-widget.scss'; + +export const isInProgress = (rollout: RolloutInfo): boolean => { + for (const rs of rollout.replicaSets || []) { + for (const p of rs.pods || []) { + const status = ParsePodStatus(p.status); + if (status === PodStatus.Pending) { + return true; + } + } + } + return false; +}; + +export const RolloutWidget = (props: {rollout: RolloutInfo; deselect: () => void; selected?: boolean}) => { + const [watching, subscribe] = React.useState(false); + let rollout = props.rollout; + useWatchRollout(props.rollout?.objectMeta?.name, watching, null, (r: RolloutInfo) => (rollout = r)); + const ref = React.useRef(null); + useClickOutside(ref, props.deselect); + + React.useEffect(() => { + if (watching) { + const to = setTimeout(() => { + if (!isInProgress(rollout)) { + subscribe(false); + } + }, 5000); + return () => clearTimeout(to); + } + }, [watching, rollout]); + + return ( + + { + subscribe(true); + setTimeout(() => { + subscribe(false); + }, 1000); + }} + /> +
+ + {(rollout.strategy || '').toLocaleLowerCase() === 'canary' && } +
+ +
{rollout.message !== 'CanaryPauseStep' && rollout.message}
+
+ subscribe(true)} indicateLoading /> + subscribe(true)} indicateLoading /> +
+ + ); +}; + +const WidgetHeader = (props: {rollout: RolloutInfo; refresh: () => void}) => { + const {rollout} = props; + const [loading, setLoading] = React.useState(false); + React.useEffect(() => { + setTimeout(() => setLoading(false), 500); + }, [loading]); + return ( +
+ {rollout.objectMeta?.name} + + + { + props.refresh(); + setLoading(true); + e.preventDefault(); + }} + /> + + + +
+ ); +}; diff --git a/ui/src/app/components/rollout/containers.tsx b/ui/src/app/components/rollout/containers.tsx index c69b30658b..552ac29a93 100644 --- a/ui/src/app/components/rollout/containers.tsx +++ b/ui/src/app/components/rollout/containers.tsx @@ -63,7 +63,8 @@ export const ContainersWidget = (props: ContainersWidgetProps) => { setError(true); } } - }}> + }} + > {error ? 'ERROR' : 'SAVE'}
diff --git a/ui/src/app/components/rollout/revision.tsx b/ui/src/app/components/rollout/revision.tsx index e2fcd11526..bd7f7410ce 100644 --- a/ui/src/app/components/rollout/revision.tsx +++ b/ui/src/app/components/rollout/revision.tsx @@ -68,7 +68,8 @@ export const RevisionWidget = (props: RevisionWidgetProps) => { onClick={() => props.rollback(Number(revision.number))} type='default' icon={} - style={{fontSize: '13px', marginRight: '10px'}}> + style={{fontSize: '13px', marginRight: '10px'}} + > Rollback )} @@ -123,11 +124,13 @@ const AnalysisRunWidget = (props: {analysisRuns: RolloutAnalysisRunInfo[]}) => { {ar.status} - }> + } + >
+ }`} + > @@ -201,7 +204,8 @@ const AnalysisRunWidget = (props: {analysisRuns: RolloutAnalysisRunInfo[]}) => { )} ); - })}> + })} + >
@@ -264,7 +268,8 @@ const AnalysisRunWidget = (props: {analysisRuns: RolloutAnalysisRunInfo[]}) => { )} ); - })}> + })} + > diff --git a/ui/src/app/components/rollout/rollout.tsx b/ui/src/app/components/rollout/rollout.tsx index 91b6c0a9d8..317d223d2d 100644 --- a/ui/src/app/components/rollout/rollout.tsx +++ b/ui/src/app/components/rollout/rollout.tsx @@ -332,7 +332,8 @@ const Step = (props: {step: GithubComArgoprojArgoRolloutsPkgApisRolloutsV1alpha1 (props.step.setMirrorRoute && openMirror) ? 'steps__step-title--experiment' : '' - }`}> + }`} + > {icon && } {content} {unit} {props.step.setCanaryScale && ( @@ -457,7 +458,7 @@ const WidgetItemSetMirror = ({value}: {value: GithubComArgoprojArgoRolloutsPkgAp {index} - Path ({stringMatcherType})
{stringMatcherValue}
- + , ); } if (val.method != null) { @@ -479,7 +480,7 @@ const WidgetItemSetMirror = ({value}: {value: GithubComArgoprojArgoRolloutsPkgAp {index} - Method ({stringMatcherType})
{stringMatcherValue}
- + , ); } return fragments; diff --git a/ui/src/app/components/rollouts-grid/rollouts-grid.scss b/ui/src/app/components/rollouts-grid/rollouts-grid.scss new file mode 100644 index 0000000000..4f6e1dc22f --- /dev/null +++ b/ui/src/app/components/rollouts-grid/rollouts-grid.scss @@ -0,0 +1,8 @@ +@import 'node_modules/argo-ui/v2/styles/colors'; + +.rollouts-grid { + display: flex; + box-sizing: border-box; + flex-wrap: wrap; + padding-top: 20px; +} diff --git a/ui/src/app/components/rollouts-grid/rollouts-grid.tsx b/ui/src/app/components/rollouts-grid/rollouts-grid.tsx new file mode 100644 index 0000000000..0784257905 --- /dev/null +++ b/ui/src/app/components/rollouts-grid/rollouts-grid.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +import './rollouts-grid.scss'; +import {RolloutInfo} from '../../../models/rollout/rollout'; +import {RolloutWidget} from '../rollout-widget/rollout-widget'; + +export const RolloutsGrid = ({rollouts}: {rollouts: RolloutInfo[]}) => { + return ( +
+ {rollouts.map((rollout, i) => ( + {}} /> + ))} +
+ ); +}; diff --git a/ui/src/app/components/rollouts-home/rollouts-home.scss b/ui/src/app/components/rollouts-home/rollouts-home.scss new file mode 100644 index 0000000000..575c8350f6 --- /dev/null +++ b/ui/src/app/components/rollouts-home/rollouts-home.scss @@ -0,0 +1,182 @@ +@import 'node_modules/argo-ui/v2/styles/colors'; + +$WIDGET_WIDTH: 400px; + +$widgetPadding: 17px; +$widgetMarginRight: 20px; +$colWidth: ($WIDGET_WIDTH + (2 * $widgetPadding)) + $widgetMarginRight; + +.rollouts-home { + height: 100vh; + display: flex; + flex-direction: column; +} + +.rollouts-list { + display: flex; + box-sizing: border-box; + flex-wrap: wrap; + + &__search-container { + width: 50% !important; + margin: 0 auto; + } + + &__search { + width: 100%; + font-size: 15px; + } + + &__rollouts-container { + padding: 20px; + display: flex; + flex-wrap: wrap; + + width: 3 * $colWidth; + margin: 0 auto; + + @media screen and (max-width: (3 * $colWidth)) { + width: 2 * $colWidth; + margin: 0 auto; + } + + @media screen and (max-width: (2 * $colWidth)) { + width: $colWidth; + + .rollouts-list__widget { + margin: 0 inherit; + width: 100%; + } + + .rollouts-list__search-container { + width: 100% !important; + } + } + } + + &__empty-message { + padding-top: 70px; + width: 50%; + margin: 0 auto; + color: $argo-color-gray-7; + h1 { + margin-bottom: 1em; + text-align: center; + } + div { + line-height: 1.5em; + } + pre { + overflow: scroll; + cursor: pointer; + line-height: 2em; + font-size: 15px; + padding: 3px 5px; + color: $argo-color-gray-8; + margin: 0.5em 0; + background-color: white; + } + a { + color: $sea; + border-bottom: 1px solid $sea; + } + + &--dark { + color: $shine; + a { + color: $sky; + border-color: $sky; + } + pre { + background-color: $space; + color: $shine; + } + } + + @media screen and (max-width: (2 * $colWidth)) { + width: 80%; + } + } + + &__toolbar { + width: 100%; + padding: 1em 0; + background-color: white; + border-bottom: 1px solid white; + + &--dark { + border-bottom: 1px solid $silver-lining; + background-color: $space; + } + } + + &__widget { + position: relative; + box-sizing: border-box; + padding: 17px; + font-size: 14px; + margin: 0 10px; + color: $argo-color-gray-7; + width: $WIDGET_WIDTH; + height: max-content; + flex-shrink: 0; + margin-bottom: 1.5em; + border-radius: 5px; + background-color: white; + box-shadow: 1px 2px 2px rgba(0, 0, 0, 0.05); + border: 1px solid $argo-color-gray-4; + z-index: 0; + + &:hover, + &--selected { + border-color: $argo-running-color; + } + + &__pods { + margin-bottom: 1em; + } + + &--dark { + color: $dull-shine; + border-color: $silver-lining; + box-shadow: 1px 2px 3px 1px $space; + background: none; + } + + &__refresh { + &:hover { + color: $argo-running-color; + } + } + + &__body { + margin-bottom: 1em; + padding-bottom: 0.75em; + border-bottom: 1px solid $argo-color-gray-4; + &--dark { + border-bottom: 1px solid $silver-lining; + } + } + + header { + color: $argo-color-gray-8; + display: flex; + align-items: center; + font-weight: 600; + font-size: 20px; + margin-bottom: 1em; + } + + &--dark header { + color: $shine; + border-bottom: 1px solid $silver-lining; + } + &__actions { + position: relative; + display: flex; + align-items: center; + margin-top: 1.5em; + z-index: 10 !important; + } + } +} diff --git a/ui/src/app/components/rollouts-home/rollouts-home.tsx b/ui/src/app/components/rollouts-home/rollouts-home.tsx new file mode 100644 index 0000000000..cb2fde0a00 --- /dev/null +++ b/ui/src/app/components/rollouts-home/rollouts-home.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; + +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faCircleNotch} from '@fortawesome/free-solid-svg-icons'; + +import {NamespaceContext} from '../../shared/context/api'; +import {useWatchRollouts} from '../../shared/services/rollout'; +import {RolloutsToolbar, defaultDisplayMode, Filters} from '../rollouts-toolbar/rollouts-toolbar'; +import {RolloutsTable} from '../rollouts-table/rollouts-table'; +import {RolloutsGrid} from '../rollouts-grid/rollouts-grid'; +import './rollouts-home.scss'; + +export const RolloutsHome = () => { + const rolloutsList = useWatchRollouts(); + const rollouts = rolloutsList.items; + const loading = rolloutsList.loading; + const namespaceCtx = React.useContext(NamespaceContext); + + const [filters, setFilters] = React.useState({ + showRequiresAttention: false, + showFavorites: false, + name: '', + displayMode: defaultDisplayMode, + status: { + progressing: false, + degraded: false, + paused: false, + healthy: false, + }, + }); + + const handleFilterChange = (newFilters: Filters) => { + setFilters(newFilters); + }; + + const [favorites, setFavorites] = React.useState(() => { + const favoritesStr = localStorage.getItem('rolloutsFavorites'); + return favoritesStr ? JSON.parse(favoritesStr) : {}; + }); + + const handleFavoriteChange = (rolloutName: string, isFavorite: boolean) => { + const newFavorites = {...favorites}; + if (isFavorite) { + newFavorites[rolloutName] = true; + } else { + delete newFavorites[rolloutName]; + } + setFavorites(newFavorites); + localStorage.setItem('rolloutsFavorites', JSON.stringify(newFavorites)); + }; + + const filteredRollouts = React.useMemo(() => { + return rollouts.filter((r) => { + if (filters.showFavorites && !favorites[r.objectMeta.name]) { + return false; + } + if (filters.showRequiresAttention && r.status !== 'Degraded' && r.status !== 'Paused' && r.message !== 'CanaryPauseStep') { + return false; + } + if (Object.values(filters.status).some((value) => value === true) && !filters.status[r.status]) { + return false; + } + let nameMatches = false; + for (let term of filters.name.split(',').map((t) => t.trim())) { + if (term === '') continue; // Skip empty terms + if (term.startsWith('!')) { + if (!r.objectMeta.name.includes(term.substring(1))) { + nameMatches = true; + break; + } + } else if (r.objectMeta.name.includes(term)) { + nameMatches = true; + break; + } + } + if (filters.name != '' && !nameMatches) return false; + return true; + }); + }, [rollouts, filters, favorites]); + + return ( +
+ +
+ {loading ? ( +
+ + Loading... +
+ ) : (rollouts || []).length > 0 ? ( + + {filters.displayMode === 'table' && } + {filters.displayMode !== 'table' && } + + ) : ( + + )} +
+
+ ); +}; + +const EmptyMessage = (props: {namespace: string}) => { + const CodeLine = (props: {children: string}) => { + return
 navigator.clipboard.writeText(props.children)}>{props.children}
; + }; + return ( +
+

No Rollouts to display!

+
+
Make sure you are running the API server in the correct namespace. Your current namespace is:
+
+ {props.namespace} +
+
+
+ To create a new Rollout and Service, run + kubectl apply -f https://raw.githubusercontent.com/argoproj/argo-rollouts/master/docs/getting-started/basic/rollout.yaml + kubectl apply -f https://raw.githubusercontent.com/argoproj/argo-rollouts/master/docs/getting-started/basic/service.yaml + or follow the{' '} + + Getting Started guide + + . +
+
+ ); +}; diff --git a/ui/src/app/components/rollouts-list/rollouts-list.tsx b/ui/src/app/components/rollouts-list/rollouts-list.tsx deleted file mode 100644 index e8c9a4dc5f..0000000000 --- a/ui/src/app/components/rollouts-list/rollouts-list.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import * as React from 'react'; -import {Key, KeybindingContext, useNav} from 'react-keyhooks'; -import {Link, useHistory} from 'react-router-dom'; -import {RolloutInfo} from '../../../models/rollout/rollout'; -import {NamespaceContext} from '../../shared/context/api'; -import {useWatchRollout, useWatchRollouts} from '../../shared/services/rollout'; -import {useClickOutside} from '../../shared/utils/utils'; -import {ParsePodStatus, PodStatus, ReplicaSets} from '../pods/pods'; -import {RolloutAction, RolloutActionButton} from '../rollout-actions/rollout-actions'; -import {RolloutStatus, StatusIcon} from '../status-icon/status-icon'; -import './rollouts-list.scss'; -import {AutoComplete, Tooltip} from 'antd'; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {faCircleNotch, faRedoAlt} from '@fortawesome/free-solid-svg-icons'; -import {InfoItemKind, InfoItemRow} from '../info-item/info-item'; - -const useRolloutNames = (rollouts: RolloutInfo[]) => { - const parseNames = (rl: RolloutInfo[]) => - (rl || []).map((r) => { - const name = r.objectMeta?.name || ''; - return { - label: name, - value: name, - }; - }); - - const [rolloutNames, setRolloutNames] = React.useState(parseNames(rollouts)); - React.useEffect(() => { - setRolloutNames(parseNames(rollouts)); - }, [rollouts]); - - return rolloutNames; -}; - -export const RolloutsList = () => { - const rolloutsList = useWatchRollouts(); - const rollouts = rolloutsList.items; - const loading = rolloutsList.loading; - const [filteredRollouts, setFilteredRollouts] = React.useState(rollouts); - const [pos, nav, reset] = useNav(filteredRollouts.length); - const [searchString, setSearchString] = React.useState(''); - const searchParam = new URLSearchParams(window.location.search).get('q'); - React.useEffect(() => { - if (searchParam && searchParam != searchString) { - setSearchString(searchParam); - } - }, []); - - const searchRef = React.useRef(null); - - React.useEffect(() => { - if (searchRef.current) { - // or, if Input component in your ref, then use input property like: - // searchRef.current.input.focus(); - searchRef.current.focus(); - } - }, [searchRef]); - - const {useKeybinding} = React.useContext(KeybindingContext); - - useKeybinding(Key.RIGHT, () => nav(1)); - useKeybinding(Key.LEFT, () => nav(-1)); - useKeybinding(Key.ESCAPE, () => { - reset(); - if (searchString && searchString !== '') { - setSearchString(''); - return true; - } else { - return false; - } - }); - - const rolloutNames = useRolloutNames(rollouts); - const history = useHistory(); - - useKeybinding(Key.SLASH, () => { - if (!searchString) { - if (searchRef) { - searchRef.current.focus(); - } - return true; - } - return false; - }); - - useKeybinding(Key.ENTER, () => { - if (pos > -1) { - history.push(`/rollout/${filteredRollouts[pos].objectMeta?.name}`); - return true; - } - return false; - }); - - React.useEffect(() => { - const filtered = (rollouts || []).filter((r) => (r.objectMeta?.name || '').includes(searchString)); - if ((filtered || []).length > 0) { - setFilteredRollouts(filtered); - } - if (searchString) { - history.replace(`/${namespaceCtx.namespace}?q=${searchString}`); - } else { - history.replace(`/${namespaceCtx.namespace}`); - } - }, [searchString, rollouts]); - - const namespaceCtx = React.useContext(NamespaceContext); - - return ( -
- {loading ? ( -
- - Loading... -
- ) : (rollouts || []).length > 0 ? ( - -
-
- history.push(`/rollout/${namespaceCtx.namespace}/${val}`)} - options={rolloutNames} - onChange={(val) => setSearchString(val)} - value={searchString} - ref={searchRef} - /> -
-
-
- {(filteredRollouts.sort((a, b) => (a.objectMeta.name < b.objectMeta.name ? -1 : 1)) || []).map((rollout, i) => ( - reset()} /> - ))} -
-
- ) : ( - - )} -
- ); -}; - -const EmptyMessage = (props: {namespace: string}) => { - const CodeLine = (props: {children: string}) => { - return
 navigator.clipboard.writeText(props.children)}>{props.children}
; - }; - return ( -
-

No Rollouts to display!

-
-
Make sure you are running the API server in the correct namespace. Your current namespace is:
-
- {props.namespace} -
-
-
- To create a new Rollout and Service, run - kubectl apply -f https://raw.githubusercontent.com/argoproj/argo-rollouts/master/docs/getting-started/basic/rollout.yaml - kubectl apply -f https://raw.githubusercontent.com/argoproj/argo-rollouts/master/docs/getting-started/basic/service.yaml - or follow the{' '} - - Getting Started guide - - . -
-
- ); -}; - -export const isInProgress = (rollout: RolloutInfo): boolean => { - for (const rs of rollout.replicaSets || []) { - for (const p of rs.pods || []) { - const status = ParsePodStatus(p.status); - if (status === PodStatus.Pending) { - return true; - } - } - } - return false; -}; - -export const RolloutWidget = (props: {rollout: RolloutInfo; deselect: () => void; selected?: boolean}) => { - const [watching, subscribe] = React.useState(false); - let rollout = props.rollout; - useWatchRollout(props.rollout?.objectMeta?.name, watching, null, (r: RolloutInfo) => (rollout = r)); - const ref = React.useRef(null); - useClickOutside(ref, props.deselect); - - React.useEffect(() => { - if (watching) { - const to = setTimeout(() => { - if (!isInProgress(rollout)) { - subscribe(false); - } - }, 5000); - return () => clearTimeout(to); - } - }, [watching, rollout]); - - return ( - - { - subscribe(true); - setTimeout(() => { - subscribe(false); - }, 1000); - }} - /> -
- - {(rollout.strategy || '').toLocaleLowerCase() === 'canary' && } -
- {(rollout.replicaSets || []).length < 1 && } - -
- subscribe(true)} indicateLoading /> - subscribe(true)} indicateLoading /> -
- - ); -}; - -const WidgetHeader = (props: {rollout: RolloutInfo; refresh: () => void}) => { - const {rollout} = props; - const [loading, setLoading] = React.useState(false); - React.useEffect(() => { - setTimeout(() => setLoading(false), 500); - }, [loading]); - return ( -
- {rollout.objectMeta?.name} - - - { - props.refresh(); - setLoading(true); - e.preventDefault(); - }} - /> - - - -
- ); -}; diff --git a/ui/src/app/components/rollouts-table/rollouts-table.scss b/ui/src/app/components/rollouts-table/rollouts-table.scss new file mode 100644 index 0000000000..9c5eee36e0 --- /dev/null +++ b/ui/src/app/components/rollouts-table/rollouts-table.scss @@ -0,0 +1,14 @@ +@import 'node_modules/argo-ui/v2/styles/colors'; + +.rollouts-table { + width: 100%; +} + +.rollouts-table_widget_actions { + display: flex; + flex-wrap: wrap; +} + +.rollouts-table_widget_actions_button { + margin-top: 10px; +} diff --git a/ui/src/app/components/rollouts-table/rollouts-table.tsx b/ui/src/app/components/rollouts-table/rollouts-table.tsx new file mode 100644 index 0000000000..7056603621 --- /dev/null +++ b/ui/src/app/components/rollouts-table/rollouts-table.tsx @@ -0,0 +1,222 @@ +import * as React from 'react'; +import {Tooltip, Table} from 'antd'; + +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {IconDefinition} from '@fortawesome/fontawesome-svg-core'; +import {faStar as faStarSolid} from '@fortawesome/free-solid-svg-icons'; +import {faStar as faStarOutline} from '@fortawesome/free-regular-svg-icons/faStar'; + +import {RolloutAction, RolloutActionButton} from '../rollout-actions/rollout-actions'; +import {RolloutStatus, StatusIcon} from '../status-icon/status-icon'; +import {ReplicaSetStatus, ReplicaSetStatusIcon} from '../status-icon/status-icon'; +import {RolloutInfo} from '../../../models/rollout/rollout'; +import {InfoItemKind, InfoItemRow} from '../info-item/info-item'; +import './rollouts-table.scss'; + +export const RolloutsTable = ({ + rollouts, + onFavoriteChange, + favorites, +}: { + rollouts: RolloutInfo[]; + onFavoriteChange: (rolloutName: string, isFavorite: boolean) => void; + favorites: {[key: string]: boolean}; +}) => { + const handleFavoriteChange = (rolloutName: string, isFavorite: boolean) => { + onFavoriteChange(rolloutName, isFavorite); + }; + + const data = rollouts + .map((rollout) => { + return { + ...rollout, + key: rollout.objectMeta?.uid, + favorite: favorites[rollout.objectMeta?.name] || false, + }; + }) + .sort((a, b) => { + if (a.favorite && !b.favorite) { + return -1; + } else if (!a.favorite && b.favorite) { + return 1; + } else { + return 0; + } + }); + + const columns = [ + { + dataIndex: 'favorite', + key: 'favorite', + render: (favorite: boolean, rollout: RolloutInfo) => { + return favorite ? ( + + ) : ( + + ); + }, + width: 50, + }, + { + title: 'Name', + dataIndex: 'objectMeta', + key: 'name', + width: 300, + render: (objectMeta: {name?: string}) => objectMeta.name, + sorter: (a: any, b: any) => a.objectMeta.name.localeCompare(b.objectMeta.name), + }, + { + title: 'Strategy', + dataIndex: 'strategy', + key: 'strategy', + align: 'left' as const, + sorter: (a: any, b: any) => a.strategy.localeCompare(b.strategy), + render: (strategy: string) => { + return ( + + ); + }, + }, + { + title: 'Step', + dataIndex: 'step', + key: 'step', + render: (text: any, record: {step?: string}) => record.step || '-', + sorter: (a: any, b: any) => { + if (a.step === undefined) { + return -1; + } + if (b.step === undefined) { + return 1; + } else return a.step.localeCompare(b.step); + }, + }, + { + title: 'Weight', + dataIndex: 'setWeight', + key: 'weight', + render: (text: any, record: {setWeight?: number}) => record.setWeight || '-', + sorter: (a: any, b: any) => a.setWeight - b.setWeight, + }, + { + title: 'ReplicaSets', + key: 'replicasets', + width: 200, + sorter: (a: RolloutInfo, b: RolloutInfo) => a.desired - b.desired, + render: (rollout: RolloutInfo) => { + const stableReplicaSets = rollout.replicaSets?.filter((rs) => rs.stable); + const canaryReplicaSets = rollout.replicaSets?.filter((rs) => rs.canary); + const previewReplicaSets = rollout.replicaSets?.filter((rs) => rs.preview); + return ( +
+ {stableReplicaSets?.length > 0 && ( +
+ Stable:{' '} + {stableReplicaSets.map((rs) => ( + + + Rev {rs.revision} ({rs.available}/{rs.replicas}) + + + ))} +
+ )} + {canaryReplicaSets?.length > 0 && ( +
+ Canary:{' '} + {canaryReplicaSets.map((rs) => ( + + + Rev {rs.revision} ({rs.available}/{rs.replicas}) + + + ))} +
+ )} + {previewReplicaSets?.length > 0 && ( +
+ Preview:{' '} + {previewReplicaSets.map((rs) => ( + + + Rev {rs.revision} ({rs.available}/{rs.replicas}) + + + ))} +
+ )} +
+ ); + }, + }, + { + title: 'Status', + sorter: (a: any, b: any) => a.status.localeCompare(b.status), + render: (record: {message?: string; status?: string}) => { + return ( +
+ + {record.status} + +
+ ); + }, + }, + { + title: 'Actions', + dataIndex: 'actions', + key: 'actions', + render: (text: any, rollout: {objectMeta?: {name?: string}}) => { + return ( +
+
+ {}} indicateLoading /> +
+
+ {}} indicateLoading /> +
+
+ {}} indicateLoading /> +
+
+ {}} indicateLoading /> +
+
+ ); + }, + }, + ]; + + return ( + record.objectMeta?.uid || ''} + style={{width: '100%', padding: '20px 20px'}} + rowClassName='rollouts-table__row' + onRow={(record: RolloutInfo) => ({ + onClick: () => { + window.location.href = `/rollouts/rollout/${record.objectMeta?.name}`; + }, + style: {cursor: 'pointer'}, + })} + /> + ); +}; diff --git a/ui/src/app/components/rollouts-toolbar/rollouts-toolbar.scss b/ui/src/app/components/rollouts-toolbar/rollouts-toolbar.scss new file mode 100644 index 0000000000..5acb36c0dc --- /dev/null +++ b/ui/src/app/components/rollouts-toolbar/rollouts-toolbar.scss @@ -0,0 +1,47 @@ +@import 'node_modules/argo-ui/v2/styles/colors'; + +.rollouts-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 10px; + border-bottom: 1px solid #fff; + background-color: #fff; +} + +.rollouts-toolbar_requires-attention-checkbox { + flex: 2; + padding-left: 20px; +} + +.rollouts-toolbar_search-container { + min-width: 300px; + padding-left: 20px; + padding-right: 20px; +} + +.rollouts-toolbar_display-modes { + margin-left: auto; +} + +.rollouts-toolbar_mode-button { + color: #989898; + border: none; + padding: 5px; + cursor: pointer; + transition: background-color 0.3s ease; + font-size: 24px; // increase the font-size to make the icon larger +} + +.rollouts-toolbar_mode-button:hover { + background-color: #b2b2b2; +} + +.rollouts-toolbar_mode-button.active { + color: #000000; +} + +.rollouts-toolbar_status-button { + cursor: pointer; + transition: background-color 0.3s ease; +} diff --git a/ui/src/app/components/rollouts-toolbar/rollouts-toolbar.tsx b/ui/src/app/components/rollouts-toolbar/rollouts-toolbar.tsx new file mode 100644 index 0000000000..6e9771c9ab --- /dev/null +++ b/ui/src/app/components/rollouts-toolbar/rollouts-toolbar.tsx @@ -0,0 +1,198 @@ +import * as React from 'react'; + +import {useHistory, useLocation} from 'react-router-dom'; + +import {AutoComplete} from 'antd'; +import {Tooltip} from 'antd'; + +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faTableList, faTableCellsLarge} from '@fortawesome/free-solid-svg-icons'; + +import {RolloutInfo} from '../../../models/rollout/rollout'; +import {StatusCount} from '../status-count/status-count'; +import './rollouts-toolbar.scss'; + +export type Filters = { + showRequiresAttention: boolean; + showFavorites?: boolean; + name: string; + displayMode?: string; + status: { + [key: string]: boolean; + }; +}; + +interface StatusCount { + [key: string]: number; +} + +export const defaultDisplayMode = 'table'; + +export const RolloutsToolbar = ({ + rollouts, + favorites, + onFilterChange, +}: { + rollouts: RolloutInfo[]; + favorites: {[key: string]: boolean}; + onFilterChange: (filters: Filters) => void; +}) => { + const history = useHistory(); + const location = useLocation(); + const searchParams = new URLSearchParams(window.location.search); + const [filters, setFilters] = React.useState({ + showRequiresAttention: searchParams.get('showRequiresAttention') === 'true', + showFavorites: searchParams.get('showFavorites') === 'true', + name: searchParams.get('name') || '', + displayMode: searchParams.get('displayMode') || defaultDisplayMode, + status: { + Progressing: searchParams.get('Progressing') === 'true', + Degraded: searchParams.get('Degraded') === 'true', + Paused: searchParams.get('Paused') === 'true', + Healthy: searchParams.get('Healthy') === 'true', + }, + }); + // Ensure that the filters are updated when the URL changes + onFilterChange(filters); + + const handleFilterChange = (newFilters: Filters) => { + setFilters(newFilters); + onFilterChange(newFilters); + }; + + const handleNameFilterChange = (value: string) => { + const newFilters = { + ...filters, + name: value, + }; + const searchParams = new URLSearchParams(location.search); + if (value) { + searchParams.set('name', value); + } else { + searchParams.delete('name'); + } + history.push({search: searchParams.toString()}); + handleFilterChange(newFilters); + }; + + const handleShowRequiresAttentionChange = (event: React.MouseEvent) => { + const newFilters = { + ...filters, + showRequiresAttention: !filters.showRequiresAttention, + }; + const searchParams = new URLSearchParams(location.search); + if (!filters.showRequiresAttention) { + searchParams.set('showRequiresAttention', 'true'); + } else { + searchParams.delete('showRequiresAttention'); + } + history.push({search: searchParams.toString()}); + handleFilterChange(newFilters); + }; + + const handleShowFavoritesChange = (event: React.MouseEvent) => { + const newFilters = { + ...filters, + showFavorites: !filters.showFavorites, + }; + const searchParams = new URLSearchParams(location.search); + if (!filters.showFavorites) { + searchParams.set('showFavorites', 'true'); + } else { + searchParams.delete('showFavorites'); + } + history.push({search: searchParams.toString()}); + handleFilterChange(newFilters); + }; + + const handleDisplayModeChange = (event: React.MouseEvent) => { + const newFilters = { + ...filters, + displayMode: event.currentTarget.id, + }; + const searchParams = new URLSearchParams(location.search); + if (event.currentTarget.id !== defaultDisplayMode) { + searchParams.set('displayMode', event.currentTarget.id); + } else { + searchParams.delete('displayMode'); + } + history.push({search: searchParams.toString()}); + handleFilterChange(newFilters); + }; + + const handleStatusFilterChange = (event: React.MouseEvent) => { + const newFilters = { + ...filters, + status: { + ...filters.status, + [event.currentTarget.id]: !filters.status[event.currentTarget.id], + }, + }; + const searchParams = new URLSearchParams(location.search); + if (event.currentTarget.id) { + searchParams.set(event.currentTarget.id, 'true'); + } else { + searchParams.delete(event.currentTarget.id); + } + history.push({search: searchParams.toString()}); + handleFilterChange(newFilters); + }; + + const statusCounts: StatusCount = React.useMemo(() => { + const counts: StatusCount = { + Progressing: 0, + Degraded: 0, + Paused: 0, + Healthy: 0, + }; + rollouts.forEach((r) => { + counts[r.status]++; + }); + + return counts; + }, [rollouts]); + + const needsAttentionCount: number = React.useMemo(() => { + const pausedRollouts = rollouts.filter((r) => r.status === 'Paused' && r.message !== 'CanaryPauseStep'); + return statusCounts['Degraded'] + pausedRollouts.length; + }, [rollouts, statusCounts]); + + const favoriteCount: number = React.useMemo(() => { + return rollouts.filter((r) => favorites[r.objectMeta.name]).length; + }, [rollouts, favorites]); + + return ( +
+
+ + + + + + + {Object.keys(statusCounts).map((status: string) => { + return ( + + + + ); + })} +
+
+ + +
+ +
+ ); +}; diff --git a/ui/src/app/components/status-count/status-count.scss b/ui/src/app/components/status-count/status-count.scss new file mode 100644 index 0000000000..9dec534d27 --- /dev/null +++ b/ui/src/app/components/status-count/status-count.scss @@ -0,0 +1,31 @@ +@import 'node_modules/argo-ui/v2/styles/colors'; + +.status-count { + display: flex; + align-items: center; + border: 1px solid $argo-color-gray-4; + border-radius: 5px; + padding: 2px; + margin: 1px; + + &__icon { + font-size: 15px; + color: $argo-color-gray-8; + margin: 5px; + + text-align: center; + flex: 0 0 auto; + } + + &__count { + font-size: 15px; + font-weight: 500; + color: $argo-color-gray-8; + margin-right: 5px; + text-align: right; + flex: 1; + } +} +.status-count.active { + background-color: $argo-color-teal-2; +} diff --git a/ui/src/app/components/status-count/status-count.tsx b/ui/src/app/components/status-count/status-count.tsx new file mode 100644 index 0000000000..83cea4f5ac --- /dev/null +++ b/ui/src/app/components/status-count/status-count.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; + +import {RolloutStatus, StatusIcon} from '../status-icon/status-icon'; + +import './status-count.scss'; + +export const StatusCount = ({status, count, defaultIcon = 'fa-exclamation-circle', active = false}: {status: String; count: Number; defaultIcon?: String; active?: boolean}) => { + return ( +
+
+ +
+
{count}
+
+ ); +}; diff --git a/ui/src/app/components/status-icon/status-icon.tsx b/ui/src/app/components/status-icon/status-icon.tsx index 257dc50567..5da619a357 100644 --- a/ui/src/app/components/status-icon/status-icon.tsx +++ b/ui/src/app/components/status-icon/status-icon.tsx @@ -9,9 +9,11 @@ export enum RolloutStatus { Healthy = 'Healthy', } -export const StatusIcon = (props: {status: RolloutStatus}): JSX.Element => { +export const StatusIcon = (props: {status: RolloutStatus; showTooltip?: boolean; defaultIcon?: String}): JSX.Element => { let icon, className; let spin = false; + const showTooltip = props.showTooltip ?? true; + const defaultIcon = props.defaultIcon ?? 'fa-question-circle'; const {status} = props; switch (status) { case 'Progressing': { @@ -36,14 +38,19 @@ export const StatusIcon = (props: {status: RolloutStatus}): JSX.Element => { break; } default: { - icon = 'fa-question-circle'; + icon = defaultIcon; className = 'unknown'; } } return ( - - - + + {showTooltip && ( + + + + )} + {!showTooltip && } + ); }; @@ -55,9 +62,11 @@ export enum ReplicaSetStatus { Progressing = 'Progressing', } -export const ReplicaSetStatusIcon = (props: {status: ReplicaSetStatus}) => { +export const ReplicaSetStatusIcon = (props: {status: ReplicaSetStatus; showTooltip?: boolean; defaultIcon?: String}) => { let icon, className; let spin = false; + const showTooltip = props.showTooltip ?? true; + const defaultIcon = props.defaultIcon ?? 'fa-question-circle'; const {status} = props; switch (status) { case 'Healthy': @@ -83,13 +92,18 @@ export const ReplicaSetStatusIcon = (props: {status: ReplicaSetStatus}) => { break; } default: { - icon = 'fa-question-circle'; + icon = defaultIcon; className = 'unknown'; } } return ( - - - + + {showTooltip && ( + + + + )} + {!showTooltip && } + ); }; diff --git a/ui/src/app/index.tsx b/ui/src/app/index.tsx index 3bc5d04223..56521a1185 100644 --- a/ui/src/app/index.tsx +++ b/ui/src/app/index.tsx @@ -6,5 +6,5 @@ ReactDOM.render( , - document.getElementById('root') + document.getElementById('root'), ); diff --git a/ui/src/models/rollout/analysisrun.tsx b/ui/src/models/rollout/analysisrun.tsx new file mode 100644 index 0000000000..b5129f7d9f --- /dev/null +++ b/ui/src/models/rollout/analysisrun.tsx @@ -0,0 +1,4 @@ +import * as Generated from './generated'; + +export type RolloutInfo = Generated.RolloutRolloutInfo; +export type Pod = Generated.RolloutPodInfo; diff --git a/ui/yarn.lock b/ui/yarn.lock index 29f5446d37..af1a13d826 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -1326,6 +1326,11 @@ resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz#88da2b70d6ca18aaa6ed3687832e11f39e80624b" integrity sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ== +"@fortawesome/fontawesome-common-types@6.4.2": + version "6.4.2" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz#1766039cad33f8ad87f9467b98e0d18fbc8f01c5" + integrity sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA== + "@fortawesome/fontawesome-free@^5.8.1": version "5.15.4" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz#ecda5712b61ac852c760d8b3c79c96adca5554e5" @@ -1338,6 +1343,13 @@ dependencies: "@fortawesome/fontawesome-common-types" "6.4.0" +"@fortawesome/free-regular-svg-icons@^6.4.0": + version "6.4.2" + resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.2.tgz#aee79ed76ce5dd04931352f9d83700761b8b1b25" + integrity sha512-0+sIUWnkgTVVXVAPQmW4vxb9ZTHv0WstOa3rBx9iPxrrrDH6bNLsDYuwXF9b6fGm+iR7DKQvQshUH/FJm3ed9Q== + dependencies: + "@fortawesome/fontawesome-common-types" "6.4.2" + "@fortawesome/free-solid-svg-icons@^6.4.0": version "6.4.0" resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz#48c0e790847fa56299e2f26b82b39663b8ad7119"