From debb848ebe70a6cf59df53b48f27ecb99313cee2 Mon Sep 17 00:00:00 2001 From: linghaoSu Date: Mon, 13 Jan 2025 17:26:11 +0800 Subject: [PATCH] feat(ui): add resources list Signed-off-by: linghaoSu --- ui/src/app/app.tsx | 8 + .../components/resources-container.tsx | 10 + .../resources-list/flex-top-bar.scss | 32 ++ .../resources-list/resources-filter.tsx | 213 ++++++++++ .../resources-list/resources-list.scss | 223 ++++++++++ .../resources-list/resources-list.tsx | 382 ++++++++++++++++++ .../resources-list/resources-tiles.scss | 29 ++ .../resources-list/resources-tiles.tsx | 235 +++++++++++ ui/src/app/resources/index.ts | 5 + ui/src/app/shared/models.ts | 8 + .../services/view-preferences-service.ts | 44 ++ 11 files changed, 1189 insertions(+) create mode 100644 ui/src/app/resources/components/resources-container.tsx create mode 100644 ui/src/app/resources/components/resources-list/flex-top-bar.scss create mode 100644 ui/src/app/resources/components/resources-list/resources-filter.tsx create mode 100644 ui/src/app/resources/components/resources-list/resources-list.scss create mode 100644 ui/src/app/resources/components/resources-list/resources-list.tsx create mode 100644 ui/src/app/resources/components/resources-list/resources-tiles.scss create mode 100644 ui/src/app/resources/components/resources-list/resources-tiles.tsx create mode 100644 ui/src/app/resources/index.ts diff --git a/ui/src/app/app.tsx b/ui/src/app/app.tsx index 2d2e192590fa2..8d382a7b4f941 100644 --- a/ui/src/app/app.tsx +++ b/ui/src/app/app.tsx @@ -6,6 +6,7 @@ import {Helmet} from 'react-helmet'; import {Redirect, Route, RouteComponentProps, Router, Switch} from 'react-router'; import {Subscription} from 'rxjs'; import applications from './applications'; +import resources from './resources'; import help from './help'; import login from './login'; import settings from './settings'; @@ -34,6 +35,7 @@ type Routes = {[path: string]: {component: React.ComponentType) => ( + + + +); diff --git a/ui/src/app/resources/components/resources-list/flex-top-bar.scss b/ui/src/app/resources/components/resources-list/flex-top-bar.scss new file mode 100644 index 0000000000000..6f965e3073811 --- /dev/null +++ b/ui/src/app/resources/components/resources-list/flex-top-bar.scss @@ -0,0 +1,32 @@ +@import 'node_modules/foundation-sites/scss/util/util'; +@import '../../../shared/config.scss'; + +.flex-top-bar { + position: fixed; + right: 0; + z-index: 5; + padding: 0 15px; + left: $sidebar-width; + align-items: center; + flex-wrap: wrap; + + + &__tools { + display: flex; + flex-grow: 1; + padding-top: 8px; + padding-bottom: 5px; + justify-content: space-between; + align-items: center; + @include breakpoint(medium down) { + flex-wrap: wrap; + } + } + + &__padder { + height: 50px; + @include breakpoint(medium down) { + height: 150px; + } + } +} diff --git a/ui/src/app/resources/components/resources-list/resources-filter.tsx b/ui/src/app/resources/components/resources-list/resources-filter.tsx new file mode 100644 index 0000000000000..a4cd2afcb0dcd --- /dev/null +++ b/ui/src/app/resources/components/resources-list/resources-filter.tsx @@ -0,0 +1,213 @@ +import {useData} from 'argo-ui/v2'; +import * as minimatch from 'minimatch'; +import * as React from 'react'; +import {Cluster, HealthStatusCode, HealthStatuses, Resource, SyncStatusCode, SyncStatuses} from '../../../shared/models'; +import {ResourcesListPreferences, services} from '../../../shared/services'; +import {Filter, FiltersGroup} from '../../../applications/components/filter/filter'; +import {ComparisonStatusIcon, HealthStatusIcon} from '../../../applications/components/utils'; + +export interface FilterResult { + sync: boolean; + health: boolean; + namespaces: boolean; + clusters: boolean; +} + +export interface FilteredResource extends Resource { + filterResult: FilterResult; +} + +export function getFilterResults(resources: Resource[], pref: ResourcesListPreferences): FilteredResource[] { + return resources.map(app => ({ + ...app, + filterResult: { + sync: pref.syncFilter.length === 0 || pref.syncFilter.includes(app.status), + health: pref.healthFilter.length === 0 || pref.healthFilter.includes(app.health?.status), + namespaces: pref.namespacesFilter.length === 0 || pref.namespacesFilter.some(ns => app.namespace && minimatch(app.namespace, ns)), + clusters: + pref.clustersFilter.length === 0 || + pref.clustersFilter.some(filterString => { + const match = filterString.match('^(.*) [(](http.*)[)]$'); + if (match?.length === 3) { + const [, name, url] = match; + return url === app.clusterServer || name === app.clusterName; + } else { + const inputMatch = filterString.match('^http.*$'); + return (inputMatch && inputMatch[0] === app.clusterServer) || (app.clusterName && minimatch(app.clusterName, filterString)); + } + }), + apiGroup: pref.apiGroupFilter.length === 0 || pref.apiGroupFilter.includes(app.group), + kind: pref.kindFilter.length === 0 || pref.kindFilter.includes(app.kind) + } + })); +} + +const optionsFrom = (options: string[], filter: string[]) => { + return options + .filter(s => filter.indexOf(s) === -1) + .map(item => { + return {label: item}; + }); +}; + +interface AppFilterProps { + apps: FilteredResource[]; + pref: ResourcesListPreferences; + onChange: (newPrefs: ResourcesListPreferences) => void; + children?: React.ReactNode; + collapsed?: boolean; +} + +const getCounts = (apps: FilteredResource[], filterType: keyof FilterResult, filter: (app: Resource) => string, init?: string[]) => { + const map = new Map(); + if (init) { + init.forEach(key => map.set(key, 0)); + } + // filter out all apps that does not match other filters and ignore this filter result + apps.filter(app => filter(app) && Object.keys(app.filterResult).every((key: keyof FilterResult) => key === filterType || app.filterResult[key])).forEach(app => + map.set(filter(app), (map.get(filter(app)) || 0) + 1) + ); + return map; +}; + +const getOptions = (apps: FilteredResource[], filterType: keyof FilterResult, filter: (app: Resource) => string, keys: string[], getIcon?: (k: string) => React.ReactNode) => { + const counts = getCounts(apps, filterType, filter, keys); + return keys.map(k => { + return { + label: k, + icon: getIcon && getIcon(k), + count: counts.get(k) + }; + }); +}; + +const SyncFilter = (props: AppFilterProps) => ( + props.onChange({...props.pref, syncFilter: s})} + options={getOptions( + props.apps, + 'sync', + app => app.status, + Object.keys(SyncStatuses), + s => ( + + ) + )} + /> +); + +const HealthFilter = (props: AppFilterProps) => ( + props.onChange({...props.pref, healthFilter: s})} + options={getOptions( + props.apps, + 'health', + app => app.health?.status || HealthStatuses.Unknown, + Object.keys(HealthStatuses), + s => ( + + ) + )} + /> +); + +const ProjectFilter = (props: AppFilterProps) => { + const [projects, loading, error] = useData( + () => services.projects.list('items.metadata.name'), + null, + () => null + ); + const projectOptions = (projects || []).map(proj => { + return {label: proj.metadata.name}; + }); + return ( + props.onChange({...props.pref, projectsFilter: s})} + field={true} + options={projectOptions} + error={error.state} + retry={error.retry} + loading={loading} + /> + ); +}; + +const ClusterFilter = (props: AppFilterProps) => { + const getClusterDetail = (dest: Resource, clusterList: Cluster[]): string => { + const cluster = (clusterList || []).find(target => target.name === dest.clusterName || target.server === dest.clusterServer); + if (!cluster) { + return dest.clusterServer || dest.clusterName; + } + if (cluster.name === cluster.server) { + return cluster.name; + } + return `${cluster.name} (${cluster.server})`; + }; + + const [clusters, loading, error] = useData(() => services.clusters.list()); + const clusterOptions = optionsFrom(Array.from(new Set(props.apps.map(app => getClusterDetail(app, clusters)).filter(item => !!item))), props.pref.clustersFilter); + + return ( + props.onChange({...props.pref, clustersFilter: s})} + field={true} + options={clusterOptions} + error={error.state} + retry={error.retry} + loading={loading} + /> + ); +}; + +const NamespaceFilter = (props: AppFilterProps) => { + const namespaceOptions = optionsFrom(Array.from(new Set(props.apps.map(app => app.namespace).filter(item => !!item))), props.pref.namespacesFilter); + return ( + props.onChange({...props.pref, namespacesFilter: s})} + field={true} + options={namespaceOptions} + /> + ); +}; + +const ApiGroupFilter = (props: AppFilterProps) => { + const apiGroupOptions = optionsFrom(Array.from(new Set(props.apps.map(app => app.group).filter(item => !!item))), props.pref.apiGroupFilter); + return ( + props.onChange({...props.pref, apiGroupFilter: s})} + field={true} + options={apiGroupOptions} + /> + ); +}; + +const KindFilter = (props: AppFilterProps) => { + const kindOptions = optionsFrom(Array.from(new Set(props.apps.map(app => app.kind).filter(item => !!item))), props.pref.kindFilter); + return props.onChange({...props.pref, kindFilter: s})} field={true} options={kindOptions} />; +}; + +export const ResourcesFilter = (props: AppFilterProps) => { + return ( + + + + + + + + + + ); +}; diff --git a/ui/src/app/resources/components/resources-list/resources-list.scss b/ui/src/app/resources/components/resources-list/resources-list.scss new file mode 100644 index 0000000000000..c8bb2e8f85b05 --- /dev/null +++ b/ui/src/app/resources/components/resources-list/resources-list.scss @@ -0,0 +1,223 @@ +@import 'node_modules/argo-ui/src/styles/config'; +@import 'node_modules/foundation-sites/scss/util/util'; +@import 'node_modules/argo-ui/src/styles/theme'; + +.resources-list { + padding: 1em; + @media screen and (max-width: 1024px) { + padding: 0; + } + min-height: 88vh; + &__title { + font-weight: bolder; + font-size: 15px; + @include themify($themes) { + color: themed('text-1'); + } + padding-top: 0.25em; + padding-bottom: 0.5em; + margin-left: 1em; + } + + &__info { + line-height: 24px; + margin: 1em 0; + } + + &__icons { + line-height: 24px; + } + + &__empty-state { + text-align: center; + } + + &__entry { + padding-left: 1em; + border-left: 5px solid $argo-color-gray-4; + padding-right: 1em; + color: $argo-color-gray-7; + + // healthy statuses + &--health-Healthy { + border-left-color: $argo-success-color; + } + + // intermediate statuses + &--health-Progressing { + border-left-color: $argo-running-color; + } + + &--health-Suspended { + border-left-color: $argo-suspended-color; + } + + // failed statuses + &--health-Degraded { + border-left-color: $argo-failed-color; + } + + &--health-Unknown { + border-left-color: $argo-color-gray-4; + } + + &--health-Missing { + border-left-color: $argo-status-warning-color; + } + + &--actions { + padding-top: 1em; + } + + } + + &__accordion { + cursor: pointer; + text-align: center; + border: none; + outline: none; + transition: 0.4s; + margin-left: 10px; + } + + &__view-type { + white-space: nowrap; + i { + cursor: pointer; + color: $argo-color-gray-4; + margin-right: 1em; + &::before { + font-size: 1.5em; + } + } + i.selected { + cursor: default; + color: $argo-color-teal-5; + } + } + + &__table-icon { + display: inline-block; + margin-right: 10px; + width: 80px; + } + + &__table-row { + & > .columns:first-child { + padding-left: 15px; + } + margin-left: -30px !important; + } + + &__search-wrapper { + margin-left: 15px; + @include breakpoint(medium down) { + flex-basis: 100%; + margin-left: 0; + } + line-height: normal; + } + + &__search { + @include themify($themes) { + background-color: themed('light-argo-gray-2'); + border: 1px solid themed('border'); + } + border-radius: 7px; + position: relative; + padding: 0 10px; + height: 33px; + display: flex; + align-items: center; + transition: width 200ms; + @include breakpoint(large up) { + flex-shrink: 1; + width: 300px; + } + i { + font-size: 12px; + color: $argo-color-gray-6; + } + .keyboard-hint { + line-height: normal; + border: 1px solid $argo-color-gray-5; + color: $argo-color-gray-7; + border-radius: 3px; + padding: 0 7px; + font-size: 12px; + font-weight: 600; + flex-shrink: 0; + text-align: center; + } + .select { + width: 100%; + border-radius: $border-radius; + } + &:focus-within { + border: 1px solid $argo-color-teal-5; + @include breakpoint(large up) { + width: 500px; + } + i { + color: $argo-color-gray-7; + } + .keyboard-hint { + display: none; + } + } + .argo-field { + border: none; + font-weight: 500; + &::placeholder { + color: $argo-color-gray-6; + } + } + } + + &__external-link { + position: absolute; + top: 1em; + right: 1em; + + .large-text-height { + line-height: 1.5; + } + } + + &__external-links-icon-container { + position: relative; + display: inline-block; + } + + .filters-group__panel { + top: 120px; + } + @include breakpoint(medium down) { + .filters-group__panel { + top: 200px; + } + } + + ul { + margin: 0; + } + + .chart-group { + margin: 0 0.8em; + } + + .chart { + justify-content: space-evenly; + } +} +i.menu_icon { + vertical-align: middle; +} + +.argo-button { + i { + @media screen and (max-width: map-get($breakpoints, large)) { + margin: 0 auto !important; + } + } +} diff --git a/ui/src/app/resources/components/resources-list/resources-list.tsx b/ui/src/app/resources/components/resources-list/resources-list.tsx new file mode 100644 index 0000000000000..0662c80bb4775 --- /dev/null +++ b/ui/src/app/resources/components/resources-list/resources-list.tsx @@ -0,0 +1,382 @@ +import {MockupList, Toolbar} from 'argo-ui'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import {Key, KeybindingContext, KeybindingProvider} from 'argo-ui/v2'; +import {RouteComponentProps} from 'react-router'; +import {combineLatest, from, merge, Observable} from 'rxjs'; +import {bufferTime, delay, filter, map, mergeMap, repeat, retryWhen} from 'rxjs/operators'; +import {AddAuthToToolbar, ClusterCtx, DataLoader, EmptyState, ObservableQuery, Page, Paginate, Query} from '../../../shared/components'; +import {Consumer, Context, ContextApis} from '../../../shared/context'; +import * as models from '../../../shared/models'; +import {services, ResourcesListPreferences} from '../../../shared/services'; +import {useSidebarTarget} from '../../../sidebar/sidebar'; +import * as AppUtils from '../../../applications/components/utils'; +import './resources-list.scss'; +import './flex-top-bar.scss'; +import {ResourceTiles} from './resources-tiles'; +import {FilteredResource, getFilterResults, ResourcesFilter} from './resources-filter'; + +const EVENTS_BUFFER_TIMEOUT = 500; +const WATCH_RETRY_TIMEOUT = 500; + +// The applications list/watch API supports only selected set of fields. +// Make sure to register any new fields in the `appFields` map of `pkg/apiclient/application/forwarder_overwrite.go`. +const APP_FIELDS = [ + 'metadata.name', + 'metadata.namespace', + 'metadata.annotations', + 'metadata.labels', + 'metadata.creationTimestamp', + 'metadata.deletionTimestamp', + 'spec', + 'operation.sync', + 'status.sync.status', + 'status.sync.revision', + 'status.health', + 'status.operationState.phase', + 'status.operationState.finishedAt', + 'status.operationState.operation.sync', + 'status.summary', + 'status.resources' +]; +const APP_LIST_FIELDS = ['metadata.resourceVersion', ...APP_FIELDS.map(field => `items.${field}`)]; +const APP_WATCH_FIELDS = ['result.type', ...APP_FIELDS.map(field => `result.application.${field}`)]; + +function loadApplications(projects: string[], appNamespace: string): Observable { + return from(services.applications.list(projects, {appNamespace, fields: APP_LIST_FIELDS})).pipe( + mergeMap(applicationsList => { + const applications = applicationsList.items; + return merge( + from([applications]), + services.applications + .watch({projects, resourceVersion: applicationsList.metadata.resourceVersion}, {fields: APP_WATCH_FIELDS}) + .pipe(repeat()) + .pipe(retryWhen(errors => errors.pipe(delay(WATCH_RETRY_TIMEOUT)))) + // batch events to avoid constant re-rendering and improve UI performance + .pipe(bufferTime(EVENTS_BUFFER_TIMEOUT)) + .pipe( + map(appChanges => { + appChanges.forEach(appChange => { + const index = applications.findIndex(item => AppUtils.appInstanceName(item) === AppUtils.appInstanceName(appChange.application)); + switch (appChange.type) { + case 'DELETED': + if (index > -1) { + applications.splice(index, 1); + } + break; + default: + if (index > -1) { + applications[index] = appChange.application; + } else { + applications.unshift(appChange.application); + } + break; + } + }); + return {applications, updated: appChanges.length > 0}; + }) + ) + .pipe(filter(item => item.updated)) + .pipe(map(item => item.applications)) + ); + }) + ); +} + +const ViewPref = ({children}: {children: (pref: ResourcesListPreferences & {page: number; search: string}) => React.ReactNode}) => ( + + {q => ( + + combineLatest([services.viewPreferences.getPreferences().pipe(map(item => item.resourcesList)), q]).pipe( + map(items => { + const params = items[1]; + const viewPref: ResourcesListPreferences = {...items[0]}; + if (params.get('proj') != null) { + viewPref.projectsFilter = params + .get('proj') + .split(',') + .filter(item => !!item); + } + if (params.get('sync') != null) { + viewPref.syncFilter = params + .get('sync') + .split(',') + .filter(item => !!item); + } + if (params.get('health') != null) { + viewPref.healthFilter = params + .get('health') + .split(',') + .filter(item => !!item); + } + if (params.get('namespace') != null) { + viewPref.namespacesFilter = params + .get('namespace') + .split(',') + .filter(item => !!item); + } + if (params.get('cluster') != null) { + viewPref.clustersFilter = params + .get('cluster') + .split(',') + .filter(item => !!item); + } + if (params.get('apiGroup') != null) { + viewPref.apiGroupFilter = params + .get('apiGroup') + .split(',') + .filter(item => !!item); + } + if (params.get('kind') != null) { + viewPref.kindFilter = params + .get('kind') + .split(',') + .filter(item => !!item); + } + return {...viewPref, page: parseInt(params.get('page') || '0', 10), search: params.get('search') || ''}; + }) + ) + }> + {pref => children(pref)} + + )} + +); + +function filterResources(resources: models.Resource[], pref: ResourcesListPreferences, search: string): {filteredResources: models.Resource[]; filterResults: FilteredResource[]} { + const filterResults = getFilterResults(resources, pref); + return { + filterResults, + filteredResources: filterResults.filter( + app => + (search === '' || app.name?.includes(search) || app.kind?.includes(search) || app.group?.includes(search) || app.namespace?.includes(search)) && + Object.values(app.filterResult).every(val => val) + ) + }; +} + +function tryJsonParse(input: string) { + try { + return (input && JSON.parse(input)) || null; + } catch { + return null; + } +} + +const SearchBar = (props: {content: string; ctx: ContextApis; resources: models.Resource[]}) => { + const {content, ctx} = {...props}; + + const searchBar = React.useRef(null); + + const query = new URLSearchParams(window.location.search); + const appInput = tryJsonParse(query.get('new')); + + const {useKeybinding} = React.useContext(KeybindingContext); + const [isFocused, setFocus] = React.useState(false); + + useKeybinding({ + keys: Key.SLASH, + action: () => { + if (searchBar.current && !appInput) { + searchBar.current.querySelector('input').focus(); + setFocus(true); + return true; + } + return false; + } + }); + + useKeybinding({ + keys: Key.ESCAPE, + action: () => { + if (searchBar.current && !appInput && isFocused) { + searchBar.current.querySelector('input').blur(); + setFocus(false); + return true; + } + return false; + } + }); + + return ( +
+
+ { + if (searchBar.current) { + searchBar.current.querySelector('input').focus(); + } + }} + /> + { + e.target.select(); + }} + onChange={e => ctx.navigation.goto('.', {search: e.target.value}, {replace: true})} + value={content || ''} + style={{fontSize: '14px'}} + className='argo-field' + placeholder='Search resources...' + /> +
/
+ {content && ctx.navigation.goto('.', {search: null}, {replace: true})} style={{cursor: 'pointer', marginLeft: '5px'}} />} +
+
+ ); +}; + +const FlexTopBar = (props: {toolbar: Toolbar | Observable}) => { + const ctx = React.useContext(Context); + const loadToolbar = AddAuthToToolbar(props.toolbar, ctx); + return ( + +
+ loadToolbar}> + {toolbar => ( + +
{toolbar.tools}
+
+ )} +
+
+
+ + ); +}; + +export const ResourcesList = (props: RouteComponentProps<{}>) => { + const query = new URLSearchParams(props.location.search); + const clusters = React.useMemo(() => services.clusters.list(), []); + const loaderRef = React.useRef(); + + function onFilterPrefChanged(ctx: ContextApis, newPref: ResourcesListPreferences) { + services.viewPreferences.updatePreferences({resourcesList: newPref}); + ctx.navigation.goto( + '.', + { + proj: newPref.projectsFilter.join(','), + sync: newPref.syncFilter.join(','), + health: newPref.healthFilter.join(','), + namespace: newPref.namespacesFilter.join(','), + cluster: newPref.clustersFilter.join(',') + }, + {replace: true} + ); + } + + const sidebarTarget = useSidebarTarget(); + + return ( + + + + {ctx => ( + + {pref => ( + + AppUtils.handlePageVisibility(() => loadApplications(pref.projectsFilter, query.get('appNamespace')))} + loadingRenderer={() => ( +
+ +
+ )}> + {(applications: models.Application[]) => { + const resources = applications + .map(app => + app.status.resources.map( + item => + ({ + ...item, + appName: app.metadata.name, + appNamespace: app.metadata.namespace, + clusterServer: app.spec.destination.server, + clusterName: app.spec.destination.name, + appProject: app.spec.project + }) as models.Resource + ) + ) + .flat(); + const {filteredResources, filterResults} = filterResources(resources, pref, pref.search); + return ( + + + {q => } + + ) + }} + /> +
+ {resources.length === 0 && pref.projectsFilter?.length === 0 ? ( + +

No resources available to you just yet

+
Create new application to start managing resources in your cluster
+ +
+ ) : ( + <> + {ReactDOM.createPortal( + services.viewPreferences.getPreferences()}> + {allpref => ( + onFilterPrefChanged(ctx, newPrefs)} + pref={pref} + collapsed={allpref.hideSidebar} + /> + )} + , + sidebarTarget?.current + )} + ( + +

No matching applications found

+
+ Change filter criteria or  + { + ResourcesListPreferences.clearFilters(pref); + onFilterPrefChanged(ctx, pref); + }}> + clear filters + +
+
+ )} + sortOptions={[{title: 'Name', compare: (a, b) => a.name.localeCompare(b.name)}]} + data={filteredResources} + onPageChange={page => ctx.navigation.goto('.', {page})}> + {data => } +
+ + )} +
+ + ); + }} +
+
+ )} +
+ )} +
+
+
+ ); +}; diff --git a/ui/src/app/resources/components/resources-list/resources-tiles.scss b/ui/src/app/resources/components/resources-list/resources-tiles.scss new file mode 100644 index 0000000000000..181e8acc580c9 --- /dev/null +++ b/ui/src/app/resources/components/resources-list/resources-tiles.scss @@ -0,0 +1,29 @@ +@import 'node_modules/argo-ui/src/styles/config'; + +.resources-tiles { + display: grid; + gap: 24px; + grid-template-columns: repeat(auto-fill,minmax(370px,1fr)); + padding: 0 12px; + + &__wrapper { + height: 100%; + } + + &__item { + display: flex; + flex-direction: column; + } + + &__actions { + margin-top: auto; + } + + .argo-table-list__row { + padding-top: 0; + padding-bottom: 0; + } + &__selected { + box-shadow: 0 0 0 1px $argo-color-teal-5; + } +} diff --git a/ui/src/app/resources/components/resources-list/resources-tiles.tsx b/ui/src/app/resources/components/resources-list/resources-tiles.tsx new file mode 100644 index 0000000000000..4c68508a35ba5 --- /dev/null +++ b/ui/src/app/resources/components/resources-list/resources-tiles.tsx @@ -0,0 +1,235 @@ +import {DataLoader} from 'argo-ui'; +import * as React from 'react'; +import {Key, KeybindingContext, NumKey, NumKeyToNumber, NumPadKey, useNav} from 'argo-ui/v2'; +import {Cluster} from '../../../shared/components'; +import {Consumer, Context} from '../../../shared/context'; +import * as models from '../../../shared/models'; +import * as AppUtils from '../../../applications/components/utils'; +import {services} from '../../../shared/services'; +import {ResourceIcon} from '../../../applications/components/resource-icon'; + +import './resources-tiles.scss'; + +export interface ResourceTilesProps { + resources: models.Resource[]; +} + +const useItemsPerContainer = (itemRef: any, containerRef: any): number => { + const [itemsPer, setItemsPer] = React.useState(0); + + React.useEffect(() => { + const handleResize = () => { + let timeoutId: any; + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + timeoutId = null; + const itemWidth = itemRef.current ? itemRef.current.offsetWidth : -1; + const containerWidth = containerRef.current ? containerRef.current.offsetWidth : -1; + const curItemsPer = containerWidth > 0 && itemWidth > 0 ? Math.floor(containerWidth / itemWidth) : 1; + if (curItemsPer !== itemsPer) { + setItemsPer(curItemsPer); + } + }, 1000); + }; + window.addEventListener('resize', handleResize); + handleResize(); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + return itemsPer || 1; +}; + +export const ResourceTiles = ({resources}: ResourceTilesProps) => { + const [selectedApp, navApp, reset] = useNav(resources.length); + + const ctxh = React.useContext(Context); + const appRef = {ref: React.useRef(null), set: false}; + const appContainerRef = React.useRef(null); + const appsPerRow = useItemsPerContainer(appRef.ref, appContainerRef); + + const {useKeybinding} = React.useContext(KeybindingContext); + + useKeybinding({keys: Key.RIGHT, action: () => navApp(1)}); + useKeybinding({keys: Key.LEFT, action: () => navApp(-1)}); + useKeybinding({keys: Key.DOWN, action: () => navApp(appsPerRow)}); + useKeybinding({keys: Key.UP, action: () => navApp(-1 * appsPerRow)}); + + useKeybinding({ + keys: Key.ENTER, + action: () => { + if (selectedApp > -1) { + ctxh.navigation.goto( + AppUtils.getAppUrl({ + metadata: { + name: resources[selectedApp].appName, + namespace: resources[selectedApp].namespace + } + } as models.Application) + ); + return true; + } + return false; + } + }); + + useKeybinding({ + keys: Key.ESCAPE, + action: () => { + if (selectedApp > -1) { + reset(); + return true; + } + return false; + } + }); + + useKeybinding({ + keys: Object.values(NumKey) as NumKey[], + action: n => { + reset(); + return navApp(NumKeyToNumber(n)); + } + }); + useKeybinding({ + keys: Object.values(NumPadKey) as NumPadKey[], + action: n => { + reset(); + return navApp(NumKeyToNumber(n)); + } + }); + return ( + + {ctx => ( + services.viewPreferences.getPreferences()}> + {pref => { + return ( +
+ {resources.map((app, i) => { + return ( +
+
+ ctx.navigation.goto( + `${AppUtils.getAppUrl({ + metadata: { + name: app.appName, + namespace: app.appNamespace + } + } as models.Application)}`, + { + view: pref.appDetails.view, + node: `/${app.kind}${app.namespace ? `/${app.namespace}` : ''}/${app.name}/0` + }, + {event: e} + ) + }> +
+
+
+ +
+
+ {app.group}/{app.version} +
+
+
+
+ Name: +
+
{app.name}
+
+
+
+ Kind: +
+
{app.kind}
+
+
+
+ Project: +
+
{app.appProject}
+
+ +
+
+ Status: +
+ +
+ {app?.health && ( + <> + {app?.health?.status} + + )}{' '} +   + {app?.status} +   +
+
+
+
+ Cluster: +
+
+ +
+
+
+
+ Namespace: +
+
{app.namespace}
+
+
+
+
+ ); + })} +
+ ); + }} +
+ )} +
+ ); +}; diff --git a/ui/src/app/resources/index.ts b/ui/src/app/resources/index.ts new file mode 100644 index 0000000000000..f83aadd1f110f --- /dev/null +++ b/ui/src/app/resources/index.ts @@ -0,0 +1,5 @@ +import {ResourceContainer} from './components/resources-container'; + +export default { + component: ResourceContainer +}; diff --git a/ui/src/app/shared/models.ts b/ui/src/app/shared/models.ts index eb65f370b011f..f5799a2e8d1ac 100644 --- a/ui/src/app/shared/models.ts +++ b/ui/src/app/shared/models.ts @@ -383,6 +383,14 @@ export interface ResourceStatus { orphaned?: boolean; } +export interface Resource extends ResourceStatus { + appProject?: string; + appName?: string; + appNamespace?: string; + clusterName?: string; + clusterServer?: string; +} + export interface ResourceRef { uid: string; kind: string; diff --git a/ui/src/app/shared/services/view-preferences-service.ts b/ui/src/app/shared/services/view-preferences-service.ts index 575c4b734d416..9c68145fe9e38 100644 --- a/ui/src/app/shared/services/view-preferences-service.ts +++ b/ui/src/app/shared/services/view-preferences-service.ts @@ -90,10 +90,44 @@ export class AppsListPreferences { public favoritesAppList: string[]; } +export class ResourcesListPreferences { + public static countEnabledFilters(pref: ResourcesListPreferences) { + return [pref.clustersFilter, pref.healthFilter, pref.namespacesFilter, pref.projectsFilter, pref.syncFilter, pref.apiGroupFilter, pref.kindFilter].reduce( + (count, filter) => { + if (filter && filter.length > 0) { + return count + 1; + } + return count; + }, + 0 + ); + } + + public static clearFilters(pref: ResourcesListPreferences) { + pref.clustersFilter = []; + pref.healthFilter = []; + pref.namespacesFilter = []; + pref.projectsFilter = []; + pref.syncFilter = []; + pref.apiGroupFilter = []; + pref.kindFilter = []; + } + + public projectsFilter: string[]; + public syncFilter: string[]; + public healthFilter: string[]; + public namespacesFilter: string[]; + public clustersFilter: string[]; + public hideFilters: boolean; + public apiGroupFilter: string[]; + public kindFilter: string[]; +} + export interface ViewPreferences { version: number; appDetails: AppDetailsPreferences; appList: AppsListPreferences; + resourcesList: ResourcesListPreferences; pageSizes: {[key: string]: number}; sortOptions?: {[key: string]: string}; hideBannerContent: string; @@ -145,6 +179,16 @@ const DEFAULT_PREFERENCES: ViewPreferences = { showHealthStatusBar: true } }, + resourcesList: { + projectsFilter: new Array(), + namespacesFilter: new Array(), + clustersFilter: new Array(), + syncFilter: new Array(), + healthFilter: new Array(), + kindFilter: new Array(), + apiGroupFilter: new Array(), + hideFilters: false + }, pageSizes: {}, hideBannerContent: '', hideSidebar: false,