diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index a13b18ed0e..510bac78af 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -110,6 +110,29 @@ 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 +``` + +Note that you can also build the API server and run this instead, + +``` +make plugin +./dist/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/info_test.go b/pkg/kubectl-argo-rollouts/info/info_test.go index d7bbe161fd..ab28b48637 100644 --- a/pkg/kubectl-argo-rollouts/info/info_test.go +++ b/pkg/kubectl-argo-rollouts/info/info_test.go @@ -169,3 +169,11 @@ func TestRolloutAborted(t *testing.T) { assert.Equal(t, "Degraded", roInfo.Status) assert.Equal(t, `RolloutAborted: metric "web" assessed Failed due to failed (1) > failureLimit (0)`, roInfo.Message) } + +func TestRolloutInfoMetadata(t *testing.T) { + rolloutObjs := testdata.NewCanaryRollout() + roInfo := NewRolloutInfo(rolloutObjs.Rollouts[0], rolloutObjs.ReplicaSets, rolloutObjs.Pods, rolloutObjs.Experiments, rolloutObjs.AnalysisRuns, nil) + assert.Equal(t, roInfo.ObjectMeta.Name, rolloutObjs.Rollouts[0].Name) + assert.Equal(t, roInfo.ObjectMeta.Annotations, rolloutObjs.Rollouts[0].Annotations) + assert.Equal(t, roInfo.ObjectMeta.Labels, rolloutObjs.Rollouts[0].Labels) +} 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..bcdb94b9df 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'; @@ -33,7 +33,12 @@ const Page = (props: {path: string; component: React.ReactNode; exact?: boolean; pageHasShortcuts={!!props.shortcuts} showHelp={() => { if (props.shortcuts) { - setShowShortcuts(true); + setShowShortcuts(!showShortcuts); + } + }} + hideHelp={() => { + if (props.shortcuts) { + setShowShortcuts(false); } }} /> @@ -84,7 +89,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/header/header.tsx b/ui/src/app/components/header/header.tsx index e267822108..8bdbfc3a83 100644 --- a/ui/src/app/components/header/header.tsx +++ b/ui/src/app/components/header/header.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import {useParams} from 'react-router'; +import {Key, KeybindingContext} from 'react-keyhooks'; import {NamespaceContext, RolloutAPIContext} from '../../shared/context/api'; import './header.scss'; @@ -11,13 +12,28 @@ import {faBook, faKeyboard} from '@fortawesome/free-solid-svg-icons'; const Logo = () => Argo Logo; -export const Header = (props: {pageHasShortcuts: boolean; changeNamespace: (val: string) => void; showHelp: () => void}) => { +export const Header = (props: {pageHasShortcuts: boolean; changeNamespace: (val: string) => void; showHelp: () => void; hideHelp: () => void}) => { const history = useHistory(); const namespaceInfo = React.useContext(NamespaceContext); const {namespace} = useParams<{namespace: string}>(); const api = React.useContext(RolloutAPIContext); const [version, setVersion] = React.useState('v?'); const [nsInput, setNsInput] = React.useState(namespaceInfo.namespace); + const {useKeybinding} = React.useContext(KeybindingContext); + + useKeybinding([Key.SHIFT, Key.H], + () => { + props.showHelp(); + return true; + }, + true + ); + + useKeybinding(Key.ESCAPE, () => { + props.hideHelp(); + return true; + }); + React.useEffect(() => { const getVersion = async () => { const v = await api.rolloutServiceVersion(); @@ -25,12 +41,14 @@ export const Header = (props: {pageHasShortcuts: boolean; changeNamespace: (val: }; getVersion(); }, []); + React.useEffect(() => { if (namespace && namespace != namespaceInfo.namespace) { props.changeNamespace(namespace); setNsInput(namespace); } }, []); + return (
diff --git a/ui/src/app/components/info-item/info-item.scss b/ui/src/app/components/info-item/info-item.scss index 0cd040dde6..17cc0d4d4d 100644 --- a/ui/src/app/components/info-item/info-item.scss +++ b/ui/src/app/components/info-item/info-item.scss @@ -8,7 +8,7 @@ margin-right: 5px; color: $argo-color-gray-8; display: flex; - align-items: center; + align-items: left; min-width: 0; &--lightweight { diff --git a/ui/src/app/components/info-item/info-item.tsx b/ui/src/app/components/info-item/info-item.tsx index 7e7bf8e617..7928310bc1 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', @@ -40,7 +40,7 @@ export const InfoItem = (props: InfoItemProps) => { /** * Displays a right justified InfoItem (or multiple InfoItems) and a left justfied label */ -export const InfoItemRow = (props: {label: string | React.ReactNode; items?: InfoItemProps | InfoItemProps[]; lightweight?: boolean}) => { +export const InfoItemRow = (props: {label: string | React.ReactNode; items?: InfoItemProps | InfoItemProps[]; lightweight?: boolean; style?: React.CSSProperties}) => { let {label, items} = props; let itemComponents = null; if (!Array.isArray(items)) { @@ -55,7 +55,7 @@ export const InfoItemRow = (props: {label: string | React.ReactNode; items?: Inf
)} - {props.items &&
{itemComponents}
} + {props.items &&
{itemComponents}
} ); }; 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..fd2ff83f7d 100644 --- a/ui/src/app/components/rollout-actions/rollout-actions.tsx +++ b/ui/src/app/components/rollout-actions/rollout-actions.tsx @@ -107,7 +107,8 @@ export const RolloutActionButton = (props: {action: RolloutAction; rollout: Roll disabled={ap.disabled} loading={loading} tooltip={ap.tooltip} - icon={}> + icon={} + > {props.action} ); diff --git a/ui/src/app/components/rollouts-list/rollouts-list.scss b/ui/src/app/components/rollout-grid-widget/rollout-grid-widget.scss similarity index 98% rename from ui/src/app/components/rollouts-list/rollouts-list.scss rename to ui/src/app/components/rollout-grid-widget/rollout-grid-widget.scss index f0ec5fdcc7..68f92bf93e 100644 --- a/ui/src/app/components/rollouts-list/rollouts-list.scss +++ b/ui/src/app/components/rollout-grid-widget/rollout-grid-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-grid-widget/rollout-grid-widget.tsx b/ui/src/app/components/rollout-grid-widget/rollout-grid-widget.tsx new file mode 100644 index 0000000000..89497124ff --- /dev/null +++ b/ui/src/app/components/rollout-grid-widget/rollout-grid-widget.tsx @@ -0,0 +1,134 @@ +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 {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 {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-grid-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 RolloutGridWidget = (props: { + rollout: RolloutInfo; + deselect: () => void; + selected?: boolean; + isFavorite: boolean; + onFavoriteChange: (rolloutName: string, isFavorite: boolean) => void; +}) => { + 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); + }} + isFavorite={props.isFavorite} + handleFavoriteChange={props.onFavoriteChange} + /> +
+ + {(rollout.strategy || '').toLocaleLowerCase() === 'canary' && } +
+ +
{rollout.message !== 'CanaryPauseStep' && rollout.message}
+
+ subscribe(true)} indicateLoading /> + subscribe(true)} indicateLoading /> +
+ + ); +}; + +const WidgetHeader = (props: {rollout: RolloutInfo; refresh: () => void; isFavorite: boolean; handleFavoriteChange: (rolloutName: string, isFavorite: boolean) => void}) => { + const {rollout} = props; + const [loading, setLoading] = React.useState(false); + React.useEffect(() => { + setTimeout(() => setLoading(false), 500); + }, [loading]); + + const handleFavoriteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + props.handleFavoriteChange(rollout.objectMeta?.name, !props.isFavorite); + }; + + return ( +
+ {props.isFavorite ? ( + + ) : ( + + )} + {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..a5c9e22a13 --- /dev/null +++ b/ui/src/app/components/rollouts-grid/rollouts-grid.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import {useHistory} from 'react-router-dom'; +import {Key, KeybindingContext, useNav} from 'react-keyhooks'; +import {RolloutInfo} from '../../../models/rollout/rollout'; +import {RolloutGridWidget} from '../rollout-grid-widget/rollout-grid-widget'; +import './rollouts-grid.scss'; + +export const RolloutsGrid = ({ + rollouts, + onFavoriteChange, + favorites, +}: { + rollouts: RolloutInfo[]; + onFavoriteChange: (rolloutName: string, isFavorite: boolean) => void; + favorites: {[key: string]: boolean}; +}) => { + const [itemsPerRow, setItemsPerRow] = React.useState(0); + const rolloutsGridRef = React.useRef(null); + + const handleFavoriteChange = (rolloutName: string, isFavorite: boolean) => { + onFavoriteChange(rolloutName, isFavorite); + }; + + const orderedRollouts = 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; + } + }); + + // Calculate the number of items per row for keyboard navigation + React.useEffect(() => { + const rolloutsGrid = rolloutsGridRef.current; + + const updateItemsPerRow = () => { + if (rolloutsGrid) { + const containerWidth = rolloutsGrid.clientWidth; + const widgetWidth = parseInt(getComputedStyle(document.querySelector('.rollouts-list__widget')).getPropertyValue('width'), 10); + const widgetPadding = parseInt(getComputedStyle(document.querySelector('.rollouts-list__widget')).getPropertyValue('padding'), 10); + const itemsPerRowValue = Math.floor(containerWidth / (widgetWidth + widgetPadding * 2)); + setItemsPerRow(itemsPerRowValue); + } + }; + + updateItemsPerRow(); + + window.addEventListener('resize', updateItemsPerRow); + + return () => { + window.removeEventListener('resize', updateItemsPerRow); + }; + }, []); + + const history = useHistory(); + const [pos, nav, reset] = useNav(orderedRollouts.length); + const {useKeybinding} = React.useContext(KeybindingContext); + + useKeybinding(Key.RIGHT, () => nav(1)); + useKeybinding(Key.LEFT, () => nav(-1)); + useKeybinding(Key.UP, () => nav(-itemsPerRow)); + useKeybinding(Key.DOWN, () => nav(itemsPerRow)); + useKeybinding(Key.ENTER, () => { + if (pos !== undefined) { + history.push(`/rollout/${orderedRollouts[pos].objectMeta?.name}`); + return true; + } + return false; + }); + + return ( +
+ {orderedRollouts.map((rollout, i) => ( + reset()} + isFavorite={favorites[rollout.objectMeta?.name] || false} + onFavoriteChange={handleFavoriteChange} + /> + ))} +
+ ); +}; 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..bd70caa2d6 --- /dev/null +++ b/ui/src/app/components/rollouts-home/rollouts-home.tsx @@ -0,0 +1,188 @@ +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 no filters are set, show all rollouts + if (filters.name === '' && !filters.showFavorites && !filters.showRequiresAttention && !Object.values(filters.status).some((value) => value === true)) { + return true; + } + + const statusFiltersSet = Object.values(filters.status).some((value) => value === true); + const nameFilterSet = filters.name !== ''; + + let favoritesMatches = false; + let requiresAttentionMatches = false; + let statusMatches = false; + let nameMatches = false; + + if (filters.showFavorites && favorites[r.objectMeta.name]) { + favoritesMatches = true; + } + if (filters.showRequiresAttention && (r.status === 'Unknown' || r.status === 'Degraded' || (r.status === 'Paused' && r.message !== 'CanaryPauseStep'))) { + requiresAttentionMatches = true; + } + if (statusFiltersSet && filters.status[r.status]) { + statusMatches = true; + } + + for (let term of filters.name.split(',').map((t) => t.trim())) { + if (term === '') continue; // Skip empty terms + + if (term.includes(':')) { + // Filter by label + const [key, value] = term.split(':'); + if (value.startsWith('"') && value.endsWith('"')) { + const exactValue = value.substring(1, value.length - 1); + if (r.objectMeta.labels && r.objectMeta.labels[key] && r.objectMeta.labels[key] === exactValue) { + nameMatches = true; + break; + } + } else if (r.objectMeta.labels && r.objectMeta.labels[key] && r.objectMeta.labels[key].includes(value)) { + nameMatches = true; + break; + } + } else { + // Filter by name + const isNegated = term.startsWith('!'); + term = term.replace(/^!/, ''); + + const isExact = term.startsWith('"') && term.endsWith('"'); + term = term.replace(/"/g, ''); + + if (isExact) { + if (isNegated) { + if (r.objectMeta.name !== term) { + nameMatches = true; + continue; + } + } else { + if (r.objectMeta.name === term) { + nameMatches = true; + break; + } + } + } else { + if (isNegated) { + if (!r.objectMeta.name.includes(term)) { + nameMatches = true; + break; + } + } else { + if (r.objectMeta.name.includes(term)) { + nameMatches = true; + break; + } + } + } + } + } + + return ( + (!nameFilterSet || nameMatches) && + (!filters.showFavorites || favoritesMatches) && + (!filters.showRequiresAttention || requiresAttentionMatches) && + (!statusFiltersSet || statusMatches) + ); + }); + }, [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)}
+            onKeyDown={() => 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..bd93df799a --- /dev/null +++ b/ui/src/app/components/rollouts-table/rollouts-table.scss @@ -0,0 +1,18 @@ +@import 'node_modules/argo-ui/v2/styles/colors'; + +.rollouts-table { + width: 100%; +} + +.rollouts-table__row__selected { + background-color: $argo-color-gray-2; + } + +.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..38124f1e60 --- /dev/null +++ b/ui/src/app/components/rollouts-table/rollouts-table.tsx @@ -0,0 +1,281 @@ +import * as React from 'react'; +import {useHistory} from 'react-router-dom'; +import {Tooltip, Table, TablePaginationConfig} from 'antd'; +import {Key, KeybindingContext} from 'react-keyhooks'; +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 { AlignType } from 'rc-table/lib/interface'; +import './rollouts-table.scss'; + +export const RolloutsTable = ({ + rollouts, + onFavoriteChange, + favorites, +}: { + rollouts: RolloutInfo[]; + onFavoriteChange: (rolloutName: string, isFavorite: boolean) => void; + favorites: {[key: string]: boolean}; +}) => { + const tableRef = React.useRef(null); + + 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 AlignType, + 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 /> +
+
+ ); + }, + }, + ]; + + const history = useHistory(); + const [selectedRow, setSelectedRow] = React.useState(undefined); + const {useKeybinding} = React.useContext(KeybindingContext); + useKeybinding(Key.UP, () => { + if (selectedRow === undefined) { + setSelectedRow(itemsPerPage - 1); + return true; + } else if (selectedRow > 0) { + setSelectedRow(selectedRow - 1); + return true; + } + return false; + }); + useKeybinding(Key.DOWN, () => { + if (selectedRow === undefined) { + setSelectedRow(0); + return true; + } else if (selectedRow < itemsPerPage - 1) { + setSelectedRow(selectedRow + 1); + return true; + } + return false; + }); + useKeybinding(Key.ENTER, () => { + if (selectedRow !== undefined) { + history.push(`/rollout/${data[selectedRow].objectMeta?.name}`); + return true; + } + return false; + }); + useKeybinding(Key.ESCAPE, () => { + setSelectedRow(undefined); + return false; // let the toolbar handle clearing the search bar + }); + + const [itemsPerPage, setItemsPerPage] = React.useState(10); + const handlePaginationChange = (pagination: TablePaginationConfig) => { + setItemsPerPage(pagination.pageSize); + }; + + return ( + ({ + className: selectedRow === index ? 'rollouts-table__row__selected' : '', + onClick: () => { + history.push(`/rollout/${record.objectMeta?.name}`); + }, + style: {cursor: 'pointer'}, + })} + pagination={ + { + pageSize: itemsPerPage, + onChange: handlePaginationChange, + } as TablePaginationConfig + } + ref={tableRef} + rowClassName='rollouts-table__row' + rowKey={(_, index) => index} + style={{width: '100%', padding: '20px 20px'}} + /> + ); +}; 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..bc234b43c3 --- /dev/null +++ b/ui/src/app/components/rollouts-toolbar/rollouts-toolbar.scss @@ -0,0 +1,55 @@ +@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; +} + +.rollouts-toolbar_status-filters { + display: flex; +} + +.rollouts-toolbar_status-buttons { + margin-left: 20px; +} \ No newline at end of file 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..487097c5d0 --- /dev/null +++ b/ui/src/app/components/rollouts-toolbar/rollouts-toolbar.tsx @@ -0,0 +1,235 @@ +import * as React from 'react'; + +import {Key, KeybindingContext} from 'react-keyhooks'; +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 = 'grid'; + +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'); + searchParams.delete('displaymode'); + } + history.push({search: searchParams.toString()}); + handleFilterChange(newFilters); + }; + + const handleStatusFilterChange = (event: React.MouseEvent) => { + const searchParams = new URLSearchParams(location.search); + + if (!filters.status[event.currentTarget.id]) { + searchParams.set(event.currentTarget.id, 'true'); + } else { + searchParams.delete(event.currentTarget.id); + } + history.push({search: searchParams.toString()}); + + const newFilters = { + ...filters, + status: { + ...filters.status, + [event.currentTarget.id]: !filters.status[event.currentTarget.id], + }, + }; + 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'); + const degradedRollouts = rollouts.filter((r) => r.status === 'Degraded'); + const unknownRollouts = rollouts.filter((r) => r.status === 'Unknown'); + return pausedRollouts.length + degradedRollouts.length + unknownRollouts.length; + }, [rollouts, statusCounts]); + + const favoriteCount: number = React.useMemo(() => { + return rollouts.filter((r) => favorites[r.objectMeta.name]).length; + }, [rollouts, favorites]); + + const searchRef = React.useRef(null); + const {useKeybinding} = React.useContext(KeybindingContext); + useKeybinding(Key.SLASH, () => { + if (searchRef) { + searchRef.current.focus(); + return true; + } + return false; + }); + + return ( +
+
+ + + + + + +
+ {Object.keys(statusCounts).map((status: string) => { + return ( + + + + ); + })} +
+
+
+ + + + + + +
+ + { + event.stopPropagation(); // Prevents shift+H from opening the help menu + }} + ref={searchRef} + /> + +
+ ); +}; 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/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"